Compare commits

...

792 Commits

Author SHA1 Message Date
Chris Truter
2c0268c72f Disable workaround for #57596 (#60849) 2025-07-11 15:33:16 +02:00
Ngoc Khuat
0c45a4825b Configure form details (#59626) 2025-07-10 20:53:54 +07:00
Chris Truter
d866195767 Use id not slug in saved action params (#60401) 2025-07-02 19:07:22 +02:00
Ngoc Khuat
20c80c7487 Trace and debug logging around actions (#60062) 2025-07-02 14:57:21 +07:00
Chris Truter
a2079a7457 All configuration of param display name (#60314) 2025-07-01 11:43:18 +02:00
Ngoc Khuat
f28514dc04 Expose new parameters in existing configuration (#59926)
Co-authored-by: Chris Truter <chris@metabase.com>
2025-06-30 16:58:53 +02:00
Denis Berezin
f38d8ab010 WRK-536 Remove bulk edit checkbox if no actions (#60212) 2025-06-27 23:44:07 +05:00
Denis Berezin
aeceb52b9b WRK-446 Allow to remove table sorting on 3rd click (#60210) 2025-06-27 23:43:12 +05:00
Konstantin Darutkin
827eb5cc78 feat: pass action scope to action form configurator (#60181)
* feat: pass action scope to action form configurator

* fix: review issues
2025-06-27 13:50:16 +01:00
Chris Truter
6cf08f7807 Support round-trip for pending parameter config (#60186) 2025-06-27 14:25:26 +02:00
Denis Berezin
858dd15f78 Improvements for action picker (#60155) 2025-06-27 01:45:51 +05:00
Denis Berezin
9ade8b5be4 WRK-525 use raw series data for question card row actions 2025-06-27 01:12:52 +05:00
Denis Berezin
386f998c04 Add action picker tests (#59960)
* Add action picker unit tests

* Add more unit tests

* List models without created actions

* Add more e2e tests

* Fix test

* Fix tests

* Try to fix flake

* Try to fix flake
2025-06-26 19:01:04 +05:00
Chris Truter
a156cf761a Fix insertion using only default values (#60054) 2025-06-25 15:39:37 +02:00
Konstantin Darutkin
ef969ecebe fix: do not call field search if select dropdown is not opened (#60047) 2025-06-25 11:54:42 +01:00
Konstantin Darutkin
49485ca97e feat: move-out built-in actions on editables into a separate settings tab (#59986) 2025-06-24 15:30:53 +01:00
Konstantin Darutkin
7165514cb9 fix: send whole row for question row actions inputs (#59988) 2025-06-24 15:30:14 +01:00
Konstantin Darutkin
76989f91b0 fix: typecheck 2025-06-24 12:05:39 +01:00
Chris Truter
8bcb334e1b Filter inactive metadata for nested deletion (#59981) 2025-06-24 12:49:08 +02:00
Chris Truter
754b4c4883 Line breaks to work around cljfmt quirk 2025-06-24 12:17:18 +02:00
Chris Truter
d89ba49521 Skip inactive fields in table action forms (#59979) 2025-06-24 12:13:07 +02:00
Denis Berezin
4241f7d9d9 New Action Picker for "action button" on dashboards (#59434)
* New Action Picker for action button on dashboards

* New Action Picker for editables

* New Action Picker - create action for model

* Add action edit, fix existing e2e tests

* Add e2e test to cover table action assign

* fix ci
2025-06-23 22:18:00 +05:00
Dan Stone
a5b1145e25 Add database_is_pk column to metabase_field (#59924)
Distinguishable from the semantic type which can be changed by the user
2025-06-23 17:29:55 +01:00
Konstantin Darutkin
3da57fdbb2 feat: use describe API for custom actions on Editables and Questions (#59931) 2025-06-23 16:48:02 +01:00
Chris Truter
5f10a1efcf Fix inputs passing through explicit mapping (#59932) 2025-06-23 15:27:35 +02:00
Chris Truter
a231cd2f93 Remove header / row action markers (for now) 2025-06-23 14:10:29 +02:00
Chris Truter
21524d6805 Support custom action being both a 'row' and 'header' action (#59922) 2025-06-23 14:03:23 +02:00
Ngoc Khuat
6a4a68e78e throw 404 if row doesn't exist 2025-06-23 18:37:30 +07:00
Ngoc Khuat
926029c4be Create or update more tests (#59832) 2025-06-23 18:27:43 +07:00
Ngoc Khuat
c325ffe379 rename configure to config-form (#59925) 2025-06-23 18:18:14 +07:00
Denis Berezin
883cdfada7 Fix broken imports after merge with master 2025-06-23 14:45:45 +05:00
Chris Truter
375a7bbd27 Fix Eastwood lint around disabled conditional 2025-06-23 11:32:26 +02:00
Chris Truter
a0cbd14db7 Merge branch 'master' into internal-tools 2025-06-23 10:55:46 +02:00
Ngoc Khuat
71b9593384 param id for query actions are randomized in tests to make sure it matches the production code 2025-06-23 14:30:29 +07:00
Chris Truter
219e7a6aa6 Merge branch 'internal-tools-merge-2025-06-19-take-2' into internal-tools 2025-06-20 17:54:16 +02:00
Chris Truter
d128868267 Fix various bugs in tmp-modal (#59848) 2025-06-20 17:11:59 +02:00
Konstantin Darutkin
998a746bad fix: built-in action IDs for form description (#59840) 2025-06-20 15:03:25 +01:00
Ngoc Khuat
3dc5340671 configure should use field name as ids (#59833) 2025-06-20 19:59:33 +07:00
Ngoc Khuat
3bb6ab0ba6 Test overhaul (#59824) 2025-06-20 18:39:19 +07:00
Chris Truter
73c836a050 Merge remote-tracking branch 'origin/master' into internal-tools-merge-2025-06-19-take-2 2025-06-19 15:44:50 +02:00
Konstantin Darutkin
5d3ff06293 fix: Executing Row Actions post incorrect Scope to the server (#59692) 2025-06-19 13:23:41 +01:00
Ngoc Khuat
6b784c9621 Fix retrieval of row-data in tmp-modal (and simplify a lot) (#59604)
Co-authored-by: Chris Truter <chris@metabase.com>
2025-06-19 10:02:24 +02:00
Chris Truter
426091f623 Don't set card-id in scope for dashboard buttons 2025-06-19 09:26:34 +02:00
Chris Truter
ee2316fa8f Initialize actions directly within questions too (#59735) 2025-06-18 22:31:44 +02:00
Chris Truter
684f01123d Init question action ids properly (#59731) 2025-06-18 20:57:43 +02:00
Konstantin Darutkin
51b4bea29d fix: send correct action id to /execute endpoint (#59640) 2025-06-18 17:48:42 +01:00
Konstantin Darutkin
945858fe98 fix: fetch initial value for remapped category select (#59698) 2025-06-18 15:03:15 +01:00
Chris Truter
f63435ec31 Set PK still on upsert, for now (#59687) 2025-06-18 14:35:58 +02:00
Chris Truter
d9f52578d6 Clarify misleading comment
The FE is not meant to submit the row-key during execution.

This is something that is meant to be configured.
2025-06-18 14:34:27 +02:00
Chris Truter
bae15c2bd1 Add schema to table references in upsert and delete actions (#59678) 2025-06-18 13:37:24 +02:00
Stas Gavrylov
75ced50409 WRK-482: Preview for Slack templates in notifications (#59615)
* WRK-482: Add link to open external Slack template preview

* WRK-482: Refactor handlePreviewClick method

* WRK-482: Simplify preview fetching logic
2025-06-17 20:06:12 +03:00
Stas Gavrylov
f69d51e50d WRK-451: Implement 'Update records' default action functionality (#59530)
* WRK-451: Implement 'Update records' default action functionality

* WRK-451: Fix BE tests
2025-06-17 15:13:09 +03:00
Dan Stone
273899c43e Ensure new v2 table list only returns active tables (#59551) 2025-06-16 15:51:00 +01:00
Dan Stone
30d94a2312 Permit unsaved system event notifications to be sent with a sample payload (#59329)
Using the new flag :use_sample_payload avoids needing to provide event_info
2025-06-16 14:11:45 +01:00
Ngoc Khuat
3266f58969 Tmp modal tests (#59450) 2025-06-16 19:52:19 +07:00
Konstantin Darutkin
c322db81ce feat: use describe API to power bulk editing row modal (#59543) 2025-06-16 13:40:13 +01:00
Ngoc Khuat
a04752468f Fix create or update on editable (#59527) 2025-06-16 19:36:47 +07:00
Dan Stone
61762e09bb WRK-457 slack template preview (#59460)
* Fix typo in sample slack recipient

Fixed a crash when previewing a slack template

* Return :preview_url from POST notification/preview_template

Allows the FE to produce a link for slack templates that shows a preview
in slacks block-kit-builder tool.

- Only applies to slack templates (obviously!)
- Uses a new setting `slack-team-id` which is maintained with the
channels/users cache

It seems possible for larger templates that URL limits could be reached,
so we should consider appropriate policy for that in a follow up (or
think of a better way to preview).
2025-06-16 11:10:40 +01:00
Konstantin Darutkin
e3b4f60079 feat: update row modal based on describe API (dashboard) (#59389) 2025-06-13 13:15:38 +01:00
Konstantin Darutkin
7144bee336 fix: refetch modal description on viz settings change (#59397) 2025-06-13 12:17:29 +01:00
Chris Truter
8d2696696a Get rid of params hack (#59444) 2025-06-13 06:27:09 +02:00
Chris Truter
688ad8d9df Initial work on /configure API (#59443)
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2025-06-13 05:15:26 +02:00
Chris Truter
8b5d57137c More touch ups to merge 2025-06-12 23:31:08 +02:00
Chris Truter
16c29e4e5f Merge into newer internal-tools 2025-06-12 17:15:56 +02:00
Chris Truter
7f0916af55 Merge remote-tracking branch 'origin/master' into internal-tools 2025-06-12 17:08:07 +02:00
Denis Berezin
fca011822e Fix e2e test in dashboard-cards/click-behavior.cy.spec.js 2025-06-12 18:35:26 +05:00
Stas Gavrylov
a08603a176 Fix e2e tests (#59383)
* Fix data-editing-alerts e2e tests

* Fix actions-on-dashboards e2e tests
2025-06-12 16:30:16 +03:00
Konstantin Darutkin
30cdc28d52 feat: add update row modal based on describe API (table view) (#59314) 2025-06-12 13:56:44 +01:00
Chris Truter
c1e3980c6a Fix initial id for data-grid actions (#59388) 2025-06-12 14:08:00 +02:00
Chris Truter
863d65dfb6 Fix initial id for data-grid actions (#59388) 2025-06-12 14:08:00 +02:00
Ngoc Khuat
173c8dc443 tmp modal small clean up (#59298) 2025-06-12 17:29:01 +07:00
Ngoc Khuat
bffdcb2f12 tmp modal small clean up (#59298) 2025-06-12 17:29:01 +07:00
Stas Gavrylov
b86c0c9252 Row action not rendering on editable table after saving (#59303)
* Fix row actions

* Fix row actions source for editables

* Test fix for e2e

* Fix TableActionExecuteModal form to only send values which did actually change
2025-06-11 18:36:29 +03:00
Stas Gavrylov
68b32f45ee Row action not rendering on editable table after saving (#59303)
* Fix row actions

* Fix row actions source for editables

* Test fix for e2e

* Fix TableActionExecuteModal form to only send values which did actually change
2025-06-11 18:36:29 +03:00
Chris Truter
64107c8c1d Use proxies until we move the /v2/execute APIs (#59309) 2025-06-11 16:24:44 +02:00
Chris Truter
9184bbb103 Use proxies until we move the /v2/execute APIs (#59309) 2025-06-11 16:24:44 +02:00
Denis Berezin
40615ca63e Add support for tables with multiple PKs (#59279) 2025-06-11 18:53:32 +05:00
Denis Berezin
8e3a948b00 Add support for tables with multiple PKs (#59279) 2025-06-11 18:53:32 +05:00
Ngoc Khuat
2abb8eb370 Delete child edge cases (#59167) 2025-06-11 18:48:04 +07:00
Ngoc Khuat
9985a18220 Delete child edge cases (#59167) 2025-06-11 18:48:04 +07:00
Ngoc Khuat
8f819a54a2 Surface create or update action (#59236) 2025-06-11 18:47:55 +07:00
Ngoc Khuat
28fb784714 Surface create or update action (#59236) 2025-06-11 18:47:55 +07:00
Konstantin Darutkin
8b0d09a3c1 feat: new create row modals on dashboards (#59305) 2025-06-11 12:32:48 +01:00
Konstantin Darutkin
3dcf9cd270 feat: new create row modals on dashboards (#59305) 2025-06-11 12:32:48 +01:00
Stas Gavrylov
62274b6b94 Migrate legacy actions to execute API (#59265)
* Migrate legacy dashcard actions to new 'execute' API

* Fix unit tests

* Fix e2e tests

* Revert (for now) "Use NULL in place of optional SQL parameters (#58713)"

This reverts commit 8a505fd419.

* Fix e2e tests

* test fixes(2)

* Fix e2e tests

* ci: empty commit

* Add enterprise/backend to list of sources for uberjar SHA generation

* Update enterprise/backend to enterprise/backend/src dependency path

---------

Co-authored-by: Chris Truter <chris@metabase.com>
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2025-06-11 12:05:00 +03:00
Stas Gavrylov
14292aed20 Migrate legacy actions to execute API (#59265)
* Migrate legacy dashcard actions to new 'execute' API

* Fix unit tests

* Fix e2e tests

* Revert (for now) "Use NULL in place of optional SQL parameters (#58713)"

This reverts commit 8a505fd419.

* Fix e2e tests

* test fixes(2)

* Fix e2e tests

* ci: empty commit

* Add enterprise/backend to list of sources for uberjar SHA generation

* Update enterprise/backend to enterprise/backend/src dependency path

---------

Co-authored-by: Chris Truter <chris@metabase.com>
Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2025-06-11 12:05:00 +03:00
Dan Stone
a3cc471254 Carry editable dashcard configuration through to table parameter definition (#59269)
Table action parameters now pick up certain properties from the editable
dashcard grids column definition

- Sorting
- Readonly specialisation
- Whether to hide altogether (:enabled flag)
2025-06-10 18:39:12 +01:00
Dan Stone
702042cfa0 Carry editable dashcard configuration through to table parameter definition (#59269)
Table action parameters now pick up certain properties from the editable
dashcard grids column definition

- Sorting
- Readonly specialisation
- Whether to hide altogether (:enabled flag)
2025-06-10 18:39:12 +01:00
Chris Truter
4ddead9bad Revert (for now) "Use NULL in place of optional SQL parameters (#58713) (#59249) 2025-06-10 14:51:50 +02:00
Chris Truter
95e347b71d Revert (for now) "Use NULL in place of optional SQL parameters (#58713) (#59249) 2025-06-10 14:51:50 +02:00
Chris Truter
86f7cd33e5 Workaround for FE generating its own action ids (#59241) 2025-06-10 14:49:38 +02:00
Chris Truter
ae4157cc6c Workaround for FE generating its own action ids (#59241) 2025-06-10 14:49:38 +02:00
Konstantin Darutkin
9430f874c9 fix: add auto populated label for new create row form (#59242) 2025-06-10 11:37:19 +01:00
Konstantin Darutkin
e1ecc1f8ae fix: add auto populated label for new create row form (#59242) 2025-06-10 11:37:19 +01:00
Dan Stone
4676257489 Expose :database_default on field-derived parameters (#59191) 2025-06-09 15:50:10 +01:00
Dan Stone
e63161b4aa Expose :database_default on field-derived parameters (#59191) 2025-06-09 15:50:10 +01:00
Konstantin Darutkin
cece23b2c6 feat: create row modal form based on describe API (#59102) 2025-06-09 15:32:59 +01:00
Konstantin Darutkin
0d7aaa8db0 feat: create row modal form based on describe API (#59102) 2025-06-09 15:32:59 +01:00
Dan Stone
59f49e7a11 Adds :input_type and :human_readable_field_id (#59173)
input_type reflects the actual behaviour and limitations of the FE as it
stands.

- Numeric inputs are not yet supported, so leave those as "text"
- All select/dropdowns (categories, fks, model action params with
values) are "dropdown"
- We best-effort textarea vs text using the viz-settings on model action
params, or the :type/Description semantic_type on fields

Extraneous information that is not yet used has been removed.
2025-06-09 15:06:55 +01:00
Dan Stone
9050af4f05 Adds :input_type and :human_readable_field_id (#59173)
input_type reflects the actual behaviour and limitations of the FE as it
stands.

- Numeric inputs are not yet supported, so leave those as "text"
- All select/dropdowns (categories, fks, model action params with
values) are "dropdown"
- We best-effort textarea vs text using the viz-settings on model action
params, or the :type/Description semantic_type on fields

Extraneous information that is not yet used has been removed.
2025-06-09 15:06:55 +01:00
Konstantin Darutkin
5a7eb8f53b fix: enable manual value input into the FK field (#59180) 2025-06-09 14:26:12 +01:00
Konstantin Darutkin
62b541669d fix: enable manual value input into the FK field (#59180) 2025-06-09 14:26:12 +01:00
Chris Truter
b7f9388cb8 Revert "Remove data-editing API (#58955)"
This reverts commit 10205ea677.
2025-06-09 14:06:51 +02:00
Chris Truter
2c5e1de8f4 Revert "Remove data-editing API (#58955)"
This reverts commit 10205ea677.
2025-06-09 14:06:51 +02:00
Stas Gavrylov
f9fc6021dc Merge remote-tracking branch 'origin/master' into internal-tools 2025-06-09 14:26:09 +03:00
Stas Gavrylov
2e4bda9314 Merge remote-tracking branch 'origin/master' into internal-tools 2025-06-09 14:26:09 +03:00
Denis Berezin
357a629698 Improve Actions picker for dashboard card tables (#58741) 2025-06-06 19:49:46 +03:00
Denis Berezin
fcf68b4cbc Improve Actions picker for dashboard card tables (#58741) 2025-06-06 19:49:46 +03:00
Chris Truter
07d01f0dde Fix fast-reload response for table actions with multiple PKs (#59107) 2025-06-06 15:05:10 +02:00
Chris Truter
03606130ff Fix fast-reload response for table actions with multiple PKs (#59107) 2025-06-06 15:05:10 +02:00
Konstantin Darutkin
e5ae6e507e fix: truncate category select labels for category edit inputs (#59098) 2025-06-06 12:05:16 +01:00
Konstantin Darutkin
72b37bf055 fix: truncate category select labels for category edit inputs (#59098) 2025-06-06 12:05:16 +01:00
Ngoc Khuat
7e13bed9bf Mallify actions (#59087) 2025-06-06 17:07:18 +07:00
Ngoc Khuat
b5603b457f Mallify actions (#59087) 2025-06-06 17:07:18 +07:00
Konstantin Darutkin
d0da8d0bfb fix: revert old create row modal (#59064) 2025-06-05 19:41:57 +01:00
Konstantin Darutkin
bdab21ab81 fix: revert old create row modal (#59064) 2025-06-05 19:41:57 +01:00
Konstantin Darutkin
14c7c49e85 feat: new action form from describe endpoint on table view (#59036)
* feat: new action form from describe endpoint on table view

* fix: review fixes
2025-06-05 18:17:40 +01:00
Konstantin Darutkin
4f161d0522 feat: new action form from describe endpoint on table view (#59036)
* feat: new action form from describe endpoint on table view

* fix: review fixes
2025-06-05 18:17:40 +01:00
Chris Truter
cb2cf88fef Remove dead code and tighten up execute code a bit (#59048) 2025-06-05 16:09:19 +02:00
Chris Truter
fb84db5724 Remove dead code and tighten up execute code a bit (#59048) 2025-06-05 16:09:19 +02:00
Chris Truter
95a07ac365 Some small fixes to "describe" (#59043) 2025-06-05 15:44:14 +02:00
Chris Truter
6b68d0cd19 Some small fixes to "describe" (#59043) 2025-06-05 15:44:14 +02:00
Dan Stone
d5f9ed74c6 Add :semantic_type to table-derived field params in tmp-modal (#59040) 2025-06-05 14:08:59 +01:00
Dan Stone
9c5785a1dc Add :semantic_type to table-derived field params in tmp-modal (#59040) 2025-06-05 14:08:59 +01:00
Konstantin Darutkin
ce78bfb768 fix: proper input/param mappings for execution endpoints (#58922) 2025-06-05 12:19:47 +01:00
Konstantin Darutkin
15d067acea fix: proper input/param mappings for execution endpoints (#58922) 2025-06-05 12:19:47 +01:00
Ngoc Khuat
d8f662ad78 Create Or Update (#58947) 2025-06-05 17:10:32 +07:00
Ngoc Khuat
b756a1f5a4 Create Or Update (#58947) 2025-06-05 17:10:32 +07:00
Chris Truter
c68f94c444 Routes for new picker (#59018) 2025-06-05 10:05:16 +02:00
Chris Truter
8879a74291 Routes for new picker (#59018) 2025-06-05 10:05:16 +02:00
Ngoc Khuat
cef86207a9 fix model resolution test 2025-06-05 12:07:50 +07:00
Ngoc Khuat
c3478d6736 fix model resolution test 2025-06-05 12:07:50 +07:00
Dan Stone
bce3de22fd Add support for drop downs to tmp-modal (#58937)
Some support for presenting search / drop downs for parameter options

- add `value_options` to mirror the action field valueOptions (casing
changed for consistency with the standard api style)
- add `field_id`, `fk_target_field_id`  for each table field derived
parameter
2025-06-04 18:51:19 +01:00
Dan Stone
f26fc559d2 Add support for drop downs to tmp-modal (#58937)
Some support for presenting search / drop downs for parameter options

- add `value_options` to mirror the action field valueOptions (casing
changed for consistency with the standard api style)
- add `field_id`, `fk_target_field_id`  for each table field derived
parameter
2025-06-04 18:51:19 +01:00
Dan Stone
e379f1e5ae Test fixes (#58942)
* Cleanup table in create-table-test

* Restore database settings on data editing test exit

* Remove ordering dependency on dashcards in list-and-add test
2025-06-04 18:19:47 +01:00
Dan Stone
a6c2703a17 Test fixes (#58942)
* Cleanup table in create-table-test

* Restore database settings on data editing test exit

* Remove ordering dependency on dashcards in list-and-add test
2025-06-04 18:19:47 +01:00
Ngoc Khuat
1619fcc222 Undoable undo (#58794) 2025-06-04 22:14:59 +07:00
Ngoc Khuat
9adf04f65b Undoable undo (#58794) 2025-06-04 22:14:59 +07:00
Ngoc Khuat
113311369b remove :arg as a way to pass row (#58929) 2025-06-04 19:35:51 +07:00
Ngoc Khuat
c0d300b20a remove :arg as a way to pass row (#58929) 2025-06-04 19:35:51 +07:00
Ngoc Khuat
b6019ad000 Fix conditional table notification not sending (#58917) 2025-06-04 17:06:01 +07:00
Ngoc Khuat
498739105e Fix conditional table notification not sending (#58917) 2025-06-04 17:06:01 +07:00
Dan Stone
5b1d891712 Extend tmp-modal to existing viz-action mappings (#58884)
- readonly support
- hidden params are hidden
- constant :value is returned (to show on screen)
- row :value is returned by lookup against the provided :input pk
2025-06-04 10:54:16 +01:00
Dan Stone
795bc60e59 Extend tmp-modal to existing viz-action mappings (#58884)
- readonly support
- hidden params are hidden
- constant :value is returned (to show on screen)
- row :value is returned by lookup against the provided :input pk
2025-06-04 10:54:16 +01:00
Chris Truter
6d333b367c Map row action parameters on the server side (#58908) 2025-06-04 10:03:29 +02:00
Chris Truter
4e5a23cc0f Map row action parameters on the server side (#58908) 2025-06-04 10:03:29 +02:00
Chris Truter
45f9294bd0 Improve Editable action init hook (#58876) 2025-06-03 21:34:09 +02:00
Chris Truter
9fa4adc15e Improve Editable action init hook (#58876) 2025-06-03 21:34:09 +02:00
Ngoc Khuat
7b14efe0fb FE for delete linked records (#58771)
* POC delete cascade fk

* remove hack in casing by returning the rows with correct names in actions

* no need to hack in get-pks too

* reduce duplication

* simplify building metadata

* am I being paranoid with these keyword/string issue?

* remove hashp

* string all the way down

* test fixes, we're :green:

* the last test fix

* remove unused type

* remove hacks

* small refacator

* return children count

* return children count

* opts passing

* Revert "opts passing"

This reverts commit 867df01a0f.

* add tests and options parsing are now in params

* test add sanity check

* fix for postgres and mysql

* test fixes

* Chris's suggestions

* a few more tests

* FE for delete linked records

* add tests

* Refactoring and cleanup for table crud cascade delete

* Add cascade delete handling to EditTableDashcardVisualization

* Update logic to handle new response schema

---------

Co-authored-by: Stas Gavrylov <stasgavrylov@gmail.com>
2025-06-03 18:17:30 +03:00
Ngoc Khuat
c624556460 FE for delete linked records (#58771)
* POC delete cascade fk

* remove hack in casing by returning the rows with correct names in actions

* no need to hack in get-pks too

* reduce duplication

* simplify building metadata

* am I being paranoid with these keyword/string issue?

* remove hashp

* string all the way down

* test fixes, we're :green:

* the last test fix

* remove unused type

* remove hacks

* small refacator

* return children count

* return children count

* opts passing

* Revert "opts passing"

This reverts commit 867df01a0f.

* add tests and options parsing are now in params

* test add sanity check

* fix for postgres and mysql

* test fixes

* Chris's suggestions

* a few more tests

* FE for delete linked records

* add tests

* Refactoring and cleanup for table crud cascade delete

* Add cascade delete handling to EditTableDashcardVisualization

* Update logic to handle new response schema

---------

Co-authored-by: Stas Gavrylov <stasgavrylov@gmail.com>
2025-06-03 18:17:30 +03:00
Chris Truter
43968a0476 Fix built-in action detection (#58864) 2025-06-03 16:28:00 +02:00
Chris Truter
ca5266b0b9 Fix built-in action detection (#58864) 2025-06-03 16:28:00 +02:00
Dan Stone
bd813d6699 Smooth over list/picker-style differences (#58857)
Allow primitive actions e.g "table.row/create" to be sent as action_ids
to tmp-modal
2025-06-03 15:04:00 +01:00
Dan Stone
63b546c447 Smooth over list/picker-style differences (#58857)
Allow primitive actions e.g "table.row/create" to be sent as action_ids
to tmp-modal
2025-06-03 15:04:00 +01:00
Ngoc Khuat
cb37f854d4 Optimize delete children (#58831) 2025-06-03 19:06:41 +07:00
Ngoc Khuat
fb84045d5e Optimize delete children (#58831) 2025-06-03 19:06:41 +07:00
Chris Truter
e0bd3b9647 Add params to v2/execute (#58838) 2025-06-03 12:28:07 +02:00
Chris Truter
ddb0333f84 Add params to v2/execute (#58838) 2025-06-03 12:28:07 +02:00
Ngoc Khuat
8165d11457 BE: cascade delete (#58559) 2025-06-02 18:10:15 +07:00
Ngoc Khuat
1849156e2f BE: cascade delete (#58559) 2025-06-02 18:10:15 +07:00
Stas Gavrylov
4350a95708 WRK-340 Improve table selection when adding editable (#58635)
* WRK-340: Initial version of updated table picker UI

* WRK-340: Add schema nesting level and enhance search results

* WRK-340: Styling adjustments
2025-06-02 13:36:28 +03:00
Stas Gavrylov
0eb3cc9691 WRK-340 Improve table selection when adding editable (#58635)
* WRK-340: Initial version of updated table picker UI

* WRK-340: Add schema nesting level and enhance search results

* WRK-340: Styling adjustments
2025-06-02 13:36:28 +03:00
Ngoc Khuat
26d47c1e34 test and kondo fixes 2025-06-02 12:54:00 +07:00
Ngoc Khuat
bf10f6e1ee test and kondo fixes 2025-06-02 12:54:00 +07:00
Ngoc Khuat
b3551813b1 fix Method code too large 2025-06-02 12:37:51 +07:00
Ngoc Khuat
49656d7c59 fix Method code too large 2025-06-02 12:37:51 +07:00
Ngoc Khuat
875601dfb4 fix undo tests 2025-06-02 11:20:50 +07:00
Ngoc Khuat
d1b6396932 fix undo tests 2025-06-02 11:20:50 +07:00
Ngoc Khuat
340cf95f51 Merge branch 'master' into internal-tools 2025-06-02 10:59:28 +07:00
Ngoc Khuat
52bcf275fa Merge branch 'master' into internal-tools 2025-06-02 10:59:28 +07:00
Chris Truter
8a505fd419 Use NULL in place of optional SQL parameters (#58713) 2025-05-30 17:01:32 +02:00
Chris Truter
2c96a49b21 Use NULL in place of optional SQL parameters (#58713) 2025-05-30 17:01:32 +02:00
Chris Truter
26d45c26e8 Fix SQL actions whose database does not match their model (#58704) 2025-05-30 15:55:39 +02:00
Chris Truter
93cc97f072 Fix SQL actions whose database does not match their model (#58704) 2025-05-30 15:55:39 +02:00
Konstantin Darutkin
53ecaaf7a5 feat: add textarea for modal table row editing (#58700)
* feat: add textarea for modal table row editing

* fix: styling
2025-05-30 14:07:12 +01:00
Konstantin Darutkin
a4a9a31107 feat: add textarea for modal table row editing (#58700)
* feat: add textarea for modal table row editing

* fix: styling
2025-05-30 14:07:12 +01:00
Chris Truter
b58bff6dd7 Fix translation, fix error message (#58697) 2025-05-30 13:58:13 +02:00
Chris Truter
9006121624 Fix translation, fix error message (#58697) 2025-05-30 13:58:13 +02:00
Stas Gavrylov
de077d3943 WRK-411: Migrate POST /action/:id/execute to new API (#58651) 2025-05-30 13:23:21 +03:00
Stas Gavrylov
ec7ef98f66 WRK-411: Migrate POST /action/:id/execute to new API (#58651) 2025-05-30 13:23:21 +03:00
Dan Stone
2a42f773cf Fix not unsetting the action_id on dashboard edit (#58695) 2025-05-30 11:16:35 +01:00
Dan Stone
faccd69795 Fix not unsetting the action_id on dashboard edit (#58695) 2025-05-30 11:16:35 +01:00
Chris Truter
aa9005b782 Execute dashcard actions via unified API (#58639) 2025-05-30 10:40:37 +02:00
Chris Truter
203b6d513c Execute dashcard actions via unified API (#58639) 2025-05-30 10:40:37 +02:00
Dan Stone
5d207dff7b Add tmp-modal endpoint (#58648)
Will be extended later once things firm up RE mappings and details

- Works for both table (negative) action ids and saved action ids
- Presents parameter requirements in a unified way
- Smooths over differences between types in query actions vs
implicit/table editing actions
2025-05-29 19:04:52 +01:00
Dan Stone
54acb4a30e Add tmp-modal endpoint (#58648)
Will be extended later once things firm up RE mappings and details

- Works for both table (negative) action ids and saved action ids
- Presents parameter requirements in a unified way
- Smooths over differences between types in query actions vs
implicit/table editing actions
2025-05-29 19:04:52 +01:00
Konstantin Darutkin
2fbd7d3b1e feat: bulk update operations on db tables & editables (#58570)
* wip: bulk update opeartions on db tables & editables

* feat: be integration

* fix: tests
2025-05-29 18:16:09 +01:00
Konstantin Darutkin
ad3df585bd feat: bulk update operations on db tables & editables (#58570)
* wip: bulk update opeartions on db tables & editables

* feat: be integration

* fix: tests
2025-05-29 18:16:09 +01:00
Stas Gavrylov
d3fdca112d Fix dashcard actions population (#58629) 2025-05-29 15:16:06 +03:00
Stas Gavrylov
90adf26588 Fix dashcard actions population (#58629) 2025-05-29 15:16:06 +03:00
Chris Truter
3784750e6d Delete obsolete REST interface for undo / redo (#58626) 2025-05-29 13:27:37 +02:00
Chris Truter
f49cdaf878 Delete obsolete REST interface for undo / redo (#58626) 2025-05-29 13:27:37 +02:00
Dan Stone
98287f0d89 Check tables are active before computing actions (#58623) 2025-05-29 10:52:33 +01:00
Dan Stone
967d0a3eb5 Check tables are active before computing actions (#58623) 2025-05-29 10:52:33 +01:00
Stas Gavrylov
e9a86902bc WRK-411: Move actions to new execute API (#58564)
* WIP

* Match action and API shape for undo / redo

* WRK-397 Integrate Table actions to button action picker (#58333)

* WRK-397: Add table actions output to table actions picker

* WRK-397: Integrate new endpoint and table actions for Action Buttons

* WRK-397 Ignore null update values (temporary)

* WRK-397: Add typings. Fix lint

* WRK-397: Various improvements for table action picker

* WRK-397: Fix tests

* WRK-397: Merge latest updates

* WRK-397 Remove nils from update params

---------

Co-authored-by: Dan Stone <wotbrew@gmail.com>

* WRK-411: Migrate rows delete to new API

* WRK-411: Migrate rows update to new API

* WRK-411: Migrate rows create to new API

* WRK-411: Migrate undo/redo to new API

* WRK-411: Fix lint

---------

Co-authored-by: Chris Truter <chris@metabase.com>
Co-authored-by: Dan Stone <wotbrew@gmail.com>
2025-05-29 12:40:08 +03:00
Stas Gavrylov
e4821f591f WRK-411: Move actions to new execute API (#58564)
* WIP

* Match action and API shape for undo / redo

* WRK-397 Integrate Table actions to button action picker (#58333)

* WRK-397: Add table actions output to table actions picker

* WRK-397: Integrate new endpoint and table actions for Action Buttons

* WRK-397 Ignore null update values (temporary)

* WRK-397: Add typings. Fix lint

* WRK-397: Various improvements for table action picker

* WRK-397: Fix tests

* WRK-397: Merge latest updates

* WRK-397 Remove nils from update params

---------

Co-authored-by: Dan Stone <wotbrew@gmail.com>

* WRK-411: Migrate rows delete to new API

* WRK-411: Migrate rows update to new API

* WRK-411: Migrate rows create to new API

* WRK-411: Migrate undo/redo to new API

* WRK-411: Fix lint

---------

Co-authored-by: Chris Truter <chris@metabase.com>
Co-authored-by: Dan Stone <wotbrew@gmail.com>
2025-05-29 12:40:08 +03:00
Ngoc Khuat
36fc93d4e1 Fix table notification emails when create/update/archive (#58505) 2025-05-29 15:25:52 +07:00
Ngoc Khuat
e3001a3e31 Fix table notification emails when create/update/archive (#58505) 2025-05-29 15:25:52 +07:00
Denis Berezin
ba82a8a02e Add edtiable table sorting (#58601) 2025-05-28 23:42:28 +03:00
Denis Berezin
5a11b1b796 Add edtiable table sorting (#58601) 2025-05-28 23:42:28 +03:00
Denis Berezin
3c0a312444 Breadcrumbs & back navigation for db tables (#58546)
* Add breadcrumbs for table edit screen

* Fix ci
2025-05-28 19:30:23 +03:00
Denis Berezin
597bf6b60c Breadcrumbs & back navigation for db tables (#58546)
* Add breadcrumbs for table edit screen

* Fix ci
2025-05-28 19:30:23 +03:00
Dan Stone
efccfe909d Fix regression with :policy opt and table action (#58573) 2025-05-28 17:18:20 +01:00
Dan Stone
7df714f9db Fix regression with :policy opt and table action (#58573) 2025-05-28 17:18:20 +01:00
Konstantin Darutkin
26fd776b30 fix: do not display leave confirmation modal on editing modal URL change (#58569) 2025-05-28 16:52:03 +01:00
Konstantin Darutkin
cbb5a47b96 fix: do not display leave confirmation modal on editing modal URL change (#58569) 2025-05-28 16:52:03 +01:00
Stas Gavrylov
f95dcb6be5 WRK-397 Integrate Table actions to button action picker (#58333)
* WRK-397: Add table actions output to table actions picker

* WRK-397: Integrate new endpoint and table actions for Action Buttons

* WRK-397 Ignore null update values (temporary)

* WRK-397: Add typings. Fix lint

* WRK-397: Various improvements for table action picker

* WRK-397: Fix tests

* WRK-397: Merge latest updates

* WRK-397 Remove nils from update params

---------

Co-authored-by: Dan Stone <wotbrew@gmail.com>
2025-05-28 17:57:34 +03:00
Stas Gavrylov
be4b723e1f WRK-397 Integrate Table actions to button action picker (#58333)
* WRK-397: Add table actions output to table actions picker

* WRK-397: Integrate new endpoint and table actions for Action Buttons

* WRK-397 Ignore null update values (temporary)

* WRK-397: Add typings. Fix lint

* WRK-397: Various improvements for table action picker

* WRK-397: Fix tests

* WRK-397: Merge latest updates

* WRK-397 Remove nils from update params

---------

Co-authored-by: Dan Stone <wotbrew@gmail.com>
2025-05-28 17:57:34 +03:00
Denis Berezin
152aaef104 Add table actions for dashboard question cards (#58380)
* Add table actions management

* Add basic working implementation

* Move actions code to enterprise, unifywith editable table

* add validation for empty field after q change

* Refactor data schema for actions viz settings

* fix issues

* revert genius ide autoreplacement
2025-05-27 17:58:39 +03:00
Denis Berezin
d892d8dbc8 Add table actions for dashboard question cards (#58380)
* Add table actions management

* Add basic working implementation

* Move actions code to enterprise, unifywith editable table

* add validation for empty field after q change

* Refactor data schema for actions viz settings

* fix issues

* revert genius ide autoreplacement
2025-05-27 17:58:39 +03:00
Denis Berezin
27c58d1157 Remove service tables from editable tables picker 2025-05-27 17:42:17 +03:00
Denis Berezin
5dccbe138e Remove service tables from editable tables picker 2025-05-27 17:42:17 +03:00
Chris Truter
25ed5d9b8a Bulk Action execution API (#58514) 2025-05-27 14:42:04 +02:00
Chris Truter
73563f787b Bulk Action execution API (#58514) 2025-05-27 14:42:04 +02:00
Konstantin Darutkin
db62348247 feat: edit table filters before saving a dashcard (#58478) 2025-05-27 12:15:50 +01:00
Konstantin Darutkin
4137c2555e feat: edit table filters before saving a dashcard (#58478) 2025-05-27 12:15:50 +01:00
Dan Stone
17353f3dbd Use u/poll to avoid sleeps in field-values test (#58513) 2025-05-27 11:53:53 +01:00
Dan Stone
d383b53923 Use u/poll to avoid sleeps in field-values test (#58513) 2025-05-27 11:53:53 +01:00
Chris Truter
0444520362 Hack to support seeding the initial editable filters (#58512) 2025-05-27 12:50:09 +02:00
Chris Truter
a82c646cb3 Hack to support seeding the initial editable filters (#58512) 2025-05-27 12:50:09 +02:00
Stas Gavrylov
51a5f4f2fc Update default templates to use scope.origin_url (#58473)
* Update default templates to use scope.origin_url

* Default templates: Fix origin url reference

* Default templates: Fix tests

* fix tests

---------

Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2025-05-27 13:07:34 +03:00
Stas Gavrylov
7905b7357b Update default templates to use scope.origin_url (#58473)
* Update default templates to use scope.origin_url

* Default templates: Fix origin url reference

* Default templates: Fix tests

* fix tests

---------

Co-authored-by: Ngoc Khuat <qn.khuat@gmail.com>
2025-05-27 13:07:34 +03:00
Ngoc Khuat
f5539cdad7 another test fix 2025-05-27 16:20:48 +09:00
Ngoc Khuat
135ea16706 another test fix 2025-05-27 16:20:48 +09:00
Ngoc Khuat
36f8ee0342 test fixes 2025-05-27 12:44:48 +09:00
Ngoc Khuat
107acc35f0 test fixes 2025-05-27 12:44:48 +09:00
Ngoc Khuat
33b5e1f905 Merge branch 'internal-tools-master' into internal-tools 2025-05-27 12:29:59 +09:00
Ngoc Khuat
01884fd049 Merge branch 'internal-tools-master' into internal-tools 2025-05-27 12:29:59 +09:00
Stas Gavrylov
4f84e33f77 Optimize EditTableDashcardVisualization rendering (memoize dependencies) (#58475) 2025-05-26 18:24:23 +03:00
Stas Gavrylov
5ff1fe73fc Optimize EditTableDashcardVisualization rendering (memoize dependencies) (#58475) 2025-05-26 18:24:23 +03:00
Chris Truter
f0583eaba5 Tests for more row action types (#58487) 2025-05-26 16:01:35 +02:00
Chris Truter
b2302e5263 Tests for more row action types (#58487) 2025-05-26 16:01:35 +02:00
Chris Truter
b5b58ebbcb New v2 execution API for actions (#58432) 2025-05-26 14:40:24 +02:00
Chris Truter
c7af65b686 New v2 execution API for actions (#58432) 2025-05-26 14:40:24 +02:00
Konstantin Darutkin
d0f20309a1 fix: remove (i) icon from db tables (#58479) 2025-05-26 12:39:04 +01:00
Konstantin Darutkin
6548d02c35 fix: remove (i) icon from db tables (#58479) 2025-05-26 12:39:04 +01:00
Konstantin Darutkin
f6154fd684 feat: render editable when added to a dashboard (#58402)
* feat: render editable when added to a dashboard

* fix: review issues
2025-05-23 15:08:40 +01:00
Konstantin Darutkin
20c44b1793 feat: render editable when added to a dashboard (#58402)
* feat: render editable when added to a dashboard

* fix: review issues
2025-05-23 15:08:40 +01:00
Oleksandr Solovyov
21f4335bea helper to split fields (#58413) 2025-05-23 16:57:00 +03:00
Oleksandr Solovyov
544e703049 helper to split fields (#58413) 2025-05-23 16:57:00 +03:00
Stas Gavrylov
757b650588 WRK-425: Hide TableDashcard actions panel when editing dashboard (#58341) 2025-05-22 15:15:48 +03:00
Stas Gavrylov
375a42a89c WRK-425: Hide TableDashcard actions panel when editing dashboard (#58341) 2025-05-22 15:15:48 +03:00
Denis Berezin
9f8adeaadb WRK-419: filter available editable tables list based on db setting 2025-05-21 19:25:29 +03:00
Denis Berezin
c6825b0461 WRK-419: filter available editable tables list based on db setting 2025-05-21 19:25:29 +03:00
Konstantin Darutkin
7d2f608ffc fix: respect action settings for bulk deletion (#58287) 2025-05-21 16:14:52 +01:00
Konstantin Darutkin
660f3f0561 fix: respect action settings for bulk deletion (#58287) 2025-05-21 16:14:52 +01:00
Konstantin Darutkin
32771e9841 feat: warn user about potential data loss when there are changes in the queue to be saved (#58286) 2025-05-21 16:09:57 +01:00
Konstantin Darutkin
7a17ed55f5 feat: warn user about potential data loss when there are changes in the queue to be saved (#58286) 2025-05-21 16:09:57 +01:00
Denis Berezin
d9c75d3c8c Hidden or non-editable fields in row actions (#58198)
* Refactor row actions mapping form, add visibility settings

* Add more options for row actions fields

* fix types

* fix types

* self review
2025-05-21 00:50:49 +03:00
Denis Berezin
fb61b66323 Hidden or non-editable fields in row actions (#58198)
* Refactor row actions mapping form, add visibility settings

* Add more options for row actions fields

* fix types

* fix types

* self review
2025-05-21 00:50:49 +03:00
Konstantin Darutkin
aa38d7d06d fix: editable menu should not display visualize another way option (#58216) 2025-05-20 16:02:03 +01:00
Konstantin Darutkin
ec4124b006 fix: editable menu should not display visualize another way option (#58216) 2025-05-20 16:02:03 +01:00
Chris Truter
de589ea335 Finish action-ifying things and improving output shapes (#58166) 2025-05-20 15:30:43 +02:00
Chris Truter
be24965e3f Finish action-ifying things and improving output shapes (#58166) 2025-05-20 15:30:43 +02:00
Stas Gavrylov
11aad82749 WRK-415, WRK-416: Minor fixes for custom templates (#58213)
* WRK-416: Add error message output to template preview

* WRK-415: Make HandlersInfo display all configured Slack channels

* Fix lint error in Editable

* WRK-416: Add e2e test for preview error display
2025-05-20 13:39:23 +03:00
Stas Gavrylov
419c02eb25 WRK-415, WRK-416: Minor fixes for custom templates (#58213)
* WRK-416: Add error message output to template preview

* WRK-415: Make HandlersInfo display all configured Slack channels

* Fix lint error in Editable

* WRK-416: Add e2e test for preview error display
2025-05-20 13:39:23 +03:00
Stas Gavrylov
b2e931b612 WRK-414: Custom templates e2e tests (#58017)
* Custom Templates: e2e tests for default and custom templates

* Custom Templates: e2e tests for alert condition, update alert, delete alert

* Custom Templates: e2e tests for template preview

* Custom Templates: e2e tests refactoring

* Custom templates: Refactor CodeMirror testing

* Custom Templates: Fix e2e async issues

* Custom Templates: Fix BE test
2025-05-20 12:13:21 +03:00
Stas Gavrylov
68ada98f9a WRK-414: Custom templates e2e tests (#58017)
* Custom Templates: e2e tests for default and custom templates

* Custom Templates: e2e tests for alert condition, update alert, delete alert

* Custom Templates: e2e tests for template preview

* Custom Templates: e2e tests refactoring

* Custom templates: Refactor CodeMirror testing

* Custom Templates: Fix e2e async issues

* Custom Templates: Fix BE test
2025-05-20 12:13:21 +03:00
Konstantin Darutkin
305f102ef9 fix: do not switch timezones when editing a date (#58090)
* fix: do not switch timezones when editing a date

* fix: reivew fixes
2025-05-19 11:35:14 +01:00
Konstantin Darutkin
fcd9cc8878 fix: do not switch timezones when editing a date (#58090)
* fix: do not switch timezones when editing a date

* fix: reivew fixes
2025-05-19 11:35:14 +01:00
Konstantin Darutkin
f3211e4cf2 fix: remove bulk edit from the UI (#58155) 2025-05-19 11:29:00 +01:00
Konstantin Darutkin
254383fb97 fix: remove bulk edit from the UI (#58155) 2025-05-19 11:29:00 +01:00
Denis Berezin
a496f9de7d Fix issue with optimistic update after successful update 2025-05-17 02:03:06 +03:00
Denis Berezin
c9422c9545 Fix issue with optimistic update after successful update 2025-05-17 02:03:06 +03:00
Denis Berezin
3da6ed1c81 Row action column mapping (#57939)
* add basic action settings state management

* Add basic remapping

* polish settings form

* review fixes
2025-05-17 01:41:47 +03:00
Denis Berezin
b25e89ca5b Row action column mapping (#57939)
* add basic action settings state management

* Add basic remapping

* polish settings form

* review fixes
2025-05-17 01:41:47 +03:00
Ngoc Khuat
7651509551 Merge branch 'master' into internal-tools 2025-05-16 20:18:35 +09:00
Ngoc Khuat
a176d39320 Merge branch 'master' into internal-tools 2025-05-16 20:18:35 +09:00
Ngoc Khuat
ad81d702ac default slack template should escape vals (#58069) 2025-05-16 16:28:32 +09:00
Ngoc Khuat
f58136e811 default slack template should escape vals (#58069) 2025-05-16 16:28:32 +09:00
Ngoc Khuat
81c056481a humanized erorr message for preview (#58063) 2025-05-16 13:15:48 +09:00
Ngoc Khuat
ca35ee3de6 humanized erorr message for preview (#58063) 2025-05-16 13:15:48 +09:00
Chris Truter
a1d44313a2 Make undo and redo into actions (#58045) 2025-05-15 20:21:59 +02:00
Chris Truter
c7959fab0a Make undo and redo into actions (#58045) 2025-05-15 20:21:59 +02:00
Chris Truter
941e5e47f1 Fix case instability issue with h2 driver (#58041) 2025-05-15 16:51:37 +02:00
Chris Truter
cb7c93beec Fix case instability issue with h2 driver (#58041) 2025-05-15 16:51:37 +02:00
Chris Truter
8777d4b850 Clean up table.row and grid-edit actions (#58033) 2025-05-15 16:24:33 +02:00
Chris Truter
15ae46d9f4 Clean up table.row and grid-edit actions (#58033) 2025-05-15 16:24:33 +02:00
Chris Truter
1c2195c4ed Fix editing tables from dashboards (#58034) 2025-05-15 14:10:36 +02:00
Chris Truter
8d122e8436 Fix editing tables from dashboards (#58034) 2025-05-15 14:10:36 +02:00
Konstantin Darutkin
266be7245e fix: refetch table on filter change (#58020) 2025-05-15 12:46:07 +01:00
Konstantin Darutkin
9dcfebd40c fix: refetch table on filter change (#58020) 2025-05-15 12:46:07 +01:00
Konstantin Darutkin
5facc8557c fix: optional/required styles in a record create/edit form (#58026) 2025-05-15 11:14:28 +01:00
Konstantin Darutkin
6c4bfe30ee fix: optional/required styles in a record create/edit form (#58026) 2025-05-15 11:14:28 +01:00
Konstantin Darutkin
53738cdba3 fix: do not display action cannot be undone message for row deletion (#58016) 2025-05-15 10:29:53 +01:00
Konstantin Darutkin
86b00192dc fix: do not display action cannot be undone message for row deletion (#58016) 2025-05-15 10:29:53 +01:00
Chris Truter
3a91374f4e New data-grid actions to replace CRUD endpoints (#57969) 2025-05-15 11:12:30 +02:00
Chris Truter
0d51788f5d New data-grid actions to replace CRUD endpoints (#57969) 2025-05-15 11:12:30 +02:00
Dan Stone
77da831305 WRK-336 temporary primitive action support (#57701)
* Add tmp-action endpoint for picker listing

* Add table primitive action support to dashboards

On saving a negative action_id, we interpret it as a primitive table
action.

This causes the actual stored dashcard to contain a new table_action
entry in its visualization settings.

From an API perspective the action entry in the dashcard is hydrated in
the normal way, and things should 'just work'

* Add quick-and-dirty test of some of the new behaviour

* Use comp

* Move destructure to where its needed

* crud-actions to table-actions

* Get rid of atom we dont need

* Get rid of now redundant action arg-map keys

* Common sub-expression elimination

* Add comment about snowplow

* Use u/qualified-name

* Use u/for-map

* Use select-fn->fn

* Be less generic

* Fix action args

We did need the database after all, and stuff changed after rebase

* More explicit prefill asserts

* Remove redundant todo

* Remove unnecessary input union in dashcard->action

* Add docstring to tmp-action
2025-05-14 18:48:34 +01:00
Dan Stone
d2607b2ddd WRK-336 temporary primitive action support (#57701)
* Add tmp-action endpoint for picker listing

* Add table primitive action support to dashboards

On saving a negative action_id, we interpret it as a primitive table
action.

This causes the actual stored dashcard to contain a new table_action
entry in its visualization settings.

From an API perspective the action entry in the dashcard is hydrated in
the normal way, and things should 'just work'

* Add quick-and-dirty test of some of the new behaviour

* Use comp

* Move destructure to where its needed

* crud-actions to table-actions

* Get rid of atom we dont need

* Get rid of now redundant action arg-map keys

* Common sub-expression elimination

* Add comment about snowplow

* Use u/qualified-name

* Use u/for-map

* Use select-fn->fn

* Be less generic

* Fix action args

We did need the database after all, and stuff changed after rebase

* More explicit prefill asserts

* Remove redundant todo

* Remove unnecessary input union in dashcard->action

* Add docstring to tmp-action
2025-05-14 18:48:34 +01:00
Dan Stone
34492caa8a internal tools CI fixes (#57956)
* kondo

* Fix scope/hydrate-test

* Fix missing scope in event handler

* Support unknown scopes (for legacy action application)

Feel free to change this, just wanna get working again

* Use legacy-action for action test scope

* Fix incorrect reference to test-scope
2025-05-14 18:26:20 +01:00
Dan Stone
e14994af04 internal tools CI fixes (#57956)
* kondo

* Fix scope/hydrate-test

* Fix missing scope in event handler

* Support unknown scopes (for legacy action application)

Feel free to change this, just wanna get working again

* Use legacy-action for action test scope

* Fix incorrect reference to test-scope
2025-05-14 18:26:20 +01:00
Chris Truter
05cd7b8ed9 By not passing scope, we were triggering a runtime error when the scope was hydrated (#57968) 2025-05-14 18:30:52 +02:00
Chris Truter
c7b8187d23 By not passing scope, we were triggering a runtime error when the scope was hydrated (#57968) 2025-05-14 18:30:52 +02:00
Konstantin Darutkin
7ab6f20abb fix: editable settings without column order settings (#57951) 2025-05-14 12:01:45 +01:00
Konstantin Darutkin
3442355cf3 fix: editable settings without column order settings (#57951) 2025-05-14 12:01:45 +01:00
Ngoc Khuat
6e040a3fc5 Pass actions scope to notification (#57948) 2025-05-14 18:00:32 +07:00
Ngoc Khuat
644a2f194f Pass actions scope to notification (#57948) 2025-05-14 18:00:32 +07:00
Konstantin Darutkin
d711427901 feat(enterprise): add row bulk deletion (#57949) 2025-05-14 11:24:15 +01:00
Konstantin Darutkin
da6dac0e92 feat(enterprise): add row bulk deletion (#57949) 2025-05-14 11:24:15 +01:00
Ngoc Khuat
15ab6f7131 Merge branch 'master' into internal-tools 2025-05-14 15:38:51 +07:00
Ngoc Khuat
850e448dc8 Merge branch 'master' into internal-tools 2025-05-14 15:38:51 +07:00
Chris Truter
e2bd53b114 Add types, normalization, and hydration for action scopes (#57883) 2025-05-13 18:29:43 +02:00
Chris Truter
1ef5d1f278 Add types, normalization, and hydration for action scopes (#57883) 2025-05-13 18:29:43 +02:00
Stas Gavrylov
e431d62b3d WRK-380, WRK-387: Custom templates improvements (#57893)
* Fix Slack template onChange method. Remove Slack markdown highlighting. Remove email template from alerts

* WRK-387: Add possibility to send alerts to multiple channels

* Some renaming in custom templates components

* Fix bug when template editor is cleared and immediately repopulated

* Fix lint
2025-05-13 18:12:14 +03:00
Stas Gavrylov
0cadbdf007 WRK-380, WRK-387: Custom templates improvements (#57893)
* Fix Slack template onChange method. Remove Slack markdown highlighting. Remove email template from alerts

* WRK-387: Add possibility to send alerts to multiple channels

* Some renaming in custom templates components

* Fix bug when template editor is cleared and immediately repopulated

* Fix lint
2025-05-13 18:12:14 +03:00
Konstantin Darutkin
fe64dfcf63 feat(enterprise): add row selection view for bulk editing (#57840) 2025-05-13 13:45:12 +01:00
Konstantin Darutkin
8c42162c79 feat(enterprise): add row selection view for bulk editing (#57840) 2025-05-13 13:45:12 +01:00
Ngoc Khuat
6aabb54d0a fix tests due to template changes 2025-05-13 15:49:08 +07:00
Ngoc Khuat
9ad7d6702e fix tests due to template changes 2025-05-13 15:49:08 +07:00
Ngoc Khuat
930f9b6fa0 Merge branch 'master' into internal-tools 2025-05-13 09:16:23 +07:00
Ngoc Khuat
4dfc9674aa Merge branch 'master' into internal-tools 2025-05-13 09:16:23 +07:00
Stas Gavrylov
e83f0988c0 Fix broken tests (#57847) 2025-05-12 19:11:40 +03:00
Stas Gavrylov
27ab7793c1 Fix broken tests (#57847) 2025-05-12 19:11:40 +03:00
Chris Truter
7b010b498a Clean up table.row actions (#57839) 2025-05-12 15:57:43 +02:00
Chris Truter
de6ebdbe39 Clean up table.row actions (#57839) 2025-05-12 15:57:43 +02:00
Chris Truter
dcf05ced7f Overload PUT table rows to support efficient bulk updates (#57843) 2025-05-12 15:44:30 +02:00
Chris Truter
20e4d5a1e4 Overload PUT table rows to support efficient bulk updates (#57843) 2025-05-12 15:44:30 +02:00
Ngoc Khuat
4674825133 Example payload with multiple channels (#57837)
* example payload now takes multiple channels

* add table-url helper

* fix make sure only updates keys are in changes and channel_types conversion

* Fix UI to display payload context properly
2025-05-12 15:56:42 +03:00
Ngoc Khuat
fefc9d666f Example payload with multiple channels (#57837)
* example payload now takes multiple channels

* add table-url helper

* fix make sure only updates keys are in changes and channel_types conversion

* Fix UI to display payload context properly
2025-05-12 15:56:42 +03:00
Stas Gavrylov
17abfc7280 WRK-366: Update default templates (#57836)
* wip

* WRK-375: Update default structure for table alerts conditions and related typings

* WRK-366: Update default templates for email and slack channels
2025-05-12 15:08:01 +03:00
Stas Gavrylov
8cff30217e WRK-366: Update default templates (#57836)
* wip

* WRK-375: Update default structure for table alerts conditions and related typings

* WRK-366: Update default templates for email and slack channels
2025-05-12 15:08:01 +03:00
Ngoc Khuat
f64cf10930 Merge branch 'master' into internal-tools 2025-05-12 17:58:09 +07:00
Ngoc Khuat
b21424cdf6 Merge branch 'master' into internal-tools 2025-05-12 17:58:09 +07:00
Ngoc Khuat
47eb63c7c9 alerts template use slack syntax (#57828)
* alerts template use slack syntax

* do that for table notifications too

* fix default template
2025-05-12 14:54:42 +07:00
Ngoc Khuat
250f6ab7cf alerts template use slack syntax (#57828)
* alerts template use slack syntax

* do that for table notifications too

* fix default template
2025-05-12 14:54:42 +07:00
Denis Berezin
7c6bfbe30b Editable editable table row actions display (#57084)
* WIP

* WIP

* Add row actions for standalone editable table

* WIP: model row actions display

* Add row actions display and execution for models

* Improvements

* Add row actions connection to dashcard

* Review fixes, add proper modal flow, add edit

* Try to fix lots of failing tests

* Fix tests

* Fix tests
2025-05-09 21:18:21 +03:00
Denis Berezin
b25bbc7b8e Editable editable table row actions display (#57084)
* WIP

* WIP

* Add row actions for standalone editable table

* WIP: model row actions display

* Add row actions display and execution for models

* Improvements

* Add row actions connection to dashcard

* Review fixes, add proper modal flow, add edit

* Try to fix lots of failing tests

* Fix tests

* Fix tests
2025-05-09 21:18:21 +03:00
Alexander Solovyov
f0aad84238 laziness you little bastard 2025-05-09 18:03:55 +03:00
Alexander Solovyov
577a9d4c7b laziness you little bastard 2025-05-09 18:03:55 +03:00
Ngoc Khuat
d5edfa8d07 Taxanomy for alert for slack (#57760) 2025-05-09 17:14:12 +07:00
Ngoc Khuat
eaf598f851 Taxanomy for alert for slack (#57760) 2025-05-09 17:14:12 +07:00
Konstantin Darutkin
71988bd3e3 fix: add scope param for optimistic updates (#57766) 2025-05-09 10:01:45 +01:00
Konstantin Darutkin
c7ef444736 fix: add scope param for optimistic updates (#57766) 2025-05-09 10:01:45 +01:00
Alexander Solovyov
9ad2b82c88 Merge remote-tracking branch 'origin/master' into internal-tools 2025-05-09 11:32:23 +03:00
Alexander Solovyov
629a30327e Merge remote-tracking branch 'origin/master' into internal-tools 2025-05-09 11:32:23 +03:00
Ngoc Khuat
463e8d9d77 Merge branch 'master' into internal-tools 2025-05-09 11:13:46 +07:00
Ngoc Khuat
d623c486e2 Merge branch 'master' into internal-tools 2025-05-09 11:13:46 +07:00
Ngoc Khuat
dee2f6bfc7 fix context path for table notification 2025-05-09 00:18:48 +07:00
Ngoc Khuat
f1c8f4f882 fix context path for table notification 2025-05-09 00:18:48 +07:00
Ngoc Khuat
2bfd1a477a Customizable slack template for alerts (#57694)
* payload example and templateable for alerts

* add a test for getting default template

* wip

* fix slack default template

* limit number of rows return

* WRK-371: Customizable templates for alerts

* use markdown instead

* no verbatim

* always use the default template

* WRK-371: Disable 'Send Now' button for invalid form

* WRK-371: Make Alerts modal footer always visible

* no more verbatim in tests

* test fixes

---------

Co-authored-by: Stas Gavrylov <stas@metabase.com>
2025-05-08 18:14:09 +03:00
Ngoc Khuat
982ec1052f Customizable slack template for alerts (#57694)
* payload example and templateable for alerts

* add a test for getting default template

* wip

* fix slack default template

* limit number of rows return

* WRK-371: Customizable templates for alerts

* use markdown instead

* no verbatim

* always use the default template

* WRK-371: Disable 'Send Now' button for invalid form

* WRK-371: Make Alerts modal footer always visible

* no more verbatim in tests

* test fixes

---------

Co-authored-by: Stas Gavrylov <stas@metabase.com>
2025-05-08 18:14:09 +03:00
Ngoc Khuat
db35b1f042 a transformation layer for event info before evaluating the condition (#57728) 2025-05-08 19:35:39 +07:00
Ngoc Khuat
ff6fc7de4c a transformation layer for event info before evaluating the condition (#57728) 2025-05-08 19:35:39 +07:00
Ngoc Khuat
8b1fa2daf7 fix kondo 2025-05-08 15:32:23 +07:00
Ngoc Khuat
c3423cb458 fix kondo 2025-05-08 15:32:23 +07:00
Ngoc Khuat
89c23637b2 fix imports, not sure why it's not auto resolved 2025-05-08 15:28:06 +07:00
Ngoc Khuat
bbf805ffa0 fix imports, not sure why it's not auto resolved 2025-05-08 15:28:06 +07:00
Ngoc Khuat
4b36020f6d Merge branch 'master' into internal-tools 2025-05-08 15:13:54 +07:00
Ngoc Khuat
de7c7fd6c5 Merge branch 'master' into internal-tools 2025-05-08 15:13:54 +07:00
Ngoc Khuat
3e01e467ce Merge branch 'master' into internal-tools 2025-05-08 11:37:22 +07:00
Ngoc Khuat
67b75dc583 Merge branch 'master' into internal-tools 2025-05-08 11:37:22 +07:00
Stas Gavrylov
3d89c4633f WRK-264: Initial version of conditional alerts UI (#57688)
* WRK-264: Initial verfsion of conditional alerts UI

* WRK-264: Move Table alerts components to enterprise

* Fix lint
2025-05-07 15:36:55 +03:00
Stas Gavrylov
784a71e12b WRK-264: Initial version of conditional alerts UI (#57688)
* WRK-264: Initial verfsion of conditional alerts UI

* WRK-264: Move Table alerts components to enterprise

* Fix lint
2025-05-07 15:36:55 +03:00
Ngoc Khuat
d5733763ad Merge branch 'master' into internal-tools 2025-05-07 18:33:35 +07:00
Ngoc Khuat
49e0e36c6a Merge branch 'master' into internal-tools 2025-05-07 18:33:35 +07:00
Ngoc Khuat
c554f3a572 Merge branch 'master' into internal-tools 2025-05-07 18:29:03 +07:00
Ngoc Khuat
9b4f21d974 Merge branch 'master' into internal-tools 2025-05-07 18:29:03 +07:00
Ngoc Khuat
dd2b87ccbe Merge branch 'master' into internal-tools 2025-05-07 18:12:50 +07:00
Ngoc Khuat
2add884349 Merge branch 'master' into internal-tools 2025-05-07 18:12:50 +07:00
Ngoc Khuat
4b6e6e7faa simplify slack impl (#57634) 2025-05-07 14:30:56 +07:00
Ngoc Khuat
4f99436879 simplify slack impl (#57634) 2025-05-07 14:30:56 +07:00
Denis Berezin
4e127f0487 Optimistic updates for table grid edits (#57361)
* almost working optimistic update

* Add errored update display

* Add proper error toast

* Self review

* Add optimistic updates to dashboard cards

* Review fixes 1

* Review fixes, added support for multiple failed updates

* Add uniq cell ids to handle state

* Self review
2025-05-06 22:29:01 +03:00
Denis Berezin
11a22e0703 Optimistic updates for table grid edits (#57361)
* almost working optimistic update

* Add errored update display

* Add proper error toast

* Self review

* Add optimistic updates to dashboard cards

* Review fixes 1

* Review fixes, added support for multiple failed updates

* Add uniq cell ids to handle state

* Self review
2025-05-06 22:29:01 +03:00
Konstantin Darutkin
2ffee29111 fix: remapped_to_column is not defined when rows are empty (#57652) 2025-05-06 18:18:56 +01:00
Konstantin Darutkin
3f93d87c28 fix: remapped_to_column is not defined when rows are empty (#57652) 2025-05-06 18:18:56 +01:00
Konstantin Darutkin
75d3bda5a0 feat: dashboard editable display new record when there is no data (#57617) 2025-05-05 20:24:51 +01:00
Konstantin Darutkin
a8bc711f08 feat: dashboard editable display new record when there is no data (#57617) 2025-05-05 20:24:51 +01:00
Chris Truter
27d987ef19 Omit model metadata when compiling Editable cards (#57597) 2025-05-05 13:44:47 +02:00
Chris Truter
afef2fc531 Omit model metadata when compiling Editable cards (#57597) 2025-05-05 13:44:47 +02:00
Chris Truter
84904235f2 Relax reverse-coercion test (#57535) 2025-05-02 16:54:12 +02:00
Chris Truter
354b2ec14d Relax reverse-coercion test (#57535) 2025-05-02 16:54:12 +02:00
Chris Truter
611b8a4036 Repurpose Bulk actions as Table actions (#57519) 2025-05-02 16:43:12 +02:00
Chris Truter
b0df8a12ce Repurpose Bulk actions as Table actions (#57519) 2025-05-02 16:43:12 +02:00
Konstantin Darutkin
ab575facbc fix: display values of FK fields when it is outside of search results (#57517) 2025-05-02 10:20:12 +01:00
Konstantin Darutkin
4d9d1378cb fix: display values of FK fields when it is outside of search results (#57517) 2025-05-02 10:20:12 +01:00
Konstantin Darutkin
652657666c feat(enterprise): add scope params for editing endpoints (#57516) 2025-05-02 10:00:21 +01:00
Konstantin Darutkin
a44014d694 feat(enterprise): add scope params for editing endpoints (#57516) 2025-05-02 10:00:21 +01:00
Ngoc Khuat
8e668e526b Sample event info with table value (#57474)
* sample with table info

* remove debugging code

* wiring up

* missing imports
2025-05-02 13:52:02 +07:00
Ngoc Khuat
7ead7d96e3 Sample event info with table value (#57474)
* sample with table info

* remove debugging code

* wiring up

* missing imports
2025-05-02 13:52:02 +07:00
Ngoc Khuat
6d518e9db9 Reverse the order of coercion in notification parsing (#57472) 2025-05-02 13:29:40 +07:00
Ngoc Khuat
79a96f7064 Reverse the order of coercion in notification parsing (#57472) 2025-05-02 13:29:40 +07:00
Konstantin Darutkin
5e04b07f2f feat(enterprise): add distinct editing modal URL for dashboard (#57425)
* feat(enterprise): add distinct editing modal URL for dashboard

* fix: review fixes
2025-05-01 14:14:31 +01:00
Konstantin Darutkin
e15f9226f1 feat(enterprise): add distinct editing modal URL for dashboard (#57425)
* feat(enterprise): add distinct editing modal URL for dashboard

* fix: review fixes
2025-05-01 14:14:31 +01:00
Konstantin Darutkin
531bcff427 fix: display a header with actions (create) even when there is no data (#57480) 2025-05-01 13:45:52 +01:00
Konstantin Darutkin
bc50d8441e fix: display a header with actions (create) even when there is no data (#57480) 2025-05-01 13:45:52 +01:00
Chris Truter
833718d160 Make undo tests e2e (#57477) 2025-05-01 10:59:17 +02:00
Chris Truter
2839918ff8 Make undo tests e2e (#57477) 2025-05-01 10:59:17 +02:00
Denis Berezin
8661631b79 Hotfix to show edit modal in case of string IDs 2025-04-30 22:57:23 +03:00
Denis Berezin
fd5a3175b1 Hotfix to show edit modal in case of string IDs 2025-04-30 22:57:23 +03:00
Stas Gavrylov
c56b10ce28 Custom templates fixes (#57433) 2025-04-30 17:23:16 +03:00
Stas Gavrylov
105e5c0691 Custom templates fixes (#57433) 2025-04-30 17:23:16 +03:00
Chris Truter
87b07be973 Fix pk selection (#57427) 2025-04-30 14:56:43 +02:00
Chris Truter
84f644dc3d Fix pk selection (#57427) 2025-04-30 14:56:43 +02:00
Ngoc Khuat
265ca5ab39 Remove link to question for table editable (#57423) 2025-04-30 19:23:19 +07:00
Ngoc Khuat
0b848b3edd Remove link to question for table editable (#57423) 2025-04-30 19:23:19 +07:00
Chris Truter
7b619a3efa Give editables better names (#57426) 2025-04-30 14:07:51 +02:00
Chris Truter
4a8986e4ba Give editables better names (#57426) 2025-04-30 14:07:51 +02:00
Chris Truter
9040d58e26 Support custom context for 'ad hoc' queries (#57421) 2025-04-30 11:22:31 +02:00
Chris Truter
879d56b0ec Support custom context for 'ad hoc' queries (#57421) 2025-04-30 11:22:31 +02:00
Stas Gavrylov
3ce75ecdf2 WRK-344: Automate custom template preview updates (#57394) 2025-04-29 19:19:21 +03:00
Stas Gavrylov
fed4186a72 WRK-344: Automate custom template preview updates (#57394) 2025-04-29 19:19:21 +03:00
Denis Berezin
ddaef972cd Remove duplicated rows in record editing on dashboard 2025-04-29 19:06:20 +03:00
Denis Berezin
0932acd6de Remove duplicated rows in record editing on dashboard 2025-04-29 19:06:20 +03:00
Stas Gavrylov
b5280e8e3e WRK-333: Implement POC for default template selection (#57387) 2025-04-29 18:31:42 +03:00
Stas Gavrylov
ad620a1b09 WRK-333: Implement POC for default template selection (#57387) 2025-04-29 18:31:42 +03:00
Konstantin Darutkin
93b04ae99c feat(enterprise): add distinct modal URLs for table editable view (#57255) 2025-04-29 16:01:21 +01:00
Konstantin Darutkin
4388967791 feat(enterprise): add distinct modal URLs for table editable view (#57255) 2025-04-29 16:01:21 +01:00
Chris Truter
5d9a5d4d38 Speed up edits to databases with high latency or huge tables (#57385) 2025-04-29 15:20:41 +02:00
Chris Truter
e9593f467d Speed up edits to databases with high latency or huge tables (#57385) 2025-04-29 15:20:41 +02:00
Ngoc Khuat
d28d12c2cb template for webhook (#57379) 2025-04-29 19:21:33 +07:00
Ngoc Khuat
9713b842c9 template for webhook (#57379) 2025-04-29 19:21:33 +07:00
Chris Truter
2c2b7a495b Segregate undo snapshots based on scope, not table (#57378) 2025-04-29 13:49:07 +02:00
Chris Truter
3b056514d6 Segregate undo snapshots based on scope, not table (#57378) 2025-04-29 13:49:07 +02:00
Stas Gavrylov
f15299d54a WRK-348: Hide 'schema' field from event payload (#57376) 2025-04-29 12:31:08 +03:00
Stas Gavrylov
e579fe45da WRK-348: Hide 'schema' field from event payload (#57376) 2025-04-29 12:31:08 +03:00
Ngoc Khuat
a49cab944b fix kondo errors 2025-04-29 16:25:38 +07:00
Ngoc Khuat
5de4b5694b fix kondo errors 2025-04-29 16:25:38 +07:00
Chris Truter
12f7aa1781 Ensure undo batch numbers increase atomically (#57375) 2025-04-29 10:32:54 +02:00
Chris Truter
b3e9875fb0 Ensure undo batch numbers increase atomically (#57375) 2025-04-29 10:32:54 +02:00
Ngoc Khuat
d1e0492261 More handlebars helper (#57374)
* add more handlebars helpers

* rename some helpers

* more tests
2025-04-29 14:27:46 +07:00
Ngoc Khuat
8b3c1a554b More handlebars helper (#57374)
* add more handlebars helpers

* rename some helpers

* more tests
2025-04-29 14:27:46 +07:00
Ngoc Khuat
79400e908a Template context: ordered fields and coercion (#57340) 2025-04-29 12:58:41 +07:00
Ngoc Khuat
3a2e02af2c Template context: ordered fields and coercion (#57340) 2025-04-29 12:58:41 +07:00
Stas Gavrylov
c172e149b9 WRK-306, WRK-307, WRK-324: Template preview. UI improvements for custom templates. (#57331)
* WRK-307: Make default template code grayed out. Add reset to default button

* Replace template example fetching mutation with query to cache results

* WRK-249: Template Preview UI. Refactoring
2025-04-28 17:57:32 +03:00
Stas Gavrylov
a6db8a7811 WRK-306, WRK-307, WRK-324: Template preview. UI improvements for custom templates. (#57331)
* WRK-307: Make default template code grayed out. Add reset to default button

* Replace template example fetching mutation with query to cache results

* WRK-249: Template Preview UI. Refactoring
2025-04-28 17:57:32 +03:00
Chris Truter
9ded97a09c Fix bad choice of truthy value
Not valid for all databases.
2025-04-28 15:39:57 +02:00
Chris Truter
7c2e666316 Fix bad choice of truthy value
Not valid for all databases.
2025-04-28 15:39:57 +02:00
Chris Truter
ff4112a40e Prune old undo history (#57335) 2025-04-28 15:09:15 +02:00
Chris Truter
a98fce71de Prune old undo history (#57335) 2025-04-28 15:09:15 +02:00
Ngoc Khuat
25148746d1 notification with single row event (#57237)
* better template for each event type (#57246)

* eval notification condition using raw event info (#57238)

* simplified payload and it matches the desired output onw (#57330)
2025-04-28 19:06:52 +07:00
Ngoc Khuat
1499357b2b notification with single row event (#57237)
* better template for each event type (#57246)

* eval notification condition using raw event info (#57238)

* simplified payload and it matches the desired output onw (#57330)
2025-04-28 19:06:52 +07:00
Chris Truter
2dca593474 Delete orphaned undo history 2025-04-28 14:05:39 +02:00
Chris Truter
5b96c1e14c Delete orphaned undo history 2025-04-28 14:05:39 +02:00
Chris Truter
682fa130c7 Use two queries, because mysql (#57329) 2025-04-28 12:21:20 +02:00
Chris Truter
9b615220df Use two queries, because mysql (#57329) 2025-04-28 12:21:20 +02:00
Ngoc Khuat
c28b50e49e alias for running workflow test 2025-04-28 16:17:21 +07:00
Ngoc Khuat
4c20c9d092 alias for running workflow test 2025-04-28 16:17:21 +07:00
Ngoc Khuat
4f852f2d17 no selecting user in as-admin (#57324) 2025-04-28 16:04:12 +07:00
Ngoc Khuat
0e764cd4ef no selecting user in as-admin (#57324) 2025-04-28 16:04:12 +07:00
Chris Truter
5e0fe4bec5 Revert test helper tweak 2025-04-28 09:55:41 +02:00
Chris Truter
a89bfd8f58 Revert test helper tweak 2025-04-28 09:55:41 +02:00
Chris Truter
53dd4236bd Merge branch 'master' into merge-master-internal-tools 2025-04-28 08:59:27 +02:00
Chris Truter
c4a27f325e Merge branch 'master' into merge-master-internal-tools 2025-04-28 08:59:27 +02:00
Chris Truter
2e826dd116 Case of inconsistent case (#57272) 2025-04-25 17:11:30 +02:00
Chris Truter
5c59166ec4 Case of inconsistent case (#57272) 2025-04-25 17:11:30 +02:00
Chris Truter
a4cb42aed3 Undo logic out of API (#57191) 2025-04-25 16:50:02 +02:00
Chris Truter
70fcb86aff Undo logic out of API (#57191) 2025-04-25 16:50:02 +02:00
Alexander Solovyov
7a7d8fedb7 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-25 13:39:45 +03:00
Alexander Solovyov
68a54d9ced Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-25 13:39:45 +03:00
Stas Gavrylov
ed937d1266 Fix ttag eslint issues (#57249) 2025-04-25 13:15:51 +03:00
Stas Gavrylov
e7c1195dc4 Fix ttag eslint issues (#57249) 2025-04-25 13:15:51 +03:00
Dan Stone
d57f4df6de Row action execution API (#57187)
* Fix formatting

* Add a new endpoint for executing actions as row actions

Allows the caller to provide a subset of the parameters expected by the
action.

Assumes parameters provided by the row share a name with the action
parameter, though user supplied parameters will be preferred.
2025-04-25 10:55:42 +01:00
Dan Stone
07fb66d6e0 Row action execution API (#57187)
* Fix formatting

* Add a new endpoint for executing actions as row actions

Allows the caller to provide a subset of the parameters expected by the
action.

Assumes parameters provided by the row share a name with the action
parameter, though user supplied parameters will be preferred.
2025-04-25 10:55:42 +01:00
Alexander Solovyov
5aa96c0e88 Merge branch 'master' into internal-tools 2025-04-25 12:35:40 +03:00
Alexander Solovyov
072a76dd0c Merge branch 'master' into internal-tools 2025-04-25 12:35:40 +03:00
Ngoc Khuat
e0a4702041 kondo and cljfmt 2025-04-25 14:37:15 +07:00
Ngoc Khuat
c773383b99 kondo and cljfmt 2025-04-25 14:37:15 +07:00
Ngoc Khuat
1822d3485a test fixes 2025-04-25 14:13:07 +07:00
Ngoc Khuat
04e1a28f4b test fixes 2025-04-25 14:13:07 +07:00
Ngoc Khuat
39dd7c9c5b Merge branch 'master' into internal-tools 2025-04-25 14:10:49 +07:00
Ngoc Khuat
e6a79d85ec Merge branch 'master' into internal-tools 2025-04-25 14:10:49 +07:00
Konstantin Darutkin
870435eba0 refactor: column config for editable tables (#57179)
* refactor: column config for editable tables

* fix: review issues
2025-04-24 16:56:00 +01:00
Konstantin Darutkin
cc89947162 refactor: column config for editable tables (#57179)
* refactor: column config for editable tables

* fix: review issues
2025-04-24 16:56:00 +01:00
Chris Truter
b4b29dcc19 Publish row modified events for all actions (#57036) 2025-04-24 16:40:02 +02:00
Chris Truter
1e316447ec Publish row modified events for all actions (#57036) 2025-04-24 16:40:02 +02:00
Stas Gavrylov
c69b53babd WRK-258: Expose default template for notifications (#57031)
* WRK-258: Expose default templates to the UI

* WRK-258: Address UI feedback

* WRK-295: Add confirmation before closing alerts modal with changes
2025-04-24 11:48:12 +03:00
Stas Gavrylov
4a5166a14b WRK-258: Expose default template for notifications (#57031)
* WRK-258: Expose default templates to the UI

* WRK-258: Address UI feedback

* WRK-295: Add confirmation before closing alerts modal with changes
2025-04-24 11:48:12 +03:00
Ngoc Khuat
4453313745 Template preview API (#56971) 2025-04-24 15:07:47 +07:00
Ngoc Khuat
0cab50cc6d Template preview API (#56971) 2025-04-24 15:07:47 +07:00
Konstantin Darutkin
8d0a374008 feat(enterprise): add editable table overlay for undo/redo (#57111) 2025-04-23 12:52:10 +01:00
Konstantin Darutkin
16d006630f feat(enterprise): add editable table overlay for undo/redo (#57111) 2025-04-23 12:52:10 +01:00
Konstantin Darutkin
09b8acf6d2 fix: move dashcard undo/redo/add buttons to card header (#57034)
* fix: move dashcard undo/redo/add buttons to card header

* fix: review fixes
2025-04-23 12:11:17 +01:00
Konstantin Darutkin
be23444e7e fix: move dashcard undo/redo/add buttons to card header (#57034)
* fix: move dashcard undo/redo/add buttons to card header

* fix: review fixes
2025-04-23 12:11:17 +01:00
Denis Berezin
d3b12475b7 fix e2e tests for internal-tools branch (#57060) 2025-04-22 22:11:51 +03:00
Denis Berezin
3c2ceb8193 fix e2e tests for internal-tools branch (#57060) 2025-04-22 22:11:51 +03:00
Denis Berezin
6dbe326997 Merge branch 'master' into internal-tools
# Conflicts:
#	frontend/src/metabase/notifications/modals/components/NotificationChannelsPicker.tsx
#	frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
2025-04-22 18:01:06 +03:00
Denis Berezin
f5d9928791 Merge branch 'master' into internal-tools
# Conflicts:
#	frontend/src/metabase/notifications/modals/components/NotificationChannelsPicker.tsx
#	frontend/src/metabase/ui/components/icons/Icon/icons/index.ts
2025-04-22 18:01:06 +03:00
Dan Stone
7d70c66324 Add GET row-action endpoint to be able to present an appropriate modal (#56807)
* Add endpoint that lists parameters that will be required if a row action

For now, if names intersect

* Fetch just what we need

No joins though
2025-04-22 15:43:11 +01:00
Dan Stone
39d15ec509 Add GET row-action endpoint to be able to present an appropriate modal (#56807)
* Add endpoint that lists parameters that will be required if a row action

For now, if names intersect

* Fetch just what we need

No joins though
2025-04-22 15:43:11 +01:00
Chris Truter
16d2514200 Thread effects up from row actions (#56966) 2025-04-22 10:04:13 +02:00
Chris Truter
73b27fcac0 Thread effects up from row actions (#56966) 2025-04-22 10:04:13 +02:00
Ngoc Khuat
08bb9ead73 Revert "Collection operators for notification condition (#56961)" (#57003)
This reverts commit 62dec0bc76.
2025-04-22 13:27:19 +07:00
Ngoc Khuat
43dcc145f3 Revert "Collection operators for notification condition (#56961)" (#57003)
This reverts commit 62dec0bc76.
2025-04-22 13:27:19 +07:00
Ngoc Khuat
5b4e18b303 Fix webhooks don't send notifications (#57001) 2025-04-22 13:24:50 +07:00
Ngoc Khuat
59b27938b3 Fix webhooks don't send notifications (#57001) 2025-04-22 13:24:50 +07:00
Ngoc Khuat
62dec0bc76 Collection operators for notification condition (#56961) 2025-04-22 12:25:46 +07:00
Ngoc Khuat
64a7e5a594 Collection operators for notification condition (#56961) 2025-04-22 12:25:46 +07:00
Ngoc Khuat
c638945787 Default template API (#56845) 2025-04-21 18:58:34 +07:00
Ngoc Khuat
9b8656be9e Default template API (#56845) 2025-04-21 18:58:34 +07:00
Stas Gavrylov
44bb373af1 WRK-250: Add autocomplete for handlebars fns and helpers (#56909)
* WRK-250: Add autocomplete for handlebars fns and helpers. Hardcoded

* Fix TableNotificationsListModal tests

* WRK-250: Integrate with BE-provided helpers list for autocomplete

* WRK-250: Add display of doc string for provided helpers

* WRK-250: Revert splitting custom helpers to block and inline
2025-04-21 13:42:33 +03:00
Stas Gavrylov
e4bb84130d WRK-250: Add autocomplete for handlebars fns and helpers (#56909)
* WRK-250: Add autocomplete for handlebars fns and helpers. Hardcoded

* Fix TableNotificationsListModal tests

* WRK-250: Integrate with BE-provided helpers list for autocomplete

* WRK-250: Add display of doc string for provided helpers

* WRK-250: Revert splitting custom helpers to block and inline
2025-04-21 13:42:33 +03:00
Chris Truter
a40053fda1 Fix bug in events for multi-row creation (#56959) 2025-04-21 10:55:04 +02:00
Chris Truter
5240e864e8 Fix bug in events for multi-row creation (#56959) 2025-04-21 10:55:04 +02:00
Chris Truter
965d54cbc5 Rework action composition (#56947) 2025-04-21 10:22:27 +02:00
Chris Truter
c8097bca83 Rework action composition (#56947) 2025-04-21 10:22:27 +02:00
Chris Truter
6552ffdc69 Fix undo and notifications for tables with an UUID pk (#56922) 2025-04-18 18:26:36 +02:00
Chris Truter
9151d1abd4 Fix undo and notifications for tables with an UUID pk (#56922) 2025-04-18 18:26:36 +02:00
Chris Truter
a25be93c6a Fix more test expectations 2025-04-18 17:25:28 +02:00
Chris Truter
98ef854d78 Fix more test expectations 2025-04-18 17:25:28 +02:00
Konstantin Darutkin
d0c3c9a4ce fix: distinct style for readonly columns and header styling fixes (#56908) 2025-04-18 12:54:14 +01:00
Konstantin Darutkin
6755248b2f fix: distinct style for readonly columns and header styling fixes (#56908) 2025-04-18 12:54:14 +01:00
Chris Truter
df2d0d5061 Use action-agnostic events to trigger table notifications (#56892) 2025-04-18 11:09:46 +02:00
Chris Truter
b45b01d953 Use action-agnostic events to trigger table notifications (#56892) 2025-04-18 11:09:46 +02:00
Denis Berezin
7ebe65686a WRK-278 sorting in editable table in dashboards improvements (#56857)
* Sorting UX improvements

* self review

* Filters UX improvements

* self review
2025-04-18 01:45:37 +03:00
Denis Berezin
b12115c8dc WRK-278 sorting in editable table in dashboards improvements (#56857)
* Sorting UX improvements

* self review

* Filters UX improvements

* self review
2025-04-18 01:45:37 +03:00
Konstantin Darutkin
622553c018 fix: focus search field in category editing dropdown (#56852) 2025-04-17 15:01:28 +01:00
Konstantin Darutkin
14f54afb65 fix: focus search field in category editing dropdown (#56852) 2025-04-17 15:01:28 +01:00
Chris Truter
bf9f135f9f Fix more compound PK stuff (#56819) 2025-04-17 15:55:37 +02:00
Chris Truter
142b6d4903 Fix more compound PK stuff (#56819) 2025-04-17 15:55:37 +02:00
Ngoc Khuat
7421a15eec Comparision handlebars helpers (#56828) 2025-04-17 18:15:19 +07:00
Ngoc Khuat
d2b5cf6aa4 Comparision handlebars helpers (#56828) 2025-04-17 18:15:19 +07:00
Ngoc Khuat
b78b470d1f Setting to get handlebars helpers list (#56827) 2025-04-17 18:13:30 +07:00
Ngoc Khuat
c6023b0bec Setting to get handlebars helpers list (#56827) 2025-04-17 18:13:30 +07:00
Konstantin Darutkin
5a50378330 fix: undo/redo icon size 2025-04-17 12:07:50 +01:00
Konstantin Darutkin
a520f31b42 fix: undo/redo icon size 2025-04-17 12:07:50 +01:00
Ngoc Khuat
9e48bfa0cb Add an envvar to skip sending existing notifications (#56782) 2025-04-17 17:55:14 +07:00
Ngoc Khuat
f73d65f010 Add an envvar to skip sending existing notifications (#56782) 2025-04-17 17:55:14 +07:00
Konstantin Darutkin
812b9ec918 feat(enterprise): Enable clearing categorical values in editable table (#56842) 2025-04-17 11:20:46 +01:00
Konstantin Darutkin
ce781e3e38 feat(enterprise): Enable clearing categorical values in editable table (#56842) 2025-04-17 11:20:46 +01:00
Chris Truter
387c8f8829 Merge branch 'master' into internal-tools 2025-04-17 09:39:31 +02:00
Chris Truter
2accf2d826 Merge branch 'master' into internal-tools 2025-04-17 09:39:31 +02:00
Chris Truter
edffc2b5b1 Fix support for compound PKs (#56816)
Turns out this was only half the cancer, rest of the fix coming after.
2025-04-16 23:05:46 +02:00
Chris Truter
9d266de7ca Fix support for compound PKs (#56816)
Turns out this was only half the cancer, rest of the fix coming after.
2025-04-16 23:05:46 +02:00
Chris Truter
34fc016df0 Comments marking where and what to do for WRK-171 2025-04-16 22:44:58 +02:00
Chris Truter
2159d9c2e7 Comments marking where and what to do for WRK-171 2025-04-16 22:44:58 +02:00
Denis Berezin
891d15deba Add table dashcard actions settings (#56756) 2025-04-16 23:40:31 +03:00
Denis Berezin
3a5f0ce528 Add table dashcard actions settings (#56756) 2025-04-16 23:40:31 +03:00
Konstantin Darutkin
c388ba0c58 feat(enterprise): add undo/redo API and basic buttons for table editing (#56717) 2025-04-16 18:15:20 +01:00
Konstantin Darutkin
4deb77340d feat(enterprise): add undo/redo API and basic buttons for table editing (#56717) 2025-04-16 18:15:20 +01:00
Ngoc Khuat
aecfe3eda9 Better template (#56487) 2025-04-16 21:15:49 +07:00
Ngoc Khuat
0efe7bc010 Better template (#56487) 2025-04-16 21:15:49 +07:00
Ngoc Khuat
01f7f5bcd9 Merge branch 'master' into internal-tools 2025-04-16 20:36:33 +07:00
Ngoc Khuat
bbf4d33a08 Merge branch 'master' into internal-tools 2025-04-16 20:36:33 +07:00
Ngoc Khuat
25e21ed9bf Merge branch 'master' into internal-tools 2025-04-16 19:56:56 +07:00
Ngoc Khuat
1fc31a99f7 Merge branch 'master' into internal-tools 2025-04-16 19:56:56 +07:00
Dan Stone
3a5be51509 Fix missing :id in field-test (#56777) 2025-04-16 12:53:17 +01:00
Dan Stone
6cdc6008e9 Fix missing :id in field-test (#56777) 2025-04-16 12:53:17 +01:00
Stas Gavrylov
b6072b7112 WRK-251,WRK-259,WRK-260,WRK-263: Add autocomplete for custom templates form (#56743)
Replaced regular TextInput and TextField with CodeMirror instances,
styled as MantineUI components to provide autocomplete support
for handlebars (specifically, html and markup).
Introduced "textinput" variant to imitate a regular single-line input.
Other minor fixes and styling adjustments.
2025-04-16 13:36:25 +03:00
Stas Gavrylov
89c58b8f6f WRK-251,WRK-259,WRK-260,WRK-263: Add autocomplete for custom templates form (#56743)
Replaced regular TextInput and TextField with CodeMirror instances,
styled as MantineUI components to provide autocomplete support
for handlebars (specifically, html and markup).
Introduced "textinput" variant to imitate a regular single-line input.
Other minor fixes and styling adjustments.
2025-04-16 13:36:25 +03:00
Chris Truter
649caa0e44 Merge branch 'master' into internal-tools 2025-04-16 10:25:42 +02:00
Chris Truter
8806e6a0f2 Merge branch 'master' into internal-tools 2025-04-16 10:25:42 +02:00
Dan Stone
ea71481630 Fix new errors due to merge (#56736)
* Fix missing emails symbol

* Fix migration order
2025-04-15 15:44:53 +01:00
Dan Stone
1e17df7eeb Fix new errors due to merge (#56736)
* Fix missing emails symbol

* Fix migration order
2025-04-15 15:44:53 +01:00
Denis Berezin
5d6dde74c5 hotfix after merge - fix db admin settings 2025-04-15 17:29:36 +03:00
Denis Berezin
e7b74d7d76 hotfix after merge - fix db admin settings 2025-04-15 17:29:36 +03:00
Denis Berezin
30f59f2e87 hotfix after merge - restore useCombobox 2025-04-15 17:09:47 +03:00
Denis Berezin
36a0db358b hotfix after merge - restore useCombobox 2025-04-15 17:09:47 +03:00
Alexander Solovyov
e43228e8fc merge master 2025-04-15 16:14:58 +03:00
Alexander Solovyov
765202249d merge master 2025-04-15 16:14:58 +03:00
Denis Berezin
9c50d21401 Add filtering for standalone editable tables (#56623) 2025-04-15 02:03:38 +03:00
Denis Berezin
b215eddbf3 Add filtering for standalone editable tables (#56623) 2025-04-15 02:03:38 +03:00
Konstantin Darutkin
805b355989 feat(enterprise): respect columns config in editable modals (#56673) 2025-04-14 16:08:23 +01:00
Konstantin Darutkin
17dc3b2444 feat(enterprise): respect columns config in editable modals (#56673) 2025-04-14 16:08:23 +01:00
Denis Berezin
b28251f8a1 [WRK-240] Remove link to a new question from editable table card (#56604) 2025-04-11 22:25:54 +03:00
Denis Berezin
7d87a73a22 [WRK-240] Remove link to a new question from editable table card (#56604) 2025-04-11 22:25:54 +03:00
Dan Stone
ef74b7830f Run data editing tests against postgres as well as H2 (#56481)
* Delete app db table record when dropping table

* Remove empty-h2-app-db calls

* Add auto-inc test for create table

* Use driver test db instead of app db

* Fix local whitespace

* Do not lose the cleanup ex in open-test-table!

* Fix postgres test issues
2025-04-11 14:55:22 +01:00
Dan Stone
6c1ebb6d0e Run data editing tests against postgres as well as H2 (#56481)
* Delete app db table record when dropping table

* Remove empty-h2-app-db calls

* Add auto-inc test for create table

* Use driver test db instead of app db

* Fix local whitespace

* Do not lose the cleanup ex in open-test-table!

* Fix postgres test issues
2025-04-11 14:55:22 +01:00
Konstantin Darutkin
ed852b65c9 feat(enterprise): show create row on editable dashcard (#56550) 2025-04-11 10:51:34 +01:00
Konstantin Darutkin
8969ae4baa feat(enterprise): show create row on editable dashcard (#56550) 2025-04-11 10:51:34 +01:00
Denis Berezin
94addf0055 Add sorting config for editable table card in dashboard (#56519)
* WIP

* Add sorting for editable table dashboard card

* Fixes after merge

* Self review
2025-04-10 21:57:36 +03:00
Denis Berezin
ede9de2787 Add sorting config for editable table card in dashboard (#56519)
* WIP

* Add sorting for editable table dashboard card

* Fixes after merge

* Self review
2025-04-10 21:57:36 +03:00
Chris Truter
3e3a6aba14 Of course I ran kondo 2025-04-10 19:05:08 +02:00
Chris Truter
b3fd8c2251 Of course I ran kondo 2025-04-10 19:05:08 +02:00
Chris Truter
2273872f63 Wire up Undo API and hack the "subscriptions" (#56560) 2025-04-10 17:56:50 +02:00
Chris Truter
663a5d15d8 Wire up Undo API and hack the "subscriptions" (#56560) 2025-04-10 17:56:50 +02:00
Konstantin Darutkin
2e04af515e fix: hide hidden columns in editable table (#56431)
* wip: hide hidden columns in editable table

* fix: flickering

* fix: review fixes
2025-04-10 12:36:02 +01:00
Konstantin Darutkin
7f881461f3 fix: hide hidden columns in editable table (#56431)
* wip: hide hidden columns in editable table

* fix: flickering

* fix: review fixes
2025-04-10 12:36:02 +01:00
Konstantin Darutkin
0bdb7c1004 feat: add edit disabled icon (#56544) 2025-04-10 12:35:30 +01:00
Konstantin Darutkin
ac410d0c9f feat: add edit disabled icon (#56544) 2025-04-10 12:35:30 +01:00
Konstantin Darutkin
f9a9c6c06c feat(enterprise): WRK-231 add fast reload for dashcards (#56427)
* feat(enterprise): WRK-231 add fast reload for dashcards

* refactor: better state handling

* fix: review fixes
2025-04-09 22:47:29 +01:00
Konstantin Darutkin
c5ab376d8a feat(enterprise): WRK-231 add fast reload for dashcards (#56427)
* feat(enterprise): WRK-231 add fast reload for dashcards

* refactor: better state handling

* fix: review fixes
2025-04-09 22:47:29 +01:00
Dan Stone
dee486d3e1 Add temporary authz skip for data editing (#56370)
Removes admin auth for table edits very temporarily!

Add new variable MB_DATA_EDITING_AUTHZ to turn it back on

This unblocks editing tables through dashcards for FE/demos
Will remove once design work is done on permissions / security policy
for dashcard actions
2025-04-09 17:22:31 +01:00
Dan Stone
ddf7bcd142 Add temporary authz skip for data editing (#56370)
Removes admin auth for table edits very temporarily!

Add new variable MB_DATA_EDITING_AUTHZ to turn it back on

This unblocks editing tables through dashcards for FE/demos
Will remove once design work is done on permissions / security policy
for dashcard actions
2025-04-09 17:22:31 +01:00
Ngoc Khuat
e8d96ac295 fix kondo 2025-04-08 19:12:22 +07:00
Ngoc Khuat
6778c3c66c fix kondo 2025-04-08 19:12:22 +07:00
Konstantin Darutkin
eec2b53807 fix: WRK-238 extra fk columns in editable dashcard display settings (#56430) 2025-04-08 13:07:02 +01:00
Konstantin Darutkin
9a53ef0c47 fix: WRK-238 extra fk columns in editable dashcard display settings (#56430) 2025-04-08 13:07:02 +01:00
Ngoc Khuat
569ba2a134 fix deps whitespace 2025-04-08 16:34:34 +07:00
Ngoc Khuat
ca78a248e5 fix deps whitespace 2025-04-08 16:34:34 +07:00
Ngoc Khuat
bf1f730534 Dynamically fetch payload example (#56421) 2025-04-08 16:28:45 +07:00
Ngoc Khuat
76dde82500 Dynamically fetch payload example (#56421) 2025-04-08 16:28:45 +07:00
Chris Truter
d989e582c2 Merge branch 'master' into internal-tools 2025-04-08 10:45:05 +02:00
Chris Truter
828be63863 Merge branch 'master' into internal-tools 2025-04-08 10:45:05 +02:00
Ngoc Khuat
4f9077f9b9 Generic action system events and example template API (#56419)
* Remodel system event notification (#56149)

* [Notification] Simplify payload for system event (#56262)

* Notification payload api (#56264)

* Update UI with new notification model (#56417)

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2025-04-08 15:10:07 +07:00
Ngoc Khuat
ec8bddf5a5 Generic action system events and example template API (#56419)
* Remodel system event notification (#56149)

* [Notification] Simplify payload for system event (#56262)

* Notification payload api (#56264)

* Update UI with new notification model (#56417)

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2025-04-08 15:10:07 +07:00
Denis Berezin
6bf89dc316 Add filters for editable table card settings (#56186) 2025-04-07 22:44:58 +03:00
Denis Berezin
bca724818f Add filters for editable table card settings (#56186) 2025-04-07 22:44:58 +03:00
Chris Truter
7dc39fad70 Core table editing Undo mechanism (#56276) 2025-04-07 14:51:19 +02:00
Chris Truter
fc4103637c Core table editing Undo mechanism (#56276) 2025-04-07 14:51:19 +02:00
Konstantin Darutkin
b0189956b2 fix: empty viz state typecheck 2025-04-07 13:19:22 +01:00
Konstantin Darutkin
ad09dfa3c8 fix: empty viz state typecheck 2025-04-07 13:19:22 +01:00
Chris Truter
95c3693642 Merge branch 'master' into internal-tools 2025-04-07 13:39:46 +02:00
Chris Truter
ddd3157d20 Merge branch 'master' into internal-tools 2025-04-07 13:39:46 +02:00
Chris Truter
349d0503fc Merge branch 'master' into internal-tools 2025-04-04 18:32:07 +02:00
Chris Truter
5a9265f2f2 Merge branch 'master' into internal-tools 2025-04-04 18:32:07 +02:00
Chris Truter
5eca4d39df Return just the number of rows created by webhook (#56293)
* Return just the number of rows created by webhook
* More defensive code, although I don't know what we're defending against
2025-04-04 16:23:26 +02:00
Chris Truter
78e305cd93 Return just the number of rows created by webhook (#56293)
* Return just the number of rows created by webhook
* More defensive code, although I don't know what we're defending against
2025-04-04 16:23:26 +02:00
Alexander Solovyov
5b2124da6e use webhook's creator_id as a current user for inserting/sending events (#56291) 2025-04-04 16:21:40 +03:00
Alexander Solovyov
479a73b16b use webhook's creator_id as a current user for inserting/sending events (#56291) 2025-04-04 16:21:40 +03:00
Alexander Solovyov
53873c22b1 a bit more clear webhook data errors (#56288) 2025-04-04 16:07:59 +03:00
Alexander Solovyov
d63e13f54d a bit more clear webhook data errors (#56288) 2025-04-04 16:07:59 +03:00
Konstantin Darutkin
cc84410ac3 feat(enterpripse): configure column visibility for editable table (#56209) 2025-04-04 14:06:37 +01:00
Konstantin Darutkin
e2a77d6951 feat(enterpripse): configure column visibility for editable table (#56209) 2025-04-04 14:06:37 +01:00
Chris Truter
3f5333cd22 Merge branch 'master' into internal-tools 2025-04-04 14:15:23 +02:00
Chris Truter
9b4c277b10 Merge branch 'master' into internal-tools 2025-04-04 14:15:23 +02:00
Ngoc Khuat
6269563254 Merge branch 'master' into internal-tools 2025-04-04 10:58:28 +07:00
Ngoc Khuat
431b5c78c1 Merge branch 'master' into internal-tools 2025-04-04 10:58:28 +07:00
Denis Berezin
b061cc3b9b Fix issues with duplicated columns (#56243) 2025-04-03 23:28:43 +03:00
Denis Berezin
21bd0c0fa5 Fix issues with duplicated columns (#56243) 2025-04-03 23:28:43 +03:00
Konstantin Darutkin
71e72f6645 feat(enterprise): api-based category search (#56088) 2025-04-03 13:02:30 +01:00
Konstantin Darutkin
cd5ffe7902 feat(enterprise): api-based category search (#56088) 2025-04-03 13:02:30 +01:00
Konstantin Darutkin
60d8da092d fix: category select btn type (#56208) 2025-04-03 12:49:24 +01:00
Konstantin Darutkin
f29c8d8c56 fix: category select btn type (#56208) 2025-04-03 12:49:24 +01:00
Konstantin Darutkin
fc5a81afe5 refactor: move editable table siderbar into separate folder (#56204) 2025-04-03 12:23:36 +01:00
Konstantin Darutkin
bc26ee23ed refactor: move editable table siderbar into separate folder (#56204) 2025-04-03 12:23:36 +01:00
Dan Stone
46500e2157 Invalidate field-values on update/insert (#56203) 2025-04-03 12:02:58 +01:00
Dan Stone
04fa5a0f76 Invalidate field-values on update/insert (#56203) 2025-04-03 12:02:58 +01:00
Uladzimir Havenchyk
71437e758e Merge branch 'master' into internal-tools 2025-04-03 13:55:46 +03:00
Uladzimir Havenchyk
87facf8751 Merge branch 'master' into internal-tools 2025-04-03 13:55:46 +03:00
Alexander Solovyov
a2ee4eaa22 sorry i messed up (#56202) 2025-04-03 10:19:37 +03:00
Alexander Solovyov
0868aed492 sorry i messed up (#56202) 2025-04-03 10:19:37 +03:00
Alexander Solovyov
91aa513d43 could this fix a pg flake? (#56201) 2025-04-03 09:41:55 +03:00
Alexander Solovyov
b85b88883f could this fix a pg flake? (#56201) 2025-04-03 09:41:55 +03:00
Chris Truter
129510e174 Merge branch 'master' into internal-tools 2025-04-03 07:15:36 +02:00
Chris Truter
806c311e90 Merge branch 'master' into internal-tools 2025-04-03 07:15:36 +02:00
Denis Berezin
4f997cf438 Fix FK display by removing double remapping (#56163) 2025-04-03 00:26:36 +03:00
Denis Berezin
dbd2e511a7 Fix FK display by removing double remapping (#56163) 2025-04-03 00:26:36 +03:00
Case Nelson
b618a16569 Merge branch 'master' into internal-tools 2025-04-02 11:50:51 -06:00
Case Nelson
a93079f0bb Merge branch 'master' into internal-tools 2025-04-02 11:50:51 -06:00
Alexander Solovyov
a1b21329ec Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 20:24:39 +03:00
Alexander Solovyov
24548bbf47 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 20:24:39 +03:00
Alexander Solovyov
98aaa51b26 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 19:46:31 +03:00
Alexander Solovyov
6a03e8b9c1 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 19:46:31 +03:00
Chris Truter
81dee319cb Fixup dashboard model validation (#56147) 2025-04-02 16:20:08 +02:00
Chris Truter
1198b27a1d Fixup dashboard model validation (#56147) 2025-04-02 16:20:08 +02:00
Konstantin Darutkin
e88dc44592 feat: add editable table dashcard configuration sidebar (#56136)
* feat: add editable table dashcard configuration sidebar

* fix: review fixes
2025-04-02 14:31:15 +01:00
Konstantin Darutkin
8294e4d176 feat: add editable table dashcard configuration sidebar (#56136)
* feat: add editable table dashcard configuration sidebar

* fix: review fixes
2025-04-02 14:31:15 +01:00
Alexander Solovyov
86dffe61d9 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 16:19:44 +03:00
Alexander Solovyov
87403bce30 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 16:19:44 +03:00
Uladzimir Havenchyk
429ade95bd attempt to fix e2e tests 2025-04-02 15:53:00 +03:00
Uladzimir Havenchyk
11f85281f0 attempt to fix e2e tests 2025-04-02 15:53:00 +03:00
Dan Stone
c228954ffe Add webhook ingestion support (#56024)
Added ability to create unauthenticated webhook endpoints that can
insert rows into the database.

Hooks are expected to fire a single record or multiple records at the
generated endpoint.

Moved some logic around (adding data_editing.clj) in order to accomodate
new public endpoints that share behaviour with the existing insert
endpoint.
2025-04-02 13:41:43 +01:00
Dan Stone
4ba89a98f8 Add webhook ingestion support (#56024)
Added ability to create unauthenticated webhook endpoints that can
insert rows into the database.

Hooks are expected to fire a single record or multiple records at the
generated endpoint.

Moved some logic around (adding data_editing.clj) in order to accomodate
new public endpoints that share behaviour with the existing insert
endpoint.
2025-04-02 13:41:43 +01:00
Chris Truter
1f867c6e64 Make editables models instead of questions (#56135)
* Make editables models instead of questions

* seems to be fine

* Make it more defensive
2025-04-02 14:27:24 +02:00
Chris Truter
e4e1812349 Make editables models instead of questions (#56135)
* Make editables models instead of questions

* seems to be fine

* Make it more defensive
2025-04-02 14:27:24 +02:00
Alexander Solovyov
aef83abf34 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 13:42:12 +03:00
Alexander Solovyov
bef8c011f5 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 13:42:12 +03:00
Alexander Solovyov
94ca368631 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 10:37:00 +03:00
Alexander Solovyov
8eeeb81741 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-02 10:37:00 +03:00
Stas Gavrylov
da350584e8 Fix unsubscribe for table notifications (#56082) 2025-04-01 18:10:01 +03:00
Stas Gavrylov
a00201596f Fix unsubscribe for table notifications (#56082) 2025-04-01 18:10:01 +03:00
Alexander Solovyov
6b3d024d9d fix schedules (#56079) 2025-04-01 17:21:56 +03:00
Alexander Solovyov
5d0b95be60 fix schedules (#56079) 2025-04-01 17:21:56 +03:00
Alexander Solovyov
37a9a6c0f3 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-01 17:00:26 +03:00
Alexander Solovyov
b5cb4c62e3 Merge remote-tracking branch 'origin/master' into internal-tools 2025-04-01 17:00:26 +03:00
Stas Gavrylov
ca30fb63a1 WRK-198: Custom notifications templates (#56065)
* WRK-198: Custom notifications templates. Tabs version

* WRK-198: Refactor UI for custom alert templates
2025-04-01 16:57:46 +03:00
Stas Gavrylov
63b91a1ef9 WRK-198: Custom notifications templates (#56065)
* WRK-198: Custom notifications templates. Tabs version

* WRK-198: Refactor UI for custom alert templates
2025-04-01 16:57:46 +03:00
Alexander Solovyov
1719ebc9c7 :row/delete also wanted some joins (#56075) 2025-04-01 15:58:09 +03:00
Alexander Solovyov
ec79b8320f :row/delete also wanted some joins (#56075) 2025-04-01 15:58:09 +03:00
Chris Truter
d9b2b96474 kick ci 2025-04-01 14:51:28 +02:00
Chris Truter
55a1df1048 kick ci 2025-04-01 14:51:28 +02:00
Alexander Solovyov
79db162e9e fix tests for new actions code (#56070) 2025-04-01 14:31:16 +03:00
Alexander Solovyov
5726032b94 fix tests for new actions code (#56070) 2025-04-01 14:31:16 +03:00
Ngoc Khuat
5189a40ce4 Spec update add support for reverse PK (#56047) 2025-04-01 17:37:13 +07:00
Ngoc Khuat
e070e949d1 Spec update add support for reverse PK (#56047) 2025-04-01 17:37:13 +07:00
Chris Truter
51ad937543 Fix lazy editable card creation. Update missed dashboard API tests. (#56061) 2025-04-01 10:57:12 +02:00
Chris Truter
f2e680e0d4 Fix lazy editable card creation. Update missed dashboard API tests. (#56061) 2025-04-01 10:57:12 +02:00
Chris Truter
1035825e94 Fix fallback to default template (#56059) 2025-04-01 10:42:05 +02:00
Chris Truter
43b1f2c182 Fix fallback to default template (#56059) 2025-04-01 10:42:05 +02:00
Alexander Solovyov
6f009d56ce Fix row create (#56010) 2025-04-01 11:29:22 +03:00
Alexander Solovyov
1859f2275d Fix row create (#56010) 2025-04-01 11:29:22 +03:00
Alexander Solovyov
44d2658f13 merge master 2025-04-01 11:28:47 +03:00
Alexander Solovyov
ec729b05df merge master 2025-04-01 11:28:47 +03:00
Chris Truter
a92fca3ef5 Allow search to return all field values (#55997) 2025-03-31 14:49:16 +02:00
Chris Truter
d57217d006 Allow search to return all field values (#55997) 2025-03-31 14:49:16 +02:00
Konstantin Darutkin
4e37127051 fix: merge conflicts 2025-03-31 11:42:27 +01:00
Konstantin Darutkin
944a1382fa fix: merge conflicts 2025-03-31 11:42:27 +01:00
Ngoc Khuat
f70ca33c0a fix condition conflicts 2025-03-31 16:25:01 +07:00
Ngoc Khuat
c8e77402dd fix condition conflicts 2025-03-31 16:25:01 +07:00
Konstantin Darutkin
f47d072729 fix: editable select value and dropdown labels (#55907)
* fix: editable select value and dropdown labels

* fix: missing func

* Apply suggestions from code review

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-03-28 15:17:06 +00:00
Konstantin Darutkin
6ba5dcf09d fix: editable select value and dropdown labels (#55907)
* fix: editable select value and dropdown labels

* fix: missing func

* Apply suggestions from code review

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-03-28 15:17:06 +00:00
Konstantin Darutkin
7e138cf216 refactor: use input button for editing category (#55906) 2025-03-28 14:02:16 +00:00
Konstantin Darutkin
ae6591cc07 refactor: use input button for editing category (#55906) 2025-03-28 14:02:16 +00:00
Konstantin Darutkin
e9de85bd37 fix: api types for field metadata (#55900)
* fix: api types for field metadata

* fix: review issues

* fix: review issues
2025-03-28 13:24:10 +00:00
Konstantin Darutkin
b6cde470a7 fix: api types for field metadata (#55900)
* fix: api types for field metadata

* fix: review issues

* fix: review issues
2025-03-28 13:24:10 +00:00
Konstantin Darutkin
3d489581dd fix(enterprise): inline editing table input styles (#55820)
* fix(enterprise): inline editing table input styles

* fix: review fixes
2025-03-28 12:53:09 +00:00
Konstantin Darutkin
6ec702642f fix(enterprise): inline editing table input styles (#55820)
* fix(enterprise): inline editing table input styles

* fix: review fixes
2025-03-28 12:53:09 +00:00
Ngoc Khuat
ce87540743 [Notification] Customizable template (#55799) 2025-03-28 18:29:21 +07:00
Ngoc Khuat
b1827d9a81 [Notification] Customizable template (#55799) 2025-03-28 18:29:21 +07:00
Ngoc Khuat
eaf1ad0085 fix migration checksum 2025-03-28 17:27:36 +07:00
Ngoc Khuat
ef8b482bbc fix migration checksum 2025-03-28 17:27:36 +07:00
Denis Berezin
506c665eb6 Editable tables polishing 2025-03-28 00:44:15 +03:00
Denis Berezin
4d960706b1 Editable tables polishing 2025-03-28 00:44:15 +03:00
Konstantin Darutkin
e71e4d0ada feat(enterprise): Add a confirmation message on Delete operations (#55821) 2025-03-27 19:01:40 +00:00
Konstantin Darutkin
e19b5e4dd3 feat(enterprise): Add a confirmation message on Delete operations (#55821) 2025-03-27 19:01:40 +00:00
Denis Berezin
a02d08fcfc Fix FK fields empty dropdown (#55851) 2025-03-27 21:55:04 +03:00
Denis Berezin
49f1e4977c Fix FK fields empty dropdown (#55851) 2025-03-27 21:55:04 +03:00
Dan Stone
e2fd4fc37e Manage coercion_strategy translations from JSON inputs to database values (#55773)
Implements a set of coercions back to database types, when the frontend provides zoned date time strings (ISO8601).

If an integer field representing UNIX seconds is presented to the frontend as a ISO8601 datetime via coercion_strategy

The frontend will not currently know that database representation of the value (a int representing seconds in this instance). Therefore data editing needs to accept ISO8601 datetime strings as inputs, as the frontend will present a date picker based on the information it has.

For lossy translations (such as ISO8601->Date or ISO8601->Time) much is left undefined, and in some situations users may lose data or find surprises on edit. A working audit log should protect against somewhat $consequences, given how easy it is to make a mistake anyway.

One definition that might work is that time/date changes keep the corresponding exclusive part of the previous row, so if you say vary the time on 2025-03-27T13:42:03Z to 14:00:00, then we take keep the date as 2025-03-27 for that row. In other words, coercion behaviour might become additionally dependent on the existing record state. A followup PR might be in order to acheive this.
2025-03-27 17:48:22 +00:00
Dan Stone
7b10a2f2b0 Manage coercion_strategy translations from JSON inputs to database values (#55773)
Implements a set of coercions back to database types, when the frontend provides zoned date time strings (ISO8601).

If an integer field representing UNIX seconds is presented to the frontend as a ISO8601 datetime via coercion_strategy

The frontend will not currently know that database representation of the value (a int representing seconds in this instance). Therefore data editing needs to accept ISO8601 datetime strings as inputs, as the frontend will present a date picker based on the information it has.

For lossy translations (such as ISO8601->Date or ISO8601->Time) much is left undefined, and in some situations users may lose data or find surprises on edit. A working audit log should protect against somewhat $consequences, given how easy it is to make a mistake anyway.

One definition that might work is that time/date changes keep the corresponding exclusive part of the previous row, so if you say vary the time on 2025-03-27T13:42:03Z to 14:00:00, then we take keep the date as 2025-03-27 for that row. In other words, coercion behaviour might become additionally dependent on the existing record state. A followup PR might be in order to acheive this.
2025-03-27 17:48:22 +00:00
Stas Gavrylov
4262ff5a3d WRK-189: Add table notifications UI (#55700)
* WRK-189: Add table notifications UI

* WRK-189: Add delete/unsubscribe confirmation modals. Refactor shared code

* WRK-189: Add display of table notifications list in user profile. Fix lint errors
2025-03-27 18:46:41 +02:00
Stas Gavrylov
524e2bc660 WRK-189: Add table notifications UI (#55700)
* WRK-189: Add table notifications UI

* WRK-189: Add delete/unsubscribe confirmation modals. Refactor shared code

* WRK-189: Add display of table notifications list in user profile. Fix lint errors
2025-03-27 18:46:41 +02:00
Dan Stone
76d7d80ecb Fix ci internal tools (#55838)
* Fix migrations file

* Fix postgres kondo

* Fix modules config

* Fix unused requires

* Test util kondo

* Fix MySQL index rollback
2025-03-27 16:44:24 +00:00
Dan Stone
da4b2d4411 Fix ci internal tools (#55838)
* Fix migrations file

* Fix postgres kondo

* Fix modules config

* Fix unused requires

* Test util kondo

* Fix MySQL index rollback
2025-03-27 16:44:24 +00:00
Ngoc Khuat
95c442b943 fix lint migration about deleteCascade by adding a proper migration for adding constraint 2025-03-27 20:30:51 +07:00
Ngoc Khuat
db8c20833c fix lint migration about deleteCascade by adding a proper migration for adding constraint 2025-03-27 20:30:51 +07:00
Ngoc Khuat
e285c32157 [Notification] Update api to work with system events (#55646) 2025-03-27 18:32:48 +07:00
Ngoc Khuat
7cbe6fd0ca [Notification] Update api to work with system events (#55646) 2025-03-27 18:32:48 +07:00
Konstantin Darutkin
68927160c8 fix: inline editing FK field style (#55813) 2025-03-27 11:12:19 +00:00
Konstantin Darutkin
522a4120bb fix: inline editing FK field style (#55813) 2025-03-27 11:12:19 +00:00
Chris Truter
01222ff5e1 Merge branch 'master' into internal-tools 2025-03-27 10:19:31 +02:00
Chris Truter
b9c4e305ea Merge branch 'master' into internal-tools 2025-03-27 10:19:31 +02:00
Denis Berezin
43c64db6dc Editable tables in dashboards (#55372)
* Support editable tables dashcards with filter support

* Test fix

* Temp

* Add table-editable visualization

* Add table selection + other fixes

* Fixes after merge

* fix e2e

* Fixes after merge

* Fix edit issues and scroll

* Fix issues with edit after merge, fix modal remappings

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2025-03-27 01:02:10 +03:00
Denis Berezin
021e0d080e Editable tables in dashboards (#55372)
* Support editable tables dashcards with filter support

* Test fix

* Temp

* Add table-editable visualization

* Add table selection + other fixes

* Fixes after merge

* fix e2e

* Fixes after merge

* Fix edit issues and scroll

* Fix issues with edit after merge, fix modal remappings

---------

Co-authored-by: Chris Truter <chris@metabase.com>
2025-03-27 01:02:10 +03:00
Konstantin Darutkin
a9169ea54e feat(enterprise): Handle nulls/defaults/generated columns when (#55675)
* feat(enterprise): Handle nulls/defaults/generated columns when creating/editing

* fix: review fixes

* feat(enterprise): add formik for creation modal (#55694)
2025-03-26 16:07:34 +00:00
Konstantin Darutkin
328dec6b84 feat(enterprise): Handle nulls/defaults/generated columns when (#55675)
* feat(enterprise): Handle nulls/defaults/generated columns when creating/editing

* fix: review fixes

* feat(enterprise): add formik for creation modal (#55694)
2025-03-26 16:07:34 +00:00
Konstantin Darutkin
a5c30576c3 feat(enterprise): update editable table values without reload (#55755) 2025-03-26 13:34:09 +00:00
Konstantin Darutkin
c8345fb90a feat(enterprise): update editable table values without reload (#55755) 2025-03-26 13:34:09 +00:00
Chris Truter
b2aa25a18e Merge branch 'master' into internal-tools 2025-03-26 13:24:53 +02:00
Chris Truter
171ace8438 Merge branch 'master' into internal-tools 2025-03-26 13:24:53 +02:00
Alexander Solovyov
5ed7432f77 improve actions code a little bit (#55749) 2025-03-26 10:23:56 +02:00
Alexander Solovyov
88c81b9087 improve actions code a little bit (#55749) 2025-03-26 10:23:56 +02:00
Ngoc Khuat
4ee43ee057 Merge branch 'master' into internal-tools 2025-03-26 13:17:41 +07:00
Ngoc Khuat
7dba7ab0cb Merge branch 'master' into internal-tools 2025-03-26 13:17:41 +07:00
Ngoc Khuat
66e30a69c7 [Notification] tables notification working e2e (#55444) 2025-03-26 12:41:22 +07:00
Ngoc Khuat
c21e9db004 [Notification] tables notification working e2e (#55444) 2025-03-26 12:41:22 +07:00
Dan Stone
a4d8cc425e Return :updated rows when modify rows with PUT (#55707)
Manually tested the UI, does not seem to break anything. 

Policy stuff is ignored for now
2025-03-25 17:57:07 +00:00
Dan Stone
22553220b9 Return :updated rows when modify rows with PUT (#55707)
Manually tested the UI, does not seem to break anything. 

Policy stuff is ignored for now
2025-03-25 17:57:07 +00:00
Dan Stone
4bee28d643 Fix clickhouse metadata test (#55710) 2025-03-25 17:27:45 +00:00
Dan Stone
878d94cb9c Fix clickhouse metadata test (#55710) 2025-03-25 17:27:45 +00:00
Chris Truter
58ac9f8dc0 Nix stale comment 2025-03-25 17:57:10 +02:00
Chris Truter
7a40a48c09 Nix stale comment 2025-03-25 17:57:10 +02:00
Chris Truter
ba47dca45f Preserve existing 'editable' on dashboard save (#55708) 2025-03-25 17:56:09 +02:00
Chris Truter
c31f0dd803 Preserve existing 'editable' on dashboard save (#55708) 2025-03-25 17:56:09 +02:00
Chris Truter
741d492778 Missed tests 2025-03-25 12:25:17 +02:00
Chris Truter
2490c818d4 Missed tests 2025-03-25 12:25:17 +02:00
Chris Truter
4073da9974 Merge branch 'master' into internal-tools 2025-03-25 11:07:44 +02:00
Chris Truter
56f473c104 Merge branch 'master' into internal-tools 2025-03-25 11:07:44 +02:00
Konstantin Darutkin
4bdbeccdd9 fix(enterprise): row deletion attempt #2 (#55644) 2025-03-24 16:13:08 +00:00
Konstantin Darutkin
730d1eeb2a fix(enterprise): row deletion attempt #2 (#55644) 2025-03-24 16:13:08 +00:00
Chris Truter
2fff1e2c4a Use POST for delete, so we can receive a body (#55642) 2025-03-24 18:04:42 +02:00
Chris Truter
c918c65e13 Use POST for delete, so we can receive a body (#55642) 2025-03-24 18:04:42 +02:00
Chris Truter
defba4bc0e Add basic malli schema to data editing APIs (#55640)
* Add basic malli schema to data editing APIs
* Track gitignore changes on master
2025-03-24 17:59:23 +02:00
Chris Truter
96240e06db Add basic malli schema to data editing APIs (#55640)
* Add basic malli schema to data editing APIs
* Track gitignore changes on master
2025-03-24 17:59:23 +02:00
Konstantin Darutkin
4cdbafc70f Revert "fix(enterprise): delete row query (#55616)" (#55639)
This reverts commit d04b760b32.
2025-03-24 15:54:34 +00:00
Konstantin Darutkin
089ffba272 Revert "fix(enterprise): delete row query (#55616)" (#55639)
This reverts commit d04b760b32.
2025-03-24 15:54:34 +00:00
Konstantin Darutkin
5fb6bcb4d5 feat(enterprise): non-editable PKs for inline table editing (#55623)
* feat(enterprise): non-editable PKs for inline table editing

* fix: review fixes
2025-03-24 15:12:13 +00:00
Konstantin Darutkin
c0d49e788a feat(enterprise): non-editable PKs for inline table editing (#55623)
* feat(enterprise): non-editable PKs for inline table editing

* fix: review fixes
2025-03-24 15:12:13 +00:00
Konstantin Darutkin
d04b760b32 fix(enterprise): delete row query (#55616) 2025-03-24 14:24:11 +00:00
Konstantin Darutkin
e5dd56ce96 fix(enterprise): delete row query (#55616) 2025-03-24 14:24:11 +00:00
Dan Stone
b257e020f6 Add create table endpoint (#55418)
* Export actions/check-data-editing-enabled-for-database!

* Add API for creating tables

Adds a new endpoint: POST ee/data-editing/database/{db-id}/table
- shares policy with data editing (admin only, data editing enabled)
- has a fixed type set (upload types) to limit security exposure for now
- does not permit changing nullable/default/generated yet
2025-03-22 10:21:36 +00:00
Dan Stone
51db0565b9 Add create table endpoint (#55418)
* Export actions/check-data-editing-enabled-for-database!

* Add API for creating tables

Adds a new endpoint: POST ee/data-editing/database/{db-id}/table
- shares policy with data editing (admin only, data editing enabled)
- has a fixed type set (upload types) to limit security exposure for now
- does not permit changing nullable/default/generated yet
2025-03-22 10:21:36 +00:00
Konstantin Darutkin
9f2e1cb040 feature(enterprise): add creation and editing modals for table editing (#55461)
* feature(enterprise): add creation and editing modals for table editing

* fix: review fixes

* feat: add backend integration

* fix: comment out delete button in header
2025-03-21 15:25:54 +00:00
Konstantin Darutkin
caed3e4365 feature(enterprise): add creation and editing modals for table editing (#55461)
* feature(enterprise): add creation and editing modals for table editing

* fix: review fixes

* feat: add backend integration

* fix: comment out delete button in header
2025-03-21 15:25:54 +00:00
Dan Stone
e5023e9e11 Fix sync dropping new columns on the floor after describe (#55447) 2025-03-21 15:14:57 +00:00
Dan Stone
c9eafb04f8 Fix sync dropping new columns on the floor after describe (#55447) 2025-03-21 15:14:57 +00:00
Dan Stone
b817729e1d Fix migration collision (#55421) 2025-03-19 15:57:48 +00:00
Dan Stone
ade045d156 Fix migration collision (#55421) 2025-03-19 15:57:48 +00:00
Konstantin Darutkin
759da5a5c8 fix(enterprise): datetime editing formatting (#55408) 2025-03-19 13:37:41 +00:00
Konstantin Darutkin
530293e059 fix(enterprise): datetime editing formatting (#55408) 2025-03-19 13:37:41 +00:00
Denis Berezin
398bacf6ed fix: hide edit mode button from non-admins 2025-03-19 16:33:22 +03:00
Denis Berezin
69a0753553 fix: hide edit mode button from non-admins 2025-03-19 16:33:22 +03:00
Konstantin Darutkin
b7cb067b6c feat(enterprise): add date and datetime table editing (#55402)
* feat(enterprise): add date and datetime table editing

* fix: minor improvement

* fix: minor improvement
2025-03-19 13:23:40 +00:00
Konstantin Darutkin
ba3edbacd8 feat(enterprise): add date and datetime table editing (#55402)
* feat(enterprise): add date and datetime table editing

* fix: minor improvement

* fix: minor improvement
2025-03-19 13:23:40 +00:00
Ngoc Khuat
cccf5a8348 [Notification] Conditional expression (#55314)
* condition syntax PoC

* remove the migration

* wire up notification with condition check (#55389)
2025-03-19 21:56:04 +09:00
Ngoc Khuat
7d49eaa300 [Notification] Conditional expression (#55314)
* condition syntax PoC

* remove the migration

* wire up notification with condition check (#55389)
2025-03-19 21:56:04 +09:00
Denis Berezin
b4b84e778a Merge branch 'master' into internal-tools 2025-03-19 14:57:26 +03:00
Denis Berezin
d7018ec8ca Merge branch 'master' into internal-tools 2025-03-19 14:57:26 +03:00
Dan Stone
06c9675131 Sync nullable, generated, default attributes for fields (#55113)
* Sync nullable, generated, default database flags

* Fix database-default for apache druid

* Add postgres test for new column attributes

* Add h2 test for new column attributes

* Fix serde baseline

* Use more random test table names
2025-03-19 11:27:15 +00:00
Dan Stone
733ad0f409 Sync nullable, generated, default attributes for fields (#55113)
* Sync nullable, generated, default database flags

* Fix database-default for apache druid

* Add postgres test for new column attributes

* Add h2 test for new column attributes

* Fix serde baseline

* Use more random test table names
2025-03-19 11:27:15 +00:00
Konstantin Darutkin
e67139aa3f fix: visual improvements for table editing (#55259) 2025-03-19 11:21:44 +00:00
Konstantin Darutkin
9e3368041c fix: visual improvements for table editing (#55259) 2025-03-19 11:21:44 +00:00
Chris Truter
7696b5e16c Editable table components in dashboards (#55345) 2025-03-19 00:07:08 +02:00
Chris Truter
f0320be9d7 Editable table components in dashboards (#55345) 2025-03-19 00:07:08 +02:00
Denis Berezin
177cc26508 Add FK handling and editing (#55303) 2025-03-18 22:45:07 +03:00
Denis Berezin
03b1ec8678 Add FK handling and editing (#55303) 2025-03-18 22:45:07 +03:00
Chris Truter
b158037771 Merge branch 'master' into internal-tools 2025-03-18 11:17:27 +02:00
Chris Truter
34e97a9970 Merge branch 'master' into internal-tools 2025-03-18 11:17:27 +02:00
Denis Berezin
5c162716e5 Switch between table view and edit mode (#55133) 2025-03-18 00:31:20 +03:00
Denis Berezin
37b07ebb40 Switch between table view and edit mode (#55133) 2025-03-18 00:31:20 +03:00
Denis Berezin
b52a3b6d34 Merge branch 'master' into internal-tools 2025-03-17 23:19:10 +03:00
Denis Berezin
df2f922342 Merge branch 'master' into internal-tools 2025-03-17 23:19:10 +03:00
Denis Berezin
57bbf5b224 Add db setting to enable table editing (#54990) 2025-03-14 22:28:49 +03:00
Denis Berezin
9edb2f2442 Add db setting to enable table editing (#54990) 2025-03-14 22:28:49 +03:00
Dan Stone
e235783fc4 Add data editing configuration checks with a flag (#55104)
* Support intended configuration checks

e.g
- database-enable-editing is required
- database-enable-actions is not required
- superuser is required

* Fix long line in docstring

* Factor out init helper

* Less lossy asserts
2025-03-14 14:24:00 +02:00
Chris Truter
3c954431f9 Merge branch 'master' into internal-tools 2025-03-14 11:05:52 +02:00
Chris Truter
5e406269a4 Naive detection and deletion of dependant records (#55076)
Walk the foreign keys to get counts, grouped by table, and a delete-the-whole-shebang method.

Doesn't limit the worst case work as much as we'd like, and the glue for working with drivers needs to be written as well. Deferring that glue until we have the necessary metadata in appdb.

There are lots of low hanging optimizations, but scaling isn't a concern while we're in scrappy mode.
2025-03-13 17:40:10 +02:00
Konstantin Darutkin
a68c2f475e feat: customize edit view data grid (#54787) 2025-03-13 09:40:46 +02:00
Denis Berezin
b26d24c90f Fix grid markup to show missing rows 2025-03-13 09:40:46 +02:00
Konstantin Darutkin
3beb60de0b feat(enterprise): add category dropdown editing (#54921) 2025-03-13 09:40:46 +02:00
Chris Truter
430d3ab213 FE for Table Editing v1 (#54289)
* API for reading tables

* Strip fields, add table

* Table editing view mode (#54218)

* Add basic component in ee

* Add table view with header

* Enable feature flag

* Code review fixes, remove token check

* Add basic e2e test for route

* Fix CI

* Boilerplate for new EE feature

* Basic CRUD URLs

* Spoke to Katya, fine to require actions being enabled

* Missing require

* Track the table-crud-apis branch

* Add simple table cell edit UI (#54679)

* Temp

* Add full code to edit a value

* Add proper feature toggle name

* Single click edit, use new api, add cell formatting

* Updated api usage

* Add feature toggle check

* Temp

* Add isolated EditingCell component

* Review fix - simplify state

* Add keyboard events

* Fixes after merge with master

* Fix be tests

* Fix fe e2e tests

* Clean-up

---------

Co-authored-by: Denis Berezin <denis.berezin@metabase.com>
2025-03-13 09:40:46 +02:00
Denis Berezin
59dd7e7dc3 Table editing view mode (#54218)
* Add basic component in ee

* Add table view with header

* Enable feature flag

* Code review fixes, remove token check

* Add basic e2e test for route

* Fix CI
2025-03-13 09:40:46 +02:00
597 changed files with 35798 additions and 4410 deletions

View File

@@ -401,9 +401,10 @@
clojure.test.check.properties/for-all clojure.core/let
clojurewerkz.quartzite.jobs/defjob clojure.core/defn
instaparse.core/defparser clojure.core/def
metabase-enterprise.data-editing.test-util/with-test-tables! clojure.core/let
metabase-enterprise.serialization.test-util/with-dbs clojure.core/fn
metabase-enterprise.serialization.test-util/with-random-dump-dir clojure.core/let
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
metabase-enterprise.content-translation.routes-test/with-new-secret-key! clojure.core/fn
metabase.actions.test-util/with-actions clojure.core/let
metabase.api.common/let-404 clojure.core/let
metabase.app-db.custom-migrations.util/with-temp-scheduler! clojure.core/fn
@@ -462,6 +463,9 @@ metabase-enterprise.content-translation.routes-test/with-new-secret-key!
metabase.test/with-temp-file clojure.core/let
metabase.test/with-user-in-groups clojure.core/let
metabase.upload.impl-test/with-upload-table! clojure.core/let
metabase.upload-test/with-upload-table! clojure.core/let
metabase.util/for-map clojure.core/for
metabase.util/with-timer-ms clojure.core/fn
metabase.util.files/with-open-path-to-resource clojure.core/let
metabase.util.malli.defn/defn schema.core/defn
metabase.util.malli.defn/defn- schema.core/defn
@@ -471,7 +475,6 @@ metabase-enterprise.content-translation.routes-test/with-new-secret-key!
metabase.util.malli/defn- schema.core/defn
metabase.util.malli/fn schema.core/fn
metabase.util.namespaces/import-fns potemkin/import-vars
metabase.util/with-timer-ms clojure.core/fn
metabase.xrays.domain-entities.malli/defn schema.core/defn
metabuild-common.core/with-open-jar-file-system clojure.core/let
metabuild-common.files/with-open-jar-file-system clojure.core/let

View File

@@ -48,14 +48,18 @@
{actions
{:team "Workflows"
:api #{metabase.actions.api
metabase.actions.args
metabase.actions.core
metabase.actions.init}
metabase.actions.init
metabase.actions.types}
:uses #{analytics
api
collections
driver
events
legacy-mbql
lib
lib-be
model-persistence
models
permissions
@@ -509,8 +513,16 @@
{:team "DevEx"
:api #{metabase.events.core
metabase.events.init}
:uses #{models
util}}
:uses #{api
audit-app
channel
config
driver
models
notification
premium-features
util
view-log}}
formatter
{:team "DashViz"
@@ -703,7 +715,8 @@
metabase.notification.init
metabase.notification.models
metabase.notification.payload.core}
:uses #{analytics
:uses #{actions
analytics
api
appearance
channel
@@ -711,6 +724,7 @@
dashboards
driver
events
lib
models
parameters
permissions
@@ -723,8 +737,10 @@
system
task
task-history
types
users
util}}
util
warehouse-schema}}
;; TODO -- too many API namespaces
parameters
@@ -864,7 +880,8 @@
metabase.queries.init
metabase.queries.models.query ; TODO FIXME
metabase.queries.schema} ; TODO FIXME
:uses #{analytics
:uses #{actions
analytics
analyze
api
app-db
@@ -1572,10 +1589,33 @@
enterprise/data-editing
{:team "Workflows"
:api #{metabase-enterprise.data-editing.api}
:api #{metabase-enterprise.data-editing.api
metabase-enterprise.data-editing.core
metabase-enterprise.data-editing.init}
:uses #{actions
api
util}}
driver
events
lib
models
notification
query-processor
upload
util
warehouse-schema}}
enterprise/data-editing-public
{:team "Workflows"
:api #{}
:uses #{actions
api
driver
events
lib
query-processor
upload
util
enterprise/data-editing}}
enterprise/database-routing
{:team "Admin Webapp"

View File

@@ -278,6 +278,7 @@
metabase.upload.impl-test/with-upload-table! [[:inner 0]]
metabase.upload.types/with-currency [[:default]]
metabase.upload.types/with-parens [[:default]]
metabase.util/for-map [[:block 1]]
metabase.util.async/cancelable-thread [[:block 0]]
metabase.util.date-2/with-time-zone-same-instant [[:default]]
metabase.util.encryption-test/with-secret-key [[:block 1]]

2
.gitignore vendored
View File

@@ -50,6 +50,7 @@
/lein-plugins/*/target
/local
/locales/metabase-*.pot
/logs
/modules/drivers/*/resources/namespaces.edn
/modules/drivers/*/target
/node_modules/
@@ -146,6 +147,7 @@ llm-payloads
.aider*
/config.yml
CLAUDE.local.md
.claude/settings.local.json
.eslintcache

View File

@@ -7,7 +7,7 @@ VERSION_PROPERTY_NAME="src_hash"
source-hash() {
# hash all the files that might change a backend-only uberjar build (for integration tests)
(
find src deps.edn resources/sample-database.db.mv.db -type f -print0 | xargs -0 shasum ;
find src enterprise/backend/src deps.edn resources/sample-database.db.mv.db -type f -print0 | xargs -0 shasum ;
find resources -type f \( -iname \*.clj -o -iname \*.edn -o -iname \*.yaml -o -iname \*.properties -o -iname \*.html \) -not -name "version.properties" -print0 | xargs -0 shasum ;
) | shasum | awk '{ print $1 }'
}

View File

@@ -36,7 +36,7 @@
com.clearspring.analytics/stream {:mvn/version "2.9.8" ; Various sketching algorithms
:exclusions [it.unimi.dsi/fastutil
org.slf4j/slf4j-api]}
com.clojure-goes-fast/jvm-alloc-rate-meter{:mvn/version "0.1.4"} ; Monitor heap allocation rate.
com.clojure-goes-fast/jvm-alloc-rate-meter {:mvn/version "0.1.4"} ; Monitor heap allocation rate.
com.clojure-goes-fast/jvm-hiccup-meter {:mvn/version "0.1.1"} ; Monitor GC pauses and other system-induced pauses.
com.draines/postal {:mvn/version "2.0.5"} ; SMTP library
com.fasterxml.woodstox/woodstox-core {:mvn/version "7.1.0"} ; trans dep of commons-codec
@@ -44,7 +44,7 @@
com.github.albfernandez/juniversalchardet {:mvn/version "2.5.0"}
com.github.blagerweij/liquibase-sessionlock {:mvn/version "1.6.9"} ; session lock for liquibase migrations
com.github.jknack/handlebars ^:antq/exclude ; 4.4.0 requires java 17+
{:mvn/version "4.3.1"} ; templating engine
{:mvn/version "4.3.1"} ; templating engine
com.github.pangloss/transducers {:git/sha "c91d39046c8c64dc31b84ebb27154a8084fcda4c" ; like medley for transducers
:git/url "https://github.com/pangloss/transducers"}
com.github.seancorfield/honeysql {:mvn/version "2.7.1310"} ; Honey SQL 2. SQL generation from Clojure data maps
@@ -53,15 +53,14 @@
com.github.vertical-blank/sql-formatter {:mvn/version "2.0.5"} ; Java SQL formatting library https://github.com/vertical-blank/sql-formatter
com.google.guava/guava {:mvn/version "33.4.8-jre"} ; dep for BigQuery, Spark, and GA. Require here rather than letting different dep versions stomp on each other — see comments on #9697
com.h2database/h2 ^:antq/exclude ; https://metaboat.slack.com/archives/CKZEMT1MJ/p1727191010259979
{:mvn/version "2.1.214"} ; embedded SQL database
{:mvn/version "2.1.214"} ; embedded SQL database
com.ibm.icu/icu4j {:mvn/version "77.1"} ; Used to transliterate Unicode characters into Latin characters
com.mchange/c3p0 {:mvn/version "0.10.1"} ; DB connection pool
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
{:mvn/version "1.0.1"} ; Snowplow analytics
com.taoensso/nippy {:mvn/version "3.5.0"} ; Fast serialization (i.e., GZIP) library for Clojure
com.vladsch.flexmark/flexmark {:mvn/version "0.64.8"} ; Markdown parsing
com.vladsch.flexmark/flexmark-ext-autolink
{:mvn/version "0.64.8"} ; Flexmark extension for auto-linking bare URLs
com.vladsch.flexmark/flexmark-ext-autolink {:mvn/version "0.64.8"} ; Flexmark extension for auto-linking bare URLs
commons-codec/commons-codec {:mvn/version "1.18.0"} ; Apache Commons -- useful codec util fns
commons-io/commons-io {:mvn/version "2.19.0"} ; Apache Commons -- useful IO util fns
commons-net/commons-net {:mvn/version "3.11.1"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533
@@ -87,7 +86,7 @@
;; TODO: replace back to normal artifact
io.github.metabase/macaw {:mvn/version "0.2.27"} ; Parse native SQL queries
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
{:mvn/version "1.7.1"} ; Support for retrying operations
{:mvn/version "1.7.1"} ; Support for retrying operations
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
junegunn/grouper {:mvn/version "0.1.1"} ; Batch processing helper
kixi/stats {:mvn/version "0.5.7" ; Various statistic measures implemented as transducers
@@ -843,7 +842,16 @@
;; clj -X:dev:ee:ee-dev:test:test/notification
:test/notification
{:exec-args {:only ["test/metabase/notification"
"test/metabase/channel"]}}
"test/metabase/channel"
"test/metabase/pulse"
"enterprise/backend/test/metabase_enterprise/notification"
metabase-enterprise.data-editing.notification-test]}}
;; clj -X:dev:ee:ee-dev:test:test/workflow
:test/workflow
{:exec-args {:only ["test/metabase/actions"
"enterprise/backend/test/metabase_enterprise/data_editing"
metabase.driver.sql-jdbc.actions-test]}}
;; test all MBQL related stuff: MLv2, the legacy shared `metabase.legacy-mbql` code, and the QP
;;

View File

@@ -55,6 +55,7 @@
[clojure.test]
[dev.debug-qp :as debug-qp]
[dev.explain :as dev.explain]
[dev.malli :as dev.malli]
[dev.memory :as dev.memory]
[dev.migrate :as dev.migrate]
[dev.model-tracking :as model-tracking]
@@ -106,6 +107,8 @@
pprint-sql]
[dev.explain
explain-query]
[dev.malli
visualize-schema!]
[dev.migrate
migrate!
rollback!

19
dev/src/dev/malli.clj Normal file
View File

@@ -0,0 +1,19 @@
(ns dev.malli
(:require
[clojure.java.shell :as sh]
[malli.dot :as md]
[metabase.util.malli.registry :as mr]
[ring.util.codec :as codec]))
(defn- graphviz-visualizer
[schema]
(format "https://dreampuf.github.io/GraphvizOnline/?engine=dot#%s"
(-> schema mr/resolve-schema md/transform codec/url-encode)))
(defn visualize-schema!
"Given a schema, visualize it using Graphviz.
(visualize-schema! [:map [:a :int] [:b :string]])
(visualize-schema! ::my/schema)"
[schema]
(let [url (graphviz-visualizer schema)]
(sh/sh "open" url)))

View File

@@ -38,7 +38,6 @@ export const createQuestionAlert = ({
subscriptions: [
{
type: "notification-subscription/cron",
event_name: null,
cron_schedule,
},
],

View File

@@ -58,7 +58,9 @@ export const viewEmailPage = (emailSubject) => {
const webmailInterface = `http://localhost:${WEB_PORT}`;
cy.window().then((win) => (win.location.href = webmailInterface));
cy.findByText(emailSubject).click();
if (emailSubject) {
cy.findByText(emailSubject).click();
}
};
export const openEmailPage = (emailSubject) => {
@@ -128,6 +130,24 @@ export function sendEmailAndVisitIt() {
});
}
export function checkEmailContent(subject, body) {
viewEmailPage(subject);
cy.get(".main-container").within(() => {
cy.get(".subject").should("contain", subject);
cy.get(".preview-iframe")
.its("0.contentDocument.body")
.should("not.be.empty")
.then(($body) => {
if (Array.isArray(body)) {
body.forEach((str) => expect($body).to.contain(str));
} else {
expect($body).to.contain(body);
}
});
});
}
export function sendAlertAndVisitIt() {
cy.intercept("POST", "/api/notification/send").as("testAlert");
cy.findByLabelText("New alert")

View File

@@ -0,0 +1,7 @@
export function setTableEditingEnabledForDB(dbId: number, enabled = true) {
return cy.request("PUT", `/api/database/${dbId}`, {
settings: {
"database-enable-table-editing": enabled,
},
});
}

View File

@@ -25,7 +25,11 @@ export const addNotificationHandlerChannel = (
if (hasNoChannelsAdded) {
modal().findByText("Add a destination").should("be.visible").click();
} else {
modal().findByText("Add another destination").should("be.visible").click();
modal()
.findByText("Add another destination")
.scrollIntoView()
.should("be.visible")
.click();
}
popover().findByText(channel).click();

View File

@@ -22,6 +22,7 @@ export * from "./e2e-embedding-sdk-assertion-helpers";
export * from "./e2e-element-visibility-helpers";
export * from "./e2e-enterprise-helpers";
export * from "./e2e-filter-helpers";
export * from "./e2e-internal-tools-helpers";
export * from "./e2e-ldap-helpers";
export * from "./e2e-misc-helpers";
export * from "./e2e-mock-app-settings-helpers";

View File

@@ -22,7 +22,7 @@ describe(
() => {
beforeEach(() => {
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
cy.intercept("POST", "/api/action/*/execute").as("executeAction");
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
cy.intercept("GET", "/api/action/*/execute?parameters=*").as(
"prefetchValues",
);

View File

@@ -18,7 +18,7 @@ const MODEL_NAME = "Test Action Model";
() => {
beforeEach(() => {
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
cy.intercept("GET", "/api/action").as("getActions");
cy.intercept("GET", "/api/action/v2/tmp-action").as("getActions");
cy.intercept("PUT", "/api/action/*").as("updateAction");
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
@@ -27,9 +27,7 @@ const MODEL_NAME = "Test Action Model";
"/api/dashboard/*/dashcard/*/execute?parameters=*",
).as("prefetchValues");
cy.intercept("POST", "/api/dashboard/*/dashcard/*/execute").as(
"executeAction",
);
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
});
H.describeWithSnowplow("adding and executing actions", () => {
@@ -1028,9 +1026,7 @@ const MODEL_NAME = "Test Action Model";
cy.wait("@executeAction").then((interception) => {
expect(
Object.values(interception.request.body.parameters)
.sort()
.join(","),
Object.values(interception.request.body.input).sort().join(","),
).to.equal("1,2020-01-01");
});
@@ -1056,14 +1052,12 @@ describe("action error handling", { tags: ["@external", "@actions"] }, () => {
modelName: MODEL_NAME,
});
cy.intercept("GET", "/api/action").as("getActions");
cy.intercept("GET", "/api/action/v2/tmp-action").as("getActions");
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
cy.intercept("GET", "/api/dashboard/*/dashcard/*/execute?parameters=*").as(
"prefetchValues",
);
cy.intercept("POST", "/api/dashboard/*/dashcard/*/execute").as(
"executeAction",
);
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
});
it("should show detailed form errors for constraint violations when executing model actions", () => {
@@ -1107,7 +1101,7 @@ describe(
() => {
beforeEach(() => {
cy.intercept("GET", /\/api\/card\/\d+/).as("getModel");
cy.intercept("GET", "/api/action").as("getActions");
cy.intercept("GET", "/api/action/v2/tmp-action").as("getActions");
cy.intercept("PUT", "/api/action/*").as("updateAction");
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
@@ -1227,6 +1221,7 @@ describe(
waitForValidActions();
cy.findByRole("dialog").within(() => {
cy.findByText("Models").click();
cy.findByText(MODEL_NAME).click();
cy.findByText(ACTION_NAME).click();
@@ -1289,6 +1284,7 @@ function createDashboardWithActionButton({
waitForValidActions();
cy.findByRole("dialog").within(() => {
cy.findByText("Models").click();
cy.findByText(modelName).click();
cy.findByText(actionName).click();
});
@@ -1376,7 +1372,9 @@ function getActionParametersInputModal() {
function waitForValidActions() {
cy.wait("@getActions").then(({ response }) => {
const { body: actions } = response;
const {
body: { actions },
} = response;
actions.forEach((action) => {
expect(action.parameters).to.have.length.gt(0);

View File

@@ -252,6 +252,7 @@ describe("issue 51020", () => {
cy.findByLabelText("Add action").click();
cy.button("Pick an action").click();
H.modal().within(() => {
cy.findByText("Models").click();
cy.findByText(modelName).click();
cy.findByText("Update").click();
cy.findAllByText("Ask the user").eq(0).click();
@@ -430,7 +431,7 @@ describe("issue 32840", () => {
H.visitModel(modelId);
});
cy.intercept("POST", "/api/action/*/execute").as("executeAction");
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
});
it("uses correct timestamp when executing implicit update action (metabase#32840)", () => {

View File

@@ -80,7 +80,7 @@ describe(
cy.intercept("GET", "/api/action/*").as("getAction");
cy.intercept("GET", "/api/action?model-id=*").as("getModelAction");
cy.intercept("PUT", "/api/action/*").as("updateAction");
cy.intercept("POST", "/api/action/*/execute").as("executeAction");
cy.intercept("POST", "/api/action/v2/execute").as("executeAction");
cy.intercept("POST", "/api/action").as("createAction");
cy.intercept("GET", "/api/table/*/query_metadata*").as("fetchMetadata");
cy.intercept("GET", "/api/search?archived=true").as("getArchived");

View File

@@ -343,10 +343,11 @@ describe("issues 55617, 55618", () => {
cy.findAllByPlaceholderText("Select a target")
.should("have.length", 2)
.eq(0)
.should("have.value", "People → ID");
.should("have.value", "Products → ID");
cy.findAllByPlaceholderText("Select a target")
.eq(1)
.should("have.value", "Products → ID");
.should("have.value", "People → ID");
cy.findAllByPlaceholderText("Select a target").eq(0).click();
H.popover().within(() => {
cy.findByText("Orders → ID").should("be.visible");
@@ -378,10 +379,10 @@ describe("issues 55617, 55618", () => {
.should("have.value", "People → ID")
.click();
H.popover().within(() => {
cy.findByText("Orders → ID").should("be.visible");
cy.findByText("People → ID").should("be.visible");
cy.findByText("Products → ID").should("be.visible");
cy.findByText("Reviews → ID").should("be.visible").click();
cy.findByText("Orders → ID").should("exist");
cy.findByText("People → ID").should("exist");
cy.findByText("Products → ID").should("exist");
cy.findByText("Reviews → ID").should("exist").click();
});
cy.findByPlaceholderText("Select a target").should(
"have.value",

View File

@@ -2187,12 +2187,12 @@ describe("scenarios > dashboard > dashboard cards > click behavior", () => {
],
expressions: ["Net"],
filters: [
"Product → Title is Doohickey",
"Product → Vendor is Doohickey",
"ID is 7021",
"Net is equal to -80",
"Reviews - Product → Reviewer is Doohickey",
"User → Longitude is equal to -80",
"Product → Vendor is Doohickey",
"Reviews - Product → Reviewer is Doohickey",
"ID is 7021",
"Product → Title is Doohickey",
"Net is equal to -80",
],
aggregations: ["Count", "Sum of Total"],
breakouts: [

View File

@@ -1529,6 +1529,7 @@ describe("issue 48878", () => {
cy.button("Add action").click();
cy.button("Pick an action").click();
H.modal().within(() => {
cy.findByText("Models").click();
cy.findByText("SQL Model").click();
cy.findByText("Test action").click();
cy.button("Done").click();

View File

@@ -0,0 +1,230 @@
import { SAMPLE_DB_ID, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import { getDashboardCard } from "e2e/support/helpers";
const { H } = cy;
describe("scenarios > data editing > actions", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
cy.intercept("PUT", "/api/dashboard/*").as("saveDashboard");
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
});
it("should allow to assign a table action to a editable table dashcard", () => {
H.createDashboard().then(({ body: { id } }) => {
H.visitDashboard(id);
});
H.createDashboard({ name: "13736 Dashboard" }).then(
({ body: { id: dashboardId } }) => {
cy.wrap(dashboardId).as("dashboardId");
},
);
H.editDashboard();
cy.findByLabelText("Add editable table").click();
cy.findByTestId("add-table-sidebar").findByText("Orders").click();
getDashboardCard(0)
.realHover()
.within(() => {
cy.findByLabelText("Configure").click();
});
cy.findByTestId("configure-editable-table-sidebar").within(() => {
cy.findByText("Actions").click();
cy.findByText("Add new connected action").click();
});
cy.findByTestId("table-action-settings-modal").should("be.visible");
H.modal().within(() => {
cy.findByText("People").click();
cy.findByText("Update").click();
cy.findByText("Choose a new action").should("be.visible");
cy.findByText(
"Where should the values for this row action come from?",
).should("be.visible");
cy.findByTestId("editable-text")
.click()
.type("{SelectAll}My Update Action");
cy.button("Done").scrollIntoView().click();
});
cy.findByTestId("editable-table-connected-actions-list")
.findByText("My Update Action")
.should("be.visible");
cy.findByTestId("edit-bar").findByText("Save").click();
cy.wait("@saveDashboard");
getDashboardCard(0).within(() => {
cy.findByText("Row Actions").should("be.visible");
cy.findAllByText("My Update Action").first().should("be.visible");
cy.findAllByText("My Update Action").first().click();
});
cy.findByTestId("data-grid-action-execute-modal")
.findByText("My Update Action")
.should("be.visible");
});
});
describe(
"scenarios > data editing > action picker",
{ tags: ["@external"] },
() => {
beforeEach(() => {
H.restore("postgres-12");
cy.signInAsAdmin();
H.setTokenFeatures("all");
cy.intercept("PUT", "/api/dashboard/*").as("saveDashboard");
cy.intercept("GET", `/api/action/v2/database/${SAMPLE_DB_ID}/table`).as(
"getSampleDbTables",
);
cy.intercept("GET", `/api/action/v2/database/${WRITABLE_DB_ID}/table`).as(
"getPostgresTables",
);
H.setTableEditingEnabledForDB(WRITABLE_DB_ID);
H.setActionsEnabledForDB(WRITABLE_DB_ID);
});
it("should allow to pick an action for editable table dashcard", () => {
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
H.createModelFromTableName({
tableName: "products",
modelName: "Products Model Test",
});
H.createDashboard().then(({ body: { id } }) => {
H.visitDashboard(id);
});
H.createDashboard({ name: "13736 Dashboard" }).then(
({ body: { id: dashboardId } }) => {
cy.wrap(dashboardId).as("dashboardId");
},
);
H.editDashboard();
cy.findByLabelText("Add editable table").click();
cy.findByTestId("add-table-sidebar").within(() => {
cy.findByText("Sample Database").click();
cy.findByText("Orders").click();
});
getDashboardCard(0)
.realHover()
.within(() => {
cy.findByLabelText("Configure").click();
});
cy.findByTestId("configure-editable-table-sidebar").within(() => {
cy.findByText("Actions").click();
cy.findByText("Add new connected action").click();
});
H.modal().within(() => {
cy.findByText("Pick an action to add").should("be.visible");
cy.log("should display list of databases");
cy.findByText("QA Postgres12").should("be.visible");
cy.findByText("Sample Database").should("be.visible");
cy.log("should have editable table db preselected");
cy.findByText("Sample Database")
.closest('[role="link"]')
.should("have.attr", "data-active");
cy.log("should allow to pick an action");
cy.findByText("QA Postgres12").click();
cy.wait("@getPostgresTables");
cy.findByText("Reviews").should("be.visible").click();
cy.findByText("Create or Update").click();
cy.findByText(
"Where should the values for this row action come from?",
).should("be.visible");
cy.log(
'should preselect current action parent entity on "choose new action"',
);
cy.findByText("Choose a new action").click();
cy.findByText("Reviews")
.closest('[role="link"]')
.should("have.attr", "data-active");
cy.findByText("QA Postgres12")
.closest('[role="link"]')
.should("have.attr", "data-active");
cy.findByText("Models").click();
cy.findByText("Products Model Test").should("be.visible");
cy.findByText("Orders Model").should("be.visible");
cy.findByText("Products Model Test").click();
cy.findByText("There are no actions for this model").should(
"be.visible",
);
cy.findByText("Create a new action").click();
});
cy.findByTestId("action-creator-modal")
.should("be.visible")
.within(() => {
H.fillActionQuery("SELECT 1");
cy.findByText("Save").click();
});
H.modal()
.should("have.length", 3)
.last()
.within(() => {
cy.findByPlaceholderText("My new fantastic action")
.type("New Action 3000")
.blur();
cy.findByText("Create").click();
});
H.modal()
.first()
.within(() => {
cy.findByText("Pick an action to add").should("be.visible");
cy.findByText("New Action 3000").should("be.visible").click();
});
H.modal().within(() => {
cy.findByText("This action has no parameters to map").should(
"be.visible",
);
cy.findByText("New Action 3000").should("be.visible");
cy.findByText("Done").click();
});
cy.findByTestId("editable-table-connected-actions-list")
.findByText("New Action 3000")
.should("be.visible");
});
},
);

View File

@@ -0,0 +1,634 @@
import { USERS, WRITABLE_DB_ID } from "e2e/support/cypress_data";
import {
createNotificationWithCustomTemplate,
fillInCustomTemplate,
verifyNotificationTemplateResponse,
} from "./e2e-helpers/notification-helpers";
import { setupAlert } from "./e2e-helpers/setup";
const { H } = cy;
const TABLE_NAME = "colors27745";
const ADMIN_NAME = `${USERS.admin.first_name} ${USERS.admin.last_name}`;
describe("scenarios > data editing > setting alerts", () => {
beforeEach(() => {
cy.intercept("GET", "/api/notification").as("getNotification");
cy.intercept("POST", "/api/notification").as("createNotification");
cy.intercept("POST", "/api/action/v2/execute-bulk").as("executeBulk");
cy.log("Setting up writable PostgresDB");
H.restore("postgres-writable");
H.resetTestTable({
type: "postgres",
table: TABLE_NAME,
});
cy.signInAsAdmin();
H.resyncDatabase({
dbId: WRITABLE_DB_ID,
tableName: TABLE_NAME,
});
H.setTokenFeatures("all");
H.setupSMTP();
H.setTableEditingEnabledForDB(WRITABLE_DB_ID);
});
it("should have icon to set alerts", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").should("be.visible");
});
describe("Show list of existing alerts", () => {
it("should show list of existing alerts", () => {
H.getTableId({
name: TABLE_NAME,
}).then((tableId) => {
setupAlert(tableId, "event/row.created");
setupAlert(tableId, "event/row.updated");
setupAlert(tableId, "event/row.deleted");
});
openDatabaseTable();
cy.then(() => {
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("alert-list-modal")
.should("be.visible")
.within(() => {
cy.findByText("Notify when new records are created").should(
"be.visible",
);
cy.findByText("Notify when records are updated").should(
"be.visible",
);
cy.findByText("Notify when records are deleted").should(
"be.visible",
);
});
});
});
});
describe("Trigger an alert for each event type", () => {
describe("create event", () => {
it("should create an alert for 'row created' events with default template", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when new records are created/i,
})
.click();
cy.findByRole("button", { name: "Done" }).click();
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-data-view-header").within(() => {
cy.findByText("New record").click();
});
H.modal().within(() => {
cy.findByRole("combobox").eq(0).type("black");
cy.document().findByRole("listbox").eq(0).click();
cy.findByRole("button", { name: "Create new record" }).click();
cy.wait("@executeBulk");
});
cy.log("Testing default email template");
H.checkEmailContent(
`A new record was added to "${TABLE_NAME}" by ${ADMIN_NAME}`,
`A new record was created in Table ${TABLE_NAME}`,
);
});
it("should create an alert for 'row created' events with custom template", () => {
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
createNotificationWithCustomTemplate(
/when new records are created/i,
CUSTOM_SUBJECT,
CUSTOM_BODY,
).then((interception) => {
verifyNotificationTemplateResponse(
interception,
CUSTOM_SUBJECT,
CUSTOM_BODY,
);
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-data-view-header").within(() => {
cy.findByText("New record").click();
});
H.modal().within(() => {
cy.findByRole("combobox").eq(0).type("black");
cy.document().findByRole("listbox").eq(0).click();
cy.findByRole("button", { name: "Create new record" }).click();
});
cy.wait("@executeBulk");
cy.log("Testing default email template");
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
"id",
"black",
]);
});
});
describe("update event", () => {
it("should trigger an alert for 'row updated' events with default template", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when any cell changes it's value/i,
})
.click();
cy.findByRole("button", { name: "Done" }).click();
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-body").within(() => {
cy.findByText("red").click();
cy.focused().type("blue").blur();
});
cy.wait("@executeBulk");
cy.log("Testing default email template");
H.checkEmailContent(
`A record was updated in "${TABLE_NAME}" by ${ADMIN_NAME}`,
[`A record was updated in Table ${TABLE_NAME}`, "redblue"],
);
});
it("should trigger an alert for 'row updated' events with custom template", () => {
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
createNotificationWithCustomTemplate(
/when any cell changes it's value/i,
CUSTOM_SUBJECT,
CUSTOM_BODY,
).then((interception) => {
verifyNotificationTemplateResponse(
interception,
CUSTOM_SUBJECT,
CUSTOM_BODY,
);
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-body").within(() => {
cy.findByText("red").click();
cy.focused().type("blue").blur();
});
cy.wait("@executeBulk");
cy.log("Testing custom email template");
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
"id",
"name",
"redblue",
]);
});
it("should trigger an alert for 'row updated' events when condition is met", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when any cell changes it's value/i,
})
.click();
cy.findByText("Add conditions for alerts").click();
cy.findByPlaceholderText("Select column").click();
cy.document()
.findByRole("option", {
name: /name/i,
})
.click();
cy.findByPlaceholderText("Select value").click().type("white").blur();
cy.findByRole("button", { name: "Done" }).click();
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-body").within(() => {
cy.findByText("red").click();
cy.focused().type("{selectall}{backspace}white").blur();
});
cy.wait("@executeBulk");
cy.log("Testing custom email template");
H.checkEmailContent(
`A record was updated in "${TABLE_NAME}" by ${ADMIN_NAME}`,
[`A record was updated in Table ${TABLE_NAME}`, "white"],
);
});
it("should not trigger an alert for 'row updated' events when condition is not met", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when any cell changes it's value/i,
})
.click();
cy.findByText("Add conditions for alerts").click();
cy.findByPlaceholderText("Select column").click();
cy.document()
.findByRole("option", {
name: /name/i,
})
.click();
cy.findByPlaceholderText("Select value").click().type("white").blur();
cy.findByRole("button", { name: "Done" }).click();
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-body").within(() => {
cy.findByText("red").click();
cy.focused().type("{selectall}{backspace}black").blur();
});
cy.wait("@executeBulk");
// Alert on updated value was not sent, since it didn't meet the condition.
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body.length).to.equal(1);
expect(body[0].subject).to.include("You set up a table notification");
});
});
});
describe("delete event", () => {
it("should trigger an alert for 'row deleted' events with default template", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when records are deleted/i,
})
.click();
cy.findByRole("button", { name: "Done" }).click();
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-body").within(() => {
cy.get("[data-index=0]").within(() => {
cy.findByRole("checkbox").click();
});
});
cy.findByTestId("table-data-view-header").within(() => {
cy.findByText("Delete").click();
cy.document().findByText("Delete 1 record").click();
cy.wait("@executeBulk");
});
cy.log("Testing default email template");
H.checkEmailContent(
`A record was deleted from "${TABLE_NAME}" by ${ADMIN_NAME}`,
[`A record was deleted in Table ${TABLE_NAME}`],
);
});
it("should trigger an alert for 'row deleted' events with custom template", () => {
const CUSTOM_SUBJECT = "My custom subject for {{table.name}}";
const CUSTOM_BODY = "{{#each record}} {{@key}}: {{@value}} {{/each}}";
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
createNotificationWithCustomTemplate(
/when records are deleted/i,
CUSTOM_SUBJECT,
CUSTOM_BODY,
).then((interception) => {
verifyNotificationTemplateResponse(
interception,
CUSTOM_SUBJECT,
CUSTOM_BODY,
);
});
cy.findByTestId("table-notification-create").should("not.exist");
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert created.").should("be.visible");
});
H.getInbox().then(({ body }: { body: { subject: string }[] }) => {
expect(body[0].subject).to.include("You set up a table notification");
});
cy.findByTestId("table-body").within(() => {
cy.get("[data-index=0]").within(() => {
cy.findByRole("checkbox").click();
});
});
cy.findByTestId("table-data-view-header").within(() => {
cy.findByText("Delete").click();
cy.document().findByText("Delete 1 record").click();
cy.wait("@executeBulk");
});
cy.log("Testing custom email template");
H.checkEmailContent(`My custom subject for ${TABLE_NAME}`, [
"id",
"name",
]);
});
});
});
describe("Update an existing alert", () => {
it("should update an existing alert", () => {
H.getTableId({
name: TABLE_NAME,
}).then((tableId) => {
setupAlert(tableId, "event/row.created");
});
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("alert-list-modal")
.should("be.visible")
.within(() => {
cy.findByText("Notify when new records are created")
.should("be.visible")
.click();
});
cy.findByTestId("table-notification-create").within(() => {
cy.findByText("Edit alert").should("be.visible");
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when any cell changes it's value/i,
})
.click();
cy.findByRole("button", { name: "Save changes" }).click();
});
cy.findByTestId("toast-undo").within(() => {
cy.findByText("Alert updated.").should("be.visible");
});
});
});
describe("Delete an existing alert", () => {
it("should delete an existing alert", () => {
H.getTableId({
name: TABLE_NAME,
}).then((tableId) => {
setupAlert(tableId, "event/row.created");
});
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("alert-list-modal")
.should("be.visible")
.within(() => {
cy.findByText("Notify when new records are created").realHover();
cy.root().findByLabelText("Delete this alert").click();
cy.document()
.findByTestId("delete-confirm")
.findByText("Delete it")
.click();
});
cy.findByTestId("toast-undo").within(() => {
cy.findByText("The alert was successfully deleted.").should(
"be.visible",
);
});
});
});
describe("Template preview", () => {
it("should display default template preview", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when new records are created/i,
})
.click();
cy.intercept("POST", "/api/notification/preview_template").as(
"previewTemplate",
);
cy.findByLabelText("Show preview").click();
cy.wait("@previewTemplate");
cy.findByTestId("preview-template-panel").should("be.visible");
cy.findByTestId("preview-template-panel").within(() => {
cy.root().contains(`A new record was added to "${TABLE_NAME}"`);
cy.root().contains(`A new record was created in Table ${TABLE_NAME}`);
});
});
});
it("should display custom template preview", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when new records are created/i,
})
.click();
fillInCustomTemplate(
"My custom subject for {{table.name}}",
"{{#each record}} {{@key}}: {{@value}} {{/each}}",
);
cy.intercept("POST", "/api/notification/preview_template").as(
"previewTemplate",
);
cy.findByLabelText("Show preview").click();
cy.wait("@previewTemplate");
cy.findByTestId("preview-template-panel").should("be.visible");
cy.findByTestId("preview-template-panel").within(() => {
cy.root().contains(`My custom subject for ${TABLE_NAME}`);
cy.root().should("not.contain", "a new record was created");
cy.root().contains(/id.*name/);
});
});
});
it("should display error message if template is incorrect", () => {
openDatabaseTable();
cy.findByTestId("table-notifications-trigger").click();
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: /when new records are created/i,
})
.click();
fillInCustomTemplate(
"My custom subject for {{table.name}}",
"{{/each}}",
);
cy.intercept("POST", "/api/notification/preview_template").as(
"previewTemplate",
);
cy.findByLabelText("Show preview").click();
cy.wait("@previewTemplate");
cy.findByTestId("preview-template-panel").should("be.visible");
cy.findByTestId("preview-template-panel").within(() => {
cy.root().should("not.contain", "My custom subject for");
cy.root().should("contain", "Failed to render template");
cy.root().contains(/found:.*expected:/);
});
});
});
});
});
function openDatabaseTable() {
H.getTableId({
name: TABLE_NAME,
}).then((tableId) => {
cy.visit(`/browse/databases/${WRITABLE_DB_ID}/tables/${tableId}/edit`);
});
}

View File

@@ -0,0 +1,136 @@
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
import { SAMPLE_DATABASE } from "e2e/support/cypress_sample_database";
const { H } = cy;
const { ORDERS_ID } = SAMPLE_DATABASE;
describe("scenarios > data-editing", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
cy.intercept("GET", "/api/database").as("getDatabases");
cy.intercept("GET", "/api/table/*").as("getTable");
});
it("should allow to open table data page", () => {
openDatabaseTable("People");
cy.wait("@getTable");
cy.findByTestId("main-navbar-root").should("not.be.visible");
cy.findByTestId("table-data-view-root")
.should("be.visible")
.within(() => {
cy.findByText("People").should("be.visible");
cy.findByText("Explore").should("be.visible");
cy.findByText("Edit").should("be.visible");
cy.findByTestId("table-root").should("be.visible");
});
});
it("should allow to open table data edit mode", () => {
openDatabaseTable("People");
cy.wait("@getTable");
cy.findByTestId("table-data-view-root").findByText("Edit").click();
cy.findByTestId("edit-table-data-root")
.should("be.visible")
.within(() => {
cy.findByText("People").should("be.visible");
cy.findByTestId("head-crumbs-container")
.findByText("Edit")
.should("be.visible");
cy.findByTestId("table-root").should("be.visible");
});
cy.findByTestId("head-crumbs-container").findByText("People").click();
cy.findByTestId("table-data-view-root").should("be.visible");
});
it("should allow to open table data explore mode - query builder", () => {
openDatabaseTable("People");
cy.wait("@getTable");
cy.findByTestId("table-data-view-root").findByText("Explore").click();
cy.findByTestId("query-builder-root")
.should("be.visible")
.within(() => {
cy.findByText("Sample Database");
cy.findByText("People");
});
});
describe("db setting is disabled", () => {
it("should not allow to open table data view by default", () => {
H.setTableEditingEnabledForDB(SAMPLE_DB_ID, false);
openDatabaseTable("People");
cy.findByTestId("query-builder-root")
.should("be.visible")
.within(() => {
cy.findByText("Sample Database");
cy.findByText("People");
});
});
});
describe("table edit mode", () => {
beforeEach(() => {
cy.intercept("GET", `/api/table/${ORDERS_ID}/query_metadata`).as(
"getOrdersTable",
);
cy.visit(`/browse/databases/${SAMPLE_DB_ID}/tables/${ORDERS_ID}/edit`);
cy.wait("@getOrdersTable");
});
it("should allow to sort", () => {
cy.findByTestId("table-header").within(() => {
cy.findByText("Quantity").click();
cy.findAllByTestId("header-sort-indicator").should("have.length", 1);
cy.findByText("Quantity")
.closest("[role=button]")
.findByLabelText("chevronup icon")
.should("be.visible");
cy.findByText("Quantity").click();
cy.findByText("Quantity")
.closest("[role=button]")
.findByLabelText("chevrondown icon")
.should("be.visible");
cy.findByText("Quantity").click();
cy.findAllByTestId("header-sort-indicator").should("have.length", 0);
});
});
});
});
function openDatabaseTable(tableName: string) {
cy.visit("/browse/databases");
cy.wait("@getDatabases");
cy.findByTestId("database-browser").findByText("Sample Database").click();
cy.findByTestId("browse-schemas").findByText(tableName).click();
}

View File

@@ -0,0 +1,85 @@
// Using type import to avoid runtime dependency on cypress types
type Interception = {
response?: {
statusCode?: number;
body?: {
handlers?: Array<{
template?: Record<string, unknown>;
}>;
};
};
};
export const fillInCustomTemplate = (subject: string, body: string) => {
cy.findByTestId("email-template-subject")
.find(".cm-content")
.then(([contentElement]) => {
const cm = contentElement.cmView?.view;
if (cm) {
cm.contentDOM.focus();
cm.dispatch({
changes: {
from: 0,
to: cm.state.doc.length,
insert: subject,
},
});
cm.contentDOM.blur();
}
cy.wait(200);
cy.document()
.findByTestId("email-template-body")
.find(".cm-content")
.then(([contentElement]) => {
const cm = contentElement.cmView?.view;
if (cm) {
cm.contentDOM.focus();
cm.dispatch({
changes: {
from: 0,
to: cm.state.doc.length,
insert: body,
},
});
cm.contentDOM.blur();
}
});
});
};
export const verifyNotificationTemplateResponse = (
response: Interception,
expectedSubject: string,
expectedBody: string,
) => {
expect(response?.response?.statusCode).to.equal(200);
const stringifiedTemplate = JSON.stringify(
response?.response?.body?.handlers?.[0]?.template,
);
expect(stringifiedTemplate).to.contain(expectedSubject);
expect(stringifiedTemplate).to.contain(expectedBody);
};
export const createNotificationWithCustomTemplate = (
eventName: string | RegExp,
subject: string,
body: string,
) => {
cy.findByTestId("table-notification-create").within(() => {
cy.findByTestId("notification-event-select").click();
cy.document()
.findByRole("option", {
name: eventName,
})
.click();
fillInCustomTemplate(subject, body);
cy.findByRole("button", { name: "Done" }).click();
});
// Wait for the notification to be created and return the response
return cy.wait("@createNotification");
};

View File

@@ -0,0 +1,26 @@
import { ADMIN_USER_ID } from "e2e/support/cypress_sample_instance_data";
export const setupAlert = (tableId: number, eventName: string) => {
return cy.request("POST", "/api/notification", {
payload_type: "notification/system-event",
payload: { event_name: eventName, table_id: tableId },
payload_id: null,
handlers: [
{
channel_type: "channel/email",
recipients: [
{
type: "notification-recipient/user",
user_id: ADMIN_USER_ID,
details: null,
},
],
},
],
condition: [
"and",
["=", ["context", "table_id"], tableId],
["=", ["context", "event_name"], eventName],
],
});
};

View File

@@ -126,7 +126,7 @@ describe("scenarios > account > notifications", () => {
.findByText("Successfully unsubscribed.")
.should("exist");
cy.findByTestId("alert-delete").within(() => {
cy.findByTestId("delete-confirm").within(() => {
cy.findByText("You’re unsubscribed. Delete this alert as well?");
cy.findByText("Delete it").click();
});

View File

@@ -53,7 +53,8 @@ describe("impersonated permission", { tags: "@external" }, () => {
setImpersonatedPermission();
});
it("have limited access", () => {
// TODO [WRK]: refactor this test to check data editing
it.skip("have limited access", () => {
cy.signInAsImpersonatedUser();
cy.visit(`/browse/databases/${PG_DB_ID}`);

View File

@@ -53,7 +53,7 @@ const SUM_OF_TOTAL_MONTH_EXCLUDE_MAY_AUG = {
"!=",
[
"field",
"CREATED_AT",
ORDERS.CREATED_AT,
{
"base-type": "type/DateTime",
"temporal-unit": "month-of-year",

View File

@@ -15,6 +15,8 @@
[metabase-enterprise.billing.api.routes]
[metabase-enterprise.content-translation.routes]
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.data-editing-public.api]
[metabase-enterprise.data-editing.api]
[metabase-enterprise.database-routing.api]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.llm.api]
@@ -43,6 +45,7 @@
:collection-cleanup (deferred-tru "Collection Cleanup")
:content-translation (deferred-tru "Content translation")
:etl-connections (deferred-tru "ETL Connections")
:table-data-editing (deferred-tru "Editing Table Data")
:llm-autodescription (deferred-tru "LLM Auto-description")
:metabot-v3 (deferred-tru "MetaBot")
:scim (deferred-tru "SCIM configuration")
@@ -77,6 +80,8 @@
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
"/content-translation" (premium-handler metabase-enterprise.content-translation.routes/routes :content-translation)
"/data-editing" (premium-handler metabase-enterprise.data-editing.api/routes :table-data-editing)
"/data-editing-public" (premium-handler metabase-enterprise.data-editing-public.api/routes :table-data-editing)
"/database-routing" (premium-handler metabase-enterprise.database-routing.api/routes :database-routing)
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
(premium-handler :attached-dwh)

View File

@@ -9,6 +9,7 @@
[metabase-enterprise.advanced-config.init]
[metabase-enterprise.audit-app.init]
[metabase-enterprise.cache.init]
[metabase-enterprise.data-editing.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]
[metabase-enterprise.metabot-v3.init]

View File

@@ -0,0 +1,113 @@
(ns metabase-enterprise.data-editing.actions
(:require
[metabase-enterprise.data-editing.data-editing :as data-editing]
[metabase-enterprise.data-editing.models.undo :as undo]
[metabase.actions.args :as actions.args]
[metabase.actions.core :as actions]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[methodical.core :as methodical])
(:import
(clojure.lang ExceptionInfo)))
(derive :data-grid.row/create :data-grid.row/common)
(derive :data-grid.row/create-or-update :data-grid.row/common)
(derive :data-grid.row/update :data-grid.row/common)
(derive :data-grid.row/delete :data-grid.row/common)
(defmethod actions/action-arg-map-schema :data-grid.row/common
[_action]
::actions.args/table.common)
(defmethod actions/normalize-action-arg-map :data-grid.row/common
[_action input]
(-> (assoc input :database (:id (actions/cached-database-via-table-id (:table-id input))))
(update :row #(update-keys % u/qualified-name))))
(defn- perform-table-row-action!
"Perform an action directly, skipping permissions checks etc, and generate outputs based on the table modifications."
[action-kw context inputs xform-outputs]
(update (actions/perform-nested-action! action-kw context inputs)
:outputs
xform-outputs))
(defn- post-process [outputs]
(for [[table-id diffs] (u/group-by :table-id identity outputs)
:let [pretty-rows (data-editing/invalidate-and-present! table-id (map :row diffs))]
[op pretty-row] (map vector (map :op diffs) pretty-rows)]
{:op op, :table-id table-id, :row pretty-row}))
(defn- coerce-inputs [inputs]
(let [input->coerced (u/for-map [[table-id inputs] (group-by :table-id inputs)
:let [coerced (data-editing/apply-coercions table-id (map :row inputs))]
input+row (map vector inputs coerced)]
input+row)]
(for [input inputs]
(u/prog1 (assoc input :row (input->coerced input))
(log/tracef "coerce row %s => %s" (:row input) (:row <>))))))
(defn- perform-data-grid-action! [action-kw context inputs]
(let [next-inputs (coerce-inputs inputs)]
(perform-table-row-action! action-kw context next-inputs
(if (= :table.row/delete action-kw)
identity
post-process))))
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/create]
[_action context inputs]
(perform-data-grid-action! :table.row/create context inputs))
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/update]
[_action context inputs]
(perform-data-grid-action! :table.row/update context inputs))
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/delete]
[_action context inputs]
(perform-data-grid-action! :table.row/delete context inputs))
(mu/defmethod actions/default-mapping :data-grid.row/common
[_ scope]
(assoc (select-keys scope [:table-id]) :row :metabase-enterprise.data-editing.api/root))
(mu/defmethod actions/default-mapping :data-grid.row/delete
[_ scope]
(assoc (select-keys scope [:table-id])
;; TODO yeah, a shorter namespace would be nice, that doesn't require importing from EE
:row :metabase-enterprise.data-editing.api/input
:delete-children [:metabase-enterprise.data-editing.api/param :delete-children]))
;; undo
(defmethod actions/action-arg-map-schema :data-editing/undo [_action] :map)
(defmethod actions/action-arg-map-schema :data-editing/redo [_action] :map)
(defmethod actions/normalize-action-arg-map :data-editing/undo [_action _input] {})
(defmethod actions/normalize-action-arg-map :data-editing/redo [_action _input] {})
(defn- translate-undo-error [e]
(case (:error (ex-data e))
:undo/none (ex-info (tru "Nothing to do") {:status-code 204} e)
:undo/cannot-undelete (ex-info (tru "You cannot undo your previous change.") {:status-code 405} e)
:undo/cannot-undo (ex-info (tru "Your previous change cannot be undone") {:status-code 405} e)
:undo/conflict (ex-info (tru "Your previous change has a conflict with another edit") {:status-code 409} e)
e))
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-editing/undo]
[_action context _inputs]
(try
{:context context
:outputs (undo/undo! context (:user-id context) (:scope context))}
(catch ExceptionInfo e
(throw (translate-undo-error e)))))
(methodical/defmethod actions/perform-action!* [:sql-jdbc :data-editing/redo]
[_action context _inputs]
(try
{:context context
:outputs (undo/redo! context (:user-id context) (:scope context))}
(catch ExceptionInfo e
(throw (translate-undo-error e)))))

View File

@@ -0,0 +1,551 @@
(ns metabase-enterprise.data-editing.api
(:require
[clojure.walk :as walk]
[metabase-enterprise.data-editing.configure :as data-editing.configure]
[metabase-enterprise.data-editing.data-editing :as data-editing]
[metabase-enterprise.data-editing.describe :as data-editing.describe]
[metabase.actions.core :as actions]
[metabase.actions.types :as types]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver :as driver]
[metabase.util :as u]
[metabase.util.i18n :as i18n]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn require-authz?
"Temporary hack to have auth be off by default, only on if MB_DATA_EDITING_AUTHZ=true.
Remove once auth policy is fixed for dashboards."
[]
(= "true" (System/getenv "MB_DATA_EDITING_AUTHZ")))
(defn- check-permissions []
(when (require-authz?)
(api/check-superuser)))
(declare execute!*)
(api.macros/defendpoint :post "/table/:table-id"
"Insert row(s) into the given table."
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
{}
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
(check-permissions)
(let [scope {:table-id table-id}]
{:created-rows (map :row (execute!* "data-grid.row/create" scope {} rows))}))
(api.macros/defendpoint :put "/table/:table-id"
"Update row(s) within the given table."
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
{}
{:keys [rows pks updates]}
:- [:multi {:dispatch #(cond
(:rows %) :mixed-updates
(:pks %) :uniform-updates)}
[:mixed-updates [:map [:rows [:sequential {:min 1} :map]]]]
[:uniform-updates [:map
[:pks [:sequential {:min 1} :map]]
[:updates :map]]]]]
(check-permissions)
(if (empty? (or rows pks))
{:updated []}
(let [scope {:table-id table-id}
rows (or rows
;; For now, it's just a shim, because we haven't implemented an efficient bulk update action yet.
;; This is a dumb shim; we're not checking that the pk maps are really (just) the pks.
(map #(merge % updates) pks))]
{:updated (map :row (execute!* "data-grid.row/update" scope nil rows))})))
;; This is a POST instead of DELETE as not all web proxies pass on the body of DELETE requests.
(api.macros/defendpoint :post "/table/:table-id/delete"
"Delete row(s) from the given table"
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
{}
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
(check-permissions)
(let [scope {:table-id table-id}]
(execute!* "data-grid.row/delete" scope nil rows)
{:success true}))
;; might later be changed, or made driver specific, we might later drop the requirement depending on admin trust
;; model (e.g are admins trusted with writing arbitrary SQL cases anyway, will non admins ever call this?)
(def ^:private Identifier
"A malli schema for strings that can be used as SQL identifiers"
[:re #"^[\w\- ]+$"])
;; upload types are used temporarily, I expect this to change
(def ^:private column-type->upload-type
{"auto_incrementing_int_pk" :metabase.upload/auto-incrementing-int-pk
"boolean" :metabase.upload/boolean
"int" :metabase.upload/int
"float" :metabase.upload/float
"varchar255" :metabase.upload/varchar-255
"text" :metabase.upload/text
"date" :metabase.upload/date
"datetime" :metabase.upload/datetime
"offset_datetime" :metabase.upload/timestamp-with-time-zone})
(def ^:private ColumnType
(into [:enum] (keys column-type->upload-type)))
(defn- ensure-database-type [driver column-type]
(if-some [upload-type (column-type->upload-type column-type)]
(driver/upload-type->database-type driver upload-type)
(throw (ex-info (i18n/tru "Not a supported column type: {0}" column-type)
{:status 400, :column-type column-type}))))
(api.macros/defendpoint :post "/database/:db-id/table"
"Creates a new table in the given database"
[{:keys [db-id]} :- [:map [:db-id ms/PositiveInt]]
_
{table-name :name
:keys [primary_key columns]}
:-
[:map
[:name Identifier]
[:primary_key [:seqable {:min-count 1} Identifier]]
[:columns [:seqable
[:map
[:name Identifier]
[:type ColumnType]]]]]]
(check-permissions)
(let [{driver :engine :as database} (api/check-404 (t2/select-one :model/Database db-id))
_ (actions/check-data-editing-enabled-for-database! database)
column-map (->> (for [{column-name :name
column-type :type} columns]
[column-name (ensure-database-type driver column-type)])
(into {}))]
(driver/create-table! driver db-id table-name column-map :primary-key (map keyword primary_key))))
(api.macros/defendpoint :post "/webhook"
"Creates a new webhook endpoint token.
The token can be used with the unauthenticated ingestion endpoint.
POST /ee/data-editing-public/webhook/{token} to insert rows."
[_
_
{:keys [table-id]}] :- [:map [:table-id ms/PositiveInt]]
(check-permissions)
(let [_ (api/check-404 (t2/select-one :model/Table table-id))
token (u/generate-nano-id)
user-id api/*current-user-id*]
(t2/insert! :table_webhook_token {:token token, :table_id table-id, :creator_id user-id})
{:table_id table-id
:token token}))
(api.macros/defendpoint :delete "/webhook/:token"
"Deletes a webhook endpoint token."
[{:keys [token]} :- [:map [:token [:string {:api/regex #"[0-9a-zA-Z-_]+"}]]]
_
_]
(check-permissions)
(let [deleted-count (t2/delete! :table_webhook_token :token token)]
(api/check-404 (pos? deleted-count)))
{})
(api.macros/defendpoint :get "/webhook"
"Lists webhook endpoints tokens for a table.
Behaviour is currently undefined if no table-id parameter is specified"
[_
{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]]
(check-permissions)
(api/check-404 (t2/select-one :model/Table table-id))
(let [include-cols [:token :table_id :creator_id]]
{:tokens (t2/select (into [:table_webhook_token] include-cols) :table_id table-id)}))
(defn- input->parameters
"We support either named or id based parameters for invoking legacy actions. This converts keys to ids, if necessary."
[parameters input]
(let [slug-or-id->id (merge (u/index-by :slug :id parameters)
(u/index-by :id :id parameters))]
(if (not-every? #(contains? slug-or-id->id (name %)) (keys input))
(throw (ex-info
"Unexpected parameter(s) provided"
{:status-code 400
:input input
:parameters parameters}))
(update-keys input (comp slug-or-id->id name)))))
(defn- execute-saved-action! [action inputs]
(doall
(for [input inputs]
(actions/execute-action! action (input->parameters (:parameters action) input)))))
(defn- execute-saved-action-from-id! [action-id inputs]
(let [action (-> (actions/select-action :id action-id :archived false) (t2/hydrate :creator) api/read-check)]
(execute-saved-action! action inputs)))
(def tmp-action
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
(api.macros/defendpoint :get "/tmp-action"
"Returns all actions across all tables and models"
[_
_
_]
(check-permissions)
(let [databases (t2/select [:model/Database :id :settings])
editable-database? (comp boolean :database-enable-table-editing :settings)
editable-databases (filter editable-database? databases)
editable-tables (when (seq editable-databases)
(t2/select :model/Table
:db_id [:in (map :id editable-databases)]
:active true))
fields (when (seq editable-tables)
(t2/select :model/Field :table_id [:in (map :id editable-tables)]))
fields-by-table (group-by :table_id fields)
table-actions (for [t editable-tables
[op op-name] actions/enabled-table-actions
:let [fields (fields-by-table (:id t))
action (actions/table-primitive-action t fields op)]]
(assoc action :table_name op-name))
saved-actions (for [a (actions/select-actions nil :archived false)]
(select-keys a [:name
:model_id
:type
:database_id
:id
:visualization_settings
:parameters]))]
{:actions (vec (concat saved-actions table-actions))})))
(mr/def ::api-action-id-saved
"Refers to a row in the actions table."
ms/PositiveInt)
(mr/def ::api-action-id-primitive-or-dashboard-or-dashcard-action
"We refer to primitive actions through their names.
For now we also encode dashboard button and dashcard action ids as strings, but make we can stop that after WRK-483."
:string)
(mr/def ::api-action-id-packed-mapping
"The picker currently returns negative integers which encodes certain primitive actions with some config.
This is just a poor man's ::api-action-expression, so maybe we can deprecate that."
ms/NegativeInt)
(mr/def ::api-action-id
"Primitive actions, saved actions, and packed encodings from the picker."
[:or
::api-action-id-saved
::api-action-id-primitive-or-dashboard-or-dashcard-action
::api-action-id-packed-mapping])
;; TODO this should become sequential so we can save the order.
(mr/def ::action.config.param-map
"Editable configuration used to transform the inputs passed to an action."
[:map-of :keyword :any])
(mr/def ::action.config.mappings
"Non -editable configuration used to transform the inputs passed to an action."
[:map-of :keyword :any])
(mr/def ::unified-action.base
[:or
[:map {:closed true}
[:action-id ms/PositiveInt]]
[:map {:closed true}
[:action-kw :keyword]
[:mapping {:optional true} [:maybe :map]]]])
;; TODO Regret this name, let's rename it to something like ::action-expression once it's pure data.
(mr/def ::unified-action
"The internal representation used by our APIs, after we've parsed the relevant ids and fetched their configuration."
[:or
::unified-action.base
[:map {:closed true}
;; TODO Having an opaque id like this inside is not great.
;; We eventually want to have fetched all relevant data already, so we can just dispatch.
;; But, for now we're wanting to reuse legacy code which dispatches on the underlying toucan instances, and the
;; time has not yet come to refactor those functions.
;; TODO make this variant more self-describing, it's not clear that this integer is a dashcard id.
[:dashboard-action ms/PositiveInt]]
[:map {:closed true}
[:name {:optional true} :string]
[:inner-action ::unified-action.base]
[:mapping {:optional true} [:maybe ::action.config.mappings]]
[:param-map ::action.config.param-map]
;; We will eventually want to generalize to support grids outside of dashboards.
[:dashcard-id {:optional true} ms/PositiveInt]
[:configurable {:optional true} :boolean]]])
(mr/def ::api-action-expression
"A more relaxed version of ::unified-action that can still have opaque ::api-action-id expressions inside."
;; TODO let's wait until we've written all our API tests and integrated the FE before typing this.
:map)
(mr/def ::api-action-id-or-expression
"All the various ways of referring to an action with the v2 APIs."
[:or ::api-action-expression ::api-action-id])
(mu/defn- fetch-unified-action :- ::unified-action
"Resolve various flavors of action-id into plain data, making it easier to dispatch on. Fetch config etc."
[scope :- ::types/scope.hydrated
raw-id :- ::api-action-id-or-expression]
(cond
(map? raw-id) (if-let [action-id (:action-id raw-id)]
(if-let [parameters (:parameters raw-id)]
(merge {:inner-action (fetch-unified-action scope action-id)}
{:param-map (u/for-ordered-map [p parameters] [(keyword (:id p)) (dissoc p :id)])}
(dissoc raw-id :action-id :parameters))
raw-id)
;; assume this is in the internal format already? probably don't want to support this.
raw-id)
(pos-int? raw-id) {:action-id raw-id}
(neg-int? raw-id) (let [[op param] (actions/unpack-encoded-action-id raw-id)]
(cond
(isa? op :table.row/common)
{:action-kw op
:mapping {:table-id param :row ::root}}
:else
(throw (ex-info "Execution not supported for given encoded action" {:status 400
:action-id raw-id
:op op
:param param}))))
(string? raw-id) (if-let [[_ dashcard-id _nested-id] (re-matches #"^dashcard:([^:]+):(.*)$" raw-id)]
;; There is a chicken-and-egg problem with creating actions inside dashcards.
;; we need to put their action id (which references the dashcard id) inside the viz settings,
;; before we save the dashcard for the first time, which only then generates its primary key.
(let [dashcard-id (when (not= "unknown" dashcard-id) (parse-long dashcard-id))
;; So, if we only have a placeholder for the dashcard id, get it from the scope.
;; This hack always works since the frontend can't invoke row actions from anywhere else.
;; From a semantic point of view, this hack still sucks. It'll be fixed by WRK-483.
dashcard-id (if (pos-int? dashcard-id) dashcard-id (:dashcard-id scope))
dashcard (api/check-404 (some->> dashcard-id (t2/select-one [:model/DashboardCard :visualization_settings])))
;; TODO: this should belong to our configuration
actions (-> dashcard :visualization_settings ((some-fn :editableTable.enabledActions :table.enabled_actions)))
viz-action (api/check-404 (first (filter (comp #{raw-id} :id) actions)))
inner-id (:actionId viz-action)
inner (fetch-unified-action scope inner-id)
action-type (:actionType viz-action "data-grid/custom-action")
mapping (:mapping viz-action)
;; TODO we should do this *later* because it's lossy - we lose the configured ordering.
param-map (->> (:parameterMappings viz-action {})
(u/index-by :parameterId #(dissoc % :parameterId))
walk/keywordize-keys)]
(assert (:enabled viz-action true) "Cannot call disabled actions")
(case action-type
("data-grid/built-in"
"data-grid/custom-action"
;; deprecated
"data-grid/row-action")
{:inner-action inner
:mapping mapping
:param-map param-map
:dashcard-id dashcard-id
:configurable (not= action-type "data-grid/built-in")}))
(if-let [[_ dashcard-id] (re-matches #"^dashcard:(\d+)$" raw-id)]
;; Dashboard buttons can only be invoked from dashboards
;; We're not checking that the scope has the correct dashboard, but if it's incorrect, there
;; will be a 404 thrown when we try to execute the action.
;; This 404 here is not the best error to return, but we can polish this later.
(let [dashboard-id (api/check-404 (:dashboard-id scope))]
(api/read-check :model/Dashboard dashboard-id)
{:dashboard-action (parse-long dashcard-id)})
(if (re-matches u/uuid-regex raw-id)
(throw (ex-info "Cannot execute an unsaved action given only its temporary id"
{:status-code 400, :action-id raw-id}))
;; Not a fancy encoded string, it must refer directly to a primitive.
(let [kw (keyword raw-id)]
{:action-kw kw
:mapping (actions/default-mapping kw scope)}))))
:else
(throw (ex-info "Unexpected id value" {:status-code 400, :action-id raw-id}))))
(defn- hydrate-mapping [mapping]
(walk/postwalk-replace
{"::root" ::root
"::key" ::key
"::input" ::input
"::params" ::params
"::param" ::param}
mapping))
(defn- augment-params
[{:keys [dashcard-id param-map] :as _action} input params]
;; TODO cool optimization where we don't fetch the row-data from the db if we only need the pk (or a subset of the pk)
(let [row (delay (let [{:keys [table_id]} (actions/cached-value
[:dashcard-viz dashcard-id]
#(t2/select-one-fn :visualization_settings :model/DashboardCard dashcard-id))]
(if-not table_id
;; this is not an Editable, it must be a question - so we use the client-side row
(update-keys input name)
;; TODO batch fetching all these rows, across all inputs
(let [fields (t2/select [:model/Field :id :name :semantic_type] :table_id table_id)
pk-fields (filter #(= :type/PK (:semantic_type %)) fields)
;; TODO we could restrict which fields we fetch in future
[row] (data-editing/query-db-rows table_id pk-fields [input])
_ (api/check-404 row)]
;; TODO i would much prefer if we used field-ids and not names in the configuration
(update-keys row name)))))]
(if-not param-map
(merge input params)
(reduce-kv
(fn [acc k v]
(let [override (when-not (:visible v) (get params k))]
(case (:sourceType v)
;; I don't think the FE can send a value for ask-user, but our tests do it...
"ask-user" (assoc acc k (if (contains? params k) override (:value v)))
"constant" (assoc acc k (or override (:value v)))
;; Hack for getting partially mapped data for /configure
"row-data" (if-not (seq input)
acc
(assoc acc k (or override (get @row (:sourceValueTarget v)))))
;; no mapping? omit the key
nil acc)))
{}
param-map))))
(defn- apply-mapping [{:keys [mapping] :as action} params inputs]
(let [mapping (hydrate-mapping mapping)
tag? #(and (vector? %2) (= %1 (first %2)))]
(if-not mapping
(if (or params (:param-map action))
(map #(augment-params action % params) inputs)
inputs)
(for [input inputs
:let [root (augment-params action input params)]]
(walk/postwalk
(fn [x]
(cond
;; TODO handle the fact this stuff can be json-ified better
(= ::root x) root
(= ::input x) input
(= ::params x) params
;; specific key
(tag? ::key x) (get root (keyword (second x)))
(tag? ::param x) (get params (keyword (second x)))
:else
x))
mapping)))))
(defn- apply-mapping-nested [{:keys [inner-action] :as outer-action} params inputs-before]
(let [inputs-after (apply-mapping outer-action params inputs-before)]
(if inner-action
(recur inner-action nil inputs-after)
inputs-after)))
(defn- execute!* [action-id scope params raw-inputs]
(let [scope (actions/hydrate-scope scope)
unified (fetch-unified-action scope action-id)
inputs (apply-mapping-nested unified params raw-inputs)]
(cond
(:action-id unified)
(let [action (api/read-check (actions/select-action :id (:action-id unified) :archived false))]
(api/check-400 (= 1 (count inputs)) "Saved actions currently only support a single input")
(execute-saved-action! action inputs))
(:action-kw unified)
(let [action-kw (keyword (:action-kw unified))]
(:outputs (actions/perform-action! action-kw scope inputs)))
(:dashboard-action unified)
(do
(api/check-400 (= 1 (count inputs)) "Saved actions currently only support a single input")
[(actions/execute-dashcard! (:dashboard-id scope)
(:dashboard-action unified)
(walk/stringify-keys (first inputs)))])
(:inner-action unified)
;; use flat namespace for now, probably want to separate form inputs from pks
(let [inner (:inner-action unified)
saved-id (:action-id inner)
action-kw (:action-kw inner)]
(cond
saved-id
(execute-saved-action-from-id! saved-id inputs)
action-kw
(:outputs (actions/perform-action! action-kw scope inputs))))
:else
(throw (ex-info "Not able to execute given action yet" {:status-code 500 :scope scope :unified unified})))))
(def execute-single
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
(api.macros/defendpoint :post "/action/v2/execute"
"The One True API for invoking actions.
It doesn't care whether the action is saved or primitive, and whether it has been placed.
In particular, it supports:
- Custom model actions as well as primitive actions, and encoded hack actions which use negative ids.
- Stand-alone actions, Dashboard actions, Row actions, and whatever else comes along.
Since actions are free to return multiple outputs even for a single output, the response is always plural."
[{}
{}
{:keys [action_id scope params input]}
:- [:map
;; TODO docstrings for these
[:action_id ::api-action-id-or-expression]
[:scope ::types/scope.raw]
[:params {:optional true} :map]
[:input :map]]]
{:outputs (execute!* action_id scope params [input])}))
(def execute-bulk
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
(api.macros/defendpoint :post "/action/v2/execute-bulk"
"The *other* One True API for invoking actions. The only difference is that it accepts multiple inputs."
[{}
{}
{:keys [action_id scope inputs params]}
:- [:map
[:action_id ::api-action-id-or-expression]
[:scope ::types/scope.raw]
[:inputs [:sequential :map]]
[:params {:optional true} [:map-of :keyword :any]]]]
{:outputs (execute!* action_id scope params inputs)}))
(defn- row-data-mapping
;; TODO get this working with arbitrary nesting of inner actions
"HACK: create a placeholder unified action who will map to the values we need from row-data, if we need any"
[{:keys [param-map] :as action}]
;; We create a version of the action that will "map" to an input which is just the row data itself.
(let [row-data-mapping (u/for-map [[_ {:keys [sourceType sourceValueTarget]}] param-map
:when (= "row-data" sourceType)]
[sourceValueTarget [::key (keyword sourceValueTarget)]])]
(when (seq row-data-mapping)
{:inner-action {:action-kw :placeholder}
:dashcard-id (:dashcard-id action)
:param-map (u/for-map [[_ {:keys [sourceType sourceValueTarget] :as param-setting}] param-map
:when (= "row-data" sourceType)]
[(keyword sourceValueTarget) param-setting])
:mapping row-data-mapping})))
(defn- get-row-data
"For a row or header action, fetch underlying database values that'll be used for specific action params in mapping."
[action input]
;; it would be nice to avoid using "apply-mapping" twice (once here on this stub action, once later on the real one)
(some-> (row-data-mapping action)
(apply-mapping-nested nil [input])
first
not-empty))
(def tmp-modal
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
(api.macros/defendpoint :post "/tmp-modal"
"Temporary endpoint for describing an actions parameters
such that they can be presented correctly in a modal ahead of execution."
[{}
{}
;; TODO support for bulk actions
{:keys [action_id scope input]}]
(let [scope (actions/hydrate-scope scope)
unified (fetch-unified-action scope action_id)
row-data (get-row-data unified input)
partial-input (first (apply-mapping-nested unified nil [input]))]
(data-editing.describe/describe-unified-action unified scope row-data partial-input))))
(def config-form
"A temporary var for our proxy in [[metabase.actions.api]] to call, until we move this endpoint there."
(api.macros/defendpoint :post "/config-form"
"This API returns a data representation of the form the FE will render. It does not update the configuration."
[{}
{}
;; TODO pour some malli on me
{:keys [action_id scope]}]
(let [scope (actions/hydrate-scope scope)
unified (fetch-unified-action scope action_id)
input (first (apply-mapping-nested unified nil [nil]))]
(data-editing.configure/configuration unified scope input))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,120 @@
(ns metabase-enterprise.data-editing.coerce
(:require
[metabase.util.date-2 :as u.date])
(:import
(clojure.lang BigInt)
(java.time LocalDateTime ZonedDateTime ZoneId)
(java.time.format DateTimeFormatter)))
(set! *warn-on-reflection* true)
(defn- json-zdt->unix-millis ^long [s]
(let [zdt->milli (fn [^ZonedDateTime zdt]
(-> zdt .toInstant .toEpochMilli))]
(-> s u.date/parse zdt->milli)))
(defn- json-zdt->unix-seconds [s]
(-> s json-zdt->unix-millis (quot 1000)))
(defn- json-zdt->unix-micros [s]
(-> s json-zdt->unix-millis ^BigInt (* 1000N) .toBigInteger))
(defn- json-zdt->unix-nanos [s]
(-> s json-zdt->unix-millis ^BigInt (* 1000000N) .toBigInteger))
(let [^DateTimeFormatter formatter (DateTimeFormatter/ofPattern "yyyyMMddHHmmss")
format (fn [t]
(.format ^DateTimeFormatter formatter t))]
(defn- json-zdt->yyyymmddhhmmss [s]
(-> s u.date/parse format))
(defn- yyyymmddhhmmss->json-zdt
[yyyymmddhhmmss]
(-> (LocalDateTime/parse yyyymmddhhmmss formatter)
(.atZone (ZoneId/of "UTC"))
str)))
;; the date/time coercions are highly ambigious
;; in one direction one can make a decision (take the local date, or clock time)
;; but how do you reverse it?
;; Often the original SQL string might have been a full date, a zoned or unzoned ISO string,
;; Needs to be considered properly at some later time.
;; Idea: make the coercion dependent on previous database string, so if lossy - modify *just* the time, or the date component
;; * still somewhat ambiguous, e.g did the user *intend* to discard the previous date/time components?
(defn- json-zdt->date [s]
(-> s ZonedDateTime/parse .toLocalDate str))
(defn- json-zdt->time [s]
(-> s ZonedDateTime/parse .toLocalTime str))
(defn- millis->json-zdt
[millis]
(-> millis
java.time.Instant/ofEpochMilli
(java.time.ZonedDateTime/ofInstant (java.time.ZoneId/systemDefault))
str))
(defn- unix-seconds->json-zdt
[unix-seconds]
(millis->json-zdt (* unix-seconds 1000)))
(defn- unix-millis->json-zdt
[unix-millis]
(millis->json-zdt unix-millis))
(defn- unix-micros->json-zdt
[unix-micros]
(millis->json-zdt (quot unix-micros 1000)))
(defn- unix-nanos->json-zdt
[unix-nanos]
(millis->json-zdt (quot unix-nanos 1000000)))
(defn- date->json-zdt
[date]
(-> date
java.time.LocalDate/parse
(.atStartOfDay (java.time.ZoneId/systemDefault))
str))
(defn- time->json-zdt
[time]
(-> time
java.time.LocalTime/parse
(.atDate (java.time.LocalDate/now))
(.atZone (java.time.ZoneId/systemDefault))
str))
(def coercion-fns
"Maps a coercion strategy to a map containing both input and output conversion functions.
:in - Function to convert from input JSON to database format
:out - Function to convert from database format to JSON output
Assumes the input/output values are valid for the coercion strategy and can fit within bounds."
;; TODO: missing (second (clojure.data/diff (into #{} (keys coercion-fns)) (descendants :Coercion/*)))
;;{:Coercion/Bytes->Temporal
;; :Coercion/UNIXTime->Temporal
;; :Coercion/String->Float
;; :Coercion/Number->Temporal
;; :Coercion/String->Number
;; :Coercion/YYYYMMDDHHMMSSBytes->Temporal
;; :Coercion/String->Temporal
;; :Coercion/ISO8601->Temporal}
{:Coercion/UNIXSeconds->DateTime {:in #'json-zdt->unix-seconds
:out #'unix-seconds->json-zdt}
:Coercion/UNIXMilliSeconds->DateTime {:in #'json-zdt->unix-millis
:out #'unix-millis->json-zdt}
:Coercion/UNIXMicroSeconds->DateTime {:in #'json-zdt->unix-micros
:out #'unix-micros->json-zdt}
:Coercion/UNIXNanoSeconds->DateTime {:in #'json-zdt->unix-nanos
:out #'unix-nanos->json-zdt}
:Coercion/YYYYMMDDHHMMSSString->Temporal {:in #'json-zdt->yyyymmddhhmmss
:out #'yyyymmddhhmmss->json-zdt}
:Coercion/ISO8601->DateTime {:in identity
:out identity}
:Coercion/ISO8601->Date {:in #'json-zdt->date
:out #'date->json-zdt}
:Coercion/ISO8601->Time {:in #'json-zdt->time
:out #'time->json-zdt}})

View File

@@ -0,0 +1,168 @@
(ns metabase-enterprise.data-editing.configure
(:require
[malli.core :as mc]
[medley.core :as m]
[metabase.actions.core :as actions]
[metabase.api.common :as api]
[metabase.models.humanization :as humanization]
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]
[metabase.warehouse-schema.models.table :as table]
[toucan2.core :as t2]))
(defn- merge-option
[entry-schema new-options]
(if (= 2 (count entry-schema))
(let [[k s] entry-schema]
[k new-options s])
(let [[k o s] entry-schema]
[k (merge o new-options) s])))
;; This schema is Order important, as it'll be the order UI elments will be shown.
(mr/def ::param-configuration
;; TODO Yeah, gross. This is currently just mirroring the config in the FE.
;; They at least have defined it using a nicer dependently typed way! See ConstantRowActionFieldSettings.
;; We could do something like that with malli... but once this stuff is opaque to the frontend, we can go wild and
;; make the shape more idiomatically Clojure, and maybe we should just wait until we've finalized the shape.
[:map {:closed true}
[:id :string]
[:configure-details {:optional true} [:sequential [:tuple :keyword :map]]]
;; omiited if we have visibility != null
(merge-option [:display-name :string]
{:optional true
::display-name "Field Name"
::input-type :text})
(merge-option [:source-type [:enum "ask-user" "row-data" "constant"]]
{:optional true
::display-name "Source Type"
::default "ask-user"
::input-type :dropdown
::options ["ask-user" "row-data" "constant"]})
;; omitted if we have visibility != null
(merge-option [:source-value :string]
{:optional true
::display-name "Pick column"
::default nil
::visible-if [:source-type "row-data"]
::input-type :column-picker})
(merge-option [:value [:or :string :int :boolean]]
{:optional true
::display-name "Value"
::input-type :field-filter
::visible-if [:source-type "constant"]
::default nil})
(merge-option [:editable :boolean] ;; only when visible is true
{:optional true
::descripion "Can you override"
::display-name "Editable"
::input-type :select
::vislble-if [[:source-type "ask-user"] [:source-type "constant"]]
::default nil})
(merge-option [:required :boolean] ;; only show when the underline fields is nullable
{:optional true
::description "Whether this field is required" ;; can't turn off if the underlying field is required
::display-name "Required"
::input-type :select
::vislble-if [[:source-type "ask-user"] [:source-type "constant"]]
::default nil})
(merge-option [:visible :boolean] ;; can't be false if the field is required
{:optional true
::description "Whether to show this field value in the form"
::display-name "Visible"
::input-type :select
::default true})])
(def ^:private default-configuration-detais
(for [[k p _s] (mc/children (mr/resolve-schema ::param-configuration))
:let [namespaced-opts (m/filter-keys namespace p)]
:when (seq namespaced-opts)]
(-> namespaced-opts
(update-keys (comp keyword name))
(assoc :id k))))
(mr/def ::action-configuration
[:map {:closed true}
[:title :string]
[:parameters [:sequential ::param-configuration]]])
(mu/defn- configuration-for-saved-action
[action-id :- pos-int?]
(let [action (-> (actions/select-action :id action-id
:archived false
{:where [:not [:= nil :model_id]]})
api/read-check
api/check-404)]
{:title (:name action)
:parameters (for [param (:parameters action)]
{:id (case (:type action)
:query (:slug param)
:implicit (:id param))
:source-type "ask-user"
:configure-details default-configuration-detais})}))
;; TODO handle exposing new inputs required by the inner-action
(defn- configuration-for-pending-action [{:keys [param-map] :as action}]
;; TODO Delegate to get this
{:title (:name action "TODO - depends on existing configuration if already saved, otherwise from the inner action as the default.")
:parameters (for [[param-id param-settings] param-map]
(-> param-settings
(assoc :id (name param-id))
(update :displayName #(or % (humanization/name->human-readable-name (name param-id))))))})
(mu/defn- configuration-for-table-action
[table-id :- pos-int?
action-kw :- :keyword]
(let [table (t2/select-one [:model/Table :display_name :field_order] table-id)
ordered-fields (table/ordered-fields table-id (:field_order table))]
{:title (format "%s: %s" (:display_name table) (u/capitalize-en (name action-kw)))
:parameters (for [field ordered-fields
:when (case action-kw
:table.row/create (not (:database_is_auto_increment field))
:table.row/update true
:table.row/delete (isa? (:semantic_type field) :type/PK))]
{:id (:name field)
:source "ask-user"
:configure-details default-configuration-detais})}))
(defn- combine-configurations
[saved-configuration raw-configuration]
(let [existing-param-ids (set (map :id (:parameters saved-configuration)))]
{:title (:title saved-configuration)
:parameters (concat (:parameters saved-configuration) (remove #(existing-param-ids (:id %)) (:parameters raw-configuration)))}))
(mu/defn configuration :- [:or ::action-configuration [:map [:status ms/PositiveInt]]]
"Returns configuration needed for a given action."
[{:keys [action-id action-kw inner-action] :as action}
scope
input]
(if (false? (:configurable action))
{:status 400, :body "Cannot configure this action"}
(cond
;; Eventually will be put inside a nicely typed :configuration key
(:param-map action)
;; Dynamically incorporate any new options added since we last saved our configuration.
(combine-configurations (configuration-for-pending-action action)
(configuration (dissoc action :param-map) scope input))
(pos-int? action-id)
(configuration-for-saved-action action-id)
(and action-kw (isa? action-kw :table.row/common))
(configuration-for-table-action (:table-id input (:table-id scope)) action-kw)
inner-action
(let [action-id (:action-id inner-action)
action-kw (:action-kw inner-action)]
(cond
(pos-int? action-id)
(configuration-for-saved-action action-id)
(and action-kw (isa? action-kw :table.row/common))
;; TODO remove assumption that all primitives are table actions
(configuration-for-table-action (:table-id input (:table-id scope)) action-kw)
:else (ex-info "Not a supported row action" {:status-code 500 :scope scope :unified action})))
;; TODO support data-grid.row and model.row actions (not important yet)
:else
(throw (ex-info "Don't know how to handle this action" {:action action, :scope scope})))))

View File

@@ -0,0 +1,6 @@
(ns metabase-enterprise.data-editing.core
(:require
[potemkin :as p]))
(comment
p/keep-me)

View File

@@ -0,0 +1,194 @@
(ns metabase-enterprise.data-editing.data-editing
(:require
[clojure.set :as set]
[java-time.api :as t]
[medley.core :as m]
[metabase-enterprise.data-editing.coerce :as data-editing.coerce]
[metabase-enterprise.data-editing.models.undo :as undo]
[metabase.actions.core :as actions]
[metabase.api.common :as api]
[metabase.events.core :as events]
[metabase.lib.core :as lib]
[metabase.lib.metadata :as lib.metadata]
[metabase.query-processor :as qp]
[metabase.query-processor.store :as qp.store]
[metabase.util :as u]
[metabase.warehouse-schema.models.field-values :as field-values]
[toucan2.core :as t2]))
(defn select-table-pk-fields
"Given a table-id, return the :model/Field instances corresponding to its PK columns. Do not assume any ordering."
[table-id]
(u/prog1 (api/check-404 (t2/select :model/Field :table_id table-id :semantic_type :type/PK :active true))
(api/check-500 (pos? (count <>)))))
(defn get-row-pks
"Given a row, strip it down to just its primary keys."
[pk-fields row]
(->> (map :name pk-fields)
(select-keys (update-keys row u/qualified-name))
;; Hack for now, pending discussion of the ideal fix
;; https://linear.app/metabase/issue/WRK-281/undo-deletes-a-record-instead-of-reverting-the-edits
;; See https://metaboat.slack.com/archives/C0641E4PB9B/p1744978660610899
(m/map-vals #(if (uuid? %) (str %) %))))
(defn- valid-pks [pks]
(every? some? (vals pks)))
(defn- apply*
"Work around the fact that the lib logical operators don't have a 0 or 1 arity."
[f clauses]
(if (<= (count clauses) 1)
(first clauses)
(apply f clauses)))
(defn- qp-result->row-map
[{:keys [rows cols]}]
;; rows from the request are keywordized
(let [col-names (map (comp keyword :name) cols)]
(map #(zipmap col-names %) rows)))
(defn query-db-rows
"Return the current representation of the given rows that we would return to the frontend, indexed by their pks."
[table-id pk-fields rows]
(assert (seq pk-fields) "Table must have at least one primary key column")
;; TODO pass in the db-id from above rather
(when (seq rows)
(let [{:keys [db_id]} (api/check-404 (t2/select-one :model/Table table-id))
row-pks (seq (map (partial get-row-pks pk-fields) rows))]
(assert (every? valid-pks row-pks) "All rows must have valid primary keys")
(qp.store/with-metadata-provider db_id
(let [mp (qp.store/metadata-provider)
query (lib/query mp (lib.metadata/table mp table-id))
query (lib/filter
query
;; We can optimize the most common case considerably.
(if (= 1 (count pk-fields))
(apply lib/in
(lib.metadata/field mp (:id (first pk-fields)))
(map (comp val first) row-pks))
;; Optimizing this could be done in many cases, but it would be complex.
(apply* lib/or
(for [row-pk row-pks]
(apply* lib/and
(for [field pk-fields]
(lib/= (lib.metadata/field mp (:id field))
(get row-pk (:name field)))))))))]
(->> query
qp/userland-query-with-default-constraints
qp/process-query
:data
qp-result->row-map))))))
(defn apply-coercions
"For fields that have a coercion_strategy, apply the coercion function (defined in data-editing.coerce) to the corresponding value in each row.
Intentionally does not coerce primary key values (behaviour for pks with coercion strategies is undefined)."
[table-id input-rows]
(let [input-keys (into #{} (mapcat keys) input-rows)
field-names (map name input-keys)
fields (t2/select :model/Field :table_id table-id :name [:in field-names])
coerce-fn (->> (for [{field-name :name, :keys [coercion_strategy, semantic_type]} fields
:when (not (isa? semantic_type :type/PK))]
[(keyword field-name)
(or (when (nil? coercion_strategy) identity)
(:in (data-editing.coerce/coercion-fns coercion_strategy))
(throw (ex-info "Coercion strategy has no defined coercion function"
{:status 400
:field field-name
:coercion_strategy coercion_strategy})))])
(into {}))
coerce (fn [k v] (some-> v ((coerce-fn (keyword k) identity))))]
(for [row input-rows]
(m/map-kv-vals coerce row))))
(defn- row-update-event
"Given a :effect/row.modified diff, figure out what kind of mutation it was."
[{:keys [before after]}]
(case [(some? before) (some? after)]
[false true] :event/row.created
[true true] :event/row.updated
[true false] :event/row.deleted
;; should not happen
[false false] ::no-op))
(defmethod actions/handle-effects!* :effect/row.modified
[_ {:keys [user-id invocation-stack scope]} diffs]
(let [table-ids (distinct (map :table-id diffs))
table->pk-fields (u/group-by identity select-table-pk-fields concat table-ids)
diff->pk-diff (u/for-map [{:keys [table-id before after] :as diff} diffs
:when (or before after)]
[diff {:pk (get-row-pks (table->pk-fields table-id) (or after before))
:before before
:after after}])]
;; undo snapshots, but only if we're not executing an undo
;; TODO fix tests that execute actions without a user scope
(when user-id
(when-not (some (comp #{:data-editing/undo :data-editing/redo} first) invocation-stack)
(undo/track-change!
user-id
scope
(u/for-map [[table-id diffs] (group-by :table-id diffs)]
[table-id (u/for-map [{:keys [before after deleted-children] :as diff} diffs
:when (or before after)
:let [{:keys [pk before after]} (diff->pk-diff diff)]]
[pk {:raw_before before
:raw_after after
:undoable (empty? deleted-children)}])]))))
;; table notification system events
(doseq [[event payloads] (->> diffs
(group-by row-update-event)
(remove (comp #{::no-op} key)))]
(doseq [[table-id payloads] (group-by :table-id payloads)
:let [db-id (:db-id (first payloads))
row-changes (for [diff payloads]
(diff->pk-diff diff))]]
(doseq [row-change row-changes]
(events/publish-event! event {:actor_id user-id
:row_change row-change
:scope (u/snake-keys (actions/hydrate-scope scope))
:args {:table_id table-id
:db_id db-id
:timestamp (t/zoned-date-time (t/zone-id "UTC"))}}))))))
(defn- invalidate-field-values! [table-id rows]
;; Be conservative with respect to case sensitivity, invalidate every field when there is ambiguity.
(let [ln->values (u/group-by first second (for [row rows [k v] row] [(u/lower-case-en (name k)) v]))
lower-names (keys ln->values)
ln->ids (when (seq lower-names)
(u/group-by
:lower_name :id
(t2/query {:select [:id [[:lower :name] :lower_name]]
:from [(t2/table-name :model/Field)]
:where [:and
[:= :table_id table-id]
[:in [:lower :name] lower-names]
[:in :has_field_values ["list" "auto-list"]]
[:= :semantic_type "type/Category"]]})))
stale-fields (->> (for [[lower-name field-ids] ln->ids
:let [new-values (into #{} (filter some?) (ln->values lower-name))
old-values (into #{} cat (t2/select-fn-vec :values :model/FieldValues
:field_id [:in field-ids]))]]
(when (seq (set/difference new-values old-values))
field-ids))
(apply concat))]
;; Note that for now we only rescan field values when values are *added* and not when they are *removed*.
(when (seq stale-fields)
;; Using a future is not ideal, it would be better to use a queue and a single worker, to avoid tying up threads.
(future
(->> (t2/select :model/Field :id [:in stale-fields])
(run! field-values/create-or-update-full-field-values!))))))
(defn invalidate-and-present!
"We invalidate the field-values, in case any new category values were added.
The FE also expects data to be formatted according to PQ logic, e.g. considering semantic types.
Actions, however, return raw values, since lossy coercions would limit composition.
So, we apply the coercions here."
[table-id rows]
;; We could optimize this significantly:
;; 1. Skip if no category fields were changes on update.
;; 2. Check whether all corresponding categorical field values are already in the database.
(invalidate-field-values! table-id rows)
;; right now the FE works off qp outputs, which coerce output row data
;; still feels messy, revisit this
(let [pk-fields (select-table-pk-fields table-id)]
(query-db-rows table-id pk-fields rows)))

View File

@@ -0,0 +1,219 @@
(ns metabase-enterprise.data-editing.describe
(:require
[metabase.actions.core :as actions]
[metabase.api.common :as api]
[metabase.util :as u]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr]
[toucan2.core :as t2]))
(mr/def ::describe-param
[:map #_{:closed true}
[:id :string]
[:display_name :string]
[:field_id {:optional true} pos-int?]
[:input_type [:enum "dropdown" "textarea" "date" "datetime" "text"]]
[:semantic_type {:optional true} :keyword]
[:optional :boolean]
[:nullable :boolean]
[:readonly :boolean]
[:database_default {:optional true} :any]
;; value can be nil, so this is optional to avoid confusion
[:value {:optional true} :any]
[:value_options {:optional true} [:sequential :any]]
;; is it more useful if we have field_id instead of this?
[:human_readable_field_id {:optional true} pos-int?]])
(mr/def ::action-description
[:map {:closed true}
[:title :string]
[:parameters [:sequential ::describe-param]]])
(defn- field-input-type
[field field-values]
(case (:type field-values)
(:list :auto-list :search) "dropdown"
(condp #(isa? %2 %1) (:semantic_type field)
:type/Description "textarea"
:type/Category "dropdown"
:type/FK "dropdown"
(condp #(isa? %2 %1) (:base_type field)
:type/Date "date"
:type/DateTime "datetime"
"text"))))
(defn- describe-table-action
[{:keys [action-kw
table-id
param-map
dashcard-viz
row-data]}]
(when-not table-id
(throw (ex-info "Must provide table-id" {:status-code 400})))
(let [table (api/read-check (t2/select-one :model/Table :id table-id :active true))
fields (-> (t2/select :model/Field :table_id table-id :active true {:order-by [[:position]]})
(t2/hydrate :dimensions
:has_field_values
:values))
;; it's a huge assumption that there's always dashcard-viz
;; TODO: this should live in our configuration
dashcard-column-editable? (or (some-> dashcard-viz :table.editableColumns set)
;; columns are assumed editable if no dashcard-viz specialisation
(constantly true))
dashcard-sort (zipmap (map :name (:table.columns dashcard-viz)) (range))
field-name->dashcard-column (u/index-by :name (:table.columns dashcard-viz))
field-sort (zipmap (map :name fields) (range))
sort-key (fn [{:keys [name]}]
(or (dashcard-sort name) ; prefer user defined sort in the dashcard
(+ (inc (count dashcard-sort))
(field-sort name))))]
{:title (format "%s: %s" (:display_name table) (u/capitalize-en (name action-kw)))
:parameters (->> (for [field (sort-by sort-key fields)
:let [{field-values :values} field
pk (= :type/PK (:semantic_type field))
param-setting (get param-map (keyword (:name field)))
dashcard-column (field-name->dashcard-column (:name field))]
:when (case action-kw
;; create does not take pk cols if auto increment, todo generated cols?
(:table.row/create :data-grid.row/create) (not (:database_is_auto_increment field))
;; delete only requires pk cols
(:table.row/delete :data-grid.row/delete) pk
;; update takes both the pk and field (if not a row action)
(:table.row/update
:table.row/create-or-update
:data-grid.row/update
:data-grid.row/create-or-update) true)
;; row-actions can explicitly hide parameters
:when (not= "hidden" (:visibility param-setting))
;; dashcard column context can hide parameters (if defined)
:when (:enabled dashcard-column true)
:let [required (or pk (:database_required field))]]
(u/remove-nils
{:id (:name field) ;; TODO we shouldn't use field name as id I think, what if we have 1 field that maps to 2 params?
:display_name (:display_name field)
:semantic_type (:semantic_type field)
:input_type (field-input-type field field-values)
:field_id (:id field)
:human_readable_field_id (-> field :dimensions first :human_readable_field_id)
:optional (not required)
:nullable (:database_is_nullable field)
:database_default (:database_default field)
:readonly (or (= "readonly" (:visibility param-setting))
(not (dashcard-column-editable? (:name field))))
;; TODO oh dear, we need to worry about case sensitivity issue now (e.g. in tests)
;; it would be much better if our mappings were based on field ids.
;; probably not an issue in practice, because FE is writing the config AND calling tmp-modal
;; with likely the same names for fields (fingers crossed)
:value (get row-data (:sourceValueTarget param-setting))}))
vec)}))
(defn- saved-param-base-type
[param-type viz-field]
(case param-type
:string/= :type/Text
:number/= :type/Number
:date/single (case (:inputType viz-field)
;; formatting needs thought
"datetime" :type/DateTime
:type/Date)
(if (= "type" (namespace param-type))
param-type
(throw
(ex-info "Unsupported query action parameter type"
{:status-code 500
:param-type param-type})))))
(defn- saved-param-input-type
[param-type viz-field]
(cond
;; we could distinguish between inline-select and dropdown (which are both options for model action params)
(seq (:valueOptions viz-field))
"dropdown"
(= "text" (:inputType viz-field))
"textarea"
:else
(condp #(isa? %2 %1) (saved-param-base-type param-type viz-field)
:type/Date "date"
:type/DateTime "datetime"
"text")))
(defn- describe-saved-action
[{:keys [action-id param-map row-data]}]
(let [action (-> (actions/select-action :id action-id
:archived false
{:where [:not [:= nil :model_id]]})
api/read-check
api/check-404)
param-id->viz-field (-> action :visualization_settings (:fields {}))]
;; TODO: this assumes this is a query action, we need to handle implicit actions as well
{:title (:name action)
:parameters (->> (for [param (:parameters action)
;; query type actions store most stuff in viz settings rather than the
;; parameter
:let [viz-field (param-id->viz-field (:id param))
param-mapping (get param-map (keyword (:id param)))]
:when (and (not (:hidden viz-field))
(not= "hidden" (:visibility param-mapping)))]
(u/remove-nils
{:id (:id param)
:display_name (or (:display-name param) (:name param))
:input_type (saved-param-input-type (:type param) viz-field)
:optional (and (not (:required param)) (not (:required viz-field)))
;; TODO Ngoc: is there a way to know this?
;; Chris: well the param would need to tell us.
;; for now let's guess that anything required is not nullabe
:nullable (not (:required param))
:readonly (= "readonly" (:visibility param-mapping))
:value (get row-data (:sourceValueTarget param-mapping))
:value_options (:valueOptions viz-field)}))
vec)}))
(mu/defn describe-unified-action :- ::action-description
"Describe parameters of an unified action."
[unified scope row-data partial-input]
;; TODO: we didn't handle dashboard-action (i.e. dashboard buttons)
(cond
;; saved action
;; hmm, having checked like this makes me insecure, what makes having an action-id enforces that this
;; is an saved question? what if put an aciton-id on a row action for some reasons?
(:action-id unified)
(describe-saved-action {:action-id (:action-id unified)})
;; table action
;; TODO remove assumption that all primitives are table actions
(:action-kw unified)
(describe-table-action
{:action-kw (:action-kw unified)
:table-id (:table-id partial-input)
:param-map (:param-map unified)
;; tried commenting it out and no tests failed, so will leave this here as reference for now
;; (also don't see any code that could ever populate this tho)
:dashcard-viz nil #_(:dashcard-viz (:dashcard-viz unified))})
(:inner-action unified)
(let [inner (:inner-action unified)
param-map (:param-map unified)
dashcard-id (:dashcard-id unified)
dashcard-viz (or (:dashcard-viz unified)
;; TODO we shouldn't need this fallback, fetch-unified-action should have fetched this for us
(t2/select-one-fn :visualization_settings :model/DashboardCard dashcard-id))
saved-id (:action-id inner)
action-kw (:action-kw inner)
table-id (:table-id partial-input)]
(cond
saved-id
(describe-saved-action {:action-id saved-id
:row-action-dashcard-id dashcard-id
:param-map param-map
:row-data row-data})
action-kw
;; TODO remove assumption that all primitives are table actions
(describe-table-action {:action-kw action-kw
:table-id table-id
:param-map param-map
:dashcard-viz dashcard-viz
:row-data row-data})
:else (ex-info "Not a supported row action" {:status-code 500 :scope scope :unified unified})))
:else
(throw (ex-info "Not able to execute given action yet" {:status-code 500 :scope scope :unified unified}))))

View File

@@ -0,0 +1,17 @@
(ns metabase-enterprise.data-editing.events
(:require
[metabase.notification.core :as notification]))
(derive ::event :metabase/event)
(derive :event/rows.created ::event)
(derive :event/rows.updated ::event)
(derive :event/rows.deleted ::event)
(derive :event/row.created ::event)
(derive :event/row.updated ::event)
(derive :event/row.deleted ::event)
(doseq [event (descendants ::event)]
(defmethod notification/notification-filter-for-topic event
[_topic event-info]
[:= :table_id (-> event-info :args :table_id)]))

View File

@@ -0,0 +1,4 @@
(ns metabase-enterprise.data-editing.init
(:require
[metabase-enterprise.data-editing.actions]
[metabase-enterprise.data-editing.events]))

View File

@@ -0,0 +1,251 @@
(ns metabase-enterprise.data-editing.models.undo
(:require
[clojure.walk :as walk]
[metabase.actions.core :as actions]
[metabase.models.interface :as mi]
[metabase.util :as u]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(def ^:private retention-total-batches 100)
(def ^:private retention-total-rows 1000)
(def ^:private retention-batches-per-scope 100)
(def ^:private retention-batches-per-user 50)
(methodical/defmethod t2/table-name :model/Undo [_model] :data_edit_undo_chain)
(t2/deftransforms :model/Undo
{:row_pk mi/transform-json
:raw_before mi/transform-json
:raw_after mi/transform-json})
(doto :model/Undo
(derive :metabase/model)
(derive :hook/timestamped?))
(defn- nested-sort
"Sort every map within the data structure."
[x]
(walk/postwalk #(if-not (map? %) % (apply sorted-map (apply concat %))) x))
(defn- serialize-scope
"Convert the scope map or string into a stable string we can do example matches on in the database. Idempotent."
[scope]
(if (string? scope)
scope
;; For now :type is ignored for undo scope, but once the FE starts passing it in, we'll probably change that.
(or (some-> scope actions/normalize-scope (dissoc :type) nested-sort pr-str) "unknown")))
(defn- next-batch [undo? user-id scope]
;; For now, we assume all the changes are to the same table.
;; In the future, we might want to skip multi-table changes when using cmd-Z.
;; We may also want to filter based on the type of interaction that caused the change (e.g., grid, workflow, etc)
(t2/select :model/Undo
:batch_num [:in
{:select [[[(if undo? :max :min) :batch_num]]]
:from [(t2/table-name :model/Undo)]
:where [:and
[:= :user_id user-id]
[:= :scope (serialize-scope scope)]
(if undo?
[:not :undone]
:undone)]}]))
(defn- batch-to-prune-from
"Return the largest batch_num that we should no longer retain, if we only want to keep a certain number of batches
matching the given where clause. Returns 0 if we do not need to prune anything."
[batches-to-keep & [where]]
(-> {:select [:batch_num]
:from [(t2/table-name :model/Undo)]
:where (or where true)
:group-by :batch_num
:order-by [[:batch_num :desc]]
:limit 1
:offset batches-to-keep}
t2/query
first
:batch_num
(or 0)))
(defn- batch-to-prune-from-for-rows
"This is like [[batch-to-prune-from], except that we're enforcing a max on the row count."
[rows-to-keep & [where]]
(-> {:select [:batch_num]
:from [(t2/table-name :model/Undo)]
:where (or where true)
:order-by [[:id :desc]]
:limit 1
:offset rows-to-keep}
t2/query
first
:batch_num
(or 0)))
(defn- prune-from-batch! [batch-num & [where]]
(t2/delete! :model/Undo
:batch_num [:<= batch-num]
{:where (or where true)}))
(defn- prune-batches! [batches-to-keep & [where]]
(prune-from-batch! (batch-to-prune-from batches-to-keep where) where))
(defn track-change!
"Insert some snapshot data based on edits made to the given table."
[user-id scope table-id->row-pk->values]
(let [scope (serialize-scope scope)]
(t2/with-transaction [_conn]
(let [seq-name "undo_batch_num"
next-batch-num (or (t2/select-one-fn :next_val [:sequences :next_val] :name seq-name {:for :update}) 1)]
(t2/update! :sequences {:name seq-name} {:next_val (inc next-batch-num)})
(t2/insert!
:model/Undo
(for [[table-id table-updates] table-id->row-pk->values
[row-pk values] table-updates]
(merge {:batch_num next-batch-num
:table_id table-id
:user_id user-id
:row_pk row-pk
:scope scope
;; default value, can be override in values
:undoable true}
values)))))
;; Delete snapshots that have been undone, as we keep a linear history and will no longer be able to "redo" them.
(when-let [{:keys [batch_num]} (first (next-batch false user-id scope))]
(t2/delete! :model/Undo
:batch_num [:>= batch_num]
:scope scope
:undone true))
;; Pruning. Fairly naive implementation. Doesn't assume we were fully pruned before this update.
(prune-from-batch! (max (batch-to-prune-from-for-rows retention-total-rows)
(batch-to-prune-from retention-total-batches)))
(let [where-scope [:= :scope scope]]
(prune-batches! retention-batches-per-scope where-scope))
(let [where-user [:= :user_id user-id]]
(prune-batches! retention-batches-per-user where-user))))
(defn next-batch-num
"Return the batch number of the new change that we would (un-)revert.
NOTE: this does not check whether there is a conflict preventing us from actually performing it."
[undo-or-redo user-id scope]
(:batch_num (first (next-batch (= :undo undo-or-redo) user-id scope))))
;; This will be used to fix conflict false positives
#_{:clj-kondo/ignore [:unused-private-var]}
(defn- diff-keys
"Give the sorted list of keys on which m1 and m2 differ."
[m1 m2]
(->> (into (apply sorted-set (keys m1)) (keys m2))
(filter #(not= (get m1 %) (get m2 %)))))
(defn- conflict?
"Has another change superseded this batch, making it non-undoable? Typically, will mean someone else has edited it."
[undo? batch]
(let [row-pks (into #{} (map :row_pk) batch)
table-ids (into #{} (map :table_id) batch)
{:keys [batch_num]} (first batch)]
;; TODO fix false positives in multi-table case where row-pks are ambiguous
;; PRODUCT: We probably also want to fix false positive conflicts when different columns were changed
;; Idea: Select all potential conflicting undos, check for any whose changed keys overlap with ours.
;; Can store a precalculated [[diff-keys]] on each :model/Undo row, or even normalize into its own table and do
;; conflict detection completely in SQL.
;; This normal form may make maintenance of the history a bit too expensive however.
(t2/exists? :model/Undo
:table_id [:in table-ids]
:row_pk [:in row-pks]
:batch_num [(if undo? :> :<) batch_num]
:undone (not undo?))))
(defn- categorize [{:keys [raw_before raw_after]}]
(cond
(nil? raw_before) :create
(nil? raw_after) :delete
:else :update))
(def ^:private invert
{:create :delete
:update :update
:delete :create})
(def ^:private category->op
{:create :created
:update :updated
:delete :deleted})
(defn- batch->rows
"Given the undo snapshots for some batch update, return the corresponding row updates."
[undo? batch]
(let [k (if undo? :raw_before :raw_after)]
(for [b batch]
(merge (:row_pk b) (k b)))))
(defn- update-table-data!
"Revert the underlying table data."
[undo-or-redo context batch]
(let [undo? (= :undo undo-or-redo)]
;; Do a single perform-nested-action! call
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
:let [rows (batch->rows undo? sub-batch)
inputs (map #(array-map :table-id table-id :row (update-keys % u/qualified-name)) rows)]]
(case (if undo? (invert category) category)
:create (try (actions/perform-nested-action! :table.row/create context inputs)
(catch Exception e
;; Sometimes cols don't support a custom value being provided, e.g., GENERATED ALWAYS AS IDENTITY
(throw (ex-info "Failed to un-delete row(s)"
{:error :undo/cannot-undelete
:batch-num (:batch_num (first batch))
:table-id table-id
:pks (map :row_pk batch)}
e))))
:update (actions/perform-nested-action! :table.row/update context inputs)
:delete (actions/perform-nested-action! :table.row/delete context inputs)))
;; don't leak action outputs, we don't want to accidentally create more coupling to them.
true))
(defn- undo*! [undo-or-redo context user-id scope]
(let [undo? (= :undo undo-or-redo)
batch (next-batch undo? user-id scope)]
(cond
(not (seq batch)) (throw (ex-info (if undo?
"No previous versions found"
"No subsequent versions found")
{:error :undo/none
:user-id user-id
:scope scope}))
(some (comp false? :undoable) batch)
(throw (ex-info "Batch contains changes that are not undoable"
{:error :undo/cannot-undo
:user-id user-id
:scope scope
:batch-num (:batch_num (first batch))}))
(conflict? undo? batch) (throw (ex-info "Blocked by other changes"
;; It would be nice if we gave the batch_num for the first conflict.
{:error :undo/conflict
:user-id user-id
:scope scope}))
:else
(do (update-table-data! undo-or-redo context batch)
(t2/update! :model/Undo
{:batch_num (:batch_num (first batch))}
{:undone undo?})
(for [[table-id sub-batch] (u/group-by :table_id batch)
:let [rows (batch->rows undo? sub-batch)]
[delta row] (map vector sub-batch rows)
:let [category ((if undo? invert identity) (categorize delta))]]
{:op (category->op category)
:table-id table-id
:row row})))))
(defn undo!
"Rollback the given user's last change to the given table."
[context user-id scope]
(undo*! :undo context user-id scope))
(defn redo!
"Rollback the given user's last change to the given table."
[context user-id scope]
(undo*! :redo context user-id scope))

View File

@@ -0,0 +1,35 @@
(ns metabase-enterprise.data-editing-public.api
(:require
[metabase.actions.core :as actions]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[toucan2.core :as t2]))
(api.macros/defendpoint :post "/webhook/:token/data"
"Inserts rows into the table associated with the token. See routes for ee/data-editing/webhook for token CRUD.
Accepts either a single row or multiple rows in an array.
Expects all required columns to be provided.
The provided row JSON must be compatible with the underlying types of the table.
e.g if you have set up a casting rule for a unix epoch seconds field to present it as a datetime, callers must provide the integer value to this API.
Callers should expect 400 errors on constraint violation (e.g the primary key already exists) or the schema is not met."
[{:keys [token]}
_
row-or-rows]
(let [hook (api/check-404 (t2/select-one :table_webhook_token :token token))
rows (if (map? row-or-rows) [row-or-rows] row-or-rows)]
(api/check-400 (seq rows) "Please supply at least one row.")
(api/check-400 (every? seq rows) "Every row should not be empty.")
(let [user-id (:creator_id hook)
scope {:webhook-id (:id hook)}
table-id (:table_id hook)
inputs (for [r rows]
{:table-id table-id, :row r})]
{:created (count (:outputs (actions/perform-action! :table.row/create scope inputs {:user-id user-id
:policy :data-editing})))})))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."
(api.macros/ns-handler *ns*))

View File

@@ -66,6 +66,7 @@
"Notification"
"NotificationCard"
"NotificationSubscription"
"NotificationSystemEvent"
"NotificationHandler"
"NotificationRecipient"
"Permissions"
@@ -89,6 +90,7 @@
"Secret"
"Session"
"TaskHistory"
"Undo"
"User"
"UserKeyValue"
"UserParameterValue"

View File

@@ -80,6 +80,7 @@
:sso_jwt true
:sso_ldap true
:sso_saml true
:table_data_editing false
:upload_management true
:whitelabel true
:collection_cleanup true

View File

@@ -0,0 +1,420 @@
(ns metabase-enterprise.data-editing.action-v2-api-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[metabase.test :as mt]
[metabase.util :as u]))
;; TODO High level stuff we should do:
;; 0. Make sure we have tests to cover every single step taken in our core user journeys.
;; 1. Cover :create-or-update as well.
;; 2. DRY up or otherwise streamline how we construct these scenarios - hard to see the forest for the boilerplate.
;; 3. Consolidate test cases between /config-form, /tmp-model, and /execute
;; Important missing tests
(comment
configure-saved-action-on-editable-on-dashboard-test
;; either copy past tests or use a doseq to vary how we construct it
configure-saved-action-on-question-on-dashboard-test
configure-table-action-on-question-on-dashboard-test)
;; For example, when picking a model action to add as a row action, we call this for the initial config form.
(deftest configure-saved-query-action-test
(let [req #(mt/user-http-request-full-response
(:user % :crowberto)
:post
"action/v2/config-form"
(select-keys % [:action_id
:scope
:input]))]
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-data-editing-enabled! true
(testing "saved actions"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Table table {}
:model/Card model {:type :model
:table_id (:id table)}
:model/Action action {:type :query
:name "Do cool thing"
:model_id (:id model)
:parameters [{:id "random-a"
:name "A"
:type "number/="
:slug "a"}
{:id "random-b"
:name "B"
:type "date/single"
:slug "b"}
{:id "random-c"
:name "C"
:type "string/="
:slug "c"}
{:id "random-d"
:name "D"
:type "string/="
:slug "d"}
{:id "random-e"
:name "E"
:type "string/="
:slug "e"}]
:visualization_settings
{:fields {"c" {:inputType "text"}
"e" {:valueOptions ["a" "b"]}}}}]
(is (=? {:status 200
:body {:title "Do cool thing"
;; TODO this is the raw format we're currently saving config like
;; but, we want to instead return "data components" which tell us how to *change* it
;; and also... as part of introducing /config-form we are free to *change* how config is saved
;; because it will now be encapsulated. we just need migrate on read to avoid breaking
;; staging and beta users.
;; for now it doesn't tell you the type or the possible values, BUT maybe we want the wrapping
;; component to know that (it just doesn't get saved)
:parameters [{:id "random-a", :displayName "A", :sourceType "ask-user"}
{:id "random-b", :displayName "B", :sourceType "ask-user"}
{:id "random-c", :displayName "C", :sourceType "ask-user"}
{:id "random-d", :displayName "D", :sourceType "ask-user"}
{:id "random-e", :displayName "E", :sourceType "ask-user"}]}}
(select-keys
(req {:scope {:model-id (:id model)
:table-id (:id table)}
:action_id {:action-id (:id action)}})
[:status :body])))))))))))
;; For example, when picking a model action to add as a row action, we call this for the initial config form.
(deftest configure-saved-implicit-action-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-data-editing-enabled! true
(testing "saved actions"
(let [expected-id-params [{:id "id", :displayName "ID", :sourceType "ask-user"}]
expected-row-params [{:id "user_id", :displayName "User ID", :sourceType "ask-user"}
{:id "product_id", :displayName "Product ID", :sourceType "ask-user"}
{:id "subtotal", :displayName "Subtotal", :sourceType "ask-user"}
{:id "tax" :displayName "Tax", :sourceType "ask-user"}
{:id "total" :displayName "Total", :sourceType "ask-user"}
{:id "discount" :displayName "Discount", :sourceType "ask-user"}
{:id "created_at" :displayName "Created At", :sourceType "ask-user"}
{:id "quantity" :displayName "Quantity", :sourceType "ask-user"}]
action-kind->expected-params {"row/create" expected-row-params
"row/update" (concat expected-id-params expected-row-params)
"row/delete" expected-id-params}]
(doseq [[action-kind expected-params] action-kind->expected-params]
(mt/with-actions [model {:type :model
:dataset_query (mt/mbql-query orders)}
{action-id :action-id} {:type :implicit
:kind action-kind
:name "Do cool thing"}]
(is (= {:title "Do cool thing"
:parameters expected-params}
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
{:scope {:model-id (:id model)
:table-id (mt/id :orders)}
:action_id {:action-id action-id}})))))))))))
;; For example, when picking a table action to add as a row action, we call this for the initial config form.
(deftest configure-table-action-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-test-tables! [test-table [{:id 'auto-inc-type
:text [:text]
:int [:int]
:timestamp [:timestamp]
:date [:date]}
{:primary-key [:id]}]]
(let [;; TODO the form for configuring a row action will need to know that ID is meant to be "locked" to
;; the pk of the table underlying the data-grid.
expected-id-params [{:id "id" :displayName "ID", :sourceType "ask-user"}]
expected-row-params [{:id "text" :displayName "Text", :sourceType "ask-user"}
{:id "int" :displayName "Int", :sourceType "ask-user"}
{:id "timestamp" :displayName "Timestamp", :sourceType "ask-user"}
{:id "date" :displayName "Date", :sourceType "ask-user"}]]
(testing "table actions"
(let [scope {:table-id test-table}
{create-id "table.row/create"
update-id "table.row/update"
delete-id "table.row/delete"} (->> (mt/user-http-request-full-response
:crowberto
:get
"action/v2/tmp-action")
:body :actions
(filter #(= test-table (:table_id %)))
(u/index-by :kind :id))]
(testing "create"
(is (=? {:title (mt/malli=? :string)
:parameters expected-row-params}
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
{:scope scope
:action_id create-id}))))
(testing "update"
(is (=? {:title (mt/malli=? :string)
:parameters (concat expected-id-params expected-row-params)}
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
{:scope scope
:action_id update-id}))))
(testing "delete"
(is (=? {:title (mt/malli=? :string)
:parameters expected-id-params}
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
{:scope scope
:action_id delete-id})))))))))))
;; This is the case where we are EDITING some kind of header or row action, for example.
;;
;; There are actually two different cases here:
;; 1. built-in actions (don't have their own configuration, but inherit parameter order from the Editable settings)
;; 2. custom actions (have their own configuration, but no inheritance (for now, Katya working on prod doc))
;;
;; Since we don't support configuration for (1) yet, this is only concerned with (2)
(deftest configure-table-action-on-editable-on-dashboard-test
(let [req #(dissoc (mt/user-http-request-full-response
(:user % :crowberto)
:post
"action/v2/config-form"
(select-keys % [:action_id
:scope
:input]))
:headers)]
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-test-tables! [source-table [{:id 'auto-inc-type
:text [:text]}
{:primary-key [:id]}]
target-table [{:id 'auto-inc-type
:text [:text]
:int [:int]
:timestamp [:timestamp]
:date [:date]}
{:primary-key [:id]}]]
(let [action-id (->> (mt/user-http-request-full-response
:crowberto
:get
"action/v2/tmp-action")
:body :actions
(filter #(= target-table (:table_id %)))
(u/index-by :kind :id)
(#(get % "table.row/create")))]
(mt/with-temp
[:model/Dashboard dashboard {}
:model/Card model {:type :model
:table_id source-table
:database_id (mt/id)
:dataset_query {:database (mt/id)
:type :query
:query {:source-table source-table}}}
:model/DashboardCard dashcard {:dashboard_id (:id dashboard)
:card_id (:id model)
:visualization_settings
{:table_id source-table
;; The data-grid config is NOT inherited by custom actions (yet)
:table.columns
[{:name "int", :enabled true}
{:name "text", :enabled true}
{:name "timetamp", :enabled true}
;; this signals date should not be shown in the grid, or be available
;; to default actions.
{:name "date", :enabled false}]
;; The data-grid config is NOT inherited by custom actions (yet)
:editableTable.columns
["int"
;; this signals text is not editable by default actions
#_"text"
"timestamp"
"date"]
:editableTable.enabledActions
;; See [[metabase.dashboards.api/create-or-fix-action-id]] for why this is unknown.
[{:id "dashcard:unknown:built-in-update"
:actionId "table.row/update"
;; TODO Katya calls these "default" actions, maybe we should rename to match?
:actionType "data-grid/built-in"
:enabled true
;; Not currently configurable.
:parameterMappings nil}
{:id "dashcard:unknown:update"
:actionId action-id
:actionType "data-grid/custom-action"
:enabled true
;; TODO make sure this stuff makes sense.
;; What does the FE really write? Have we messed anything up?
;; Have we missed any cases?
:parameterMappings [{:parameterId "id"
:displayName "Identifier"
:sourceType "ask-user"}
{:parameterId "int"
:sourceType "constant"
:value 42}
{:parameterId "text"
:sourceType "row-data"
:sourceValueTarget "text"
:visibility "readonly"}
{:parameterId "timestamp"
:visibility "hidden"}]}]}}]
;; insert a row for the row action
(mt/user-http-request :crowberto :post 200
(data-editing.tu/table-url source-table)
{:rows [{:text "a very important string"}]})
(testing "default table action on a data-grid"
(is (=? {:status 400}
(req {:action_id "dashcard:unknown:built-in-update"
:scope {:dashcard-id (:id dashcard)}}))))
(testing "custom table action on a data-grid"
(is (=? {:status 200
:body {:parameters
[{:id "id", :displayName "Identifier", :sourceType "ask-user"}
{:id "int", :displayName "Int", :sourceType "constant", :value 42}
{:id "text", :displayName "Text", :sourceType "row-data", :sourceValueTarget "text", :visibility "readonly"}
{:id "timestamp", :displayName "Timestamp", :visibility "hidden"}
{:id "date", :displayName "Date", :sourceType "ask-user"}]}}
(req {:action_id "dashcard:unknown:update"
:scope {:dashcard-id (:id dashcard)}})))))))))))
(deftest configure-saved-action-on-editable-on-dashboard-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-test-tables! [test-table [{:id 'auto-inc-type
:text [:text]
:int [:int]
:timestamp [:timestamp]
:date [:date]}
{:primary-key [:id]}]]
(mt/with-temp
[:model/Dashboard dash {}
:model/Card model {:type :model
:table_id test-table
:database_id (mt/id)
:dataset_query {:database (mt/id)
:type :query
:query {:source-table test-table}}}
:model/Action action {:type :query
:name "update"
:model_id (:id model)
:parameters [{:id "pid:id"
:name "Id"
:slug "id"}
{:id "pid:name"
:name "Name"
:slug "name"}
{:id "pid:status"
:name "Status"
:slug "status"}]}
:model/ImplicitAction _ {:action_id (:id action)
:kind "row/update"}
:model/DashboardCard dashcard {:dashboard_id (:id dash)
:card_id (:id model)
:visualization_settings
{:table_id test-table
:editableTable.enabledActions
[{:id "dashcard:unknown:default"
:actionId (:id action)
:actionType "data-grid/custom-action"
:enabled true}
{:id "dashcard:unknown:configured"
:actionId (:id action)
:actionType "data-grid/custom-action"
:parameterMappings [{:parameterId "pid:id"
:displayName "Identifier"
:sourceType "ask-user"}
;; missing name
{:parameterId "pid:status"
:displayName "How u?"
:sourceType "row-data"
:sourceValueTarget "text"}]
:enabled true}]}}]
;; insert a row for the row action
(mt/user-http-request :crowberto :post 200
(data-editing.tu/table-url test-table)
{:rows [{:text "a very important string"}]})
(testing "configure for unsaved action will contains all action params"
(is (= {:parameters [{:id "pid:id", :displayName "Id", :sourceType "ask-user"}
{:id "pid:name", :displayName "Name", :sourceType "ask-user"}
{:id "pid:status", :displayName "Status", :sourceType "ask-user"}]}
(select-keys
(mt/user-http-request :crowberto :post 200
"action/v2/config-form" {:action_id "dashcard:unknown:default"
:scope {:dashcard-id (:id dashcard)}})
[:parameters]))))
(testing "saved configurations includes any new parameter if exists"
(is (= {:parameters [{:id "pid:id", :displayName "Identifier", :sourceType "ask-user"}
{:id "pid:status", :displayName "How u?", :sourceType "row-data", :sourceValueTarget "text"}
;; name is added even though it's not originally in the saved parameterMappings
{:id "pid:name", :displayName "Name", :sourceType "ask-user"}]}
(select-keys
(mt/user-http-request :crowberto :post 200
"action/v2/config-form" {:action_id "dashcard:unknown:configured"
:scope {:dashcard-id (:id dashcard)}})
[:parameters])))))))))
;; This covers a more exotic case where we're coming back to edit the config for an action before it has been saved.
;; This should cover both the cases where it has never been saved, or where it's simply been edited at least once since
;; it was last saved.
(deftest configure-pending-table-action-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-test-tables! [test-table [{:id 'auto-inc-type
:text [:text]
:int [:int]
:timestamp [:timestamp]}
{:primary-key [:id]}]]
(testing "custom data-grid calling a table action, with pending configuration changes"
(let [{action-id "table.row/update"} (->> (mt/user-http-request-full-response
:crowberto
:get
"action/v2/tmp-action")
:body :actions
(filter #(= test-table (:table_id %)))
(u/index-by :kind :id))
pending-config {:parameters
[{:id "id", :sourceType "row-data"}
{:id "int", :sourceType "constant", :value 42}
{:id "text", :sourceType "row-data", :visibility "readonly"}
{:id "timestamp", :visibility "hidden"}]}
scope {:table-id test-table}]
(is (=? {:title "Custom Row Action 489"
:parameters (:parameters pending-config)}
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
{:scope scope
:action_id {:action-id action-id
:name "Custom Row Action 489"
:parameters (:parameters pending-config)}})))))))))
(deftest configure-pending-saved-implicit-action-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/with-data-editing-enabled! true
(testing "saved actions"
(let [mapped-pk-params [{:id "id" :displayName "ID", :sourceType "row-data"}]
pending-row-params [{:id "user_id" :displayName "User ID", :sourceType "row-data"}
{:id "product_id" :displayName "Product ID", :sourceType "ask-user"}
{:id "subtotal" :displayName "Subtotal", :sourceType "constant" :value 10}
{:id "tax" :displayName "Tax", :sourceType "constant" :value 0}
{:id "total" :displayName "Total", :sourceType "constant" :value 0}
{:id "discount" :displayName "Discount", :sourceType "constant" :value 10}
{:id "created_at" :displayName "Created At", :sourceType "ask-user"}
{:id "quantity" :displayName "Quantity", :sourceType "constant" :value 1}]
action-kind->params {"row/create" pending-row-params
"row/update" (concat mapped-pk-params pending-row-params)
"row/delete" mapped-pk-params}]
(doseq [[action-kind expected-params] action-kind->params]
(mt/with-actions [model {:type :model, :dataset_query (mt/mbql-query orders)}
{action-id :action-id} {:type :implicit, :kind action-kind, :name "Implicit Action"}]
(is (= {:title "My Row Action"
:parameters expected-params}
(mt/user-http-request :crowberto :post 200 "action/v2/config-form"
{:scope {:model-id (:id model)
:table-id (mt/id :orders)}
:action_id {:action-id action-id
:name "My Row Action"
:parameters expected-params}})))))))))))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
(ns metabase-enterprise.data-editing.coerce-test
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.data-editing.coerce :as coerce]
[metabase.test :as mt]))
(deftest coercion-conversions-test
(mt/with-clock #t "2000-01-01T00:00:00Z"
(let [test-cases
[{:strategy :Coercion/UNIXSeconds->DateTime
:input "2024-03-20T15:30:45Z[UTC]"
:output 1710948645}
{:strategy :Coercion/UNIXMilliSeconds->DateTime
:input "2024-03-20T15:30:45Z[UTC]"
:output 1710948645000}
{:strategy :Coercion/UNIXMicroSeconds->DateTime
:input "2024-03-20T15:30:45Z[UTC]"
:output 1710948645000000}
{:strategy :Coercion/UNIXNanoSeconds->DateTime
:input "2024-03-20T15:30:45Z[UTC]"
:output 1710948645000000000}
{:strategy :Coercion/YYYYMMDDHHMMSSString->Temporal
:input "2024-03-20T15:30Z[UTC]" ;; Note: seconds set to 00 since the format doesn't preserve seconds
:output "20240320153000"}
{:strategy :Coercion/ISO8601->DateTime
:input "2024-03-20T15:30:45Z[UTC]"
:output "2024-03-20T15:30:45Z[UTC]"}
{:strategy :Coercion/ISO8601->Date
:input "2024-03-20T00:00Z[UTC]"
:output "2024-03-20"}
{:strategy :Coercion/ISO8601->Time
:input "2025-05-01T15:30:45Z[UTC]"
:reversed #(str/ends-with? % "T15:30:45Z[UTC]")
:output "15:30:45"}]]
(doseq [{:keys [strategy input reversed output]} test-cases]
(testing (format "Testing %s conversions" strategy)
(let [reversed? (or reversed #{input})
{:keys [in out]} (get coerce/coercion-fns strategy)]
;; Test input conversion (JSON -> Database format)
(testing "Input conversion"
(is (= output (in input))
(format "Input conversion failed for %s: expected %s, got %s"
strategy output (in input))))
;; Test output conversion (Database format -> JSON)
(testing "Output conversion"
(is (reversed? (out output))
(format "Output conversion failed for %s: expected %s, got %s"
strategy
(if-not reversed
input
(str "something like " input))
(out output))))
;; Test roundtrip conversion
(testing "Roundtrip conversion"
(is (reversed? (-> input in out))
(format "Roundtrip conversion failed for %s: %s -> %s -> %s"
strategy input (in input) (out (in input)))))))))))
(deftest coercion-fns-static-test
(testing "all coercion pair have to have an in and out function"
(testing (every? #(and (fn? (:in %)) (fn? (:out %))) coerce/coercion-fns)))
;; TODO: fix this test by implementing all strategies
#_(testing "all coercion strategies are covered"
(is (empty? (second (diff (into #{} (keys coerce/coercion-fns)) (descendants :Coercion/*)))))))

View File

@@ -0,0 +1,410 @@
(ns metabase-enterprise.data-editing.models.undo-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.models.undo :as undo]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[metabase.query-processor :as qp]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest diff-keys-test
(testing "Detect which keys have changes"
(is (= [:a :b :c] (#'undo/diff-keys
nil
(zipmap [:a :b :c] (range)))))
(is (= [:a :b :c] (#'undo/diff-keys
(zipmap [:a :b :c] (range))
nil)))
(is (= [:b :c :d] (#'undo/diff-keys
(zipmap [:a :b :c] (range))
(zipmap [:a :c :d] (range)))))))
(defn- table-rows [table-id]
(mt/rows
(qp/process-query
{:database (mt/id)
:type :query
:query {:source-table table-id}})))
(defn- create-row! [user-id table-id row]
(mt/user-http-request user-id :post 200 (data-editing.tu/table-url table-id) {:rows [row]}))
(defn- update-row! [user-id table-id row]
(mt/user-http-request user-id :put 200 (data-editing.tu/table-url table-id) {:rows [row]}))
(defn- delete-row! [user-id table-id pk]
(mt/user-http-request user-id :post 200 (str (data-editing.tu/table-url table-id) "/delete") {:rows [pk]}))
(defn- next-batch-num [undo-or-redo user-id scope]
(undo/next-batch-num undo-or-redo user-id scope))
(defn- undo-via-api! [user-id scope]
(mt/user-http-request user-id :post "/action/v2/execute"
{:action_id "data-editing/undo"
:scope scope
:input {}}))
(defn- redo-via-api! [user-id scope]
(mt/user-http-request user-id :post "/action/v2/execute"
{:action_id "data-editing/redo"
:scope scope
:input {}}))
(defn- write-sequence! [table-id pk states]
(loop [prior nil
states states]
(when (seq states)
(let [[user-id value] (first states)]
(case [(some? prior) (some? value)]
[false false] nil
[false true] (create-row! user-id table-id (merge pk value))
[true false] (delete-row! user-id table-id pk)
[true true] (update-row! user-id table-id (merge pk value)))
(recur value (rest states))))))
;; I'm OK reducing this scenario's scope once we have e2e tests.
;; Until then, I think this is ideal.
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
(deftest undo-redo-single-user-single-table-single-record-integration-test
(mt/with-empty-h2-app-db!
(mt/with-premium-features #{:table-data-editing}
(testing "Single-user chain, non-generated pk"
;; TODO test with a real PK
;; Beware: h2 will not allow un-delete, we'll need to adjust the test not to include deletion in its history.
;; and, we'll want to test the failure we get when we try to un-delete
;; Blocker for other drivers: https://linear.app/metabase/issue/WRK-223/pk-creation-for-non-h2-drivers
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
:name [:text]
:favourite_food [:text]}
{:primary-key [:id]}]]
(let [user-id (mt/user->id :crowberto)
test-scope {:table-id table-id}]
(write-sequence! table-id {:id 1} [[user-id {:name "Snorkmaiden" :favourite_food "pork"}]
[user-id {:name "Snorkmaiden" :favourite_food "orc"}]
[user-id nil]])
(is (= [] (table-rows table-id)))
(is (next-batch-num :undo user-id test-scope))
(is (not (next-batch-num :redo user-id test-scope)))
(is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]}
(undo-via-api! user-id test-scope)))
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
(is (next-batch-num :undo user-id test-scope))
(is (next-batch-num :redo user-id test-scope))
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]}
(undo-via-api! user-id test-scope)))
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
(is (next-batch-num :undo user-id test-scope))
(is (next-batch-num :redo user-id test-scope))
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]}
(undo-via-api! user-id test-scope)))
(is (= [] (table-rows table-id)))
(is (not (next-batch-num :undo user-id test-scope)))
(is (next-batch-num :redo user-id test-scope))
(is (= "Nothing to do" (undo-via-api! user-id test-scope)))
(is (not (next-batch-num :undo user-id test-scope)))
(is (next-batch-num :redo user-id test-scope))
(is (= {:outputs [{:op "created", :table-id table-id :row {:id 1, :name "Snorkmaiden", :favourite_food "pork"}}]}
(redo-via-api! user-id test-scope)))
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
(is (next-batch-num :undo user-id test-scope))
(is (next-batch-num :redo user-id test-scope))
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 1, :name "Snorkmaiden", :favourite_food "orc"}}]}
(redo-via-api! user-id test-scope)))
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
(is (next-batch-num :undo user-id test-scope))
(is (next-batch-num :redo user-id test-scope))
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 1}}]}
(redo-via-api! user-id test-scope)))
(is (= [] (table-rows table-id)))
(is (next-batch-num :undo user-id test-scope))
(is (not (next-batch-num :redo user-id test-scope)))
(is (= "Nothing to do" (redo-via-api! user-id test-scope)))
(is (next-batch-num :undo user-id test-scope))
(is (not (next-batch-num :redo user-id test-scope)))))))))
;; I'm OK reducing this scenario's scope once we have e2e tests.
;; Until then, I think this is ideal.
#_{:clj-kondo/ignore [:metabase/i-like-making-cams-eyes-bleed-with-horrifically-long-tests]}
(deftest undo-redo-multi-user-single-table-single-record-integration-test
(mt/with-empty-h2-app-db!
(mt/with-premium-features #{:table-data-editing}
(testing "Multi-user chain"
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
:name [:text]
:power [:int]}
{:primary-key [:id]}]]
(let [user-1 (mt/user->id :crowberto)
user-2 (mt/user->id :rasta)
test-scope {:table-id table-id}]
;; NOTE: this test relies on the "conflicts even when different columns changed" semantics
;; If we improve the semantics, we'll need to improve this test!
(write-sequence! table-id {:id 2} [[user-1 {:name "Moomintroll" :power 3}]
[user-1 {:name "Moomintroll" :power 9001}]
[user-2 {:name "Moominswole" :power 9001}]
[user-1 nil]])
(is (= [] (table-rows table-id)))
(is (next-batch-num :undo user-2 test-scope))
(is (not (next-batch-num :redo user-2 test-scope)))
(is (= "Your previous change has a conflict with another edit" (undo-via-api! user-2 test-scope)))
(is (next-batch-num :undo user-1 test-scope))
(is (not (next-batch-num :redo user-1 test-scope)))
(is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]}
(undo-via-api! user-1 test-scope)))
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (= "Your previous change has a conflict with another edit" (undo-via-api! user-1 test-scope)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (next-batch-num :undo user-2 test-scope))
(is (not (next-batch-num :redo user-2 test-scope)))
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]}
(undo-via-api! user-2 test-scope)))
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]}
(undo-via-api! user-1 test-scope)))
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]}
(undo-via-api! user-1 test-scope)))
(is (= [] (table-rows table-id)))
(is (not (next-batch-num :undo user-1 test-scope)))
(is (next-batch-num :redo user-1 test-scope))
(is (= "Nothing to do" (undo-via-api! user-1 test-scope)))
(is (not (next-batch-num :undo user-1 test-scope)))
(is (next-batch-num :redo user-1 test-scope))
(is (= {:outputs [{:op "created", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 3}}]}
(redo-via-api! user-1 test-scope)))
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moomintroll", :power 9001}}]}
(redo-via-api! user-1 test-scope)))
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (= {:outputs [{:op "updated", :table-id table-id, :row {:id 2, :name "Moominswole", :power 9001}}]}
(redo-via-api! user-2 test-scope)))
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (next-batch-num :redo user-1 test-scope))
(is (= {:outputs [{:op "deleted", :table-id table-id, :row {:id 2}}]}
(redo-via-api! user-1 test-scope)))
(is (= [] (table-rows table-id)))
(is (next-batch-num :undo user-1 test-scope))
(is (not (next-batch-num :redo user-1 test-scope)))
(is (= "Nothing to do" (redo-via-api! user-1 test-scope)))
(is (next-batch-num :undo user-2 test-scope))
(is (not (next-batch-num :redo user-2 test-scope)))
(is (= "Nothing to do" (redo-via-api! user-2 test-scope)))
(is (next-batch-num :undo user-1 test-scope))
(is (not (next-batch-num :redo user-1 test-scope)))))))))
(deftest undo-reverted-changes-integration-test
(mt/with-empty-h2-app-db!
(mt/with-premium-features #{:table-data-editing}
(testing "Reverted changes have their snapshots deleted when there are further changes"
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
:name [:text]
:status [:text]}
{:primary-key [:id]}]]
(let [user-id (mt/user->id :crowberto)
test-scope {:table-id table-id}]
;; NOTE: this test relies on the "conflicts even when different columns changed" semantics
;; If we improve the semantics, we'll need to improve this test!
(write-sequence! table-id {:id 1} [[user-id {:name "Too-ticky" :status "sitting"}]
[user-id {:name "Too-tickley" :status "squirming"}]
[user-id nil]])
(write-sequence! table-id {:id 2} [[user-id {:name "Toffle" :status "uncomfortable"}]
[user-id {:name "Toffle" :status "comforted"}]
[user-id nil]])
(create-row! user-id table-id {:id 1, :name "Too-tickley", :status "squirming"})
(create-row! user-id table-id {:id 2, :name "Toggle", :status "restored"})
(delete-row! user-id table-id {:id 2})
(is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id)))
(is (nil? (next-batch-num :redo user-id test-scope)))
(is (next-batch-num :undo user-id test-scope))
(undo-via-api! user-id test-scope)
(is (= [[1 "Too-tickley" "squirming"]
[2 "Toggle" "restored"]] (table-rows table-id)))
(redo-via-api! user-id test-scope)
(is (= [[1 "Too-tickley" "squirming"]] (table-rows table-id)))))))))
(defn- count-batches [& [where]]
(val (ffirst (t2/query {:select [[[:count [:distinct :batch_num]] :cnt]]
:from [(t2/table-name :model/Undo)]
:where (or where true)}))))
(deftest prune-snapshots-test
(mt/with-empty-h2-app-db!
(mt/with-premium-features #{:table-data-editing}
(testing "We delete older batches when they exceed our retention limits"
(data-editing.tu/with-test-tables! [table-1 [{:id [:int]} {:primary-key [:id]}]
table-2 [{:id [:int]} {:primary-key [:id]}]]
(let [user-1 (mt/user->id :crowberto)
user-2 (mt/user->id :rasta)]
(testing "Total rows"
(with-redefs [undo/retention-total-rows 17]
(dotimes [i 25]
(undo/track-change! user-1
{:table-id table-1}
{table-1
{{:id 1} {:raw_before (if (even? i) {} nil)
:raw_after (if (even? i) nil {})}
{:id 2} {:raw_before (if (odd? i) {} nil)
:raw_after (if (odd? i) nil {})}}})))
(is (= 16 (t2/count :model/Undo)))
(is (= 8 (count-batches))))
(testing "Total batches"
(with-redefs [undo/retention-total-batches 15]
(dotimes [i 25]
(undo/track-change! user-1
{:table-id table-1}
{table-1
{{:id 1} {:raw_before (if (even? i) {} nil)
:raw_after (if (even? i) nil {})}
{:id 2} {:raw_before (if (odd? i) {} nil)
:raw_after (if (odd? i) nil {})}}})))
(is (= 30 (t2/count :model/Undo)))
(is (= 15 (count-batches))))
(testing "User id"
(with-redefs [undo/retention-batches-per-user 5]
(dotimes [i 25]
;; just toggle existence
(undo/track-change! (if (zero? (mod i 3)) user-1 user-2)
{:table-id table-1}
{table-1
{{:id 1} {:raw_before (if (even? i) {} nil)
:raw_after (if (even? i) nil {})}
{:id 2} {:raw_before (if (odd? i) {} nil)
:raw_after (if (odd? i) nil {})}}})))
(is (= 20 (t2/count :model/Undo)))
(is (= 10 (count-batches)))
(is (= 5 (count-batches [:= :user_id user-1])))
(is (= 5 (count-batches [:= :user_id user-2]))))
(testing "Scope"
(t2/delete! :model/Undo)
(with-redefs [undo/retention-batches-per-scope 9]
(dotimes [i 35]
;; just toggle existence
(let [table-id (if (zero? (mod i 5)) table-1 table-2)]
(undo/track-change! user-1
{:table-id table-id}
{table-id
{{:id 1} {:raw_before (if (even? i) {} nil)
:raw_after (if (even? i) nil {})}
{:id 2} {:raw_before (if (odd? i) {} nil)
:raw_after (if (odd? i) nil {})}}})))
(is (= 32 (t2/count :model/Undo)))
(is (= 16 (count-batches)))
(is (= 7 (count-batches [:= :table_id table-1])))
(is (= 9 (count-batches [:= :table_id table-2])))))
(testing "A haphazard mix"
(with-redefs [undo/retention-total-rows 17
undo/retention-total-batches 21
undo/retention-batches-per-user 5
undo/retention-batches-per-scope 9]
(dotimes [i 25]
;; just toggle existence
(undo/track-change! (if (zero? (mod i 3)) user-1 user-2)
{:dashboard-id 1}
{(if (zero? (mod i 5)) table-1 table-2)
{{:id 1} {:raw_before (if (even? i) {} nil)
:raw_after (if (even? i) nil {})}
{:id 2} {:raw_before (if (odd? i) {} nil)
:raw_after (if (odd? i) nil {})}}})))
(is (= 16 (t2/count :model/Undo)))
(is (= 8 (count-batches)))
(is (= 3 (count-batches [:= :user_id user-1])))
(is (= 5 (count-batches [:= :user_id user-2])))
(is (= 1 (count-batches [:= :table_id table-1])))
(is (= 7 (count-batches [:= :table_id table-2]))))))))))
(deftest undo-non-undoable-batch-test
(mt/with-empty-h2-app-db!
(mt/with-premium-features #{:table-data-editing}
(testing "Cannot undo a batch marked as undoable: false"
(data-editing.tu/with-test-tables! [table-id [{:id [:int]
:name [:text]}
{:primary-key [:id]}]]
(let [user-id (mt/user->id :crowberto)
test-scope {:table-id table-id}]
;; Create a regular undoable change first
(create-row! user-id table-id {:id 1, :name "Undoable change"})
;; Manually create a non-undoable change using track-change! directly
(undo/track-change!
user-id
test-scope
{table-id
{{:id 2} {:raw_before nil ; before (nil for create)
:raw_after {:id 2, :name "Non-undoable change"} ; after
:undoable false}}}) ; mark as non-undoable
(is (= [[1 "Undoable change"]] (table-rows table-id)))
;; Should have batches available to undo
(is (next-batch-num :undo user-id test-scope))
(is (not (next-batch-num :redo user-id test-scope)))
;; Try to undo - should fail because the latest batch has undoable: false
(let [before-batch-num (next-batch-num :undo user-id test-scope)]
(is (= "Your previous change cannot be undone"
(undo-via-api! user-id test-scope)))
(testing "batchnum is unchanged"
(is (= before-batch-num (next-batch-num :undo user-id test-scope)))))
;; Table should remain unchanged
(is (= [[1 "Undoable change"]] (table-rows table-id)))))))))

View File

@@ -0,0 +1,556 @@
(ns metabase-enterprise.data-editing.notification-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[metabase.actions.test-util :as actions.tu]
[metabase.channel.urls :as urls]
[metabase.notification.events.notification :as events.notification]
[metabase.notification.test-util :as notification.tu]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2]))
(use-fixtures :each (fn [thunk]
(mt/with-premium-features #{:table-data-editing}
(thunk))))
(defn- assert-email-contains
[email expected-subject body-patterns]
(let [expected-body-map (zipmap body-patterns (repeat (count body-patterns) true))]
(is (=? {:subject expected-subject
:body [(update-keys expected-body-map str)]}
(apply mt/summarize-multipart-single-email email body-patterns)))))
(defn all-handlers
[http-channel-id]
[{:channel_type :channel/email
:recipients [{:type :notification-recipient/user
:user_id (mt/user->id :crowberto)}]}
{:channel_type :channel/slack
:recipients [{:type :notification-recipient/raw-value
:details {:value "#test-pulse"}}]}
{:channel_type :channel/http
:channel_id http-channel-id}])
(defn test-row-notification!
[prop request-fn channel-type->assert-fns]
(data-editing.tu/with-temp-test-db!
(mt/with-temp [:model/Channel chn {:type :channel/http}]
(let [table-id (mt/id (or (:table prop) :categories))]
(notification.tu/with-system-event-notification!
[notification {:notification-system-event {:event_name (:event_name prop)
:table_id table-id}
:notification {:condition (or
(:condition prop)
[:and
[:= [:context :table_id] table-id]
[:= [:context :event_name] (u/qualified-name (:event_name prop))]])}
:handlers (all-handlers (:id chn))}]
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
(let [channel-type->captured-message (notification.tu/with-captured-channel-send!
(request-fn notification))]
(doseq [[channel-type assert-fn] channel-type->assert-fns]
(testing (format "channel-type = %s" channel-type)
(assert-fn (get channel-type->captured-message channel-type)))))))))))
(deftest create-row-notification-test
(test-row-notification!
{:event_name :event/row.created}
(fn [_notification]
(mt/user-http-request
:crowberto
:post
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:NAME "New Category"}]}))
{:channel/slack (fn [[message :as msgs]]
(is (= 1 (count msgs)))
(is (=? {:blocks [{:type "section"
:text
{:type "mrkdwn"
:text (format (str
"*A new record was _created_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
"• *ID*: 76\n"
"• *NAME*: New Category\n"
"<%1$s|View table in Metabase>")
(urls/table-url (mt/id) (mt/id :categories)))}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(assert-email-contains
email
"A new record was added to \"CATEGORIES\" by Crowberto Corv"
[(re-pattern (format "<strong>A new record was <i>created</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
(urls/table-url (mt/id) (mt/id :categories))))
#"Table CATEGORIES"
#"Crowberto Corv"
#"Field"
#"Value"
#"NAME"
#"New Category"]))
:channel/http (fn [[req :as reqs]]
(is (= 1 (count reqs)))
(is (=? {:body (mt/malli=? :map)} req)))}))
(deftest update-row-notification-test
(test-row-notification!
{:event_name :event/row.updated}
(fn [_notification]
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:ID 1 :NAME "Updated Category"}]}))
{:channel/slack (fn [[message :as msgs]]
(is (= 1 (count msgs)))
(is (=? {:blocks [{:type "section"
:text {:type "mrkdwn"
:text (format (str "*A record was _updated_* in <%s|Table CATEGORIES> by Crowberto Corv\n"
"*Changed Fields*\n"
"• *NAME*: ~African~ → Updated Category\n"
"\n"
"*Current Record Details*\n"
"• *ID*: 1\n"
"• *NAME*: Updated Category\n"
"<%1$s|View table in Metabase>")
(urls/table-url (mt/id) (mt/id :categories)))}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(assert-email-contains
email
"A record was updated in \"CATEGORIES\" by Crowberto Corv"
[(re-pattern (format "<strong>A record was <i>updated</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
(urls/table-url (mt/id) (mt/id :categories))))
#"Table CATEGORIES"
#"Crowberto Corv"
#"Changed Fields:"
#"NAME"
#"African"
#"Updated Category"
#"Current Record Details"
#"Field"
#"Value"]))
:channel/http (fn [[req :as reqs]]
(is (= 1 (count reqs)))
(is (=? {:body (mt/malli=? :map)} req)))}))
(deftest delete-row-notification-test
(test-row-notification!
{:event_name :event/row.deleted}
(fn [_notification]
(mt/user-http-request
:crowberto
:post
(format "%s/delete" (data-editing.tu/table-url (mt/id :categories)))
{:rows [{:ID 1}]}))
{:channel/slack (fn [[message :as msgs]]
(is (= 1 (count msgs)))
(is (=? {:blocks [{:type "section"
:text {:type "mrkdwn"
:text (format (str
"*A record was _deleted_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
"• ~*ID*~: 1\n"
"• ~*NAME*~: African\n\n"
"This record is no longer available\n"
"<%1$s|View table in Metabase>")
(urls/table-url (mt/id) (mt/id :categories)))}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(assert-email-contains
email
"A record was deleted from \"CATEGORIES\" by Crowberto Corv"
[(re-pattern (format "<strong>A record was <i>deleted</i></strong> in <a href=\"%s\" target=\"_blank\">Table CATEGORIES</a> by Crowberto Corv"
(urls/table-url (mt/id) (mt/id :categories))))
#"Table CATEGORIES"
#"Crowberto Corv"
#"Field"
#"Value"
#"NAME"
#"African"
#"This record is no longer available"]))
:channel/http (fn [[req :as reqs]]
(is (= 1 (count reqs)))
(is (=? {:body (mt/malli=? :map)} req)))}))
(deftest conditional-notification-test
(let [condition [:and
[:= [:context :event_name] "event/row.updated"]
[:= [:context :record :NAME] "Facebook"]]]
(testing "no notification sends if condition does not match"
(test-row-notification!
{:event_name :event/row.updated
:condition condition}
(fn [_notification]
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:ID 1 :NAME "Twitter"}]}))
{:channel/slack (fn [msgs] (is (zero? (count msgs))))
:channel/email (fn [emails] (is (zero? (count emails))))
:channel/http (fn [reqs] (is (zero? (count reqs))))}))
(testing "notification sends if condition matches"
(test-row-notification!
{:event_name :event/row.updated
:condition condition}
(fn [_notification]
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:ID 1 :NAME "Facebook"}]}))
{:channel/slack (fn [msgs] (is (pos-int? (count msgs))))
:channel/email (fn [emails] (is (pos-int? (count emails))))
:channel/http (fn [reqs] (is (pos-int? (count reqs))))}))))
(deftest default-slack-notification-is-escaped
(test-row-notification!
{:event_name :event/row.updated}
(fn [_notification]
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:ID 1 :NAME "Metabase's Notification"}]}))
{:channel/slack (fn [[message :as msgs]]
(is (= 1 (count msgs)))
(is (=? {:blocks [{:type "section"
:text {:type "mrkdwn"
:text (format
(str "*A record was _updated_* in <%1$s|Table CATEGORIES> by Crowberto Corv\n"
"*Changed Fields*\n"
"• *NAME*: ~African~ → Metabase's Notification\n"
"\n"
"*Current Record Details*\n"
"• *ID*: 1\n"
"• *NAME*: Metabase's Notification\n"
"<%1$s|View table in Metabase>")
(urls/table-url (mt/id) (mt/id :categories)))}}]
:channel "#test-pulse"}
message)))}))
(deftest record-and-changes-in-template-context-are-ordered
(actions.tu/with-actions-test-data-tables #{"people"}
(test-row-notification!
{:event_name :event/row.updated
:table :people}
(fn [notification]
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "Hello"
:body (str "Row: {{#each record}}{{@key}},{{/each}}\n"
"Changes: {{#each changes}}{{@key}},{{/each}}")}}]
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :people))
{:rows [{:ID 1 :NAME "Ngoc Khuat" :CITY "Ha Noi" :EMAIL "ngoc@metabase.com"}]})))
{:channel/email (fn [[email :as _emails]]
(is (=? {:body [{:content (str "Row: ID,ADDRESS,EMAIL,PASSWORD,NAME,CITY,LONGITUDE,STATE,SOURCE,BIRTH_DATE,ZIP,LATITUDE,CREATED_AT,\n"
"Changes: EMAIL,NAME,CITY,")
:type "text/html; charset=utf-8"}]}
email)))})
(testing "respect field-order"
(test-row-notification!
{:event_name :event/row.updated
:table :people}
(fn [notification]
(t2/update! :model/Table (mt/id :people) {:field_order :alphabetical})
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "Hello"
:body (str "Row: {{#each record}}{{@key}},{{/each}}\n"
"Changes: {{#each changes}}{{@key}},{{/each}}")}}]
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :people))
{:rows [{:ID 1 :NAME "Ngoc Khuat" :CITY "Ha Noi" :EMAIL "ngoc@metabase.com"}]})))
{:channel/email (fn [[email :as _emails]]
(is (=? {:body [{:content (str "Row: ADDRESS,BIRTH_DATE,CITY,CREATED_AT,EMAIL,ID,LATITUDE,LONGITUDE,NAME,PASSWORD,SOURCE,STATE,ZIP,\n"
"Changes: CITY,EMAIL,NAME,")
:type "text/html; charset=utf-8"}]}
email)))}))))
(deftest record-and-changes-is-coerced-properly
(actions.tu/with-actions-test-data-tables #{"categories"}
(test-row-notification!
{:event_name :event/row.updated}
(fn [notification]
;; why testing coercion against the name column?
;; for one it's quite hard to setup custom test data so I decided to just roll with it
;; but also this test that failure in coercion is ignored (e.g: failed to convert name to datetime)
(mt/with-temp [:model/ChannelTemplate {tmpl-id :id} {:name "My Custom template"
:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "Hello"
:body (str "Name: {{record.NAME}}\n"
"Name: {{changes.NAME.before}}\n"
"Name: {{changes.NAME.after}}")}}]
(t2/update! :model/NotificationHandler (->> notification :handlers first :id) {:template_id tmpl-id})
(t2/update! :model/Field (mt/id :categories :name) {:coercion_strategy :Coercion/ISO8601->Date})
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:ID 1 :NAME "2025-03-25T00:00:00Z"}]})))
{:channel/email (fn [[email :as _emails]]
(is (=? {:body [{:content "Name: 2025-03-25T00:00Z[UTC]\nName: African\nName: 2025-03-25T00:00Z[UTC]"
:type "text/html; charset=utf-8"}]}
email)))})))
(deftest create-row-notification-webhook-test
(test-row-notification!
{:event_name :event/row.created}
(fn [_notification]
(let [token (:token (mt/user-http-request :crowberto
:post "ee/data-editing/webhook"
{:table-id (mt/id :categories)}))]
(mt/user-http-request
:crowberto
:post
(data-editing.tu/webhook-ingest-url token)
[{:NAME "New Category"}])))
{:channel/slack (fn [[message :as msgs]]
(is (= 1 (count msgs)))
(is (=? {:blocks [{:type "section"
:text {:type "mrkdwn"
:text (format (str
"*A new record was _created_* in <%1$s|Table CATEGORIES> by Crowberto Corv.\n"
"• *ID*: 76\n"
"• *NAME*: New Category\n"
"<%1$s|View table in Metabase>")
(urls/table-url (mt/id) (mt/id :categories)))}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(assert-email-contains
email
"A new record was added to \"CATEGORIES\" by Crowberto Corv"
[#"<strong>A new record was <i>created</i></strong> in <a[^>]*>Table CATEGORIES</a> by Crowberto Corv"
#"Field"
#"Value"
#"NAME"
#"New Category"]))
:channel/http (fn [[req :as reqs]]
(is (= 1 (count reqs)))
(is (=? {:body (mt/malli=? :map)} req)))}))
(deftest filter-notifications-test
(testing "getting table notifications will return only notifications of a table and action"
(doseq [event [:event/row.created
:event/row.updated
:event/row.deleted]]
(notification.tu/with-system-event-notification!
[_create-categories {:notification-system-event {:event_name event
:table_id (mt/id :categories)}}]
(notification.tu/with-system-event-notification!
[create-orders {:notification-system-event {:event_name event
:table_id (mt/id :orders)}}]
(is (= [(:id create-orders)]
(map :id (#'events.notification/notifications-for-topic
event
{:args {:table_id (mt/id :orders)}})))))))))
(deftest example-payload-row-create-test
(doseq [channel-type [:channel/slack :channel/email]]
(is (=? {:context {:event_name "event/row.created"}
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
:table {:id (mt/id :orders) :name "ORDERS"}
:settings {}
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
:USER_ID :TAX :CREATED_AT}
(set (keys %)))])}
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
{:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.created
:table_id (mt/id :orders)}
:creator_id (mt/user->id :crowberto)}
:channel_types [channel-type]})
[channel-type
:payload])))))
(deftest example-payload-row-update-test
(doseq [channel-type [:channel/slack :channel/email]]
(is (=? {:context {:event_name "event/row.updated"}
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
:table {:id (mt/id :orders) :name "ORDERS"}
:settings {}
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
:USER_ID :TAX :CREATED_AT}
(set (keys %)))])
:changes (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
:USER_ID :TAX :CREATED_AT}
(set (keys %)))])}
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
{:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.updated
:table_id (mt/id :orders)}
:creator_id (mt/user->id :crowberto)}
:channel_types [channel-type]})
[channel-type
:payload])))))
(deftest example-payload-row-delete-test
(doseq [channel-type [:channel/slack :channel/email]]
(is (=? {:context {:event_name "event/row.deleted"}
:creator {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
:editor {:first_name "Meta" :last_name "Bot" :common_name "Meta Bot" :email "bot@metabase.com"}
:table {:id (mt/id :orders) :name "ORDERS"}
:settings {}
:record (mt/malli=? [:fn #(= #{:ID :PRODUCT_ID :QUANTITY :SUBTOTAL :DISCOUNT :TOTAL
:USER_ID :TAX :CREATED_AT}
(set (keys %)))])}
(get-in (mt/user-http-request :crowberto :post 200 "notification/payload"
{:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.deleted
:table_id (mt/id :orders)}
:creator_id (mt/user->id :crowberto)}
:channel_types [channel-type]})
[channel-type
:payload])))))
(deftest preview-notification-test
(is (=? {:context (mt/malli=? :map)
:rendered {:from "notifications@metabase.com"
:bcc ["bot@metabase.com"]
:subject "Meta Bot has created a row for orders"
:body [{:type "text/html; charset=utf-8" :content "New record: active"}]}}
(mt/user-http-request :crowberto :post 200 "notification/preview_template"
{:template {:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
:body "New record: {{record.status}}"}}
:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.created}}}))))
(deftest preview-notification-nice-error-message-test
(testing "invalide handlebars template"
(is (=? "Failed to render template: found: '}'\n{{}\n ^"
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
{:template {:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "subject"
:body "{{}"}}
:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.created}}}))))
(testing "invalid subject template"
(is (=? "Failed to render template: Invalid query: found '[[' or '{{' with no matching ']]' or '}}'"
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
{:template {:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "{{"
:body "{{}"}}
:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.created}}})))))
(deftest preview-notification-custom-payload-test
(testing "use the custom context if it's valid"
(is (=? {:context (mt/malli=? :map)
:rendered {:from "notifications@metabase.com"
:bcc ["bot@metabase.com"]
:subject "Ngoc Khuat has created a row for orders"
:body [{:type "text/html; charset=utf-8" :content "New record: cancelled"}]}}
(mt/user-http-request :crowberto :post 200 "notification/preview_template"
{:template {:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
:body "New record: {{record.status}}"}}
:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.created}}
:custom_context {:context {:event_name :event/row.created
:timestamp "2023-01-01T10:00:00Z"
:scope {:type "table"
:origin_url "https://metabase.com/table/1"}}
:creator {:common_name "Meta Bot",
:email "bot@metabase.com",
:first_name "Meta",
:last_name "Bot"},
:editor {:common_name "Ngoc Khuat",
:email "ngoc@metabase.com",
:first_name "Ngoc",
:last_name "Khuat"},
:record {:id 1, :status "cancelled"}
:settings {},
:table {:id 1, :name "orders" :url "http://localhost:3000/table/1"}}}))))
(testing "fail if the custom context does not match the schema"
(is (=? {:errors (mt/malli=? :map)
:specific-errors (mt/malli=? :map)}
(mt/user-http-request :crowberto :post 400 "notification/preview_template"
{:template {:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
:body "Created {{count records}} records"}}
:notification {:payload_type :notification/system-event
:payload {:event_name :event/row.created}}
:custom_context {::context true}})))))
(deftest custom-template-test
(data-editing.tu/with-temp-test-db!
(mt/with-temp [:model/Channel {http-chn :id} {:type :channel/http}
:model/ChannelTemplate {http-tmpl :id} {:channel_type :channel/http
:details {:type :http/handlebars-text
:body "{\"record\": {{{json-encode record}}}, \"new_name\": \"{{changes.NAME.after}}\" }"}}
:model/ChannelTemplate {slack-tmpl :id} {:channel_type :channel/slack
:details {:type :slack/handlebars-text
:body "Name Was changed from {{changes.NAME.before}} to {{record.NAME}}"}}
:model/ChannelTemplate {email-tmpl :id} {:channel_type :channel/email
:details {:type :email/handlebars-text
:subject "{{editor.first_name}} {{editor.last_name}} has created a row for {{table.name}}"
:body "New record: {{record.status}}"}}]
(notification.tu/with-system-event-notification!
[_notification {:notification-system-event {:event_name :event/row.updated
:table_id (mt/id :categories)}
:handlers [{:channel_type :channel/email
:template_id email-tmpl
:recipients [{:type :notification-recipient/user
:user_id (mt/user->id :crowberto)}]}
{:channel_type :channel/slack
:template_id slack-tmpl
:recipients [{:type :notification-recipient/raw-value
:details {:value "#test-pulse"}}]}
{:channel_type :channel/http
:template_id http-tmpl
:channel_id http-chn}]}]
(is (=? {:channel/email [{:bcc ["crowberto@metabase.com"]
:body [{:content "New record: "
:type "text/html; charset=utf-8"}]
:from "notifications@metabase.com"
:subject "Crowberto Corv has created a row for CATEGORIES"}]
:channel/http [{:body {"record" {"ID" 1, "NAME" "Updated Category"}
"new_name" "Updated Category"}}]
:channel/slack [{:blocks [{:text {:text "Name Was changed from African to Updated Category"
:type "mrkdwn"}
:type "section"}]
:channel "#test-pulse"}]}
(notification.tu/with-captured-channel-send!
(notification.tu/with-channel-fixtures [:channel/email :channel/slack]
(mt/user-http-request
:crowberto
:put
(data-editing.tu/table-url (mt/id :categories))
{:rows [{:ID 1 :NAME "Updated Category"}]})))))))))

View File

@@ -0,0 +1,122 @@
(ns metabase-enterprise.data-editing.test-util
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[clojure.walk :as walk]
[metabase.actions.test-util :as actions.tu]
[metabase.driver :as driver]
[metabase.sync.core :as sync]
[metabase.test :as mt]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn sync-new-table!
"Syncs a new table by name, returns the table id."
[db table-name]
(let [table (sync/create-table! db {:name table-name
;; todo figure out how to determine default schema from driver
:schema (case (:engine db) :postgres "public" nil)
:display_name table-name
:field_order :database})]
(sync/sync-fields-for-table! db table)
(:id table)))
(defn- create-test-table! [db table-name column-map create-table-opts]
(let [_ (driver/create-table! (:engine db)
(:id db)
table-name
column-map
create-table-opts)]
(sync-new-table! db table-name)))
(defn table-url
"Returns the URL for data editing of the table with the given ID."
[table-id]
(format "ee/data-editing/table/%d" table-id))
(defn webhook-ingest-url
"Returns the URL for the webhook ingest endpoint."
[token]
(format "ee/data-editing-public/webhook/%s/data" token))
(defn toggle-data-editing-enabled! [db-id on-or-off]
(let [current-settings (t2/select-one-fn :settings :model/Database db-id)]
(t2/update! :model/Database db-id {:settings (assoc current-settings :database-enable-table-editing (boolean on-or-off))})))
(defmacro with-data-editing-enabled! [on-or-off & body]
`(mt/with-temp-vals-in-db :model/Database (mt/id) {:settings {:database-enable-table-editing ~on-or-off}}
~@body))
(def default-test-table
"The default test table config."
[{:id 'auto-inc-type
:name [:text]
:song [:text]}
{:primary-key [:id]}])
(defn do-with-test-tables!
"Impl of [[with-test-tables!]]."
[[column-map create-table-opts] thunk]
(let [db (t2/select-one :model/Database (mt/id))
driver (:engine db)
auto-inc-type (driver/upload-type->database-type driver :metabase.upload/auto-incrementing-int-pk)
column-map (walk/postwalk-replace {'auto-inc-type auto-inc-type} column-map)
table-name (str "temp_table_" (str/replace (random-uuid) "-" "_"))
cleanup (fn []
(driver/drop-table! driver (mt/id) table-name)
(t2/delete! :model/Table :name table-name))]
(try
(thunk (create-test-table! db table-name column-map create-table-opts))
(catch Exception e
(try (cleanup) (catch Exception cleanup-ex (.addSuppressed e cleanup-ex)))
(throw e)))))
(defmacro with-test-tables!
"Execute `body` with temporary table(s) created in the test database that has actions and table editing enabled.
You may specify multiple table specifications to create multiple tables.
Each table specification should be a vector containing `[column-map create-table-opts]`.
The symbol `auto-inc-type` can be used in column maps to denote the driver-specific auto-incrementing type for
primary keys.
Each table is created with a unique name and is automatically cleaned up after the body concludes. The body is
executed within an empty database context (mt/with-empty-db).
DOES NOT CREATE TABLES UNTIL THE BODY IS EXECUTED!
;; create a single table with default schema
(with-test-tables! [table-id [{:id 'auto-inc-type, :name [:text], :song [:text]}
{:primary-key [:id]}]]
...)
;; create multiple tables
(with-test-tables! [users-table [{:id 'auto-inc-type, :name [:text]} {:primary-key [:id]}]
posts-table [{:id 'auto-inc-type, :user_id [:int], :content [:text]} {:primary-key [:id]}]]
...)"
[[table-binding table-spec & more] & body]
`(mt/with-empty-db
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing true
:database-enable-actions true}})
(do-with-test-tables!
~table-spec
(fn [~table-binding]
~@(if (seq more)
[`(with-test-tables! ~(vec more) ~@body)]
body)))))
(defmacro with-temp-test-db!
"Sets up a temporary database in the appdb to do destrcutive tests.
Use (mt/id), (mt/db) etc to get the database id and database object."
[& body]
`(actions.tu/with-actions-test-data
(toggle-data-editing-enabled! (mt/id) true)
~@body))
(defmacro with-actions-temp-db
"Like [[actions.tu/with-actions-temp-db]] but with table editing enabled"
[dataset-definition & body]
`(actions.tu/with-actions-temp-db ~dataset-definition
(t2/update! :model/Database (mt/id) {:settings {:database-enable-table-editing true
:database-enable-actions true}})
~@body))

View File

@@ -0,0 +1 @@
(ns metabase-enterprise.data-editing.util)

View File

@@ -54,6 +54,7 @@
:model/Notification
:model/NotificationCard
:model/NotificationSubscription
:model/NotificationSystemEvent
:model/NotificationHandler
:model/NotificationRecipient
:model/ParameterCard
@@ -78,6 +79,7 @@
:model/Session
:model/TaskHistory
:model/TimelineEvent
:model/Undo
:model/User
:model/UserParameterValue
:model/UserKeyValue

View File

@@ -14,20 +14,18 @@
site-name "Metabase Test"
site-url "https://metabase.com"
admin-email "ngoc@metabase.com"]
(is (= {:payload_type :notification/system-event
:payload {:event_info {:foo :bar}
:event_topic :event/user-joined
:custom {}}
:context {:application_name "Metabase Test"
:application_color "#509EE3"
:application_logo_url "http://static.metabase.com/email_logo.png"
:include_branding false
:site_name "Metabase Test"
:site_url "https://metabase.com"
:admin_email "ngoc@metabase.com"
:style {:button true}}
:creator nil}
(is (= {:payload {:foo :bar
:event_name :event/user-joined}
:context {:application_name "Metabase Test"
:application_color "#509EE3"
:application_logo_url "http://static.metabase.com/email_logo.png"
:site_name "Metabase Test"
:site_url "https://metabase.com"
:admin_email "ngoc@metabase.com"
:include_branding false
:style {:button true}}
:creator nil}
(-> (notification.payload/notification-payload {:payload_type :notification/system-event
:payload {:event_topic :event/user-joined
:event_info {:foo :bar}}})
:payload {:event_name :event/user-joined}
:event_info {:foo :bar}})
(update-in [:context :style :button] string?))))))))

View File

@@ -16,7 +16,8 @@
:handlers [@notification.tu/default-email-handler]}]
(let [send-alert-by-user! (fn [user-kw]
(-> (assoc notification :creator_id (mt/user->id user-kw))
notification.payload/payload
notification.payload/notification-payload
:payload
:card_part
:result
:data

View File

@@ -26,6 +26,7 @@ const CHART_ANALYSIS_ENABLED = {
scalar: false,
smartscalar: false,
table: false,
"table-editable": false,
text: false,
} as const satisfies { [display in VisualizationDisplay]: boolean };

View File

@@ -9,5 +9,6 @@ export * from "./gdrive";
export * from "./db-routing";
export * from "./saml";
export * from "./scim";
export * from "./table-data-edit";
export * from "./tags";
export * from "./upload-management";

View File

@@ -0,0 +1,138 @@
import type { SkipToken } from "@reduxjs/toolkit/query";
import type {
DescribeActionFormRequest,
DescribeActionFormResponse,
TableDeleteRowsRequest,
TableDeleteRowsResponse,
TableExecuteActionRequest,
TableExecuteActionResponse,
TableInsertRowsRequest,
TableInsertRowsResponse,
TableUndoRedoRequest,
TableUndoRedoResponse,
TableUpdateRowsRequest,
TableUpdateRowsResponse,
} from "metabase-enterprise/data_editing/tables/types";
import type { DataGridWritebackAction } from "metabase-types/api/actions";
import { EnterpriseApi } from "./api";
export const tableDataEditApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
insertTableRows: builder.mutation<
TableInsertRowsResponse,
TableInsertRowsRequest
>({
query: ({ rows, scope }) => ({
method: "POST",
url: `/api/action/v2/execute-bulk`,
body: { inputs: rows, scope, action_id: "data-grid.row/create" },
}),
}),
updateTableRows: builder.mutation<
TableUpdateRowsResponse,
TableUpdateRowsRequest
>({
query: ({ inputs, params, scope }) => ({
method: "POST",
url: `/api/action/v2/execute-bulk`,
body: { inputs, params, scope, action_id: "data-grid.row/update" },
}),
}),
deleteTableRows: builder.mutation<
TableDeleteRowsResponse,
TableDeleteRowsRequest
>({
query: ({ rows, scope, params }) => ({
method: "POST",
url: `/api/action/v2/execute-bulk`,
body: {
inputs: rows,
scope,
action_id: "data-grid.row/delete",
...(params && { params }),
},
}),
}),
tableUndo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
query: ({ tableId, scope }) => ({
method: "POST",
url: `/api/action/v2/execute`,
body: {
input: {
"table-id": tableId,
},
scope,
action_id: "data-editing/undo",
},
}),
}),
tableRedo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
query: ({ tableId, scope }) => ({
method: "POST",
url: `/api/action/v2/execute`,
body: {
input: {
"table-id": tableId,
},
scope,
action_id: "data-editing/redo",
},
}),
}),
getActions: builder.query<
DataGridWritebackAction[],
null | SkipToken | void
>({
query: (params) => ({
method: "GET",
url: `/api/action/v2/tmp-action`,
params,
}),
transformResponse: (response: { actions: DataGridWritebackAction[] }) =>
response?.actions,
}),
executeAction: builder.mutation<
TableExecuteActionResponse,
TableExecuteActionRequest
>({
query: ({ actionId, scope, input, params }) => ({
method: "POST",
url: `/api/action/v2/execute`,
body: {
input,
params,
scope,
action_id: actionId,
},
}),
transformResponse: (response: {
outputs: [{ "rows-affected": number }];
}) => {
return response?.outputs?.[0];
},
}),
describeActionForm: builder.mutation<
DescribeActionFormResponse,
DescribeActionFormRequest
>({
query: (body) => ({
method: "POST",
url: `/api/action/v2/tmp-modal`,
body,
}),
}),
}),
});
export const {
useInsertTableRowsMutation,
useUpdateTableRowsMutation,
useDeleteTableRowsMutation,
useTableUndoMutation,
useTableRedoMutation,
useGetActionsQuery,
useExecuteActionMutation,
useDescribeActionFormMutation,
} = tableDataEditApi;

View File

@@ -0,0 +1,31 @@
import { useMemo } from "react";
import type { EditableTableActionsDisplaySettings } from "metabase-types/api";
export const useBuiltInActions = (
actionsVizSettings: EditableTableActionsDisplaySettings[] | undefined,
) => {
return useMemo(() => {
let hasCreateAction = false;
let hasDeleteAction = false;
let hasUpdateAction = true;
actionsVizSettings?.forEach((action) => {
if (action.actionId === "data-grid.row/create" && action.enabled) {
hasCreateAction = true;
}
if (action.actionId === "data-grid.row/delete" && action.enabled) {
hasDeleteAction = true;
}
if (action.actionId === "data-grid.row/update" && !action.enabled) {
hasUpdateAction = false;
}
});
return {
hasCreateAction,
hasDeleteAction,
hasUpdateAction,
};
}, [actionsVizSettings]);
};

View File

@@ -0,0 +1,46 @@
.root {
/* position: relative; */
display: grid;
width: 100%;
height: calc(100% - 5.5rem);
overflow: auto;
}
.rootPreviewOpen {
grid-template-columns: 50% 50%;
transition: grid-template-columns 0.3s ease;
}
.rootNoPreview {
grid-template-columns: 100%;
transition: grid-template-columns 0.3s ease;
}
.modalContent {
height: 100%;
display: flex;
flex-direction: column;
}
.modalBody {
padding: 0;
height: 100%;
overflow: auto;
flex-grow: 1;
}
.modalFooter {
position: relative;
z-index: 1002;
background-color: var(--mb-color-bg-white);
}
.modalHeader {
position: relative;
z-index: 1003;
background: transparent;
}
.modalClose {
z-index: 1004;
}

View File

@@ -0,0 +1,641 @@
import { skipToken } from "@reduxjs/toolkit/query";
import cx from "classnames";
import { useCallback, useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import { isEqual } from "underscore";
import {
useCreateNotificationMutation,
useGetChannelInfoQuery,
useGetDefaultNotificationTemplateQuery,
useGetNotificationPayloadExampleQuery,
useLazyPreviewNotificationTemplateQuery,
useListChannelsQuery,
usePreviewNotificationTemplateQuery,
useUpdateNotificationMutation,
} from "metabase/api";
import ButtonWithStatus from "metabase/common/components/ButtonWithStatus";
import { ConfirmModal } from "metabase/common/components/ConfirmModal";
import { AutoWidthSelect } from "metabase/common/components/Schedule/AutoWidthSelect";
import { useEscapeToCloseModal } from "metabase/common/hooks/use-escape-to-close-modal";
import CS from "metabase/css/core/index.css";
import { openInBlankWindow } from "metabase/lib/dom";
import { alertIsValid } from "metabase/lib/notifications";
import {
getHasConfiguredAnyChannel,
getHasConfiguredEmailChannel,
} from "metabase/lib/pulse";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { ChannelSetupModal } from "metabase/notifications/modals/shared/ChannelSetupModal";
import { AlertModalSettingsBlock } from "metabase/notifications/modals/shared/components/AlertModalSettingsBlock/AlertModalSettingsBlock";
import { AlertTriggerIcon } from "metabase/notifications/modals/shared/components/AlertTriggerIcon";
import { NotificationChannelsPicker } from "metabase/notifications/modals/shared/components/NotificationChannels/NotificationChannelsPicker/NotificationChannelsPicker";
import { getDefaultTableNotificationRequest } from "metabase/notifications/utils";
import { addUndo } from "metabase/redux/undo";
import { canAccessSettings, getUser } from "metabase/selectors/user";
import { Button, Flex, Modal, Stack, rem } from "metabase/ui";
import type {
ChannelApiResponse,
ChannelTemplate,
ConditionalAlertExpression,
CreateTableNotificationRequest,
NotificationChannelType,
NotificationHandler,
NotificationTriggerEvent,
TableId,
TableNotification,
UpdateTableNotificationRequest,
User,
UserId,
} from "metabase-types/api";
import S from "./CreateOrEditTableNotificationModal.module.css";
import { AlertConditionBuilder } from "./components/AlertConditionBuilder/AlertConditionBuilder";
import { PreviewTemplatePanel } from "./components/PreviewTemplatePanel/PreviewTemplatePanel";
type TableNotificationTriggerOption = {
value: {
eventName: NotificationTriggerEvent;
};
get label(): string;
};
const NOTIFICATION_TRIGGER_OPTIONS_MAP: Record<
NotificationTriggerEvent,
TableNotificationTriggerOption
> = {
"event/row.created": {
value: {
eventName: "event/row.created",
},
get label() {
return t`When new records are created`;
},
},
"event/row.updated": {
value: {
eventName: "event/row.updated",
},
get label() {
return t`When any cell changes it's value`;
},
},
"event/row.deleted": {
value: {
eventName: "event/row.deleted",
},
get label() {
return t`When records are deleted`;
},
},
};
const triggerOptions = (
[
"event/row.created",
"event/row.updated",
"event/row.deleted",
] as NotificationTriggerEvent[]
).map((event) => ({
value: event,
label: NOTIFICATION_TRIGGER_OPTIONS_MAP[event].label,
option: NOTIFICATION_TRIGGER_OPTIONS_MAP[event],
}));
interface CreateOrEditTableNotificationModalProps {
tableId: TableId;
notification?: TableNotification | null;
onNotificationCreated?: () => void;
onNotificationUpdated?: () => void;
onClose: () => void;
}
// Custom hooks to encapsulate logic from the modal.
// Fetch necessary data.
const useNotificationTemplates = (
requestBody:
| CreateTableNotificationRequest
| UpdateTableNotificationRequest
| null,
user: { id: UserId } | null,
) => {
const channelTypes = useMemo(
() =>
requestBody?.handlers
? requestBody.handlers
.map((h) => h.channel_type)
.filter((type): type is NotificationChannelType => !!type)
: [],
[requestBody?.handlers],
);
const { data: defaultTemplates } = useGetDefaultNotificationTemplateQuery(
requestBody?.payload_type &&
requestBody?.payload?.event_name &&
user &&
channelTypes.length > 0
? {
notification: {
payload_type: requestBody.payload_type,
payload: requestBody.payload,
},
channel_types: channelTypes,
}
: skipToken,
{
skip: !requestBody?.payload?.event_name || channelTypes.length === 0,
},
);
const { data: templateContext } = useGetNotificationPayloadExampleQuery(
requestBody?.payload_type &&
requestBody?.payload?.event_name &&
user &&
channelTypes.length > 0
? {
notification: {
payload_type: requestBody.payload_type,
payload: requestBody.payload,
creator_id: user.id,
},
channel_types: channelTypes,
}
: skipToken,
{
skip: !requestBody?.payload?.event_name || channelTypes.length === 0,
},
);
return {
defaultTemplates,
templateContext,
};
};
const useNotificationFormState = (
tableId: TableId,
notification: TableNotification | null,
user: User | null,
channelSpec: ChannelApiResponse | undefined,
userCanAccessSettings: boolean,
) => {
const { data: hookChannels } = useListChannelsQuery();
const [requestBody, setRequestBody] = useState<
CreateTableNotificationRequest | UpdateTableNotificationRequest | null
>(null);
useEffect(() => {
// Ensure all required data is loaded before initializing
if (!requestBody && user && channelSpec && hookChannels) {
const eventName: NotificationTriggerEvent =
notification?.payload.event_name ?? "event/row.created";
const initialRequestBody = getDefaultTableNotificationRequest({
tableId,
eventName,
currentUserId: user.id,
channelSpec,
hookChannels,
userCanAccessSettings,
});
// If editing, merge existing notification data
if (notification) {
setRequestBody({ ...initialRequestBody, ...notification });
} else {
setRequestBody(initialRequestBody);
}
}
}, [
tableId,
notification,
requestBody,
user,
channelSpec,
hookChannels,
userCanAccessSettings,
]);
const handleEventNameChange = useCallback((value: string | null) => {
if (value) {
const selectedOption =
NOTIFICATION_TRIGGER_OPTIONS_MAP[value as NotificationTriggerEvent];
if (selectedOption) {
setRequestBody((requestBody) =>
requestBody
? {
...requestBody,
payload: {
...requestBody.payload,
event_name: selectedOption.value.eventName,
},
}
: null,
);
}
}
}, []);
const handleChannelHandlersChange = useCallback(
(newHandlers: NotificationHandler[]) => {
setRequestBody((requestBody) =>
requestBody
? {
...requestBody,
handlers: newHandlers,
}
: null,
);
},
[],
);
const handleConditionChange = useCallback(
(newCondition: ConditionalAlertExpression) => {
setRequestBody((requestBody) =>
requestBody
? {
...requestBody,
condition: newCondition,
}
: null,
);
},
[],
);
return {
requestBody,
handleEventNameChange,
handleChannelHandlersChange,
handleConditionChange,
};
};
// Manage the preview panel state and data fetching
const useNotificationTemplatePreview = (
requestBody:
| CreateTableNotificationRequest
| UpdateTableNotificationRequest
| null,
defaultTemplates:
| Record<NotificationChannelType, ChannelTemplate>
| undefined,
) => {
const [previewOpen, setPreviewOpen] = useState(false);
const [previewRequest, setPreviewRequest] = useState<{
notification:
| CreateTableNotificationRequest
| UpdateTableNotificationRequest;
template: ChannelTemplate;
} | null>(null);
const {
data: previewData,
isLoading: isPreviewLoading,
error: previewError,
} = usePreviewNotificationTemplateQuery(previewRequest ?? skipToken, {
skip: !previewRequest,
});
const [fetchPreview] = useLazyPreviewNotificationTemplateQuery();
const handlePreviewClick = useCallback(
async (channelType: NotificationChannelType) => {
if (previewOpen && channelType === "channel/email") {
setPreviewOpen(false);
setPreviewRequest(null);
return;
}
let previewRequest = null;
if (requestBody && channelType) {
const handler = requestBody.handlers.find(
(h) => h.channel_type === channelType && h.template,
);
const currentTemplate =
handler?.template || defaultTemplates?.[channelType];
if (currentTemplate) {
previewRequest = {
notification: requestBody,
template: currentTemplate,
};
}
}
if (channelType === "channel/email") {
setPreviewOpen(true);
setPreviewRequest(previewRequest);
}
if (channelType === "channel/slack" && previewRequest) {
const { data } = await fetchPreview(previewRequest);
if (data?.preview_url) {
openInBlankWindow(data.preview_url);
}
}
},
[defaultTemplates, fetchPreview, previewOpen, requestBody],
);
const handlePreviewClose = useCallback(() => {
setPreviewOpen(false);
}, []);
return {
previewOpen,
previewData,
isPreviewLoading,
previewError,
handlePreviewClick,
handlePreviewClose,
};
};
// Manage saving/updating and the confirmation modal
const useNotificationSave = (
requestBody:
| CreateTableNotificationRequest
| UpdateTableNotificationRequest
| null,
notification: TableNotification | null,
isEditMode: boolean,
onNotificationCreated?: () => void,
onNotificationUpdated?: () => void,
) => {
const dispatch = useDispatch();
const [createNotification] = useCreateNotificationMutation();
const [updateNotification] = useUpdateNotificationMutation();
const hasChanges = useMemo(
() => notification && !isEqual(requestBody, notification),
[requestBody, notification],
);
const onCreateOrEditAlert = useCallback(async () => {
if (!requestBody) {
return;
}
try {
if (isEditMode && notification) {
await updateNotification({
...requestBody,
id: notification.id,
} as UpdateTableNotificationRequest);
dispatch(
addUndo({
message: isEditMode ? t`Alert updated.` : t`Alert created.`,
}),
);
if (isEditMode) {
onNotificationUpdated?.();
} else {
onNotificationCreated?.();
}
} else {
await createNotification(requestBody as CreateTableNotificationRequest);
dispatch(
addUndo({
message: isEditMode ? t`Alert updated.` : t`Alert created.`,
}),
);
onNotificationCreated?.();
}
} catch (error) {
console.error("Failed to save notification:", error);
throw error;
}
}, [
requestBody,
isEditMode,
notification,
updateNotification,
createNotification,
dispatch,
onNotificationCreated,
onNotificationUpdated,
]);
return {
hasChanges,
onCreateOrEditAlert,
};
};
export const CreateOrEditTableNotificationModal = ({
tableId,
notification,
onNotificationCreated,
onNotificationUpdated,
onClose,
}: CreateOrEditTableNotificationModalProps) => {
const user = useSelector(getUser);
const userCanAccessSettings = useSelector(canAccessSettings);
const isEditMode = !!notification?.id;
const { data: channelSpec, isLoading: isLoadingChannelInfo } =
useGetChannelInfoQuery();
const {
requestBody,
handleEventNameChange,
handleChannelHandlersChange,
handleConditionChange,
} = useNotificationFormState(
tableId,
notification ?? null,
user,
channelSpec,
userCanAccessSettings,
);
const { defaultTemplates, templateContext } = useNotificationTemplates(
requestBody,
user,
);
const {
previewOpen,
previewData,
isPreviewLoading,
previewError,
handlePreviewClick,
handlePreviewClose,
} = useNotificationTemplatePreview(requestBody, defaultTemplates);
const { hasChanges, onCreateOrEditAlert } = useNotificationSave(
requestBody,
notification ?? null,
isEditMode,
onNotificationCreated,
onNotificationUpdated,
);
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false);
const handleCloseAttempt = useCallback(() => {
if (isConfirmModalOpen) {
return;
}
if (hasChanges) {
setIsConfirmModalOpen(true);
} else {
onClose();
}
}, [hasChanges, onClose, isConfirmModalOpen]);
const handleConfirmDiscard = useCallback(() => {
setIsConfirmModalOpen(false);
onClose();
}, [onClose]);
useEscapeToCloseModal(handleCloseAttempt, { capture: false });
const hasConfiguredAnyChannel = useMemo(
() => getHasConfiguredAnyChannel(channelSpec),
[channelSpec],
);
const hasConfiguredEmailChannel = useMemo(
() => getHasConfiguredEmailChannel(channelSpec),
[channelSpec],
);
const channelRequirementsMet = userCanAccessSettings
? hasConfiguredAnyChannel
: hasConfiguredEmailChannel;
if (!isLoadingChannelInfo && channelSpec && !channelRequirementsMet) {
return (
<ChannelSetupModal
userCanAccessSettings={userCanAccessSettings}
onClose={onClose}
/>
);
}
if (!requestBody || !requestBody.payload) {
return null;
}
return (
<Modal
data-testid="table-notification-create"
opened
size={previewOpen ? rem(1360) : rem(680)}
onClose={handleCloseAttempt}
padding="2.5rem"
closeOnEscape={false}
title={isEditMode ? t`Edit alert` : t`New alert`}
maw="90%"
classNames={{
header: S.modalHeader,
content: S.modalContent,
body: S.modalBody,
close: S.modalClose,
}}
>
<div
className={cx(
S.root,
previewOpen ? S.rootPreviewOpen : S.rootNoPreview,
)}
>
<Stack gap="xl" mt="1.5rem" mb="2rem" px="2.5rem">
<AlertModalSettingsBlock
title={t`What do you want to be notified about?`}
>
<Flex gap="lg" align="center">
<AlertTriggerIcon />
<AutoWidthSelect
data-testid="notification-event-select"
data={triggerOptions}
value={requestBody.payload.event_name}
onChange={(value) => {
if (value) {
handleEventNameChange(value);
}
}}
/>
</Flex>
<Flex mt="md">
<AlertConditionBuilder
tableId={tableId}
eventType={requestBody?.payload.event_name}
onChange={handleConditionChange}
initialExpression={notification?.condition}
/>
</Flex>
</AlertModalSettingsBlock>
<AlertModalSettingsBlock
title={t`Where do you want to send the alerts?`}
contentProps={{
style: {
// To display autocomplete popovers properly.
overflow: "visible",
},
}}
>
<NotificationChannelsPicker
enableTemplates={{ email: true, slack: true }}
notificationHandlers={requestBody.handlers}
channels={channelSpec ? channelSpec.channels : undefined}
onChange={handleChannelHandlersChange}
templateContext={templateContext}
defaultTemplates={defaultTemplates}
onPreviewClick={handlePreviewClick}
isPreviewOpen={previewOpen}
getInvalidRecipientText={(domains) =>
t`You're only allowed to email alerts to addresses ending in ${domains}`
}
/>
</AlertModalSettingsBlock>
</Stack>
{previewOpen && (
<PreviewTemplatePanel
onClose={handlePreviewClose}
isLoading={isPreviewLoading}
error={previewError}
previewContent={previewData?.rendered}
/>
)}
</div>
<Flex
h="5.5rem"
justify="flex-end"
px="2.5rem"
py="lg"
className={cx(CS.borderTop, S.modalFooter)}
>
<Button
onClick={handleCloseAttempt}
className={CS.mr2}
>{t`Cancel`}</Button>
<ButtonWithStatus
titleForState={{
default: isEditMode && hasChanges ? t`Save changes` : t`Done`,
}}
disabled={
!alertIsValid(requestBody.handlers, channelSpec) ||
(isEditMode && !hasChanges)
}
onClickOperation={onCreateOrEditAlert}
/>
</Flex>
{hasChanges && (
<ConfirmModal
size="md"
opened={isConfirmModalOpen}
title={t`Discard unsaved changes?`}
message={t`You have unsaved changes. Are you sure you want to discard them?`}
onClose={() => setIsConfirmModalOpen(false)}
onConfirm={handleConfirmDiscard}
confirmButtonText={t`Discard`}
closeButtonText={t`Cancel`}
closeOnEscape={false}
/>
)}
</Modal>
);
};

View File

@@ -0,0 +1,450 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { setupEnterprisePlugins } from "__support__/enterprise";
import {
setupNotificationChannelsEndpoints,
setupUserRecipientsEndpoint,
} from "__support__/server-mocks";
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
import { mockSettings } from "__support__/settings";
import { createMockEntitiesState } from "__support__/store";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import type { UserWithApplicationPermissions } from "metabase/plugins";
import type {
ChannelApiResponse,
NotificationChannel,
NotificationTriggerEvent,
TableNotification,
} from "metabase-types/api";
import {
createMockTable,
createMockTokenFeatures,
createMockUser,
} from "metabase-types/api/mocks";
import { createMockChannel } from "metabase-types/api/mocks/channel";
import {
createMockNotificationHandlerEmail,
createMockNotificationRecipientUser,
} from "metabase-types/api/mocks/notification";
import { createSampleDatabase } from "metabase-types/api/mocks/presets";
import { CreateOrEditTableNotificationModal } from "./CreateOrEditTableNotificationModal";
describe("CreateOrEditTableNotificationModal", () => {
beforeEach(() => {
fetchMock.reset();
fetchMock.post("path:/api/notification/payload", { body: { id: 123 } });
});
afterEach(() => {
fetchMock.restore();
});
it("should display first available channel by default - Email", async () => {
setup({
isAdmin: true,
});
await waitFor(() => {
expect(
screen.getByTestId("table-notification-create"),
).toBeInTheDocument();
});
expect(screen.getByText("Email")).toBeInTheDocument();
});
it.each([{ isAdmin: true }, { isAdmin: false, userCanAccessSettings: true }])(
"should display first available channel by default - Slack %p",
async (setupConfig) => {
setup({
isEmailSetup: false,
isSlackSetup: true,
...setupConfig,
});
await waitFor(() => {
expect(
screen.getByTestId("table-notification-create"),
).toBeInTheDocument();
});
expect(screen.queryByText("Email")).not.toBeInTheDocument();
expect(screen.getByText("Slack")).toBeInTheDocument();
},
);
it.each([{ isAdmin: true }, { isAdmin: false, userCanAccessSettings: true }])(
"should display first available channel by default - Webhook %p",
async (setupConfig) => {
const mockWebhook = createMockChannel();
setup({
isEmailSetup: false,
isHttpSetup: true,
webhooksResult: [mockWebhook],
...setupConfig,
});
await waitFor(() => {
expect(
screen.getByTestId("table-notification-create"),
).toBeInTheDocument();
});
expect(screen.queryByText("Email")).not.toBeInTheDocument();
expect(screen.getByText(mockWebhook.name)).toBeInTheDocument();
},
);
it("should not show channels if user does not have permissions", async () => {
const mockWebhook = createMockChannel();
setup({
isEmailSetup: false,
isSlackSetup: true,
isHttpSetup: true,
webhooksResult: [mockWebhook],
userCanAccessSettings: false,
});
await waitFor(() => {
expect(
screen.getByTestId("alerts-channel-setup-modal"),
).toBeInTheDocument();
});
expect(
screen.queryByTestId("table-notification-create"),
).not.toBeInTheDocument();
});
it("should show set up channels model for non-admin users with access setting permission", async () => {
setup({
isEmailSetup: false,
isSlackSetup: false,
isHttpSetup: false,
userCanAccessSettings: true,
});
await waitFor(() => {
expect(
screen.getByTestId("alerts-channel-setup-modal"),
).toBeInTheDocument();
});
expect(
screen.getByTestId("alerts-channel-create-webhook"),
).toBeInTheDocument();
});
it("should show row created event as the default trigger", async () => {
setup({
isAdmin: true,
isEmailSetup: true,
});
await waitFor(() => {
expect(screen.getByText("New alert")).toBeInTheDocument();
});
// Check that the correct option is selected (UI displays the label, not the value)
const triggerSelect = screen.getByTestId("notification-event-select");
expect(triggerSelect).toHaveValue("When new records are created");
});
it("should show the correct event options for table notifications", async () => {
setup({
isAdmin: true,
isEmailSetup: true,
});
await waitFor(() => {
expect(screen.getByText("New alert")).toBeInTheDocument();
});
// Click on the event select to open the dropdown
const triggerSelect = screen.getByTestId("notification-event-select");
await userEvent.click(triggerSelect);
// Verify all event options are available
expect(
screen.getByRole("option", { name: /When new records are created/i }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: /When any cell changes it's value/i }),
).toBeInTheDocument();
expect(
screen.getByRole("option", { name: /When records are deleted/i }),
).toBeInTheDocument();
});
it("should show notification data when in edit mode", async () => {
// Create a custom notification with 'row updated' event
const mockNotification = createMockTableNotification({
event_name: "event/row.updated",
});
setup({
isAdmin: true,
isEmailSetup: true,
editingNotification: mockNotification,
});
await waitFor(() => {
// Should show "Edit alert" instead of "New alert"
expect(screen.getByText("Edit alert")).toBeInTheDocument();
});
// Verify the correct event is selected (UI displays the label, not the value)
const triggerSelect = screen.getByTestId("notification-event-select");
expect(triggerSelect).toHaveValue("When any cell changes it's value");
});
it("should create a new notification with default settings", async () => {
// Setup fetchMock for API call
fetchMock.postOnce("path:/api/notification", { body: { id: 123 } });
const onNotificationCreatedMock = jest.fn();
setup({
isAdmin: true,
isEmailSetup: true,
onNotificationCreatedMock,
});
await waitFor(() => {
expect(screen.getByText("New alert")).toBeInTheDocument();
});
// Click save button without changing anything
const saveButton = screen.getByRole("button", { name: /done/i });
await userEvent.click(saveButton);
// Verify the API was called with the default settings
const calls = fetchMock.calls("path:/api/notification");
expect(calls.length).toBe(1);
const requestBody = await calls[0][1]?.body;
const parsedBody = JSON.parse(requestBody as string);
await waitFor(() => {
expect(parsedBody.payload.event_name).toBe("event/row.created");
});
// Verify it has the default table_id
expect(parsedBody.payload.table_id).toBe(42);
// Verify it has at least one handler
expect(parsedBody.handlers.length).toBeGreaterThan(0);
expect(onNotificationCreatedMock).toHaveBeenCalledTimes(1);
});
it("should change notification event and create new notification", async () => {
// Setup fetchMock for API call
fetchMock.postOnce("path:/api/notification", { body: { id: 123 } });
const onNotificationCreatedMock = jest.fn();
setup({
isAdmin: true,
isEmailSetup: true,
onNotificationCreatedMock,
});
await waitFor(() => {
expect(screen.getByText("New alert")).toBeInTheDocument();
});
// Change the event to "row updated"
const triggerSelect = screen.getByTestId("notification-event-select");
await userEvent.click(triggerSelect);
await userEvent.click(
screen.getByRole("option", { name: /When any cell changes it's value/i }),
);
// Click save button
const saveButton = screen.getByRole("button", { name: /done/i });
await userEvent.click(saveButton);
// Verify the API was called with the updated event
const calls = fetchMock.calls("path:/api/notification");
expect(calls.length).toBe(1);
const requestBody = await calls[0][1]?.body;
const parsedBody = JSON.parse(requestBody as string);
await waitFor(() => {
expect(parsedBody.payload.event_name).toBe("event/row.updated");
});
// Additional assertions...
});
it("should update an existing notification when in edit mode", async () => {
const notificationId = 42;
// Setup fetchMock for API call
fetchMock.putOnce(`path:/api/notification/${notificationId}`, {
body: { id: notificationId },
});
const onNotificationUpdatedMock = jest.fn();
// Create a notification with 'row created' event
const mockNotification = createMockTableNotification({
id: notificationId,
event_name: "event/row.created",
});
setup({
isAdmin: true,
isEmailSetup: true,
editingNotification: mockNotification,
onNotificationUpdatedMock,
});
await waitFor(() => {
expect(screen.getByText("Edit alert")).toBeInTheDocument();
});
// Change event from 'row created' to 'row updated'
const triggerSelect = screen.getByTestId("notification-event-select");
await userEvent.click(triggerSelect);
const updateOption = screen.getByRole("option", {
name: /When any cell changes it's value/i,
});
await userEvent.click(updateOption);
// Click Save button
const saveButton = screen.getByRole("button", { name: /save changes/i });
await userEvent.click(saveButton);
// Verify the API was called with the updated event
const calls = fetchMock.calls(`path:/api/notification/${notificationId}`);
expect(calls.length).toBe(1);
await waitFor(async () => {
const requestBody = await calls[0][1]?.body;
const parsedBody = JSON.parse(requestBody as string);
return parsedBody; // Return the parsed body for later assertions
}).then((parsedBody) => {
// Verify the event has been changed to 'row updated'
expect(parsedBody.payload.event_name).toBe("event/row.updated");
});
expect(onNotificationUpdatedMock).toHaveBeenCalledTimes(1);
});
});
// Helper function to create a mock table notification
function createMockTableNotification({
id = 123,
event_name = "event/row.created",
table_id = 42,
user_id = 1,
}: {
id?: number;
event_name?: NotificationTriggerEvent;
table_id?: number;
user_id?: number;
}): TableNotification {
const user = createMockUser({ id: user_id });
return {
id,
active: true,
creator_id: user_id,
creator: user,
handlers: [
createMockNotificationHandlerEmail({
recipients: [
createMockNotificationRecipientUser({
user_id,
user,
}),
],
}),
],
created_at: "2025-01-07T12:00:00Z",
updated_at: "2025-01-07T12:00:00Z",
payload_type: "notification/system-event",
payload: {
event_name,
table_id,
},
payload_id: null,
condition: ["=", ["field", "id"], 1],
};
}
function setup({
userCanAccessSettings = false,
isAdmin = false,
isEmailSetup = true,
isSlackSetup = false,
isHttpSetup = false,
webhooksResult = [],
editingNotification,
onNotificationCreatedMock = jest.fn(),
onNotificationUpdatedMock = jest.fn(),
}: {
userCanAccessSettings?: boolean;
isAdmin?: boolean;
isEmailSetup?: boolean;
isSlackSetup?: boolean;
isHttpSetup?: boolean;
webhooksResult?: NotificationChannel[];
editingNotification?: TableNotification;
onNotificationCreatedMock?: jest.Mock;
onNotificationUpdatedMock?: jest.Mock;
}) {
const settings = mockSettings({
"token-features": createMockTokenFeatures({
advanced_permissions: true,
}),
});
setupEnterprisePlugins();
setupNotificationChannelsEndpoints({
slack: { configured: isSlackSetup },
email: { configured: isEmailSetup },
http: { configured: isHttpSetup },
} as ChannelApiResponse["channels"]);
setupWebhookChannelsEndpoint(webhooksResult);
setupUserRecipientsEndpoint({ users: [] });
const currentUser = createMockUser(
isAdmin ? { is_superuser: true } : undefined,
);
if (userCanAccessSettings) {
(currentUser as UserWithApplicationPermissions).permissions = {
can_access_setting: true,
can_access_monitoring: false,
can_access_subscription: false,
};
}
const database = createSampleDatabase();
const _table = createMockTable({ id: 42 });
return renderWithProviders(
<CreateOrEditTableNotificationModal
tableId={_table.id}
notification={editingNotification ?? null}
onClose={jest.fn()}
onNotificationCreated={onNotificationCreatedMock}
onNotificationUpdated={onNotificationUpdatedMock}
/>,
{
storeInitialState: {
currentUser,
settings,
entities: createMockEntitiesState({
databases: [database],
tables: [_table],
}),
},
},
);
}

View File

@@ -0,0 +1,753 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import {
useGetTableDataQuery,
useGetTableQueryMetadataQuery,
} from "metabase/api";
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
import { getBaseCondition } from "metabase/notifications/utils";
import {
Button,
Flex,
Icon,
Menu,
Select,
Stack,
Text,
rem,
} from "metabase/ui";
import { EditingBodyCellConditional } from "metabase-enterprise/data_editing/tables/edit/inputs";
import * as Lib from "metabase-lib";
import type {
ComparisonOperator,
ConditionalAlertExpression,
FieldId,
FieldWithMetadata,
Literal,
LogicalOperator,
MultipleConditionalAlertExpressions,
NotificationTriggerEvent,
SingleConditionalAlertExpression,
TableId,
} from "metabase-types/api";
import type { DatasetColumn } from "metabase-types/api/dataset";
// Types for the condition builder UI
type ColumnType =
| "type/Text"
| "type/Integer"
| "type/BigInteger"
| "type/Float"
| "type/Boolean"
| "type/Date"
| "type/DateTime"
| "type/DateTimeWithLocalTZ";
type ConditionBuilderColumn = DatasetColumn & {
field: FieldWithMetadata;
isNumeric: boolean;
isBoolean: boolean;
isStringOrStringLike: boolean;
};
const SUPPORTED_TYPES = [
"type/PK",
"type/Text",
"type/Integer",
"type/BigInteger",
"type/Float",
"type/Boolean",
];
interface OperatorOption {
value: ComparisonOperator;
label: string;
applicableTypes: ColumnType[];
}
interface ConditionRow {
id: string;
columnId: FieldId | null;
columnName: string | null;
operator: ComparisonOperator | null;
value: Literal;
}
interface AlertConditionBuilderProps {
tableId: TableId;
eventType: NotificationTriggerEvent;
onChange?: (expression: ConditionalAlertExpression) => void;
initialExpression?: ConditionalAlertExpression;
}
// Define operators outside of component to avoid recreation on each render
const OPERATORS: OperatorOption[] = [
{
value: "=",
label: "Equal to",
applicableTypes: [
"type/Text",
"type/Integer",
"type/BigInteger",
"type/Float",
"type/Boolean",
"type/Date",
"type/DateTime",
],
},
{
value: "!=",
label: "Not equal to",
applicableTypes: [
"type/Text",
"type/Integer",
"type/BigInteger",
"type/Float",
"type/Boolean",
"type/Date",
"type/DateTime",
],
},
{
value: ">",
label: "Higher than",
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
},
{
value: "<",
label: "Lower than",
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
},
{
value: ">=",
label: "Greater than or equal to",
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
},
{
value: "<=",
label: "Less than or equal to",
applicableTypes: ["type/Integer", "type/BigInteger", "type/Float"],
},
];
// Component for a single condition row
const ConditionRowComponent = ({
condition,
onUpdate,
onRemove,
columns,
}: {
condition: ConditionRow;
onUpdate: (id: string, updates: Partial<ConditionRow>) => void;
onRemove: (id: string) => void;
columns: Array<ConditionBuilderColumn>;
}) => {
const selectedColumn = condition.columnId
? columns.find((col) => col.id === Number(condition.columnId))
: columns[0];
const columnType = selectedColumn?.effective_type as ColumnType | undefined;
// Filter operators based on column type
// Get operators based on column type, or all operators if no column selected
const filteredOperators = columnType
? OPERATORS.filter((op: OperatorOption) =>
op.applicableTypes.includes(columnType),
)
: OPERATORS;
// Helper to get a default value based on column type
const getDefaultValueForType = (columnType: ColumnType | undefined): any => {
if (!columnType) {
return undefined;
}
switch (true) {
case columnType.includes("Integer"):
case columnType.includes("Float"):
case columnType.includes("Decimal"):
return 0;
case columnType.includes("Boolean"):
return true;
case columnType.includes("Date"):
case columnType.includes("Time"):
return new Date().toISOString().split("T")[0]; // YYYY-MM-DD format
case columnType.includes("Text"):
return "";
default:
return undefined;
}
};
const handleColumnChange = (columnId: FieldId | null) => {
// When a column is selected, automatically select the first applicable operator and set default value
if (columnId) {
const selectedColumn = columns.find((col) => col.id === Number(columnId));
const columnType = selectedColumn?.effective_type as
| ColumnType
| undefined;
if (columnType) {
const applicableOperators = OPERATORS.filter((op) =>
op.applicableTypes.includes(columnType),
);
if (applicableOperators.length > 0) {
const defaultValue = getDefaultValueForType(columnType);
onUpdate(condition.id, {
columnId,
columnName: selectedColumn?.name || null,
operator: applicableOperators[0].value as ComparisonOperator,
value: defaultValue,
});
return;
}
}
}
// If no column selected or no applicable operators found
onUpdate(condition.id, {
columnId: null,
columnName: null,
operator: null,
value: null,
});
};
const handleOperatorChange = (operator: ComparisonOperator | null) => {
onUpdate(condition.id, { operator });
};
const handleValueChange = (value: string) => {
const parsedValue = parseLiteral(value, selectedColumn);
onUpdate(condition.id, { value: parsedValue });
};
// Helper function to get operator icon/symbol
const getOperatorIcon = (operator: ComparisonOperator | null) => {
switch (operator) {
case "=":
return "=";
case "!=":
return "≠";
case ">":
return ">";
case "<":
return "<";
case ">=":
return "≥";
case "<=":
return "≤";
default:
return "=";
}
};
// Prepare UI for the condition row
return (
<Flex gap="xs" align="center" w="100%">
<div
style={{
display: "grid",
gridTemplateColumns: "1fr 32px 1fr",
gap: "8px",
alignItems: "center",
width: "100%",
}}
>
<Select
data={columns.map((col) => ({
value: col.id?.toString() || "",
label: col.display_name,
icon: getColumnIcon(col),
}))}
value={condition.columnId?.toString() || ""}
onChange={(newValue) => handleColumnChange(Number(newValue))}
placeholder={t`Select column`}
searchable
clearable
style={{ width: "100%" }}
/>
{selectedColumn && (
<>
<Menu position="bottom" withinPortal>
<Menu.Target>
<Button
style={{
width: "32px",
height: "32px",
padding: 0,
display: "flex",
justifyContent: "center",
alignItems: "center",
}}
fz="lg"
fw={500}
>
<Text>{getOperatorIcon(condition.operator)}</Text>
</Button>
</Menu.Target>
<Menu.Dropdown>
{filteredOperators.map((op: OperatorOption) => (
<Menu.Item
key={op.value}
onClick={() => handleOperatorChange(op.value)}
leftSection={
<Text fw={600}>
{getOperatorIcon(op.value as ComparisonOperator)}
</Text>
}
>
{op.label}
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
<EditingBodyCellConditional
field={selectedColumn?.field}
datasetColumn={selectedColumn}
initialValue={condition.value}
onSubmit={(value) => handleValueChange(value as string)}
onCancel={() => {}}
inputProps={{ disabled: false, placeholder: t`Select value` }}
/>
</>
)}
</div>
<Button
variant="subtle"
onClick={() => onRemove(condition.id)}
p={0}
style={{
width: "2rem",
flexShrink: 0,
display: "flex",
justifyContent: "center",
}}
styles={{ label: { display: "flex" } }}
>
<Icon name="close" />
</Button>
</Flex>
);
};
export const AlertConditionBuilder = ({
tableId,
eventType,
onChange,
initialExpression,
}: AlertConditionBuilderProps) => {
const { data: tableMetadata } = useGetTableQueryMetadataQuery({
id: tableId,
});
const { data: datasetData } = useGetTableDataQuery({
tableId,
});
const [areConditionsEnabled, setConditionsEnabled] = useState(false);
// Convert table fields to column options
const columns = useMemo(() => {
return (datasetData?.data?.cols || [])
.map((col) => {
const field = tableMetadata?.fields?.find((f) => f.name === col.name);
const typeInfo = Lib.legacyColumnTypeInfo(col);
return {
...col,
field,
isNumeric: Lib.isNumeric(typeInfo),
isStringOrStringLike: Lib.isStringOrStringLike(typeInfo),
isBoolean: Lib.isBoolean(typeInfo),
} as ConditionBuilderColumn;
})
.filter(
(col) =>
col.visibility_type === "normal" &&
(SUPPORTED_TYPES.includes(col.field?.semantic_type as string) ||
SUPPORTED_TYPES.includes(col.field?.effective_type as ColumnType)),
);
}, [datasetData?.data?.cols, tableMetadata?.fields]);
// Initialize conditions from initialExpression or with a single empty condition
const [conditions, setConditions] = useState<ConditionRow[]>([]);
const [logicalOperator, setLogicalOperator] =
useState<LogicalOperator>("and");
const populateInitialState = useCallback(() => {
const {
conditions: initialConditions,
logicalOperator: initialLogicalOperator,
} = parseInitialExpression(initialExpression, columns);
setConditions(initialConditions);
setLogicalOperator(initialLogicalOperator);
}, [initialExpression, columns]);
// Initial state population.
useEffect(() => {
if (!initialExpression) {
return;
}
populateInitialState();
if (doesExpressionHaveClientSideConditions(initialExpression)) {
setConditionsEnabled(true);
}
}, [populateInitialState, initialExpression]);
// Handle adding a new condition
// const handleAddCondition = () => {
// setConditions([
// ...conditions,
// {
// id: crypto.randomUUID(),
// columnId: null,
// columnName: null,
// operator: null,
// value: null,
// },
// ]);
// };
// Handle updating a condition
const handleUpdateCondition = useCallback(
(id: string, updates: Partial<ConditionRow>) => {
setConditions((conditions) =>
conditions.map((condition) =>
condition.id === id ? { ...condition, ...updates } : condition,
),
);
},
[],
);
// Handle removing a condition
const handleRemoveCondition = (id: string) => {
// TODO: Consider running onChange here.
const newConditions = conditions.filter((condition) => condition.id !== id);
setConditions(newConditions);
if (newConditions.length === 0) {
// onChange?.(null);
setConditionsEnabled(false);
}
// } else {
// onChange?.(generateExpression(tableId, newConditions, logicalOperator));
// }
};
// Handle changing the logical operator
const handleOperatorChange = useCallback((value: string | null) => {
if (value === "and" || value === "or") {
setLogicalOperator(value);
}
}, []);
useEffect(() => {
const expression = generateExpression(
tableId,
eventType,
conditions,
logicalOperator,
);
onChange?.(expression);
}, [
initialExpression,
conditions,
logicalOperator,
onChange,
tableId,
eventType,
]);
if (!areConditionsEnabled) {
return (
<Button
variant="subtle"
p={0}
onClick={() => {
populateInitialState();
setConditionsEnabled(true);
}}
>
{t`Add conditions for alerts`}
</Button>
);
}
return (
<Stack gap="sm" w="100%">
<Text size="lg" style={{ marginRight: "auto" }}>
{t`Notify if the following condition is met`}
</Text>
{conditions.length > 1 && (
<Flex align="center">
<Select
data={[
{ value: "and", label: t`AND` },
{ value: "or", label: t`OR` },
]}
value={logicalOperator}
onChange={handleOperatorChange}
style={{ width: rem(100) }}
/>
</Flex>
)}
<Stack gap="xs">
{conditions.map((condition) => (
<ConditionRowComponent
key={condition.id}
condition={condition}
onUpdate={handleUpdateCondition}
onRemove={handleRemoveCondition}
columns={columns}
/>
))}
</Stack>
{/* Multiple conditions support will be implemented later. */}
{/* <Button
variant="outline"
leftSection={<Icon name="add" />}
onClick={handleAddCondition}
mt="sm"
>
{t`Add condition`}
</Button> */}
</Stack>
);
};
// Parse initial expression into condition rows
function parseInitialExpression(
initialExpression: ConditionalAlertExpression | undefined,
columns: ConditionBuilderColumn[],
): { conditions: ConditionRow[]; logicalOperator: LogicalOperator } {
const defaultCondition = {
conditions: [
{
id: crypto.randomUUID(),
columnId: null,
columnName: null,
operator: null,
value: null,
},
],
logicalOperator: "and" as LogicalOperator,
};
// As a temporary solution, we need to hide an important implementation detail
// from the client.
// Currently, each condition has a required table_id comparison at the top level.
// Eg.: '["=",["context","table_id"],8]'.
// But it should not be exposed to the Condition Builder component, user cannot affect it.
// An example of an actual conditional expression in that case would look like this:
// Eg.: '["and",["=",["context","table_id"],8],["=",["context","PAYMENT"],0]]'
// So we need to omit the table_id condition, and move directly to the 2nd nesting level,
// before parsing the expression.
const clientSideCondition = doesExpressionHaveClientSideConditions(
initialExpression,
)
? initialExpression[3]
: null;
if (!clientSideCondition) {
// If no initial expression, start with a single empty condition
return defaultCondition;
}
try {
// Check if it's a single condition or multiple conditions
if (Array.isArray(clientSideCondition) && clientSideCondition.length > 0) {
const firstElement = clientSideCondition[0];
// Case 1: Single condition - ["=", ["context", "row_change", "before", "field_name"], value]
if (
typeof firstElement === "string" &&
["=", "!=", ">", "<", ">=", "<="].includes(firstElement)
) {
const operator = firstElement as ComparisonOperator;
const fieldRef = clientSideCondition[1];
const value = clientSideCondition[2] as any;
if (
Array.isArray(fieldRef) &&
fieldRef[0] === "context" &&
fieldRef.length > 1
) {
const columnName = fieldRef[2] as string;
return {
conditions: [
{
id: crypto.randomUUID(),
columnId:
columns.find((col) => col.name === columnName)?.id || null,
columnName,
operator,
value,
},
],
logicalOperator: "and" as LogicalOperator,
};
}
}
// Case 2: Multiple conditions - ["and"/"or", [...condition1], [...condition2], ...]
if (
typeof firstElement === "string" &&
["and", "or"].includes(firstElement)
) {
const logicalOperator = firstElement as LogicalOperator;
const parsedConditions: ConditionRow[] = [];
// Process each condition expression
for (let i = 1; i < clientSideCondition.length; i++) {
const conditionExpr = clientSideCondition[i];
if (Array.isArray(conditionExpr) && conditionExpr.length >= 3) {
const operator = conditionExpr[0] as ComparisonOperator;
const fieldRef = conditionExpr[1];
const value = conditionExpr[2] as any;
if (
Array.isArray(fieldRef) &&
fieldRef[0] === "context" &&
fieldRef.length > 1
) {
const columnName = fieldRef[2] as string;
parsedConditions.push({
id: crypto.randomUUID(),
columnId:
columns.find((col) => col.name === columnName)?.id || null,
columnName,
operator,
value,
});
}
}
}
if (parsedConditions.length > 0) {
return {
conditions: parsedConditions,
logicalOperator,
};
}
}
}
} catch (error) {
console.error("Error parsing initial expression:", error);
}
// Fallback to default if parsing fails
return defaultCondition;
}
// Generate the expression based on current conditions
function generateExpression(
tableId: TableId,
eventType: NotificationTriggerEvent,
conditions?: ConditionRow[],
logicalOperator?: LogicalOperator,
): ConditionalAlertExpression {
const baseExpression = getBaseCondition(tableId, eventType);
if (!conditions) {
return baseExpression;
}
const validConditions = conditions.filter(
(c) =>
c.columnId && c.operator && c.value !== null && c.value !== undefined,
);
if (validConditions.length === 0) {
return baseExpression;
}
// TODO: This is a temprorary workaround until we have a better abstraction
// on the API side to specify conditions for different event types.
// Basically, the meaningful payload of the event can have different paths.
// For 'create' and 'update' event, it will be under 'after' key.
// For 'delete' event, it will be under 'before' key.
const payloadPath = ["context", "record"];
if (validConditions.length === 1) {
const condition = validConditions[0];
return [
...baseExpression,
[
condition.operator as ComparisonOperator,
[...payloadPath, condition.columnName],
condition.value,
] as SingleConditionalAlertExpression,
];
}
return [
...baseExpression,
[
logicalOperator as LogicalOperator,
...validConditions.map((condition) => [
condition.operator,
[...payloadPath, condition.columnName],
condition.value,
]),
] as MultipleConditionalAlertExpressions,
];
}
function getColumnIcon(column: ConditionBuilderColumn) {
if (column.isBoolean) {
return "io";
}
const semanticTypeIcon =
FIELD_SEMANTIC_TYPES_MAP[
(column.semantic_type ||
column.effective_type ||
column.base_type) as string
]?.icon;
if (semanticTypeIcon) {
return semanticTypeIcon;
}
if (column.isNumeric) {
return "int";
}
return "string";
}
function doesExpressionHaveClientSideConditions(
expression?: ConditionalAlertExpression,
): expression is ConditionalAlertExpression {
return Array.isArray(expression) && Array.isArray(expression[3]);
}
function parseLiteral(
value: string,
selectedColumn: ConditionBuilderColumn | undefined,
) {
if (!selectedColumn) {
return value;
}
if (selectedColumn.isBoolean) {
return value === "true";
}
if (selectedColumn.isNumeric) {
return Number(value);
}
return value;
}

View File

@@ -0,0 +1,63 @@
.root {
position: absolute;
top: 0;
right: 0;
height: calc(100% - 5.5rem);
min-height: 0;
width: 50%;
border-left: 1px solid var(--mb-color-border);
display: flex;
flex-direction: column;
padding: 2.5rem;
gap: var(--mantine-spacing-md);
background: var(--mb-color-bg-white);
}
.header {
display: inline-flex;
align-items: center;
gap: var(--mantine-spacing-sm);
height: 1.625rem;
z-index: 1003;
max-width: calc(100% - 1.5rem);
}
.closePreviewIcon {
color: var(--mb-color-text-tertiary);
cursor: pointer;
&:hover {
color: var(--mb-color-text-secondary);
}
}
.scrollBox {
flex-grow: 1;
min-height: 0;
padding: 0;
}
.previewStack {
background: var(--mb-color-bg-white);
width: 100%;
height: 100%;
}
.previewHeader {
display: grid;
grid-template-columns: auto 1fr;
align-items: start;
column-gap: 1rem;
}
.previewDetails {
display: grid;
grid-template-columns: auto 1fr;
align-items: baseline;
gap: 0.25rem 0.5rem;
}
.previewBody {
overflow: auto;
flex-grow: 1;
}

View File

@@ -0,0 +1,112 @@
import { t } from "ttag";
import { Avatar } from "metabase/common/components/UserAvatar";
import {
ActionIcon,
Box,
Flex,
Icon,
Loader,
Stack,
Text,
Title,
Tooltip,
} from "metabase/ui";
import type { PreviewNotificationTemplateResponse } from "metabase-types/api";
import S from "./PreviewTemplatePanel.module.css";
interface PreviewTemplatePanelProps {
onClose: () => void;
isLoading: boolean;
error: any;
previewContent?: PreviewNotificationTemplateResponse["rendered"];
}
export const PreviewTemplatePanel = ({
onClose,
isLoading,
error,
previewContent,
}: PreviewTemplatePanelProps) => {
const htmlContent = previewContent?.body?.[0]?.content;
return (
<Flex
direction="column"
mah="100%"
gap="md"
className={S.root}
data-testid="preview-template-panel"
>
<Flex gap="sm" align="center" h="1.625rem" className={S.header}>
<Icon name="eye" size={16} />
<Title size="h4">{t`Email message preview`}</Title>
<Tooltip label={t`Close Preview`}>
<ActionIcon variant="transparent" ml="auto">
<Icon
name="arrow_left_to_line"
size={20}
onClick={onClose}
className={S.closePreviewIcon}
/>
</ActionIcon>
</Tooltip>
</Flex>
<Box p={0} className={S.scrollBox}>
{isLoading && (
<Flex align="center" justify="center" gap="sm" py="md">
<Loader size={12} />
<Text
size="lg"
lh="1.25rem"
c="text-medium"
>{t`Loading preview...`}</Text>
</Flex>
)}
{error && (
<code style={{ color: "var(--mb-color-error)" }}>{error}</code>
)}
{previewContent && !error ? (
<Stack className={S.previewStack}>
<Box className={S.previewHeader}>
<Avatar>{previewContent.from || "?"}</Avatar>
<Box className={S.previewDetails}>
<Text size="sm" fw={600}>
{t`From:`}
</Text>
<Text size="sm">{previewContent.from}</Text>
{previewContent.bcc && previewContent.bcc.length > 0 && (
<>
<Text size="sm" fw={600}>
{t`BCC:`}
</Text>
<Text size="sm">{previewContent.bcc.join(", ")}</Text>
</>
)}
<Text size="sm" fw={600}>
{t`Subject:`}
</Text>
<Text size="sm">{previewContent.subject}</Text>
</Box>
</Box>
{htmlContent ? (
<div
className={S.previewBody}
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
) : (
<Text c="text-medium">{t`(No body content)`}</Text>
)}
</Stack>
) : (
!isLoading &&
!error && <Text c="text-medium">{t`No preview available.`}</Text>
)}
</Box>
</Flex>
);
};

View File

@@ -0,0 +1 @@
export { CreateOrEditTableNotificationModal } from "./CreateOrEditTableNotificationModal";

View File

@@ -0,0 +1,6 @@
import type { NotificationTriggerEvent } from "metabase-types/api";
export type TableNotificationTriggerOption = {
value: NotificationTriggerEvent;
label: string;
};

View File

@@ -0,0 +1,34 @@
.notificationListItem {
position: relative;
border-radius: 0.5rem;
border: 1px solid var(--mb-color-border);
.itemTitle {
line-height: 1rem;
margin-bottom: 0.25rem;
}
}
.notificationListItemEditable {
&:hover {
background: var(--mb-base-color-brand-10);
cursor: pointer;
border-color: transparent;
.itemTitle {
color: var(--mb-color-brand);
}
.handlersContainer {
color: var(--mb-color-text-primary);
}
}
}
.actionButtonContainer {
position: absolute;
/* 1rem - 0.6875rem (small icon button paddings) */
right: 0.3125rem;
top: 0.3125rem;
}

View File

@@ -0,0 +1,143 @@
import cx from "classnames";
import { type MouseEvent, useState } from "react";
import { t } from "ttag";
import { formatCreatorMessage } from "metabase/account/notifications/components/NotificationCard/utils";
import { getNotificationHandlersGroupedByTypes } from "metabase/lib/notifications";
import { useSelector } from "metabase/lib/redux";
import {
HandlersInfo,
NotificationActionButton,
} from "metabase/notifications/modals/shared/components";
import { getUser } from "metabase/selectors/user";
import { Box, Group, Text } from "metabase/ui";
import type {
NotificationChannel,
NotificationTriggerEvent,
TableNotification,
User,
} from "metabase-types/api";
import S from "./TableNotificationsListItem.module.css";
type TableNotificationsListItemProps = {
notification: TableNotification;
canEdit: boolean;
users: User[] | undefined;
httpChannelsConfig: NotificationChannel[] | undefined;
onEdit: (notification: TableNotification) => void;
onUnsubscribe: (notification: TableNotification) => void;
onDelete: (notification: TableNotification) => void;
};
export const TableNotificationsListItem = ({
notification,
canEdit,
users,
httpChannelsConfig,
onEdit,
onUnsubscribe,
onDelete,
}: TableNotificationsListItemProps) => {
const user = useSelector(getUser);
const [showHoverActions, setShowHoverActions] = useState(false);
const { emailHandler, slackHandler, hookHandlers } =
getNotificationHandlersGroupedByTypes(notification.handlers);
const handleEdit = () => {
if (canEdit) {
onEdit(notification);
}
};
const handleUnsubscribe = (e: MouseEvent) => {
e.stopPropagation();
onUnsubscribe(notification);
};
const handleDelete = (e: MouseEvent) => {
e.stopPropagation();
onDelete(notification);
};
const handleMouseEnter = () => {
setShowHoverActions(true);
};
const handleMouseLeave = () => {
setShowHoverActions(false);
};
return (
<Box
className={cx(
S.notificationListItem,
canEdit && S.notificationListItemEditable,
)}
p="1rem"
onClick={handleEdit}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<Text className={S.itemTitle} size="md" lineClamp={1} fw="bold">
{formatTitle(notification.payload.event_name)}
</Text>
<Group gap="xs" align="center" c="text-secondary">
{user && (
<>
<Text size="sm" c="text-light">
•
</Text>
<Text size="sm" c="inherit">
{formatCreatorMessage(notification, user?.id)}
</Text>
</>
)}
</Group>
<HandlersInfo
emailHandler={emailHandler}
slackHandler={slackHandler}
hookHandlers={hookHandlers}
users={users}
httpChannelsConfig={httpChannelsConfig}
mt="1rem"
/>
{showHoverActions && (
<div className={S.actionButtonContainer}>
{canEdit ? (
<NotificationActionButton
label={t`Delete this alert`}
iconName="trash"
onClick={handleDelete}
/>
) : (
<NotificationActionButton
label={t`Unsubscribe from this`}
iconName="unsubscribe"
onClick={handleUnsubscribe}
/>
)}
</div>
)}
</Box>
);
};
const formatTitle = (eventName: NotificationTriggerEvent): string => {
switch (eventName) {
case "event/row.created":
return t`Notify when new records are created`;
case "event/row.updated":
return t`Notify when records are updated`;
case "event/row.deleted":
return t`Notify when records are deleted`;
default:
return t`Notification`;
}
};

View File

@@ -0,0 +1,53 @@
import { action } from "@storybook/addon-actions";
import type { StoryFn } from "@storybook/react";
import { type ComponentProps, useState } from "react";
import {
createMockAlertNotification,
createMockNotificationHandlerEmail,
createMockNotificationHandlerSlack,
} from "metabase-types/api/mocks/notification";
import { TableNotificationsListModal } from "./TableNotificationsListModal";
export default {
title: "Notifications/TableNotificationsListModal",
component: TableNotificationsListModal,
};
const Template: StoryFn<ComponentProps<typeof TableNotificationsListModal>> = (
args,
) => {
const [isOpen, setIsOpen] = useState(true);
return (
<TableNotificationsListModal
{...args}
opened={isOpen}
onClose={() => {
args.onClose();
setIsOpen(false);
}}
/>
);
};
const tableNotifications = [
createMockAlertNotification({
handlers: [
createMockNotificationHandlerEmail(),
createMockNotificationHandlerSlack(),
],
}),
];
export const Default = {
render: Template,
args: {
tableNotifications,
onCreate: action("onCreate"),
onEdit: action("onEdit"),
onClose: action("onClose"),
},
};

View File

@@ -0,0 +1,92 @@
import { t } from "ttag";
import _ from "underscore";
import { useListChannelsQuery, useListUserRecipientsQuery } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import {
canManageSubscriptions as canManageSubscriptionsSelector,
getUser,
} from "metabase/selectors/user";
import { Button, Modal, type ModalProps, Stack, rem } from "metabase/ui";
import type { TableNotification } from "metabase-types/api";
import { TableNotificationsListItem } from "./TableNotificationsListItem";
type TableNotificationsListModal = {
notifications?: TableNotification[];
opened: boolean;
onCreate: () => void;
onEdit: (notification: TableNotification) => void;
onDelete: (notification: TableNotification) => void;
onUnsubscribe: (notification: TableNotification) => void;
onClose: () => void;
};
export const TableNotificationsListModal = ({
notifications,
opened,
onCreate,
onEdit,
onDelete,
onUnsubscribe,
onClose,
...modalProps
}: TableNotificationsListModal & ModalProps) => {
const user = useSelector(getUser);
const canManageSubscriptions = useSelector(canManageSubscriptionsSelector);
const isAdmin = user?.is_superuser;
const { data: users } = useListUserRecipientsQuery();
const { data: httpChannelsConfig } = useListChannelsQuery();
if (!notifications) {
return null;
}
const isCreatedByCurrentUser = (notification: TableNotification) => {
return user ? notification.creator.id === user.id : false;
};
const [ownNotifications, othersNotifications] = _.partition(
notifications,
isCreatedByCurrentUser,
);
// user's own alert should be shown first if it exists
const sortedNotifications = [...ownNotifications, ...othersNotifications];
return (
<Modal
data-testid="alert-list-modal"
opened={opened}
size={rem(600)}
onClose={onClose}
padding="xl"
title={t`Edit alerts`}
{...modalProps}
>
<Stack gap="lg" mb="lg" mt="1rem">
{sortedNotifications.map((notification) => {
const canEditNotification =
isAdmin ||
(canManageSubscriptions && isCreatedByCurrentUser(notification));
return (
<TableNotificationsListItem
key={notification.id}
notification={notification}
users={users?.data}
httpChannelsConfig={httpChannelsConfig}
canEdit={canEditNotification}
onEdit={onEdit}
onDelete={onDelete}
onUnsubscribe={onUnsubscribe}
/>
);
})}
</Stack>
<div>
<Button variant="filled" onClick={onCreate}>{t`New alert`}</Button>
</div>
</Modal>
);
};

View File

@@ -0,0 +1,259 @@
import userEvent from "@testing-library/user-event";
import fetchMock from "fetch-mock";
import { screen } from "__support__/ui";
import { createNotificationForUser, setup } from "./test-utils";
describe("TableNotificationsListModal", () => {
beforeEach(() => {
fetchMock.reset();
});
afterEach(() => {
fetchMock.restore();
});
it("should render modal with notification list", () => {
const currentUserId = 1;
const notifications = [
createNotificationForUser(currentUserId),
createNotificationForUser(2),
];
setup({ notifications, currentUserId });
expect(screen.getByTestId("alert-list-modal")).toBeInTheDocument();
expect(screen.getByText("Edit alerts")).toBeInTheDocument();
// The Box component in TableNotificationsListItem doesn't have a data-testid
// Use getAllByText since we'll have multiple elements with the same text
const notificationTitles = screen.getAllByText(
"Notify when new records are created",
);
expect(notificationTitles).toHaveLength(2);
});
it("should display current user's notifications first", () => {
const currentUserId = 1;
const otherUserId = 2;
// Create notifications with different created_at timestamps to ensure
// sort is by ownership, not time
const userNotification = createNotificationForUser(currentUserId);
userNotification.created_at = "2025-03-01T12:00:00.000Z";
const otherNotification = createNotificationForUser(otherUserId);
otherNotification.created_at = "2025-03-27T12:00:00.000Z"; // more recent
setup({
notifications: [otherNotification, userNotification],
currentUserId,
});
// We can determine the order of notifications by checking the creator message
// which should contain info about the current user
const creatorMessages = screen.getAllByText(/Created by/);
expect(creatorMessages.length).toBe(2);
// The first message should be for the current user's notification
expect(creatorMessages[0]).toHaveTextContent(/you/);
});
it("should allow editing for admin users regardless of ownership", () => {
const currentUserId = 1;
const otherUserId = 2;
const notifications = [
createNotificationForUser(currentUserId),
createNotificationForUser(otherUserId),
];
setup({
notifications,
currentUserId,
isAdmin: true,
});
// Test that we can click on both notification titles (admin can edit all)
const notificationItems = screen.getAllByText(
"Notify when new records are created",
);
expect(notificationItems).toHaveLength(2);
});
it("should allow editing only user's own notifications for non-admin users with subscription permissions", () => {
const currentUserId = 1;
const otherUserId = 2;
const notifications = [
createNotificationForUser(currentUserId),
createNotificationForUser(otherUserId),
];
setup({
notifications,
currentUserId,
isAdmin: false,
canManageSubscriptions: true,
});
// Get all title elements for notifications
const titles = screen.getAllByText(/Notify when/);
expect(titles).toHaveLength(2);
// We can't easily test hover interactions in JSDOM
// Instead, let's verify that user with permissions can see the creator info for both items
const creatorInfoTexts = screen.getAllByText(/Created by/);
expect(creatorInfoTexts).toHaveLength(2);
// First should be from the current user (shown as 'you')
expect(creatorInfoTexts[0]).toHaveTextContent(/you/);
});
it("should not allow editing any notifications for users without permissions", () => {
const currentUserId = 1;
const notifications = [
createNotificationForUser(currentUserId),
createNotificationForUser(2),
];
setup({
notifications,
currentUserId,
isAdmin: false,
canManageSubscriptions: false,
});
expect(screen.queryAllByLabelText("pencil icon")).toHaveLength(0);
});
it("should call onCreate when 'New alert' button is clicked", async () => {
const onCreate = jest.fn();
setup({ onCreate });
const createButton = screen.getByText("New alert");
await userEvent.click(createButton);
expect(onCreate).toHaveBeenCalledTimes(1);
});
it("should call onEdit when edit button is clicked", async () => {
const currentUserId = 1;
const notification = createNotificationForUser(currentUserId, 0, {
payload: {
event_name: "event/row.updated",
table_id: 1,
},
});
const onEdit = jest.fn();
setup({
notifications: [notification],
currentUserId,
isAdmin: true,
onEdit,
});
// Clicking on the notification item itself triggers edit
// We'll click directly on the title which is part of the notification item
const notificationTitle = screen.getByText(
"Notify when records are updated",
);
await userEvent.click(notificationTitle);
expect(onEdit).toHaveBeenCalledWith(notification);
});
it("should call onDelete when delete button is clicked", async () => {
const currentUserId = 1;
const notification = createNotificationForUser(currentUserId, 0, {
payload: {
event_name: "event/row.deleted",
table_id: 1,
},
});
const onDelete = jest.fn();
setup({
notifications: [notification],
currentUserId,
isAdmin: true,
onDelete,
});
// Get the notification title and test that it's rendered
const notificationTitle = screen.getByText(
"Notify when records are deleted",
);
expect(notificationTitle).toBeInTheDocument();
// Find the containing notification item (we're looking at structure not implementation)
// and hover over it
await userEvent.hover(notificationTitle);
// Now find and click the delete button (trash icon)
const deleteButton = await screen.findByLabelText("Delete this alert");
await userEvent.click(deleteButton);
// Verify onDelete was called with the notification
expect(onDelete).toHaveBeenCalledWith(notification);
});
it("should call onUnsubscribe when unsubscribe button is clicked", async () => {
const currentUserId = 1;
const otherUserId = 2; // Use another user's notification
const notification = createNotificationForUser(otherUserId);
const onUnsubscribe = jest.fn();
// Set up with non-admin user who can't edit others' notifications
setup({
notifications: [notification],
currentUserId,
isAdmin: false,
canManageSubscriptions: false,
onUnsubscribe,
});
// Verify the notification is rendered
const notificationTitle = screen.getByText(
"Notify when new records are created",
);
expect(notificationTitle).toBeInTheDocument();
// Hover over the notification title to trigger the hover state on the container
await userEvent.hover(notificationTitle);
// Now find and click the unsubscribe button
const unsubscribeButton = await screen.findByLabelText(
"Unsubscribe from this",
);
await userEvent.click(unsubscribeButton);
// Verify onUnsubscribe was called with the notification
expect(onUnsubscribe).toHaveBeenCalledWith(notification);
});
it("should call onClose when modal is closed", async () => {
const onClose = jest.fn();
setup({ onClose });
const closeButton = screen.getByLabelText("Close");
await userEvent.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
it("should handle case with no notifications", () => {
setup({ notifications: [] });
// There should be no notification titles when the list is empty
expect(screen.queryAllByText(/Notify when/)).toHaveLength(0);
expect(screen.getByText("New alert")).toBeInTheDocument();
});
it("should render null if notifications is undefined", () => {
setup({ notifications: undefined, opened: false });
expect(screen.queryByTestId("alert-list-modal")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1 @@
export { TableNotificationsListModal } from "./TableNotificationsListModal";

View File

@@ -0,0 +1,106 @@
import { setupUserRecipientsEndpoint } from "__support__/server-mocks";
import { setupWebhookChannelsEndpoint } from "__support__/server-mocks/channel";
import { mockSettings } from "__support__/settings";
import { renderWithProviders } from "__support__/ui";
import type { UserWithApplicationPermissions } from "metabase/plugins";
import { TableNotificationsListModal } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsListModal/TableNotificationsListModal";
import type { TableNotification, User } from "metabase-types/api";
import {
createMockTableNotification,
createMockUser,
} from "metabase-types/api/mocks";
export interface SetupOpts {
notifications?: TableNotification[];
isAdmin?: boolean;
canManageSubscriptions?: boolean;
currentUserId?: number;
onCreate?: jest.Mock;
onEdit?: jest.Mock;
onDelete?: jest.Mock;
onUnsubscribe?: jest.Mock;
onClose?: jest.Mock;
webhooksConfig?: any[];
users?: User[];
opened?: boolean;
}
export const setup = ({
notifications = [],
isAdmin = false,
canManageSubscriptions = false,
currentUserId = 1,
onCreate = jest.fn(),
onEdit = jest.fn(),
onDelete = jest.fn(),
onUnsubscribe = jest.fn(),
onClose = jest.fn(),
webhooksConfig = [],
users = [],
opened = true,
}: SetupOpts = {}) => {
// Set up mock users and webhooks
setupWebhookChannelsEndpoint(webhooksConfig);
setupUserRecipientsEndpoint({ users });
// Create current user with appropriate permissions
const currentUser = createMockUser({
id: currentUserId,
is_superuser: isAdmin,
});
if (canManageSubscriptions) {
(currentUser as UserWithApplicationPermissions).permissions = {
can_access_setting: false,
can_access_monitoring: false,
can_access_subscription: true,
};
}
const settings = mockSettings();
return {
...renderWithProviders(
<TableNotificationsListModal
notifications={notifications}
opened={opened}
onCreate={onCreate}
onEdit={onEdit}
onDelete={onDelete}
onUnsubscribe={onUnsubscribe}
onClose={onClose}
/>,
{
storeInitialState: {
currentUser,
settings,
},
},
),
onCreate,
onEdit,
onDelete,
onUnsubscribe,
onClose,
};
};
export const createNotificationForUser = (
userId: number,
index = 0,
extra?: Partial<TableNotification>,
): TableNotification => {
return {
...createMockTableNotification(),
id: userId * 100 + index, // Ensure unique IDs
creator: createMockUser({ id: userId, first_name: `User ${userId}` }),
creator_id: userId,
// Use payload to make each notification unique for testing
payload: {
event_name: "event/row.created",
table_id: index + 1,
},
payload_id: null,
...extra,
};
};

View File

@@ -0,0 +1,6 @@
/* Specificity to override already overridden custom styling without !important */
.alertIcon[data-variant="subtle"] {
&:hover {
color: var(--mb-color-brand);
}
}

View File

@@ -0,0 +1,186 @@
import { useState } from "react";
import { usePreviousDistinct } from "react-use";
import { t } from "ttag";
import {
useTableNotificationsQuery,
useUnsubscribeFromNotificationMutation,
useUpdateNotificationMutation,
} from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
import { DeleteConfirmModal } from "metabase/notifications/modals/shared/DeleteConfirmModal";
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/shared/UnsubscribeConfirmModal";
import { addUndo } from "metabase/redux/undo";
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
import type { TableId, TableNotification } from "metabase-types/api";
import { CreateOrEditTableNotificationModal } from "../CreateOrEditTableNotificationModal/CreateOrEditTableNotificationModal";
import { TableNotificationsListModal } from "../TableNotificationsListModal/TableNotificationsListModal";
import S from "./TableNotificationsTrigger.module.css";
interface TableNotificationsModalProps {
tableId: TableId;
}
type AlertModalMode =
| "list-modal"
| "create-edit-modal"
| "delete-confirm-modal"
| "unsubscribe-confirm-modal";
export const TableNotificationsTrigger = ({
tableId,
...props
}: TableNotificationsModalProps) => {
const [isOpen, setIsOpen] = useState(false);
const { data: tableNotifications } = useTableNotificationsQuery({
table_id: tableId,
include_inactive: false,
payload_type: "notification/system-event",
});
const [editingItem, setEditingItem] = useState<TableNotification | null>(
null,
);
const [activeModal, setActiveModal] = useState<AlertModalMode | null>(null);
const hasNotifications = tableNotifications && tableNotifications.length > 0;
const handleOpen = () => {
setIsOpen(true);
setActiveModal(hasNotifications ? "list-modal" : "create-edit-modal");
};
const handleClose = () => {
setIsOpen(false);
setActiveModal(null);
setEditingItem(null);
};
const previousActiveModal = usePreviousDistinct(activeModal);
const handleInternalModalClose = () => {
if (previousActiveModal === "list-modal") {
setActiveModal("list-modal");
} else {
handleClose();
}
};
const dispatch = useDispatch();
const [updateNotification] = useUpdateNotificationMutation();
const handleDelete = async (itemToDelete: TableNotification) => {
const result = await updateNotification({
...itemToDelete,
active: false,
});
if (result.error) {
dispatch(
addUndo({
icon: "warning",
toastColor: "error",
message: t`An error occurred`,
}),
);
return;
}
dispatch(addUndo({ message: t`The alert was successfully deleted.` }));
const notificationsCount = tableNotifications?.length || 0;
// if we have just unsubscribed from the last alert, close the popover
if (notificationsCount <= 1) {
handleClose();
} else {
handleInternalModalClose();
}
};
const [unsubscribe] = useUnsubscribeFromNotificationMutation();
const handleUnsubscribe = async (alert: TableNotification) => {
const result = await unsubscribe(alert.id);
if (result.error) {
dispatch(
addUndo({
icon: "warning",
toastColor: "error",
message: t`An error occurred`,
}),
);
return;
}
dispatch(addUndo({ message: t`Successfully unsubscribed.` }));
const notificationsCount = tableNotifications?.length || 0;
// if we have just unsubscribed from the last alert, close the popover
if (notificationsCount <= 1) {
handleClose();
}
};
return (
<>
<Tooltip label={hasNotifications ? t`Edit alerts` : t`Create alert`}>
<ActionIcon
data-testid="table-notifications-trigger"
className={S.alertIcon}
variant="subtle"
size="lg"
onClick={handleOpen}
{...props}
>
<Icon name="alert" />
</ActionIcon>
</Tooltip>
{activeModal === "list-modal" && (
<TableNotificationsListModal
opened={isOpen}
notifications={tableNotifications}
onCreate={() => setActiveModal("create-edit-modal")}
onEdit={(notification) => {
setEditingItem(notification);
setActiveModal("create-edit-modal");
}}
onClose={handleClose}
onDelete={(notification) => {
setEditingItem(notification);
setActiveModal("delete-confirm-modal");
}}
onUnsubscribe={(notification) => {
setEditingItem(notification);
setActiveModal("unsubscribe-confirm-modal");
}}
/>
)}
{activeModal === "create-edit-modal" && (
<CreateOrEditTableNotificationModal
tableId={tableId}
notification={editingItem}
onClose={handleClose}
onNotificationCreated={handleClose}
onNotificationUpdated={handleClose}
/>
)}
{activeModal === "delete-confirm-modal" && editingItem && (
<DeleteConfirmModal
title={t`Delete this alert?`}
onConfirm={() => handleDelete(editingItem)}
onClose={handleInternalModalClose}
/>
)}
{activeModal === "unsubscribe-confirm-modal" && editingItem && (
<UnsubscribeConfirmModal
title={t`Unsubscribe from this alert?`}
description={t`You'll stop receiving this alert from now on. Depending on your organization’s permissions you might need to ask a moderator to be re-added in the future.`}
onConfirm={() => handleUnsubscribe(editingItem)}
onClose={handleInternalModalClose}
/>
)}
</>
);
};

View File

@@ -0,0 +1,7 @@
import type { FieldWithMetadata } from "metabase-types/api";
export function canEditField(field?: FieldWithMetadata) {
return (
field && field.semantic_type !== "type/PK" && !field.database_is_generated
);
}

View File

@@ -0,0 +1,16 @@
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { isDatabaseTableEditingEnabled } from "./settings";
import { EditTableDataContainer } from "./tables/edit";
import { EditTableDashcardVisualization } from "./tables/edit/EditTableDashcardVisualization";
import { BrowseTableData } from "./tables/view";
if (hasPremiumFeature("table_data_editing")) {
PLUGIN_DATA_EDITING.isEnabled = () => true;
PLUGIN_DATA_EDITING.isDatabaseTableEditingEnabled =
isDatabaseTableEditingEnabled;
PLUGIN_DATA_EDITING.VIEW_PAGE_COMPONENT = BrowseTableData;
PLUGIN_DATA_EDITING.EDIT_PAGE_COMPONENT = EditTableDataContainer;
PLUGIN_DATA_EDITING.CARD_TABLE_COMPONENT = EditTableDashcardVisualization;
}

View File

@@ -0,0 +1,8 @@
import type Database from "metabase-lib/v1/metadata/Database";
import type { Database as DatabaseApi } from "metabase-types/api";
export function isDatabaseTableEditingEnabled(
database: Database | DatabaseApi,
) {
return database.settings?.["database-enable-table-editing"] ?? false;
}

View File

@@ -0,0 +1,75 @@
import { useMemo } from "react";
import { t } from "ttag";
import { isNotNull } from "metabase/lib/types";
import * as Urls from "metabase/lib/urls";
import { HeadBreadcrumbs } from "metabase/query_builder/components/view/ViewHeader/components"; // TODO: we should not use query builder components
import type { DataSourcePart } from "metabase/query_builder/components/view/ViewHeader/components/QuestionDataSource/utils"; // TODO: we should not use query builder components
import { ViewHeading } from "metabase/query_builder/components/view/ViewSection"; // TODO: we should not use query builder components
import type DatabaseV1 from "metabase-lib/v1/metadata/Database";
import type TableV1 from "metabase-lib/v1/metadata/Table";
import type { Database, Table } from "metabase-types/api";
type TableBreadcrumbsProps = {
database: Pick<DatabaseV1 | Database, "id" | "name"> | undefined;
table: TableV1 | Table | undefined;
isEditMode?: boolean;
};
export const TableBreadcrumbs = ({
database,
table,
isEditMode,
}: TableBreadcrumbsProps) => {
const breadcrumbsParts = useMemo(
() =>
database && table
? getBreadcrumbsParts({
database,
table,
isEditMode,
})
: [],
[database, isEditMode, table],
);
return (
<ViewHeading color="medium">
<HeadBreadcrumbs parts={breadcrumbsParts} variant="head" />
</ViewHeading>
);
};
const getBreadcrumbsParts = ({
database,
table,
isEditMode = false,
}: {
database: Pick<DatabaseV1 | Database, "id" | "name">;
table: TableV1 | Table;
isEditMode?: boolean;
}): DataSourcePart[] => {
const parts: DataSourcePart[] = [
{
icon: "database" as const,
model: "database" as const,
name: database.name,
href: Urls.browseDatabase(database),
},
{
icon: "table" as const,
model: "table" as const,
name: table.display_name,
href: isEditMode ? Urls.browseTable(table) : undefined,
},
isEditMode
? {
get name() {
return t`Edit`;
},
}
: null,
].filter(isNotNull);
return parts;
};

View File

@@ -0,0 +1,511 @@
import { useDisclosure } from "@mantine/hooks";
import cx from "classnames";
import { memo, useCallback, useEffect, useMemo } from "react";
import { push } from "react-router-redux";
import { useLocation } from "react-use";
import { t } from "ttag";
import { NoDataError } from "metabase/common/components/errors/NoDataError";
import { useDispatch } from "metabase/lib/redux";
import {
ActionIcon,
Box,
Button,
Divider,
Flex,
Group,
Icon,
Stack,
Text,
rem,
} from "metabase/ui";
import { ShortMessage } from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView.styled";
import { useBuiltInActions } from "metabase-enterprise/data_editing/actions/use-built-in-actions";
import { canEditField } from "metabase-enterprise/data_editing/helpers";
import { DataGridActionExecuteModal } from "metabase-enterprise/table-actions/execution/DataGridActionExecuteModal";
import { useDataGridRowActions } from "metabase-enterprise/table-actions/execution/use-datagrid-row-actions";
import type Question from "metabase-lib/v1/Question";
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils";
import { formatRowCount } from "metabase-lib/v1/queries/utils/row-count";
import type {
ConcreteTableId,
DashCardVisualizationSettings,
DatasetData,
} from "metabase-types/api";
import { BuiltInTableAction } from "../types";
import S from "./EditTableData.module.css";
import { EditTableDataGrid } from "./EditTableDataGrid";
import { EditTableDataOverlay } from "./EditTableDataOverlay";
import { ActionBulkUpdateRowFormModal } from "./modals/ActionBulkUpdateRowFormModal";
import { ActionCreateRowFormModal } from "./modals/ActionCreateRowFormModal";
import { ActionUpdateRowFormModal } from "./modals/ActionUpdateRowFormModal";
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
import { ForeignKeyConstraintModal } from "./modals/ForeignKeyConstraintModal";
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
import { useActionUpdateRowModalFromDatasetWithObjectId } from "./modals/use-action-update-row-modal-with-object-id";
import { useForeignKeyConstraintHandling } from "./modals/use-foreign-key-constraint-handling";
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
import { useEditableTableColumnConfigFromVisualizationSettings } from "./use-editable-column-config";
import { useActionFormDescription } from "./use-table-action-form-description";
import { useTableCRUD } from "./use-table-crud";
import { useEditingTableRowSelection } from "./use-table-row-selection";
import { useTableSorting } from "./use-table-sorting";
import { useTableEditingStateDashcardUpdateStrategy } from "./use-table-state-dashcard-update-strategy";
import { useTableEditingUndoRedo } from "./use-table-undo-redo";
import { getRowPkValues } from "./utils";
type EditTableDashcardVisualizationProps = {
title: string;
dashcardId: number;
cardId: number;
tableId: ConcreteTableId;
data: DatasetData;
className?: string;
visualizationSettings?: DashCardVisualizationSettings; // TODO: move editable table viz settings type to Card Visualization
question: Question;
withLeaveUnsavedConfirmation?: boolean;
isEditing?: boolean;
};
export const EditTableDashcardVisualization = memo(
({
title,
dashcardId,
cardId,
tableId,
data,
className,
visualizationSettings,
question,
withLeaveUnsavedConfirmation = true,
isEditing,
}: EditTableDashcardVisualizationProps) => {
const dispatch = useDispatch();
const location = useLocation();
const objectIdParam = useMemo(() => {
const searchParams = new URLSearchParams(location.search);
const objectIdParam = searchParams.get("objectId");
const parsedParams = parseModalCompositeObjectId(objectIdParam);
if (parsedParams?.dashcardId === dashcardId) {
return parsedParams.objectId ?? undefined;
}
return undefined;
}, [location.search, dashcardId]);
const handleCurrentObjectIdChange = useCallback(
(objectId?: string) => {
const searchParams = new URLSearchParams(location.search);
if (objectId) {
searchParams.set(
"objectId",
getModalCompositeObjectId(objectId, dashcardId),
);
} else {
searchParams.delete("objectId");
}
dispatch(
push({
...location,
search: "?" + searchParams.toString(),
}),
);
},
[location, dispatch, dashcardId],
);
const stateUpdateStrategy = useTableEditingStateDashcardUpdateStrategy(
dashcardId,
cardId,
);
const editingScope = useMemo(() => {
return { "dashcard-id": dashcardId };
}, [dashcardId]);
const {
isInserting,
isDeleting,
isUpdating,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
error,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowUpdateBulk,
handleRowDelete,
handleRowDeleteBulk,
handleRowDeleteWithCascade,
} = useTableCRUD({
tableId,
scope: editingScope,
datasetData: data,
stateUpdateStrategy,
});
const {
opened: isUpdateRowModalOpen,
rowIndex: updateModalRowIndex,
rowData: updateModalRowData,
actionFormDescription: updateActionFormDescription,
refetchActionFormDescription: refetchUpdateRowFormDescription,
openUpdateRowModal,
closeUpdateRowModal,
} = useActionUpdateRowModalFromDatasetWithObjectId({
datasetData: data,
scope: editingScope,
fetchOnMount: false,
currentObjectId: objectIdParam,
onObjectIdChange: handleCurrentObjectIdChange,
});
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
useTableEditingUndoRedo({
tableId,
scope: editingScope,
stateUpdateStrategy,
});
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
visualizationSettings,
);
const { hasCreateAction, hasUpdateAction, hasDeleteAction } =
useBuiltInActions(
visualizationSettings?.["editableTable.enabledActions"],
);
const hasEditableAndVisibleColumns = useMemo(() => {
return data.cols.some(
(column) =>
!columnsConfig?.isColumnReadonly(column.name) &&
!columnsConfig?.isColumnHidden(column.name) &&
canEditField(tableFieldMetadataMap[column.name]),
);
}, [data.cols, tableFieldMetadataMap, columnsConfig]);
const hasBulkEditing = hasEditableAndVisibleColumns;
const hasRowSelection = hasBulkEditing || hasDeleteAction;
const {
rowActions,
selectedRowAction,
onRowActionButtonClick,
onRowActionFormClose,
} = useDataGridRowActions({
actionSettings: visualizationSettings?.["editableTable.enabledActions"],
datasetData: data,
// For editable table actions we need to use only primary key values as input
getActionInputFromRow: getRowPkValues,
});
const { rowSelection, selectedRowIndices, setRowSelection } =
useEditingTableRowSelection();
const {
isDeleteBulkRequested,
requestDeleteBulk,
cancelDeleteBulk,
onDeleteBulkConfirmation,
} = useTableBulkDeleteConfirmation({
handleRowDeleteBulk,
selectedRowIndices,
setRowSelection,
});
const {
isForeignKeyModalOpen,
foreignKeyError,
handleForeignKeyConfirmation,
handleForeignKeyCancel,
} = useForeignKeyConstraintHandling({
onCascadeDelete: handleRowDeleteWithCascade,
selectedRowIndices,
setRowSelection,
constraintError: error,
});
const { getColumnSortDirection } = useTableSorting({
question,
});
const [
isBulkEditingRequested,
{ open: requestBulkEditing, close: closeBulkEditing },
] = useDisclosure();
const [
isCreateRowModalOpen,
{ open: openCreateRowModal, close: closeCreateRowModal },
] = useDisclosure(false);
const {
data: createRowFormDescription,
refetch: refetchCreateRowFormDescription,
} = useActionFormDescription({
actionId: BuiltInTableAction.Create,
scope: editingScope,
fetchOnMount: false,
});
useEffect(() => {
if (isEditing) {
return;
}
refetchUpdateRowFormDescription();
if (hasCreateAction) {
refetchCreateRowFormDescription();
}
}, [
visualizationSettings, // refetch on visualizationSettings change
hasCreateAction,
isEditing,
refetchCreateRowFormDescription,
refetchUpdateRowFormDescription,
]);
const shouldDisableActions = isUndoLoading || isRedoLoading;
return (
<Stack className={cx(S.container, className)} gap={0}>
<Flex
p="0.5rem"
px="1rem"
style={{ borderBottom: "1px solid var(--mb-color-border)" }}
justify="space-between"
align="center"
>
<Group gap="sm" align="center">
<Text fw="bold">{title}</Text>
{!hasUpdateAction && <Icon name="lock" />}
</Group>
{!isEditing && (
<Group gap="sm" align="center">
{hasBulkEditing && (
<ActionIcon
size="md"
onClick={requestBulkEditing}
disabled={shouldDisableActions || !selectedRowIndices.length}
>
<Icon
name="pencil"
tooltip={
selectedRowIndices.length
? t`Edit selected records`
: t`Select records to edit`
}
/>
</ActionIcon>
)}
{hasDeleteAction && (
<ActionIcon
size="md"
onClick={requestDeleteBulk}
disabled={shouldDisableActions || !selectedRowIndices.length}
>
<Icon
name="trash"
tooltip={
selectedRowIndices.length
? t`Delete selected records`
: t`Select records to delete`
}
/>
</ActionIcon>
)}
{(hasBulkEditing || hasDeleteAction) && (
<Box h={rem(16)}>
<Divider orientation="vertical" h="100%" />
</Box>
)}
<ActionIcon
size="md"
onClick={undo}
disabled={shouldDisableActions}
loading={isUndoLoading}
>
<Icon name="undo" tooltip={t`Undo changes`} />
</ActionIcon>
<ActionIcon
size="md"
onClick={redo}
disabled={shouldDisableActions}
loading={isRedoLoading}
>
<Icon name="redo" tooltip={t`Redo changes`} />
</ActionIcon>
{hasCreateAction && (
<>
<Box h={rem(16)}>
<Divider orientation="vertical" h="100%" />
</Box>
<ActionIcon
size="md"
onClick={openCreateRowModal}
disabled={shouldDisableActions}
>
<Icon name="add" tooltip={t`New record`} />
</ActionIcon>
</>
)}
</Group>
)}
</Flex>
{data.rows.length === 0 ? (
<Stack
h="100%"
justify="center"
align="center"
c="var(--mb-color-text-tertiary)"
>
<NoDataError data-testid="no-results-image" />
<ShortMessage>{t`No results!`}</ShortMessage>
<Button
leftSection={<Icon name="add" />}
variant="filled"
onClick={openCreateRowModal}
disabled={shouldDisableActions}
>{t`New Record`}</Button>
</Stack>
) : (
<>
<Box pos="relative" className={S.gridWrapper}>
<EditTableDataOverlay
show={shouldDisableActions}
message={currentActionLabel ?? ""}
/>
<EditTableDataGrid
data={data}
fieldMetadataMap={tableFieldMetadataMap}
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
onCellValueUpdate={handleCellValueUpdate}
onRowExpandClick={openUpdateRowModal}
columnsConfig={columnsConfig}
getColumnSortDirection={getColumnSortDirection}
rowActions={rowActions}
onActionClick={onRowActionButtonClick}
hasRowSelection={hasRowSelection}
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
/>
</Box>
<Flex
p="xs"
px="1rem"
justify="flex-end"
align="center"
className={S.gridFooterDashcardVisualization}
>
<Text fz="sm" fw="bold">
{getEditTableRowCountMessage(data)}
</Text>
</Flex>
</>
)}
<ActionCreateRowFormModal
description={createRowFormDescription}
opened={isCreateRowModalOpen}
onClose={closeCreateRowModal}
isInserting={isInserting}
onRowCreate={handleRowCreate}
/>
<ActionUpdateRowFormModal
rowIndex={updateModalRowIndex}
rowData={updateModalRowData}
description={updateActionFormDescription}
opened={isUpdateRowModalOpen}
onClose={closeUpdateRowModal}
onRowUpdate={handleRowUpdate}
onRowDelete={handleRowDelete}
withDelete={hasDeleteAction}
/>
<ActionBulkUpdateRowFormModal
opened={isBulkEditingRequested}
selectedRowIndices={selectedRowIndices}
setRowSelection={setRowSelection}
description={updateActionFormDescription}
onClose={closeBulkEditing}
onRowsUpdate={handleRowUpdateBulk}
onRowsDelete={handleRowDeleteBulk}
withDelete={hasDeleteAction}
/>
<DataGridActionExecuteModal
action={selectedRowAction?.action}
actionInput={selectedRowAction?.input}
scope={editingScope}
onClose={onRowActionFormClose}
/>
<DeleteBulkRowConfirmationModal
opened={isDeleteBulkRequested}
rowCount={selectedRowIndices.length}
isLoading={isDeleting}
onConfirm={onDeleteBulkConfirmation}
onClose={cancelDeleteBulk}
/>
{withLeaveUnsavedConfirmation && (
<UnsavedLeaveConfirmationModal
isUpdating={isUpdating}
isDeleting={isDeleting}
isInserting={isInserting}
/>
)}
<ForeignKeyConstraintModal
opened={isForeignKeyModalOpen}
onClose={handleForeignKeyCancel}
onConfirm={handleForeignKeyConfirmation}
isLoading={isDeleting}
childRecords={foreignKeyError?.children || {}}
message={foreignKeyError?.message}
/>
</Stack>
);
},
);
EditTableDashcardVisualization.displayName = "EditTableDashcardVisualization";
function getEditTableRowCountMessage(data: DatasetData): string {
const rowCount = data.rows.length;
if (data.rows_truncated > 0) {
return t`Showing first ${formatRowCount(rowCount)}`;
}
if (rowCount === HARD_ROW_LIMIT) {
return t`Showing first ${HARD_ROW_LIMIT} rows`;
}
return t`Showing ${formatRowCount(rowCount)}`;
}
const MODAL_COMPOSITE_OBJECT_ID_SEPARATOR = "_";
function getModalCompositeObjectId(objectId: string, dashcardId: number) {
return `${dashcardId}${MODAL_COMPOSITE_OBJECT_ID_SEPARATOR}${objectId}`;
}
function parseModalCompositeObjectId(compositeObjectId: string | null) {
if (!compositeObjectId) {
return undefined;
}
// objectId can contain separator symbol itself, so we should slice the first part
const separatorIndex = compositeObjectId.indexOf(
MODAL_COMPOSITE_OBJECT_ID_SEPARATOR,
);
if (separatorIndex === -1) {
return undefined;
}
const dashcardId = compositeObjectId.slice(0, separatorIndex);
const objectId = compositeObjectId.slice(separatorIndex + 1);
return { dashcardId: parseInt(dashcardId), objectId };
}

View File

@@ -0,0 +1,100 @@
.container {
height: 100%;
width: 100%;
overflow: hidden;
}
.tableRoot {
height: 100%;
width: 100%;
overflow: hidden;
}
.gridWrapper {
overflow: hidden;
flex-grow: 1;
}
.gridFooter {
border: 1px solid var(--mb-color-border);
background-color: var(--mb-color-background);
color: var(--mb-color-text-secondary);
}
.gridFooterDashcardVisualization {
border-top: 1px solid var(--mb-color-border);
}
/** DataGrid overrides */
.tableRow {
&:hover .tableBodyCell {
background-color: var(--mb-base-color-blue-10);
}
&:hover .cellWithUpdateFail {
background-color: var(--mb-base-color-dubloon-5);
}
&:hover .tableHeaderCell {
/* Draggable hover highlight for `SortableHeader` */
box-shadow: none;
}
&[data-row-selected="true"] .tableBodyCell {
background-color: var(--mb-base-color-blue-10);
}
}
.tableHeaderContainer {
--cell-bg-color: var(--mb-base-color-orion-5);
--cell-hover-bg-color: var(--mb-base-color-orion-5);
background-color: var(--mb-base-color-orion-5);
}
.tableHeaderCell {
border-right: 1px solid var(--mb-color-border);
&[data-column-id="__MB_ROW_SELECT"] {
border-right: none;
}
}
.tableBodyContainer {
background-color: var(--mb-color-bg-white);
}
.tableBodyCell {
background-color: var(--mb-color-bg-white);
border-right: 1px solid var(--mb-color-border-alpha-30);
&[data-column-id="__MB_ROW_SELECT"] {
border-right: none;
}
}
.headerCellContainer {
background-color: var(--cell-bg-color);
height: 100%;
display: flex;
align-items: center;
padding: 0 0.75rem;
}
.sortIndicator {
flex-shrink: 0;
margin-left: 0.25rem;
}
.readonlyCell {
color: var(--mb-color-text-disabled);
cursor: not-allowed;
}
.cellWithUpdateFail {
background-color: var(--mb-base-color-dubloon-5);
outline: 2px solid var(--mb-base-color-dubloon-30);
outline-offset: -2px;
border-radius: 0.25rem;
}

View File

@@ -0,0 +1,31 @@
import { Link } from "react-router";
import { t } from "ttag";
import * as Urls from "metabase/lib/urls";
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
import type Table from "metabase-lib/v1/metadata/Table";
import type { Table as TableApi } from "metabase-types/api";
export const EditTableDataBackButton = ({
table,
}: {
table: Table | TableApi;
}) => {
const label = t`Back to ${table.display_name}`;
return (
<Tooltip label={label}>
<Link to={Urls.browseTable(table)}>
<ActionIcon
variant="outline"
radius="xl"
size="2.625rem"
color="border"
aria-label={label}
>
<Icon c="text-dark" name="arrow_left" />
</ActionIcon>
</Link>
</Tooltip>
);
};

View File

@@ -0,0 +1,335 @@
import { useDisclosure } from "@mantine/hooks";
import type { Location } from "history";
import { useCallback, useMemo } from "react";
import { push } from "react-router-redux";
import { useMount } from "react-use";
import { t } from "ttag";
import {
skipToken,
useGetAdhocQueryQuery,
useGetDatabaseQuery,
} from "metabase/api";
import { GenericError } from "metabase/common/components/ErrorPages";
import { useDispatch } from "metabase/lib/redux";
import { closeNavbar } from "metabase/redux/app";
import { Box, Flex, Stack, Text } from "metabase/ui";
import { extractRemappedColumns } from "metabase/visualizations";
import { isDatabaseTableEditingEnabled } from "metabase-enterprise/data_editing/settings";
import { getRowCountMessage } from "metabase-lib/v1/queries/utils/row-count";
import { BuiltInTableAction } from "../types";
import S from "./EditTableData.module.css";
import { EditTableDataGrid } from "./EditTableDataGrid";
import { EditTableDataHeader } from "./EditTableDataHeader";
import { EditTableDataOverlay } from "./EditTableDataOverlay";
import { ActionBulkUpdateRowFormModal } from "./modals/ActionBulkUpdateRowFormModal";
import { ActionCreateRowFormModal } from "./modals/ActionCreateRowFormModal";
import { ActionUpdateRowFormModal } from "./modals/ActionUpdateRowFormModal";
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
import { ForeignKeyConstraintModal } from "./modals/ForeignKeyConstraintModal";
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
import { useActionUpdateRowModalFromDatasetWithObjectId } from "./modals/use-action-update-row-modal-with-object-id";
import { useForeignKeyConstraintHandling } from "./modals/use-foreign-key-constraint-handling";
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
import { getTableEditPathname } from "./url";
import { useStandaloneTableQuery } from "./use-standalone-table-query";
import { useActionFormDescription } from "./use-table-action-form-description";
import { useTableCRUD } from "./use-table-crud";
import { useEditingTableRowSelection } from "./use-table-row-selection";
import { useTableSorting } from "./use-table-sorting";
import { useTableEditingStateApiUpdateStrategy } from "./use-table-state-api-update-strategy";
import { useTableEditingUndoRedo } from "./use-table-undo-redo";
type EditTableDataContainerProps = {
params: {
dbId: string;
tableId: string;
objectId?: string;
};
location: Location<{ filter?: string }>;
};
export const EditTableDataContainer = ({
params: { dbId: dbIdParam, tableId: tableIdParam, objectId: objectIdParam },
location,
}: EditTableDataContainerProps) => {
const databaseId = parseInt(dbIdParam, 10);
const tableId = parseInt(tableIdParam, 10);
const dispatch = useDispatch();
const { data: database } = useGetDatabaseQuery({ id: databaseId });
const { fakeTableQuestion, fakeTableQuery, table, handleQuestionChange } =
useStandaloneTableQuery({ tableId, databaseId, location });
const {
data: rawDatasetResult,
isFetching,
isLoading,
refetch,
} = useGetAdhocQueryQuery(fakeTableQuery || skipToken, {
// Invalidates cache when filter changes (some records might be updated)
refetchOnMountOrArgChange: true,
});
const datasetData = useMemo(() => {
return rawDatasetResult
? extractRemappedColumns(rawDatasetResult.data)
: undefined;
}, [rawDatasetResult]);
const handleCurrentObjectIdChange = useCallback(
(objectId?: string) => {
dispatch(
push({
...location,
pathname: getTableEditPathname(databaseId, tableId, objectId),
}),
);
},
[databaseId, tableId, location, dispatch],
);
const stateUpdateStrategy =
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
const editingScope = useMemo(() => {
return { "table-id": tableId };
}, [tableId]);
const { rowSelection, selectedRowIndices, setRowSelection } =
useEditingTableRowSelection();
const {
isInserting,
isDeleting,
isUpdating,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
error,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowUpdateBulk,
handleRowDelete,
handleRowDeleteBulk,
handleRowDeleteWithCascade,
} = useTableCRUD({
tableId,
scope: editingScope,
datasetData,
stateUpdateStrategy,
setRowSelection,
});
const {
isForeignKeyModalOpen,
foreignKeyError,
handleForeignKeyConfirmation,
handleForeignKeyCancel,
} = useForeignKeyConstraintHandling({
onCascadeDelete: handleRowDeleteWithCascade,
selectedRowIndices,
setRowSelection,
constraintError: error,
});
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
useTableEditingUndoRedo({
tableId,
scope: editingScope,
stateUpdateStrategy,
});
const [
isCreateRowModalOpen,
{ open: openCreateRowModal, close: closeCreateRowModal },
] = useDisclosure(false);
const { data: createRowFormDescription } = useActionFormDescription({
actionId: BuiltInTableAction.Create,
scope: editingScope,
});
const {
opened: isUpdateRowModalOpen,
rowIndex: updateModalRowIndex,
rowData: updateModalRowData,
actionFormDescription: updateActionFormDescription,
openUpdateRowModal,
closeUpdateRowModal,
} = useActionUpdateRowModalFromDatasetWithObjectId({
datasetData,
scope: editingScope,
currentObjectId: objectIdParam,
onObjectIdChange: handleCurrentObjectIdChange,
});
const {
isDeleteBulkRequested,
requestDeleteBulk,
cancelDeleteBulk,
onDeleteBulkConfirmation,
} = useTableBulkDeleteConfirmation({
handleRowDeleteBulk,
selectedRowIndices,
setRowSelection,
});
const { getColumnSortDirection, handleChangeColumnSort } = useTableSorting({
question: fakeTableQuestion,
handleQuestionChange,
});
const [
isBulkEditingRequested,
{ open: requestBulkEditing, close: closeBulkEditing },
] = useDisclosure();
useMount(() => {
dispatch(closeNavbar());
});
// Do not trigger leave confirmation modals on modal URL change
const handleIsLeaveLocationAllowed = useCallback(
(location: Location | undefined) => {
if (
location?.pathname.startsWith(getTableEditPathname(databaseId, tableId))
) {
return true;
}
return false;
},
[databaseId, tableId],
);
if (!database || isLoading || !fakeTableQuestion) {
// TODO: show loader
return null;
}
if (!rawDatasetResult || !datasetData || !tableFieldMetadataMap) {
// TODO: show error
return null;
}
const shouldDisableTable = isUndoLoading || isRedoLoading;
return (
<>
<Stack className={S.container} gap={0} data-testid="edit-table-data-root">
{table && (
<EditTableDataHeader
database={database}
table={table}
question={fakeTableQuestion}
isLoading={isFetching}
isUndoLoading={isUndoLoading}
isRedoLoading={isRedoLoading}
selectedRowIndices={selectedRowIndices}
onCreate={openCreateRowModal}
onQuestionChange={handleQuestionChange}
refetchTableDataQuery={refetch}
onUndo={undo}
onRedo={redo}
onRequestDeleteBulk={requestDeleteBulk}
onRequestBulkEditing={requestBulkEditing}
/>
)}
{isDatabaseTableEditingEnabled(database) ? (
<>
<Box pos="relative" className={S.gridWrapper}>
<EditTableDataOverlay
show={shouldDisableTable}
message={currentActionLabel ?? ""}
/>
<EditTableDataGrid
data={datasetData}
fieldMetadataMap={tableFieldMetadataMap}
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
getColumnSortDirection={getColumnSortDirection}
onCellValueUpdate={handleCellValueUpdate}
onRowExpandClick={openUpdateRowModal}
onRowSelectionChange={setRowSelection}
hasRowSelection
rowSelection={rowSelection}
onColumnSort={handleChangeColumnSort}
/>
</Box>
<Flex
py="0.5rem"
px="1.5rem"
h="2.5rem"
justify="flex-end"
align="center"
className={S.gridFooter}
>
<Text fw="bold" size="md" c="inherit" component="span">
{getRowCountMessage(rawDatasetResult)}
</Text>
</Flex>
</>
) : (
<GenericError
title={t`Table editing is not enabled for this database`}
message={t`Please ask your admin to enable table editing`}
details={undefined}
/>
)}
</Stack>
<ActionUpdateRowFormModal
rowIndex={updateModalRowIndex}
rowData={updateModalRowData}
description={updateActionFormDescription}
opened={isUpdateRowModalOpen}
onClose={closeUpdateRowModal}
onRowUpdate={handleRowUpdate}
onRowDelete={handleRowDelete}
withDelete
/>
<ActionCreateRowFormModal
description={createRowFormDescription}
opened={isCreateRowModalOpen}
onClose={closeCreateRowModal}
isInserting={isInserting}
onRowCreate={handleRowCreate}
/>
<ActionBulkUpdateRowFormModal
opened={isBulkEditingRequested}
selectedRowIndices={selectedRowIndices}
setRowSelection={setRowSelection}
description={updateActionFormDescription}
onClose={closeBulkEditing}
onRowsUpdate={handleRowUpdateBulk}
onRowsDelete={handleRowDeleteBulk}
withDelete
/>
<DeleteBulkRowConfirmationModal
opened={isDeleteBulkRequested}
rowCount={selectedRowIndices.length}
isLoading={isDeleting}
onConfirm={onDeleteBulkConfirmation}
onClose={cancelDeleteBulk}
/>
<UnsavedLeaveConfirmationModal
isUpdating={isUpdating}
isDeleting={isDeleting}
isInserting={isInserting}
isLocationAllowed={handleIsLeaveLocationAllowed}
/>
<ForeignKeyConstraintModal
opened={isForeignKeyModalOpen}
onClose={handleForeignKeyCancel}
onConfirm={handleForeignKeyConfirmation}
isLoading={isDeleting}
childRecords={foreignKeyError?.children || {}}
message={foreignKeyError?.message}
/>
</>
);
};

View File

@@ -0,0 +1,40 @@
/* copied from metabase/query_builder/components/view/ViewHeader/ViewTitleHeader.module.css */
.FilterButton {
&[data-variant="default"] {
transition: background 300ms linear;
&:hover {
color: var(--mb-color-filter);
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
}
}
}
.FilterButtonAttachment {
&[data-variant="default"] {
padding: 0.5rem;
transition: background 300ms linear;
&:hover {
background-color: color-mix(in srgb, var(--mb-color-filter) 10%, white);
}
&:hover,
&[data-expanded="true"] {
.FilterCountChip {
color: var(--mb-color-text-selected);
background-color: var(--mb-color-filter);
}
}
}
}
.FilterCountChip {
font-size: 0.6875rem;
border-radius: 10px;
line-height: 1rem;
padding-inline: 0.5rem;
color: var(--mb-color-text-primary);
background-color: color-mix(in srgb, var(--mb-color-filter) 15%, white);
}

View File

@@ -0,0 +1,83 @@
import { useDisclosure } from "@mantine/hooks";
import cx from "classnames";
import { useMemo } from "react";
import { t } from "ttag";
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
import { MultiStageFilterPicker } from "metabase/querying/filters/components/FilterPicker/MultiStageFilterPicker";
import { Button, Icon, Popover, Tooltip } from "metabase/ui";
import type * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import ViewTitleHeaderS from "./EditTableDataFilterButton.module.css";
interface EditTableDataFilterButtonProps {
className?: string;
question: Question;
isExpanded: boolean;
onExpand: () => void;
onCollapse: () => void;
onQuestionChange: (newQuestion: Question) => void;
}
// copied from metabase/query_builder/components/view/ViewHeader/components/FilterHeaderButton.tsx
export function EditTableDataFilterButton({
className,
question,
isExpanded,
onExpand,
onCollapse,
onQuestionChange,
}: EditTableDataFilterButtonProps) {
const [isOpened, { close, toggle }] = useDisclosure();
const query = question.query();
const items = useMemo(() => (query ? getFilterItems(query) : []), [query]);
const hasFilters = items.length > 0;
const label = isExpanded ? t`Hide filters` : t`Show filters`;
const handleQueryChange = (newQuery: Lib.Query) => {
const newQuestion = question.setQuery(newQuery);
onQuestionChange(newQuestion);
};
return (
<Button.Group>
<Popover opened={isOpened} position="bottom-start" onDismiss={close}>
<Popover.Target>
<Button
className={cx(className, ViewTitleHeaderS.FilterButton)}
leftSection={<Icon name={hasFilters ? "filter_plus" : "filter"} />}
onClick={toggle}
data-testid="table-edit-filter-header"
>
{t`Filter`}
</Button>
</Popover.Target>
<Popover.Dropdown>
<MultiStageFilterPicker
query={query}
canAppendStage={false}
onChange={handleQueryChange}
onClose={close}
/>
</Popover.Dropdown>
</Popover>
{hasFilters && (
<Tooltip label={label}>
<Button
aria-label={label}
className={ViewTitleHeaderS.FilterButtonAttachment}
onClick={isExpanded ? onCollapse : onExpand}
data-testid="filters-visibility-control"
data-expanded={isExpanded}
style={{ borderLeft: "none" }} // mantine puts a double border between buttons in groups
>
<div className={ViewTitleHeaderS.FilterCountChip}>
{items?.length}
</div>
</Button>
</Tooltip>
)}
</Button.Group>
);
}

View File

@@ -0,0 +1,265 @@
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import type React from "react";
import { useCallback, useMemo } from "react";
import { Ellipsified } from "metabase/common/components/Ellipsified";
import {
type ColumnOptions,
DataGrid,
type DataGridRowAction,
type RowIdColumnOptions,
useDataGridInstance,
} from "metabase/data-grid";
import { ROW_ID_COLUMN_ID } from "metabase/data-grid/constants";
import { formatValue } from "metabase/lib/formatting/value";
import { Box, Icon } from "metabase/ui";
import type { OrderByDirection } from "metabase-lib";
import type {
DatasetColumn,
DatasetData,
Field,
FieldWithMetadata,
RowValue,
RowValues,
} from "metabase-types/api";
import { canEditField } from "../../helpers";
import type { CellUniqKey, UpdateCellValueHandlerParams } from "../types";
import S from "./EditTableData.module.css";
import { EditingBodyCellWrapper } from "./EditingBodyCell";
import type { EditableTableColumnConfig } from "./use-editable-column-config";
import {
ROW_SELECT_COLUMN_ID,
useTableColumnRowSelect,
} from "./use-table-column-row-select";
import { useTableEditing } from "./use-table-editing";
import {
getCellUniqKey,
getPkColumns,
getRowUniqueKeyByPkIndexes,
} from "./utils";
type EditTableDataGridProps = {
data: DatasetData;
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
onCellValueUpdate: (params: UpdateCellValueHandlerParams) => void;
onRowExpandClick: (rowIndex: number) => void;
columnsConfig?: EditableTableColumnConfig;
getColumnSortDirection?: (
column: DatasetColumn,
) => OrderByDirection | undefined;
cellsWithFailedUpdatesMap?: Record<CellUniqKey, true>;
rowActions?: DataGridRowAction[];
onActionClick?: (action: DataGridRowAction, row: Row<RowValues>) => void;
hasRowSelection: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onColumnSort?: (field: Field) => void;
};
export const EditTableDataGrid = ({
data,
fieldMetadataMap,
onCellValueUpdate,
onRowExpandClick,
columnsConfig,
getColumnSortDirection,
cellsWithFailedUpdatesMap,
rowActions,
onActionClick,
hasRowSelection,
rowSelection,
onRowSelectionChange,
onColumnSort,
}: EditTableDataGridProps) => {
const { cols, rows } = data;
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
useTableEditing();
const columnOrder = useMemo(
() =>
columnsConfig?.columnOrder.length
? [ROW_SELECT_COLUMN_ID, ...columnsConfig.columnOrder]
: [ROW_SELECT_COLUMN_ID, ...cols.map(({ name }) => name)],
[cols, columnsConfig],
);
const columnVisibility = useMemo(
() => (columnsConfig ? columnsConfig.columnVisibilityMap : undefined),
[columnsConfig],
);
const columnSizingMap = useMemo(() => ({}), []);
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
const { indexes: pkColumnIndexes } = getPkColumns(cols);
return cols.map((column, columnIndex) => {
const isEditableColumn =
!columnsConfig || !columnsConfig.isColumnReadonly(column.name);
const sortDirection = getColumnSortDirection?.(column);
const options: ColumnOptions<RowValues, RowValue> = {
id: column.name,
name: column.display_name,
accessorFn: (row: RowValues) => row[columnIndex],
formatter: (value) => formatValue(value, { column }),
wrap: false,
sortDirection,
header: function EditingHeader() {
return (
<Box className={S.headerCellContainer}>
<Ellipsified>{column.display_name}</Ellipsified>
{sortDirection != null ? (
<Icon
className={S.sortIndicator}
data-testid="header-sort-indicator"
name={sortDirection === "asc" ? "chevronup" : "chevrondown"}
size={10}
/>
) : null}
</Box>
);
},
editingCell: (cellContext) => (
<EditingBodyCellWrapper
cellContext={cellContext}
column={column}
field={fieldMetadataMap?.[column.name]}
onCellValueUpdate={onCellValueUpdate}
onCellEditCancel={onCellEditCancel}
/>
),
getIsCellEditing: (cellId: string) => editingCellId === cellId,
getCellClassNameByCellId: (cellContext) => {
const rowIndex = cellContext.row.index;
const columnName = cellContext.column.id;
const rowData = rows[rowIndex];
const rowUniqueKey = getRowUniqueKeyByPkIndexes(
pkColumnIndexes,
rowData,
);
const cellUniqKey = getCellUniqKey(rowUniqueKey, columnName);
return cellsWithFailedUpdatesMap?.[cellUniqKey]
? S.cellWithUpdateFail
: undefined;
},
};
if (!isEditableColumn) {
options.getCellClassName = () => S.readonlyCell;
}
return options;
});
}, [
cols,
columnsConfig,
getColumnSortDirection,
fieldMetadataMap,
onCellValueUpdate,
onCellEditCancel,
editingCellId,
rows,
cellsWithFailedUpdatesMap,
]);
const rowId: RowIdColumnOptions = useMemo(
() => ({
variant: "expandButton",
onRowExpandClick,
}),
[onRowExpandClick],
);
const columnRowSelectOptions = useTableColumnRowSelect(hasRowSelection);
const tableProps = useDataGridInstance({
data: rows,
rowId,
columnOrder,
columnSizingMap,
columnsOptions,
columnVisibility,
columnPinning: { left: [ROW_SELECT_COLUMN_ID, ROW_ID_COLUMN_ID] },
enableRowSelection: hasRowSelection,
rowSelection,
onRowSelectionChange,
columnRowSelectOptions: columnRowSelectOptions,
rowActionsColumn:
rowActions?.length && onActionClick
? { actions: rowActions, onActionClick }
: undefined,
});
const handleBodyCellClick = useCallback(
(
e: React.MouseEvent<HTMLDivElement>,
{
cellId,
columnId,
}: {
cellId: string;
columnId: string;
},
) => {
const field = fieldMetadataMap?.[columnId];
if (columnsConfig && columnsConfig.isColumnReadonly(columnId)) {
return;
}
// Disables editing for some columns, such as primary keys
if (!canEditField(field)) {
return;
}
// Prevents event from bubbling up inside editing cell
// Otherwise requires special handling in EditingBodyCell
if (editingCellId !== cellId) {
onCellClickToEdit(cellId);
}
},
[onCellClickToEdit, editingCellId, fieldMetadataMap, columnsConfig],
);
const handleHeaderCellClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>, columnId?: string) => {
if (columnId) {
const field = fieldMetadataMap?.[columnId];
if (field) {
onColumnSort?.(field);
}
}
},
[fieldMetadataMap, onColumnSort],
);
return (
<DataGrid
{...tableProps}
classNames={{
headerContainer: S.tableHeaderContainer,
headerCell: S.tableHeaderCell,
bodyContainer: S.tableBodyContainer,
bodyCell: S.tableBodyCell,
row: S.tableRow,
}}
styles={{
// Overrides theme constants and default white bg
bodyCell: {
backgroundColor: undefined,
},
}}
theme={{ cell: { backgroundColor: "" } }}
onBodyCellClick={handleBodyCellClick}
onHeaderCellClick={handleHeaderCellClick}
/>
);
};

View File

@@ -0,0 +1,137 @@
import { useDisclosure } from "@mantine/hooks";
import { useMemo } from "react";
import { t } from "ttag";
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip"; // TODO: we should not use query builder components
import { QuestionFiltersHeader } from "metabase/query_builder/components/view/ViewHeader/components"; // TODO: we should not use query builder components
import { getFilterItems } from "metabase/querying/filters/components/FilterPanel/utils";
import { ActionIcon, Button, Flex, Group, Icon, Stack } from "metabase/ui";
import { TableNotificationsTrigger } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsTrigger/TableNotificationsTrigger";
import type Question from "metabase-lib/v1/Question";
import type TableV1 from "metabase-lib/v1/metadata/Table";
import type { Database } from "metabase-types/api";
import { TableBreadcrumbs } from "../common/TableBreadcrumbs";
import { EditTableDataFilterButton } from "./EditTableDataFilterButton";
interface EditTableDataHeaderProps {
database: Database;
table: TableV1;
question: Question;
isLoading: boolean;
isUndoLoading: boolean;
isRedoLoading: boolean;
selectedRowIndices: number[];
onCreate: () => void;
onQuestionChange: (newQuestion: Question) => void;
refetchTableDataQuery: () => void;
onUndo: () => void;
onRedo: () => void;
onRequestDeleteBulk: () => void;
onRequestBulkEditing: () => void;
}
export const EditTableDataHeader = ({
database,
table,
question,
isLoading,
isUndoLoading,
isRedoLoading,
selectedRowIndices,
onCreate,
onQuestionChange,
refetchTableDataQuery,
onUndo,
onRedo,
onRequestDeleteBulk,
onRequestBulkEditing,
}: EditTableDataHeaderProps) => {
const hasFilters = useMemo(
() =>
question.query() ? getFilterItems(question.query()).length > 0 : false,
[question],
);
const [
areFiltersExpanded,
{ open: onExpandFilters, close: onCollapseFilters },
] = useDisclosure(hasFilters);
const shouldDisableActions = isUndoLoading || isRedoLoading;
return (
<Stack gap={0}>
<Group
justify="space-between"
align="center"
p="0.5rem 1rem 0.5rem 2rem"
mih="4rem"
bg="var(--mb-color-background)"
data-testid="table-data-view-header"
>
<TableBreadcrumbs database={database} table={table} isEditMode />
<Group>
<EditTableDataFilterButton
question={question}
isExpanded={areFiltersExpanded}
onExpand={onExpandFilters}
onCollapse={onCollapseFilters}
onQuestionChange={onQuestionChange}
/>
<Button
leftSection={<Icon name="add" />}
variant="filled"
onClick={() => onCreate()}
disabled={shouldDisableActions}
>{t`New record`}</Button>
<Button
leftSection={<Icon name="pencil" />}
onClick={onRequestBulkEditing}
disabled={shouldDisableActions || !selectedRowIndices.length}
>{t`Edit`}</Button>
<Button
leftSection={<Icon name="trash" />}
variant="filled"
color="error"
onClick={onRequestDeleteBulk}
disabled={shouldDisableActions || !selectedRowIndices.length}
>{t`Delete`}</Button>
<Flex gap="xs">
<RunButtonWithTooltip
iconSize={16}
onlyIcon
medium
compact
isRunning={isLoading}
onRun={refetchTableDataQuery}
/>
<TableNotificationsTrigger tableId={table.id} />
<ActionIcon
onClick={onUndo}
size="lg"
loading={isUndoLoading}
disabled={shouldDisableActions}
>
<Icon name="undo" tooltip={t`Undo changes`} />
</ActionIcon>
<ActionIcon
onClick={onRedo}
size="lg"
loading={isRedoLoading}
disabled={shouldDisableActions}
>
<Icon name="redo" tooltip={t`Redo changes`} />
</ActionIcon>
</Flex>
</Group>
</Group>
<QuestionFiltersHeader
expanded={areFiltersExpanded}
question={question}
updateQuestion={onQuestionChange}
/>
</Stack>
);
};

View File

@@ -0,0 +1,30 @@
.overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
/* +1 for DataGrid `headerContainer` (which has z-index: 2) */
z-index: 3;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 1.5rem;
color: var(--mb-color-brand);
background-color: color-mix(
in srgb,
var(--mb-color-bg-white) 75%,
transparent
);
transition:
opacity 0.5s,
background-color 0.5s;
}
.message {
font-size: 1.5rem;
font-weight: 700;
color: var(--mb-color-brand);
text-transform: uppercase;
}

View File

@@ -0,0 +1,24 @@
import { Flex, Loader, Text } from "metabase/ui";
import S from "./EditTableDataOverlay.module.css";
type EditTableDataOverlayProps = {
message: string;
show?: boolean;
};
export function EditTableDataOverlay({
show = true,
message,
}: EditTableDataOverlayProps) {
if (!show) {
return null;
}
return (
<Flex className={S.overlay}>
<Loader size="lg" color="currentColor" />
<Text className={S.message}>{message}</Text>
</Flex>
);
}

View File

@@ -0,0 +1,24 @@
.inlineEditingTextInput {
transition: none;
outline: 2px solid var(--mb-base-color-blue-50);
outline-offset: -2px;
border-color: transparent;
box-sizing: border-box;
font-weight: 700;
background-color: var(--mb-base-color-white);
border-radius: 0;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
/* Should be in sync with DEFAULT_FONT_SIZE */
font-size: 12.5px;
/* Properly alignment with BaseCell, to avoid text flickering (BaseCell has only border-bottom 1px) */
border-top-width: 0;
/* Keep outline consistent for inline editing, since focused and non focused state is semantically the same */
&:focus {
outline: 2px solid var(--mb-base-color-blue-50);
}
}

View File

@@ -0,0 +1,76 @@
import type { CellContext } from "@tanstack/react-table";
import { useCallback } from "react";
import type {
DatasetColumn,
FieldWithMetadata,
RowValue,
RowValues,
} from "metabase-types/api";
import type { UpdateCellValueHandlerParams } from "../types";
import S from "./EditingBodyCell.module.css";
import { EditingBodyCellConditional } from "./inputs";
interface EditingBodyCellWrapperProps<TRow, TValue> {
column: DatasetColumn;
field?: FieldWithMetadata;
cellContext: CellContext<TRow, TValue>;
onCellValueUpdate: (params: UpdateCellValueHandlerParams) => void;
onCellEditCancel: () => void;
}
export const EditingBodyCellWrapper = (
props: EditingBodyCellWrapperProps<RowValues, RowValue>,
) => {
const {
onCellEditCancel,
onCellValueUpdate,
column,
field,
cellContext: {
getValue,
column: { id: columnName },
row: { index: rowIndex },
},
} = props;
const initialValue = getValue<RowValue>();
const doCellValueUpdate = useCallback(
(value: RowValue) => {
if (value !== initialValue) {
onCellValueUpdate({
updatedData: { [columnName]: value },
rowIndex,
columnName,
});
}
// Hide the editing cell after submitting the value
onCellEditCancel();
},
[initialValue, onCellEditCancel, onCellValueUpdate, columnName, rowIndex],
);
return (
<EditingBodyCellConditional
autoFocus
inputProps={{
variant: "unstyled",
size: "sm",
}}
classNames={{
textInputElement: S.inlineEditingTextInput,
selectTextInputElement: S.inlineEditingTextInput,
dateTextInputElement: S.inlineEditingTextInput,
}}
field={field}
initialValue={initialValue}
datasetColumn={column}
onSubmit={doCellValueUpdate}
onCancel={onCellEditCancel}
/>
);
};

View File

@@ -0,0 +1,44 @@
import { useState } from "react";
import { t } from "ttag";
import { Button, Group, Icon, Text } from "metabase/ui";
import { getUpdateApiErrorMessage } from "./utils";
type ErrorUpdateToastProps = {
error: unknown;
};
export const ErrorUpdateToast = ({ error }: ErrorUpdateToastProps) => {
const [showDetails, setShowDetails] = useState(false);
const errorMessage = getUpdateApiErrorMessage(error);
if (showDetails) {
return (
<Text c="text-white">
{t`Couldn't save table changes:`}
<br />
{errorMessage}
</Text>
);
}
return (
<Group gap="2.5rem">
<Group gap="0.5rem">
<Icon name="warning" c="danger" size={12} />
<Text c="text-white" fw={700}>{t`Couldn't save table changes`}</Text>
</Group>
<Button
size="compact-lg"
color="var(--mb-base-color-orion-80)"
variant="filled"
autoContrast
radius="0.5rem"
onClick={() => setShowDetails(true)}
>{t`More info`}</Button>
</Group>
);
};

View File

@@ -0,0 +1 @@
export { EditTableDataContainer } from "./EditTableDataContainer";

View File

@@ -0,0 +1,39 @@
import { Input } from "metabase/ui";
import type { EditingBodyPrimitiveProps } from "./types";
export const EditingBodyCellBasicInput = ({
autoFocus,
inputProps,
initialValue,
classNames,
onSubmit,
onCancel,
onChangeValue,
}: EditingBodyPrimitiveProps) => {
return (
<Input
defaultValue={(initialValue ?? "").toString()}
autoFocus={autoFocus}
onKeyUp={(event) => {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onSubmit(event.currentTarget.value);
}
}}
onChange={(event) => {
// Convert empty string to null
onChangeValue?.(event.currentTarget.value || null);
}}
onBlur={(event) => {
onSubmit(event.currentTarget.value || null);
}}
classNames={{
wrapper: classNames?.wrapper,
input: classNames?.textInputElement,
}}
{...inputProps}
/>
);
};

View File

@@ -0,0 +1,309 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { t } from "ttag";
import { useSearchFieldValuesQuery } from "metabase/api";
import { useDebouncedValue } from "metabase/common/hooks/use-debounced-value";
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
import {
Box,
Combobox,
Icon,
Input,
Loader,
type SelectOption,
Text,
TextInput,
useCombobox,
} from "metabase/ui";
import type { RemappingHydratedDatasetColumn } from "metabase/visualizations/types";
import type { FieldValue } from "metabase-types/api";
import type { EditingBodyPrimitiveProps } from "./types";
type EditingBodyCellCategorySelectProps = EditingBodyPrimitiveProps & {
withCreateNew?: boolean;
getDropdownLabelText?: (item: SelectOption) => string;
getSelectedLabelText?: (item: SelectOption) => string;
};
const DefaultItemLabelTextGetter = (item: SelectOption) => item.label;
const DefaultSelectedLabelTextGetter = (item: SelectOption) => item.value;
export const EditingBodyCellCategorySelect = ({
autoFocus,
inputProps,
initialValue,
datasetColumn,
withCreateNew = true,
classNames,
field,
getDropdownLabelText = DefaultItemLabelTextGetter,
getSelectedLabelText = DefaultSelectedLabelTextGetter,
onSubmit,
onChangeValue,
onCancel,
}: EditingBodyCellCategorySelectProps) => {
const searchInputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(initialValue?.toString() ?? "");
const [search, setSearch] = useState("");
const focusSearchInput = useCallback(() => {
setTimeout(() => searchInputRef.current?.focus(), 0);
}, []);
const combobox = useCombobox({
defaultOpened: autoFocus,
onDropdownClose: onCancel,
onDropdownOpen: focusSearchInput,
});
const { options, isLoading, isFetching } = useCategorySelectSearchOptions({
search,
fieldId: datasetColumn?.id,
// Mostly used for FKs, e.g. an ID is remapped to a name
searchFieldId: datasetColumn.remapped_to_column?.id ?? datasetColumn.id,
getDropdownLabelText,
});
const handleOptionSubmit = useCallback(
(value: string | null) => {
setValue(value ?? "");
onSubmit(value);
onChangeValue?.(value);
combobox.toggleDropdown();
},
[onSubmit, setValue, onChangeValue, combobox],
);
const optionValueToOptionMap = useMemo(
() =>
options.reduce(
(map, item) => ({
...map,
[item.value]: item,
}),
{} as Record<string, SelectOption>,
),
[options],
);
const inputLabel = useMemo(() => {
if (isLoading) {
return (
<Input.Placeholder c="text-light">{t`Loading...`}</Input.Placeholder>
);
}
if (!value && inputProps?.placeholder) {
return (
<Input.Placeholder c="text-light">
{inputProps.placeholder}
</Input.Placeholder>
);
}
if (value) {
// Lookup ad-hoc search results (e.g. might be different from the initial value)
if (value in optionValueToOptionMap) {
return getSelectedLabelText(optionValueToOptionMap[value]);
}
// Lookup column remapped values (usually when FK value is not presented in the current search results)
const maybeHydratedDatasetColumn =
datasetColumn as RemappingHydratedDatasetColumn;
const intValue = parseInt(value, 10);
if (
maybeHydratedDatasetColumn.remapping?.has(value) ||
maybeHydratedDatasetColumn.remapping?.has(intValue)
) {
const remappedValue =
maybeHydratedDatasetColumn.remapping.get(value) ??
maybeHydratedDatasetColumn.remapping.get(intValue);
return (
<Text truncate="end">
{getSelectedLabelText({
value: value,
label: remappedValue,
})}
</Text>
);
}
// Fallback to the raw value instead of a label
return <Text truncate="end">{value}</Text>;
}
return null;
}, [
isLoading,
value,
optionValueToOptionMap,
inputProps?.placeholder,
getSelectedLabelText,
datasetColumn,
]);
const isNullable = field?.database_is_nullable;
const shouldDisplayClearButton =
isNullable && !!value && !inputProps?.disabled;
return (
<Combobox
store={combobox}
position="bottom-start"
onOptionSubmit={handleOptionSubmit}
>
<Combobox.Target>
<Input
component="button"
type="button"
pointer
onClick={() => combobox.openDropdown()}
classNames={{
wrapper: classNames?.wrapper,
input: classNames?.selectTextInputElement,
}}
style={{ overflow: "hidden" }} // for label truncation
rightSectionPointerEvents="all"
rightSection={
shouldDisplayClearButton && (
<Icon
name="close"
color="var(--mb-color-text-light)"
onClick={() => handleOptionSubmit(null)}
onMouseDown={(event) => event.stopPropagation()}
/>
)
}
{...inputProps}
>
{inputLabel}
</Input>
</Combobox.Target>
<Combobox.Dropdown mah="none" miw={250}>
<Box p="0.5rem" pb="0" bg="white" pos="sticky" top={0}>
<TextInput
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
placeholder={t`Search the list`}
leftSection={<Icon name="search" />}
rightSection={isFetching ? <Loader size="xs" /> : undefined}
// Auto focus works when dropdown is initially rendered as opened (e.g. inline editing)
// whereas `onDropdownOpen` is called only when dropdown is opened by clicking the button (e.g. modal editing)
autoFocus
ref={searchInputRef}
/>
</Box>
<Combobox.Options p="0.5rem">
{options.length > 0 ? (
options.map((item) => (
<Combobox.Option
selected={value === item.value}
value={item.value}
key={item.value}
>
{getDropdownLabelText(item)}
</Combobox.Option>
))
) : isLoading ? (
<Combobox.Empty>{t`Loading values...`}</Combobox.Empty>
) : withCreateNew ? (
<Combobox.Option value={search}>
{t`Add option:`} <strong>{search}</strong>
</Combobox.Option>
) : (
<Combobox.Empty>{t`Nothing found`}</Combobox.Empty>
)}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
);
};
const SEARCH_LIMIT_DEFAULT = 20;
const SEARCH_DEBOUNCE = 500;
type UseCategorySelectSearchOptionsProps = {
search: string;
fieldId?: number;
searchFieldId?: number;
limit?: number;
getDropdownLabelText: (item: SelectOption) => string;
};
function useCategorySelectSearchOptions({
search,
fieldId,
searchFieldId,
limit = SEARCH_LIMIT_DEFAULT,
getDropdownLabelText,
}: UseCategorySelectSearchOptionsProps) {
const debouncedSearch = useDebouncedValue(search, SEARCH_DEBOUNCE);
const [searchValueToFetch, setSearchValueToFetch] = useState(debouncedSearch);
// Values that are fetched without search value (all values with some limit)
const [initialFieldValues, setInitialFieldValues] = useState<
FieldValue[] | undefined
>(undefined);
// Local search is performed if there are less than `limit` initial values
const shouldPerformLocalSearch =
initialFieldValues && initialFieldValues.length < limit;
// Update the search value to fetch on every debounced search value change
// if the local search is disabled
useEffect(() => {
if (!shouldPerformLocalSearch) {
setSearchValueToFetch(debouncedSearch);
}
}, [debouncedSearch, shouldPerformLocalSearch]);
const {
data: fieldValues,
isLoading,
isFetching,
} = useSearchFieldValuesQuery(
{
fieldId: fieldId ?? -1, // type guard
searchFieldId: searchFieldId ?? -1, // type guard
// empty string is invalid from the API perspective
value: searchValueToFetch === "" ? undefined : searchValueToFetch,
limit: SEARCH_LIMIT_DEFAULT,
},
{ skip: fieldId === undefined },
);
useEffect(() => {
if (!initialFieldValues && fieldValues) {
setInitialFieldValues(fieldValues);
}
}, [initialFieldValues, fieldValues]);
const options = useMemo(() => {
if (fieldValues) {
const options = getFieldOptions(fieldValues);
if (shouldPerformLocalSearch) {
return options.filter((item) =>
getDropdownLabelText(item)
.toLowerCase()
.includes(search.toLowerCase().trim()),
);
}
return options;
}
return [];
}, [shouldPerformLocalSearch, fieldValues, search, getDropdownLabelText]);
return {
options,
isLoading,
isFetching:
(isFetching || search !== debouncedSearch) && !shouldPerformLocalSearch,
};
}

View File

@@ -0,0 +1,74 @@
import { t } from "ttag";
import { canEditField } from "../../../helpers";
import { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
import { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
import { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
import { EditingBodyCellTextarea } from "./EditingBodyCellTextarea";
import type { EditingBodyPrimitiveProps } from "./types";
export const EditingBodyCellConditional = (
props: EditingBodyPrimitiveProps,
) => {
const { datasetColumn: column, field, inputProps, withTextarea } = props;
const disabled = !canEditField(field);
const placeholder = field?.database_default
? t`Auto populated`
: field?.database_is_nullable
? t`Optional`
: props.inputProps?.placeholder;
if (
column.semantic_type === "type/State" ||
column.semantic_type === "type/Country" ||
column.semantic_type === "type/Category"
) {
return (
<EditingBodyCellCategorySelect
{...props}
inputProps={{ placeholder, disabled, ...inputProps }}
/>
);
}
if (column.semantic_type === "type/FK") {
return (
<EditingBodyCellFKSelect
{...props}
inputProps={{ placeholder, disabled, ...inputProps }}
/>
);
}
if (
column.effective_type === "type/Date" ||
column.effective_type === "type/DateTime" ||
column.effective_type === "type/DateTimeWithLocalTZ"
) {
return (
<EditingBodyCellDatetime
{...props}
inputProps={{ placeholder, disabled, ...inputProps }}
/>
);
}
if (withTextarea && column.semantic_type === "type/Description") {
return (
<EditingBodyCellTextarea
{...props}
inputProps={{ placeholder, disabled, ...inputProps }}
/>
);
}
return (
<EditingBodyCellBasicInput
{...props}
inputProps={{ placeholder, disabled, ...inputProps }}
/>
);
};

View File

@@ -0,0 +1,112 @@
import { DatesProvider, useDatesContext } from "@mantine/dates";
import dayjs from "dayjs";
import { type KeyboardEvent, useCallback, useMemo, useState } from "react";
import {
DEFAULT_DATE_STYLE,
DEFAULT_TIME_STYLE,
} from "metabase/lib/formatting/datetime-utils";
import { useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
import { DateInput } from "metabase/ui";
import type { EditingBodyPrimitiveProps } from "./types";
const DEFAULT_DATETIME_STYLE = `${DEFAULT_DATE_STYLE}, ${DEFAULT_TIME_STYLE}`;
export const EditingBodyCellDatetime = ({
autoFocus,
inputProps,
initialValue,
datasetColumn,
classNames,
onSubmit,
onChangeValue,
onCancel,
}: EditingBodyPrimitiveProps) => {
const isDateTime =
datasetColumn.effective_type === "type/DateTime" ||
datasetColumn.effective_type === "type/DateTimeWithLocalTZ";
const { locale, firstDayOfWeek, consistentWeeks } = useDatesContext();
const reportTimezone = useSelector((state) =>
getSetting(state, "report-timezone-long"),
);
const { restoreTimezone, initialDate } = useMemo(
() => ({
initialDate: initialValue ? new Date(initialValue.toString()) : null,
restoreTimezone: (date: Date | null) => {
if (!date) {
return null;
}
return dayjs(date)
.tz(reportTimezone)
.format("YYYY-MM-DDTHH:mm:ss.SSSZ");
},
}),
[initialValue, reportTimezone],
);
const valueFormat = isDateTime ? DEFAULT_DATETIME_STYLE : DEFAULT_DATE_STYLE;
const [value, setValue] = useState<Date | null>(initialDate);
const [isFocused, setFocused] = useState(false);
const handleChange = useCallback(
(value: Date | null) => {
setValue(value);
onChangeValue?.(restoreTimezone(value));
},
[onChangeValue, restoreTimezone],
);
const handleFocus = useCallback(() => {
setFocused(true);
}, [setFocused]);
const handleBlur = useCallback(() => {
setFocused(false);
onSubmit(restoreTimezone(value));
}, [value, onSubmit, restoreTimezone]);
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onSubmit(restoreTimezone(value));
}
},
[value, onCancel, onSubmit, restoreTimezone],
);
return (
<DatesProvider
settings={{
timezone: reportTimezone,
locale,
firstDayOfWeek,
consistentWeeks,
}}
>
<DateInput
autoFocus={autoFocus}
value={value}
valueFormat={valueFormat}
classNames={{
wrapper: classNames?.wrapper,
input: classNames?.dateTextInputElement,
}}
// Keeps popover mounted when focused to improve time editing UX
popoverProps={{ opened: isFocused }}
onChange={handleChange}
onBlur={handleBlur}
onFocus={handleFocus}
onKeyUp={handleKeyUp}
{...inputProps}
/>
</DatesProvider>
);
};

View File

@@ -0,0 +1,29 @@
import { useCallback } from "react";
import type { SelectOption } from "metabase/ui";
import { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
import type { EditingBodyPrimitiveProps } from "./types";
export const EditingBodyCellFKSelect = (props: EditingBodyPrimitiveProps) => {
const getDropdownLabelText = useCallback(
(item: SelectOption) =>
item.label !== item.value ? `${item.label} [${item.value}]` : item.value,
[],
);
const getSelectedLabelText = useCallback(
(item: SelectOption) => item.label,
[],
);
return (
<EditingBodyCellCategorySelect
{...props}
getDropdownLabelText={getDropdownLabelText}
getSelectedLabelText={getSelectedLabelText}
// Temporary enable create new for FKs (see WRK-490)
withCreateNew={true}
/>
);
};

View File

@@ -0,0 +1,45 @@
import { Textarea } from "metabase/ui";
import type { EditingBodyPrimitiveProps } from "./types";
const MAX_ROWS = 8;
const MIN_ROWS = 1;
export const EditingBodyCellTextarea = ({
autoFocus,
inputProps,
initialValue,
classNames,
onSubmit,
onCancel,
onChangeValue,
}: EditingBodyPrimitiveProps) => {
return (
<Textarea
defaultValue={(initialValue ?? "").toString()}
autoFocus={autoFocus}
onKeyUp={(event) => {
if (event.key === "Escape") {
onCancel();
}
}}
onChange={(event) => {
// Convert empty string to null
onChangeValue?.(event.currentTarget.value || null);
}}
onBlur={(event) => {
onSubmit(event.currentTarget.value || null);
}}
classNames={{
wrapper: classNames?.wrapper,
input: classNames?.textInputElement,
}}
{...inputProps}
// Match the line height for single line text (should look like a regular input)
styles={{ input: { lineHeight: "165%" } }}
maxRows={MAX_ROWS}
minRows={MIN_ROWS}
autosize
/>
);
};

View File

@@ -0,0 +1,5 @@
export { EditingBodyCellBasicInput } from "./EditingBodyCellBasicInput";
export { EditingBodyCellCategorySelect } from "./EditingBodyCellCategorySelect";
export { EditingBodyCellFKSelect } from "./EditingBodyCellFKSelect";
export { EditingBodyCellDatetime } from "./EditingBodyCellDatetime";
export { EditingBodyCellConditional } from "./EditingBodyCellConditional";

Some files were not shown because too many files have changed in this diff Show More