Compare commits

...

321 Commits

Author SHA1 Message Date
Stas Gavrylov
5ebbbd4e5a Move TableDataEditing API to OSS. Minor refactoring 2025-06-02 14:09:48 +03: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
Ngoc Khuat
26d47c1e34 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
875601dfb4 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
Chris Truter
8a505fd419 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
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
Chris Truter
b58bff6dd7 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
Dan Stone
2a42f773cf 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
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
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
Stas Gavrylov
d3fdca112d 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
Dan Stone
98287f0d89 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
Ngoc Khuat
36fc93d4e1 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
3c0a312444 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
Konstantin Darutkin
26fd776b30 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
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
27c58d1157 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
Konstantin Darutkin
db62348247 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
Chris Truter
0444520362 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
Ngoc Khuat
f5539cdad7 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
33b5e1f905 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
Chris Truter
f0583eaba5 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
Konstantin Darutkin
d0f20309a1 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
Oleksandr Solovyov
21f4335bea 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
Denis Berezin
9f8adeaadb 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
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
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
Konstantin Darutkin
aa38d7d06d 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
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
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
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
f3211e4cf2 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
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
Ngoc Khuat
7651509551 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
81c056481a 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
941e5e47f1 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
1c2195c4ed 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
5facc8557c 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
Chris Truter
3a91374f4e 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
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
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
Konstantin Darutkin
7ab6f20abb 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
Konstantin Darutkin
d711427901 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
Chris Truter
e2bd53b114 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
Konstantin Darutkin
fe64dfcf63 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
930f9b6fa0 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
Chris Truter
7b010b498a 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
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
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
Ngoc Khuat
f64cf10930 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
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
Alexander Solovyov
f0aad84238 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
Konstantin Darutkin
71988bd3e3 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
Ngoc Khuat
463e8d9d77 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
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
db35b1f042 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
89c23637b2 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
3e01e467ce 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
Ngoc Khuat
d5733763ad 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
dd2b87ccbe 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
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
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
75d3bda5a0 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
84904235f2 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
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
652657666c 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
6d518e9db9 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
531bcff427 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
Denis Berezin
8661631b79 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
Chris Truter
87b07be973 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
Chris Truter
7b619a3efa 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
Stas Gavrylov
3ce75ecdf2 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
Stas Gavrylov
b5280e8e3e 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
Chris Truter
5d9a5d4d38 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
Chris Truter
2c2b7a495b 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
Ngoc Khuat
a49cab944b 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
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
79400e908a 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
Chris Truter
9ded97a09c 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
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
Chris Truter
2dca593474 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
Ngoc Khuat
c28b50e49e 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
Chris Truter
5e0fe4bec5 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
2e826dd116 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
Alexander Solovyov
7a7d8fedb7 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
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
Alexander Solovyov
5aa96c0e88 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
1822d3485a 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
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
Chris Truter
b4b29dcc19 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
Ngoc Khuat
4453313745 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
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
Denis Berezin
d3b12475b7 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
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
Chris Truter
16d2514200 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
5b4e18b303 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
c638945787 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
Chris Truter
a40053fda1 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
6552ffdc69 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
Konstantin Darutkin
d0c3c9a4ce 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
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
Konstantin Darutkin
622553c018 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
Ngoc Khuat
7421a15eec 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
Konstantin Darutkin
5a50378330 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
Konstantin Darutkin
812b9ec918 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
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
34fc016df0 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
Konstantin Darutkin
c388ba0c58 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
01f7f5bcd9 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
Dan Stone
3a5be51509 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
Chris Truter
649caa0e44 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
Denis Berezin
5d6dde74c5 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
Alexander Solovyov
e43228e8fc 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
Konstantin Darutkin
805b355989 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
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
Konstantin Darutkin
ed852b65c9 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
Chris Truter
3e3a6aba14 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
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
0bdb7c1004 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
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
Ngoc Khuat
e8d96ac295 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
Ngoc Khuat
569ba2a134 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
Chris Truter
d989e582c2 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
Denis Berezin
6bf89dc316 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
Konstantin Darutkin
b0189956b2 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
349d0503fc 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
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
53873c22b1 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
Chris Truter
3f5333cd22 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
Denis Berezin
b061cc3b9b 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
60d8da092d 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
Dan Stone
46500e2157 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
Alexander Solovyov
a2ee4eaa22 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
Chris Truter
129510e174 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
Case Nelson
b618a16569 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
98aaa51b26 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
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
Alexander Solovyov
86dffe61d9 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
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
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
Alexander Solovyov
aef83abf34 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
Stas Gavrylov
da350584e8 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
37a9a6c0f3 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
Alexander Solovyov
1719ebc9c7 :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
Alexander Solovyov
79db162e9e 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
Chris Truter
51ad937543 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
Alexander Solovyov
6f009d56ce 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
Chris Truter
a92fca3ef5 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
Ngoc Khuat
f70ca33c0a 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
7e138cf216 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
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
Ngoc Khuat
ce87540743 [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
Denis Berezin
506c665eb6 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
Denis Berezin
a02d08fcfc 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
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
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
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
e285c32157 [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
Chris Truter
01222ff5e1 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
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
a5c30576c3 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
Alexander Solovyov
5ed7432f77 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
66e30a69c7 [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
4bee28d643 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
ba47dca45f 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
4073da9974 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
Chris Truter
2fff1e2c4a 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
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
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
d04b760b32 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
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
Dan Stone
e5023e9e11 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
Konstantin Darutkin
759da5a5c8 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
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
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
Denis Berezin
b4b84e778a 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
Konstantin Darutkin
e67139aa3f 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
Denis Berezin
177cc26508 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
Denis Berezin
5c162716e5 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
57bbf5b224 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
510 changed files with 27573 additions and 3095 deletions

View File

@@ -455,6 +455,9 @@
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
@@ -464,7 +467,6 @@
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

@@ -36,11 +36,13 @@
{actions
{:api #{metabase.actions.api
metabase.actions.core
metabase.actions.init}
metabase.actions.init
metabase.actions.types}
:uses #{analytics
api
collections
driver
events
legacy-mbql
lib
model-persistence
@@ -449,8 +451,16 @@
events
{:api #{metabase.events.core
metabase.events.init}
:uses #{models
util}}
:uses #{api
audit-app
config
channel
driver
models
notification
premium-features
util
view-log}}
formatter
{:api #{metabase.formatter.core}
@@ -630,7 +640,8 @@
metabase.notification.init
metabase.notification.models
metabase.notification.payload.core}
:uses #{analytics
:uses #{actions
analytics
api
appearance
channel
@@ -639,6 +650,7 @@
driver
events
models
lib
parameters
permissions
premium-features
@@ -650,8 +662,10 @@
system
task
task-history
types
users
util}}
util
warehouse-schema}}
;; TODO -- too many API namespaces
parameters
@@ -1449,16 +1463,48 @@
enterprise/core
{:api #{metabase-enterprise.core.init}
:uses :any}
:uses #{enterprise/advanced-config
enterprise/audit-app
enterprise/cache
enterprise/data-editing
enterprise/enhancements
enterprise/llm
enterprise/metabot-v3
enterprise/gsheets
enterprise/scim
enterprise/sso
enterprise/stale}}
enterprise/dashboard-subscription-filters
{:api #{}
:uses #{premium-features}}
enterprise/data-editing
{:api #{metabase-enterprise.data-editing.api}
{:api #{metabase-enterprise.data-editing.core
metabase-enterprise.data-editing.api
metabase-enterprise.data-editing.init}
:uses #{api
actions
driver
events
lib
models
notification
query-processor
upload
util
warehouse-schema}}
enterprise/data-editing-public
{:api #{}
:uses #{api
actions
enterprise/data-editing
driver
events
lib
query-processor
upload
util}}
enterprise/database-routing

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]]

1
.gitignore vendored
View File

@@ -146,5 +146,6 @@ llm-payloads
.aider*
/config.yml
CLAUDE.local.md
.claude/settings.local.json
.eslintcache

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
@@ -86,7 +85,7 @@
:git/url "https://github.com/eerohele/pp"}
io.github.metabase/macaw {:mvn/version "0.2.25"} ; 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]
@@ -105,6 +106,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

@@ -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/ee/data-editing/tmp-action").as("getActions");
cy.intercept("PUT", "/api/action/*").as("updateAction");
cy.intercept("GET", "/api/action?model-id=*").as("getModelActions");
@@ -1376,7 +1376,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

@@ -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

@@ -2176,12 +2176,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",
"Reviews - Product → Reviewer is Doohickey",
"ID is 7021",
"Product → Vendor is Doohickey",
"Product → Title is Doohickey",
"Net is equal to -80",
],
aggregations: ["Count", "Sum of Total"],
breakouts: [

View File

@@ -0,0 +1,636 @@
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/ee/data-editing/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(() => {
// Focus on second input, omitting "ID" field which is auto-populated.
cy.get("input").eq(1).type("black");
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(() => {
// Focus on second input, omitting "ID" field which is auto-populated.
cy.get("input").eq(1).type("black");
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,97 @@
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
const { H } = cy;
describe("scenarios > embedding > dashboard parameters", () => {
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");
});
});
});
});
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

@@ -14,6 +14,7 @@
[metabase-enterprise.audit-app.api.routes]
[metabase-enterprise.billing.api.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]
@@ -78,6 +79,7 @@
"/autodescribe" (premium-handler 'metabase-enterprise.llm.api :llm-autodescription)
"/billing" metabase-enterprise.billing.api.routes/routes
"/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)
"/gsheets" (-> gsheets.api/routes ;; gsheets requires both features.
(premium-handler :attached-dwh)
(premium-handler :etl-connections))

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,107 @@
(ns metabase-enterprise.data-editing.actions
(:require
[clojure.spec.alpha :as s]
[metabase-enterprise.data-editing.data-editing :as data-editing]
[metabase-enterprise.data-editing.undo :as undo]
[metabase.actions.core :as actions]
[metabase.lib.schema.actions :as lib.schema.actions]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli :as mu]
[metabase.util.malli.registry :as mr])
(:import
(clojure.lang ExceptionInfo)))
(derive :data-grid.row/create :data-grid.row/common)
(derive :data-grid.row/update :data-grid.row/common)
(derive :data-grid.row/delete :data-grid.row/common)
(s/def :actions.args.data-grid/common
:actions.args.crud.table/row)
(defmethod actions/action-arg-map-spec :data-grid.row/common
[_action]
:actions.args.data-grid/common)
(defmethod actions/normalize-action-arg-map :data-grid.row/common
[_action input]
(update-keys input u/qualified-name))
(defn- scope->table-id [context]
(u/prog1 (-> context :scope :table-id)
(when-not <>
(throw (ex-info "Cannot perform data-grid actions without a table in scope."
{:error :data-grid.row/unknown-table})))))
(defn- map-inputs [table-id inputs]
(for [row (data-editing/apply-coercions table-id inputs)]
{:table-id table-id :row row}))
(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- perform! [action-kw context inputs]
;; We could enhance this in future to work more richly with multi-table editable models.
(let [table-id (scope->table-id context)
next-inputs (map-inputs table-id inputs)]
(perform-table-row-action! action-kw context next-inputs
(if (= :table.row/delete action-kw)
identity
post-process))))
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/create]
[_action context inputs :- [:sequential ::lib.schema.actions/row]]
(perform! :table.row/create context inputs))
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/update]
[_action context inputs :- [:sequential ::lib.schema.actions/row]]
(perform! :table.row/update context inputs))
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-grid.row/delete]
[_action context inputs :- [:sequential ::lib.schema.actions/row]]
(perform! :table.row/delete context inputs))
;; undo
(s/def :actions.args/none map?)
(defmethod actions/action-arg-map-spec :data-editing/undo [_action] :actions.args/none)
(defmethod actions/action-arg-map-spec :data-editing/redo [_action] :actions.args/none)
(defmethod actions/normalize-action-arg-map :data-editing/undo [_action _input] {})
(defmethod actions/normalize-action-arg-map :data-editing/redo [_action _input] {})
(mr/def ::lib.schema.actions/nothing [:map {:closed true}])
(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/conflict (ex-info (tru "Your previous change has a conflict with another edit") {:status-code 409} e)
e))
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-editing/undo]
[_action context _inputs :- [:sequential ::lib.schema.actions/nothing]]
(try
{:context context
:outputs (undo/undo! context (:user-id context) (:scope context))}
(catch ExceptionInfo e
(throw (translate-undo-error e)))))
(mu/defmethod actions/perform-action!* [:sql-jdbc :data-editing/redo]
[_action context _inputs :- [:sequential ::lib.schema.actions/nothing]]
(try
{:context context
:outputs (undo/redo! context (:user-id context) (:scope context))}
(catch ExceptionInfo e
(throw (translate-undo-error e)))))

View File

@@ -1,38 +1,547 @@
(ns metabase-enterprise.data-editing.api
(:require
[clojure.walk :as walk]
[metabase-enterprise.data-editing.data-editing :as data-editing]
[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]))
(defn- perform-bulk-action! [action-kw table-id rows]
(actions/perform-action! action-kw
{:database (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))
:table-id table-id
:arg rows}))
(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)))
(api.macros/defendpoint :post "/table/:table-id"
"Insert row(s) into the given table."
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
{}
{:keys [rows]}]
(perform-bulk-action! :bulk/create table-id rows))
{:keys [rows scope]} :- [:map
[:rows [:sequential {:min 1} :map]]
;; TODO make this non-optional in the future
[:scope {:optional true} ::types/scope.raw]]]
(check-permissions)
(let [;; This is a bit unfortunate... we ignore the table-id in the path when called with a custom scope...
;; The solution is to stop accepting custom scope once we migrate the data grid to action/execute
scope (or scope {:table-id table-id})]
{:created-rows (map :row (:outputs (actions/perform-action! :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]}]
(perform-bulk-action! :bulk/update table-id rows))
{:keys [rows pks updates scope]}
:- [:multi {:dispatch #(cond
(:rows %) :mixed-updates
(:pks %) :uniform-updates)}
[:mixed-updates [:map
[:rows [:sequential {:min 1} :map]]
;; TODO make :scope required
[:scope {:optional true} ::types/scope.raw]]]
[:uniform-updates [:map
[:pks [:sequential {:min 1} :map]]
[:updates :map]
;; TODO make :scope required
[:scope {:optional true} ::types/scope.raw]]]]]
(check-permissions)
(if (empty? (or rows pks))
{:updated []}
(let [;; This is a bit unfortunate... we ignore the table-id in the path when called with a custom scope...
;; The solution is to stop accepting custom scope once we migrate the data grid to action/execute
scope (or 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 (:outputs (actions/perform-action! :data-grid.row/update scope rows)))})))
(api.macros/defendpoint :delete "/table/:table-id"
;; 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]}]
(perform-bulk-action! :bulk/delete table-id rows))
{:keys [rows scope]} :- [:map
[:rows [:sequential {:min 1} :map]]
;; make this non-optional in the future
[:scope {:optional true} ::types/scope.raw]]]
(check-permissions)
;; This is a bit unfortunate... we ignore the table-id in the path when called with a custom scope...
;; The solution is to stop accepting custom scope once we migrate the data grid to action/execute
(let [scope (or scope {:table-id table-id})]
(actions/perform-action! :data-grid.row/delete scope 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]}
_
_]
(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)}))
(api.macros/defendpoint :get "/row-action/:action-id"
"Like /api/action but specialises the parameter requirements for a dashcard context where selected rows
maybe provided values for e.g primary key values.
Parameter names that intersect with the tables fields will be omitted from the parameter list.
Later when evaluated as a row action using /row-action/:action-id/execute, you will also be able to omit these parameters."
[{:keys [action-id]} :- [:map [:action-id :string]]
{:keys [dashcard-id]} :- [:map [:dashcard-id ms/PositiveInt]]]
(let [action (-> (actions/select-action :id (parse-long action-id) :archived false)
(t2/hydrate :creator)
api/read-check)
;; TODO flatten this into a single query
card-id (api/check-404 (t2/select-one-fn :card_id [:model/DashboardCard :card_id] dashcard-id))
table-id (api/check-404 (t2/select-one-fn :table_id [:model/Card :table_id] card-id))
fields (t2/select [:model/Field :name] :table_id table-id)
field-names (set (map :name fields))
include? #(not (contains? field-names (:slug %)))]
(update action :parameters #(some->> % (filterv include?)))))
(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!
"Implementation handling a sub-sub-case."
[action input]
(actions/execute-action! action (input->parameters (:parameters action) input)))
(defn- execute-dashcard-row-action-on-saved-action!
"Implementation handling a sub-sub-case."
[action-id dashcard-id pks inputs & [_mapping]]
(let [action (-> (actions/select-action :id action-id :archived false)
(t2/hydrate :creator)
api/read-check)
;; TODO flatten this into a single query
card-id (api/check-404 (t2/select-one-fn :card_id [:model/DashboardCard :card_id] dashcard-id))
table-id (api/check-404 (t2/select-one-fn :table_id [:model/Card :table_id] card-id))
fields (t2/select [:model/Field :id :name :semantic_type] :table_id table-id)
field-names (set (map :name fields))
pk-fields (filter #(= :type/PK (:semantic_type %)) fields)
_ (assert (= 1 (count pks)) "Further work needed to handle matching up database to input rows")
params (first inputs)
[row] (data-editing/query-db-rows table-id pk-fields pks)
_ (api/check-404 row)
row-params (->> (:parameters action)
(keep (fn [{:keys [id slug]}]
;; TODO handle custom mapping
(when (contains? field-names (or slug id))
[id (row (keyword (or slug id)))])))
(into {}))
provided (input->parameters (:parameters action) params)]
[(actions/execute-action! action (merge row-params provided))]))
(defn- execute-dashcard-row-action-on-primitive-action!
"Implementation handling a sub-sub-case."
[action-kw scope dashcard-id pks inputs _mapping]
;; TODO flatten this into a single query
(let [card-id (api/check-404 (t2/select-one-fn :card_id [:model/DashboardCard :card_id] dashcard-id))
table-id (api/check-404 (t2/select-one-fn :table_id [:model/Card :table_id] card-id))
pk-fields (t2/select [:model/Field :id :name :semantic_type] :table_id table-id :semantic_type :type/PK)
_ (assert (= 1 (count pks)) "Further work needed to handle matching up database to input rows")
[row] (data-editing/query-db-rows table-id pk-fields pks)
_ (api/check-404 row)
;; TODO handle custom mapping
inputs (for [input inputs]
(if (:row input)
(assoc input :row (merge row (:row input)))
(merge row input)))]
(:outputs (actions/perform-action! action-kw scope inputs))))
(api.macros/defendpoint :post "/row-action/:action-id/execute"
"Executes an action as a row action. The allows action parameters sharing a name with column names to be derived from a specific row.
The caller is still able to supply parameters, which will be preferred to those derived from the row.
Discovers the table via the provided dashcard-id, assumes a model/editable for now."
[{:keys [action-id]} :- [:map [:action-id :string]]
{:keys [dashcard-id]} :- [:map [:dashcard-id ms/PositiveInt]]
{:keys [pk params]} :- [:map
[:pk :any]
[:params :any]]]
(first (execute-dashcard-row-action-on-saved-action! (parse-long action-id) dashcard-id [pk] [params])))
(api.macros/defendpoint :get "/tmp-action"
"Returns all actions across all tables and models"
[_
_
_]
(api/check-superuser)
(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 [:table.row/create :table.row/update :table.row/delete]
:let [fields (fields-by-table (:id t))
action (actions/table-primitive-action t fields op)]]
(assoc action :table_name (:name t)))
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 ::unified-action.base
[:or
[:map {:closed true}
[:action-id ms/PositiveInt]]
[:map {:closed true}
[:action-kw :keyword]
[:table-id {:optional true} ms/PositiveInt]]])
(mr/def ::unified-action
[:or
::unified-action.base
[:map {:closed true}
[:dashboard-action ms/PositiveInt]]
[:map {:closed true}
[:row-action ::unified-action.base]
;; TODO type our mappings
[:mapping :map]
;; TODO generalize so we can support grids outside of dashboards
[:dashcard-id ms/PositiveInt]]])
(mu/defn- fetch-unified-action :- ::unified-action
"Resolve various types of action id into a semantic map which is easier to dispatch on."
[scope :- ::types/scope.hydrated
raw-id :- [:or :string ms/NegativeInt ms/PositiveInt]]
(cond
(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, :table-id param}
: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)]
(let [dashcard-id (if (= "unknown" dashcard-id) (:dashcard-id scope) (parse-long dashcard-id))
dashcard (api/check-404 (some->> dashcard-id (t2/select-one [:model/DashboardCard :visualization_settings])))
actions (-> dashcard :visualization_settings :editableTable.enabledActions)
;; TODO actual_id should get renamed to id at some point in the FE
viz-action (api/check-404 (first (filter (comp #{raw-id} #(or (:actual_id %) (:id %))) actions)))
;; TODO id should get renamed to action_id at some point as well
inner-id (or (:action_id viz-action) (:id viz-action))
unified (fetch-unified-action scope inner-id)
action-type (:type viz-action "row-action")
mapping (:parameterMappings viz-action {})]
(assert (:enabled viz-action) "Cannot call disabled actions")
(case action-type
"row-action" {:row-action unified, :mapping mapping, :dashcard-id dashcard-id}))
(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)})
;; Not a fancy encoded string, it must refer directly to a primitive.
{:action-kw (keyword raw-id)}))
:else
(throw (ex-info "Unexpected id value" {:status 400, :action-id raw-id}))))
(defn- execute!* [action-id scope inputs]
(let [scope (actions/hydrate-scope scope)
unified (fetch-unified-action scope action-id)]
(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 (first inputs))])
(:action-kw unified)
(let [action-kw (keyword (:action-kw unified))]
(:outputs
;; Weird magic currying we've been doing implicitly.
(if (and (isa? action-kw :table.row/common) (:table-id unified))
(actions/perform-action! action-kw scope (for [i inputs] {:table-id (:table-id unified), :row i}))
(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)))])
(:row-action unified)
;; use flat namespace for now, probably want to separate form inputs from pks
(let [row-action (:row-action unified)
mapping (:mapping unified)
;; will need to generalize this once we can use actions on fullscreen tables / editables / questions.
dashcard-id (:dashcard-id unified)
saved-id (:action-id row-action)
action-kw (:action-kw row-action)
;; TODO probably take the row separately from inputs
pks inputs
inputs (for [input inputs]
(if (seq mapping)
(walk/postwalk-replace {"::root" input} mapping)
input))]
(cond
saved-id
(execute-dashcard-row-action-on-saved-action! saved-id dashcard-id pks inputs mapping)
action-kw
(let [table-id (:table-id row-action)]
;; Weird magic currying we've been doing implicitly.
(if (and table-id (isa? action-kw :table.row/common))
(let [inputs (for [input inputs] {:table-id table-id, :row input})]
(execute-dashcard-row-action-on-primitive-action! action-kw scope dashcard-id pks inputs mapping))
(execute-dashcard-row-action-on-primitive-action! action-kw scope dashcard-id pks inputs mapping)))))
:else
(throw (ex-info "Not able to execute given action yet" {:status-code 500, :scope scope, :unified unified})))))
(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 input]}
:- [:map
[:action_id [:or :string ms/NegativeInt ms/PositiveInt]]
[:scope ::types/scope.raw]
[:input :map]]]
{:outputs (execute!* action_id scope [input])})
(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]}
:- [:map
[:action_id [:or :string ms/NegativeInt ms/PositiveInt]]
[:scope ::types/scope.raw]
[:inputs [:sequential :map]]]]
{:outputs (execute!* action_id scope inputs)})
(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."
[{}
{}
{:keys [action_id scope #_input]}]
(let [scope (actions/hydrate-scope scope)
unified (fetch-unified-action scope action_id)
;; todo mapping support
describe-saved-action
(fn [& {:keys [action-id _row-action-dashcard-id _mapping]}]
(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 {}))]
{: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))]
:when (not (:hidden viz-field))]
(u/remove-nils
;; todo dropdown options
{:id (:id param)
:display_name (or (:display-name param) (:name param))
:type (case (:type param)
:string/= :type/Text
:number/= :type/Number
:date/single :type/Date
(if (= "type" (namespace (:type param)))
(:type param)
(throw
(ex-info "Unsupported query action parameter type"
{:status-code 500
:param-type (:type param)
:scope scope
:unified unified}))))
:optional (and (not (:required param)) (not (:required viz-field)))}))
vec)}))
;; todo mapping support
describe-table-action
(fn [& {:keys [action-kw table-id _mapping]}]
(let [table (api/read-check (t2/select-one :model/Table :id table-id :active true))]
{:title (format "%s: %s" (:display_name table) (u/capitalize-en (name action-kw)))
:parameters
(->> (for [field (->> (t2/select :model/Field :table_id table-id)
(sort-by :position))
:let [pk (= :type/PK (:semantic_type field))]
:when (case action-kw
;; create does not take pk cols if auto increment, todo generated cols?
:table.row/create (not (:database_is_auto_increment field))
;; delete only requires pk cols
:table.row/delete pk
;; update takes both the pk and field (if not a row action)
:table.row/update true)
:let [required (or pk (:database_required field))]]
(u/remove-nils
{:id (:name field)
:display_name (:display_name field)
:type (:base_type field)
:optional (not required)
:nullable (:database_is_nullable field)}))
vec)}))]
(cond
;; saved action
(:action-id unified)
(describe-saved-action :action-id (:action-id unified))
;; table action
(:action-kw unified)
(let [action-kw (keyword (:action-kw unified))
table-id (:table-id unified)]
(describe-table-action :action-kw action-kw
:table-id table-id))
(:row-action unified)
(let [row-action (:row-action unified)
mapping (:mapping unified)
dashcard-id (:dashcard-id unified)
saved-id (:action-id row-action)
action-kw (:action-kw row-action)]
(cond
saved-id
(describe-saved-action :action-id saved-id
:row-action-dashcard-id dashcard-id
:row-action-mapping mapping)
action-kw
(let [table-id (:table-id row-action)]
(describe-table-action :action-kw action-kw
:table-id table-id
:row-action-mapping mapping))
: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})))))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."

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,6 @@
(ns metabase-enterprise.data-editing.core
(:require
[potemkin :as p]))
(comment
p/keep-me)

View File

@@ -0,0 +1,203 @@
(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.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 (comp keyword :name) pk-fields)
;; Hideous workaround for QP and direct JDBC disagreeing on case
(select-keys (merge (update-keys row (comp keyword u/upper-case-en name))
(u/lower-case-map-keys row)
row))
;; 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 on primary key column")
;; TODO pass in the db-id from above rather
(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")
(when row-pks
(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 first vals) 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 (keyword (: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)
table->keymap (u/for-map [table-id table-ids
:let [fields (t2/select-fn-vec :name [:model/Field :name] :table_id table-id)]]
[table-id (merge (u/for-map [f fields]
[(keyword (u/lower-case-en f)) (keyword f)])
(u/for-map [f fields]
[(keyword (u/upper-case-en f)) (keyword f)])
(let [kws (map keyword fields)]
(zipmap kws kws)))])
diff->pk-diff (u/for-map [{:keys [table-id before after] :as diff} diffs
:when (or before after)
:let [keymap (table->keymap table-id)]]
[diff {:pk (get-row-pks (table->pk-fields table-id) (or after before))
:before (when before (update-keys before keymap))
:after (when after (update-keys after keymap))}])]
;; 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)
((requiring-resolve 'metabase-enterprise.data-editing.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] :as diff} diffs
:when (or before after)
:let [{:keys [pk before after]} (diff->pk-diff diff)]]
[pk [before after]])]))))
;; 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,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,81 @@
(ns metabase-enterprise.data-editing.foreign-keys
(:require
[clojure.set :as set]
[metabase.util :as u])
(:import (clojure.lang PersistentQueue)))
#_(defn lookup-children-in-db [{:keys [table fk pk]} parents]
(jdbc/query
'db
{:select pk
:from [table]
:where (if (= 1 (count fk))
[:in (key (first fk)) (map (val (first fk)) parents)]
(into [:or] (for [p parents]
(into [:and] (for [[fk-col pk-col] fk]
[:= fk-col (get p pk-col)])))))
:limit 501}))
(defn- pop-queue [{:keys [queue] :as state}]
(if-let [nxt (peek queue)]
[nxt (update state :queue pop)]
[nil state]))
(defn- queue-items [state item-type items]
(if-not (seq items)
state
(let [new-items (remove (set (get-in state [:results item-type])) items)]
(-> state
(update :queue conj [item-type new-items])
(update-in [:results item-type] (fnil into #{}) new-items)))))
(defn- step [metadata children-fn state]
(let [[[parent-type items] state'] (pop-queue state)]
(if-not parent-type
state'
(let [type-metadata (get metadata parent-type)
child-keys (for [relationship type-metadata]
(children-fn relationship items))]
(reduce (fn [state [item-type items]]
(queue-items state item-type items))
state'
child-keys)))))
(defn- state->results [{:keys [results queue]}]
;; This is not precise; we should check whether there is at least one child for what's in the queue.
{:complete? (empty? queue)
:items results})
(defn- walk* [item-type items metadata children-fn {:keys [max-queries]
:or {max-queries 100}}]
(reduce
(fn [state _]
(step metadata children-fn state))
{:queue (conj PersistentQueue/EMPTY [item-type items])
:results {item-type (set items)}}
(range max-queries)))
(defn walk
"Given some starting items, return their descendants."
[item-type items metadata children-fn & {:as opts}]
(-> (walk* item-type items metadata children-fn opts)
(update :results (fn [results]
(u/remove-nils
(update results item-type (comp not-empty set/difference) items))))
(state->results)))
(defn count-descendants
"Given some starting items, count the number of descendants they have, according to their types."
[item-type items metadata children-fn & {:as opts}]
(let [{:keys [complete? items]} (walk item-type items metadata children-fn opts)]
{:complete? complete?
:counts (update-vals items count)}))
(defn delete-recursively
"Delete the given items, along with all their descendants."
[item-type items metadata children-fn delete-fn & {:as opts}]
(let [{:keys [queue results]} (walk* item-type items metadata children-fn opts)]
(if (seq queue)
(throw (ex-info "Cannot delete all descendants, as we could not enumerate them" {:queue queue}))
(do (delete-fn results)
(update-vals results count)))))

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,242 @@
(ns metabase-enterprise.data-editing.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->old-new-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->old-new-values
[row-pk [old new]] table-updates]
{:batch_num next-batch-num
:table_id table-id
:user_id user-id
:row_pk row-pk
:scope scope
:raw_before old
:raw_after new}))))
;; 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}))
(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

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

View File

@@ -76,6 +76,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,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,150 @@
(ns metabase-enterprise.data-editing.foreign-keys-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.foreign-keys :as fks]))
(def ^:private metadata
{:orders [{:table :order-items, :fk {:order-id :id}, :pk [:id]}]
;; family tree
:people [{:table :people, :fk {:father_id :id}, :pk [:id]}
{:table :people, :fk {:mother_id :id}, :pk [:id]}]
;; project tracker
:users [{:table :teams, :fk {:managed-by :id}, :pk [:id]}
{:table :users, :fk {:reports-to :id}, :pk [:id]}
{:table :programmes, :fk {:guided-by :id}, :pk [:id]}
{:table :tasks, :fk {:assigned-to :id}, :pk [:id]}]
:teams [{:table :users, :fk {:member-of :id}, :pk [:id]}]
:programmes [{:table :projects, :fk {:part-of :id}, :pk [:id]}
{:table :teams, :fk {:focus :id}, :pk [:id]}]
:projects [{:table :tasks, :fk {:project :id} :pk [:id]}]})
(def db
{:orders [{:id 42}
{:id 43}]
:order-items [{:id 1337, :order-id 42}
{:id 1338, :order-id 42}
{:id 1339, :order-id 43}]
;; family tree
:people [{:id 1}
{:id 2}
{:id 3 :father_id 1, :mother_id 2}
{:id 4 :father_id 1, :mother_id 3}
{:id 5 :father_id nil, :mother_id 3}]
;; project tracker
:users [{:id :user/ceo}
{:id :user/cto, :reports-to :user/ceo}
{:id :user/cpo, :reports-to :user/ceo}
{:id :user/em, :reports-to :user/cto, :member-of :team/alpha}
{:id :user/pm, :reports-to :user/em, :member-of :team/alpha}
{:id :user/alice, :reports-to :user/em, :member-of :team/alpha}
{:id :user/bob, :reports-to :user/em, :member-of :team/alpha}
{:id :user/clarence, :reports-to :user/em, :member-of :team/alpha}]
:teams [{:id :team/alpha, :managed-by :user/em, :focus :programme/skunk-works}
{:id :team/bravo, :managed-by :user/cto}]
:programmes [{:id :programme/skunk-works, :guided-by :user/cpo}]
:projects [{:id :project/gamma, :part-of :programme/skunk-works}]
:tasks [{:id :task/foo, :project :project/gamma, :assigned-to :user/alice}]})
(defn matches? [parent fks child]
(= (map parent (vals fks))
(map child (keys fks))))
(defn lookup-children
([relationship parents]
(lookup-children db relationship parents))
([db {:keys [table fk pk]} parents]
[table
(for [child (get db table)
parent parents
:when (matches? parent fk child)]
(select-keys child pk))]))
(deftest walk-test
(is (= {:complete? true
:items {:order-items #{{:id 1338} {:id 1337}}}}
(fks/walk :orders [{:id 42}] metadata lookup-children)))
(is (= {:complete? true
:items {:people #{{:id 5} {:id 4} {:id 3}}}}
(fks/walk :people [{:id 1}] metadata lookup-children)))
(is (= {:complete? true
:items {:users #{{:id :user/cto}
{:id :user/cpo}
{:id :user/em}
{:id :user/pm}
{:id :user/bob}
{:id :user/clarence}
{:id :user/alice}}
:teams #{{:id :team/alpha}
{:id :team/bravo}}
:programmes #{{:id :programme/skunk-works}}
:projects #{{:id :project/gamma}}
:tasks #{{:id :task/foo}}}}
(fks/walk :users [{:id :user/ceo}] metadata lookup-children))))
(deftest count-descendants-test
(is (= {:complete? true
:counts {:order-items 2}}
(fks/count-descendants :orders [{:id 42}] metadata lookup-children)))
(is (= {:complete? true
:counts {:people 3}}
(fks/count-descendants :people [{:id 1}] metadata lookup-children)))
(is (= {:complete? true
:counts {:users 7
:teams 2
:programmes 1
:projects 1
:tasks 1}}
(fks/count-descendants :users [{:id :user/ceo}] metadata lookup-children))))
(defn- delete-items [db items]
(reduce
(fn [db [item-type items]]
(update db item-type (fn [existing]
(filterv #(not (items (select-keys % (keys (first items)))))
existing))))
db
items))
(defn- after-delete [table rows]
(let [*db (atom db)
lookup-fn #(apply lookup-children @*db %&)
delete-fn #(swap! *db delete-items %)]
(fks/delete-recursively table rows metadata lookup-fn delete-fn)
(select-keys @*db
(case table
:orders [:orders :order-items]
:people [:people]
:users (remove #{:orders :order-items :people} (keys db))))))
(deftest delete-recursively-test
(is (= {:orders [{:id 43}], :order-items [{:id 1339, :order-id 43}]}
(after-delete :orders [{:id 42}])))
(is (= {:people [{:id 2}]}
(after-delete :people [{:id 1}])))
(is (= {:programmes [], :projects [], :tasks [], :teams [], :users []}
(after-delete :users [{:id :user/ceo}])))
(is (= {:users [{:id :user/ceo}
{:id :user/cto, :reports-to :user/ceo}
{:id :user/cpo, :reports-to :user/ceo}]
:teams [{:id :team/bravo, :managed-by :user/cto}],
:programmes [{:guided-by :user/cpo, :id :programme/skunk-works}],
:projects [{:id :project/gamma, :part-of :programme/skunk-works}],
:tasks []}
(after-delete :users [{:id :user/em}]))))

View File

@@ -0,0 +1,520 @@
(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 [: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 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,95 @@
(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])
(:import
(clojure.lang IDeref)
(java.io Closeable)))
(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})]
(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 alter-appdb-settings! [f & args]
(let [id (mt/id)
settings (t2/select-one-fn :settings :model/Database id)
new-settings (apply f settings args)]
(t2/update! :model/Database id {:settings new-settings})))
(defn toggle-data-editing-enabled! [on-or-off]
(alter-appdb-settings! assoc :database-enable-table-editing (boolean on-or-off)))
(defn open-test-table!
"Sets up an anonymous table in the test db (mt/id). Return a box that can be deref'd for the table-id.
Optionally accepts the column map and opts inputs to driver/create-table!.
The symbol auto-inc-type can be used to denote the driver-specific auto-incrementing type for primary keys.
e.g (open-test-table {:id 'auto-inc-type, :name [:text]} {:primary-key [:id]})
Returned box is java.io.Closeable so you can clean up with `with-open`.
Otherwise .close the box to drop the table when finished."
(^Closeable []
(open-test-table!
{:id 'auto-inc-type
:name [:text]
:song [:text]}
{:primary-key [:id]}))
(^Closeable [column-map create-table-opts]
(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
(let [table-id (create-test-table! db table-name column-map create-table-opts)]
(reify Closeable
IDeref
(deref [_] table-id)
(close [_] (cleanup))))
(catch Exception e
(try (cleanup) (catch Exception cleanup-ex (.addSuppressed e cleanup-ex)))
(throw e))))))
(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! true)
~@body))

View File

@@ -0,0 +1,381 @@
(ns metabase-enterprise.data-editing.undo-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[metabase-enterprise.data-editing.undo :as undo]
[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 "/ee/data-editing/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 "/ee/data-editing/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
(with-open [table-ref (data-editing.tu/open-test-table! {:id [:int]
:name [:text]
:favourite_food [:text]}
{:primary-key [:id]})]
(let [table-id @table-ref
user-id (mt/user->id :crowberto)
test-scope {:table-id table-id}]
(data-editing.tu/toggle-data-editing-enabled! true)
(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"
(with-open [table-ref (data-editing.tu/open-test-table! {:id [:int]
:name [:text]
:power [:int]}
{:primary-key [:id]})]
(let [table-id @table-ref
user-1 (mt/user->id :crowberto)
user-2 (mt/user->id :rasta)
test-scope {:table-id table-id}]
(data-editing.tu/toggle-data-editing-enabled! true)
;; 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"
(with-open [table-ref (data-editing.tu/open-test-table! {:id [:int]
:name [:text]
:status [:text]}
{:primary-key [:id]})]
(let [table-id @table-ref
user-id (mt/user->id :crowberto)
test-scope {:table-id table-id}]
(data-editing.tu/toggle-data-editing-enabled! true)
;; 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"
(with-open [table-ref-1 (data-editing.tu/open-test-table! {:id [:int]} {:primary-key [:id]})
table-ref-2 (data-editing.tu/open-test-table! {:id [:int]} {:primary-key [:id]})]
(let [table-1 @table-ref-1
table-2 @table-ref-2
user-1 (mt/user->id :crowberto)
user-2 (mt/user->id :rasta)]
(data-editing.tu/toggle-data-editing-enabled! true)
(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} [(if (even? i) {} nil)
(if (even? i) nil {})]
{:id 2} [(if (odd? i) {} nil)
(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} [(if (even? i) {} nil)
(if (even? i) nil {})]
{:id 2} [(if (odd? i) {} nil)
(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} [(if (even? i) {} nil)
(if (even? i) nil {})]
{:id 2} [(if (odd? i) {} nil)
(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} [(if (even? i) {} nil)
(if (even? i) nil {})]
{:id 2} [(if (odd? i) {} nil)
(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} [(if (even? i) {} nil)
(if (even? i) nil {})]
{:id 2} [(if (odd? i) {} nil)
(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])))))))))))

View File

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

View File

@@ -53,6 +53,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

@@ -0,0 +1,26 @@
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;
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;
}
});
return {
hasCreateAction,
hasDeleteAction,
};
}, [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,632 @@
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,
useListChannelsQuery,
usePreviewNotificationTemplateQuery,
useUpdateNotificationMutation,
} from "metabase/api";
import { useEscapeToCloseModal } from "metabase/common/hooks/use-escape-to-close-modal";
import ButtonWithStatus from "metabase/components/ButtonWithStatus";
import { ConfirmModal } from "metabase/components/ConfirmModal";
import { AutoWidthSelect } from "metabase/components/Schedule/AutoWidthSelect";
import CS from "metabase/css/core/index.css";
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 [channelType, setChannelType] =
useState<NotificationChannelType | null>(null);
const previewRequest = useMemo(() => {
if (!requestBody || !channelType) {
return null;
}
const handler = requestBody.handlers.find(
(h) => h.channel_type === channelType && h.template,
);
const currentTemplate =
handler?.template || defaultTemplates?.[channelType];
if (!currentTemplate) {
return null;
}
return {
notification: requestBody,
template: currentTemplate,
};
}, [requestBody, defaultTemplates, channelType]);
const {
data: previewData,
isLoading: isPreviewLoading,
error: previewError,
} = usePreviewNotificationTemplateQuery(previewRequest ?? skipToken, {
skip: !previewRequest,
});
const handlePreviewClick = useCallback(
(channelType: NotificationChannelType) => {
if (previewOpen) {
setPreviewOpen(false);
return;
}
const handler = requestBody?.handlers.find(
(h) => h.channel_type === channelType && h.template,
);
const currentTemplate =
handler?.template || defaultTemplates?.[channelType];
if (currentTemplate) {
setPreviewOpen(true);
setChannelType(channelType);
}
},
[requestBody, defaultTemplates, previewOpen],
);
const handlePreviewClose = useCallback(() => {
setPreviewOpen(false);
setChannelType(null);
}, []);
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/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,453 @@
import { useDisclosure } from "@mantine/hooks";
import cx from "classnames";
import { memo, useCallback, useMemo } from "react";
import { push } from "react-router-redux";
import { useLocation } from "react-use";
import { t } from "ttag";
import Modal from "metabase/components/Modal";
import { NoDataError } from "metabase/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 { TableActionExecuteModalContent } from "metabase-enterprise/table-actions/execution/TableActionExecuteModalContent";
import { useTableActionsExecute } from "metabase-enterprise/table-actions/execution/use-table-actions-execute";
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,
TableActionDisplaySettings,
} from "metabase-types/api";
import S from "./EditTableData.module.css";
import { EditTableDataGrid } from "./EditTableDataGrid";
import { EditTableDataOverlay } from "./EditTableDataOverlay";
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
import { EditBulkRowsModal } from "./modals/EditBulkRowsModal";
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
import { useTableEditingModalControllerWithObjectId } from "./modals/use-table-modal-with-object-id";
import { useEditableTableColumnConfigFromVisualizationSettings } from "./use-editable-column-config";
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";
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 {
state: modalState,
openCreateRowModal,
openEditRowModal,
closeModal,
} = useTableEditingModalControllerWithObjectId({
currentObjectId: objectIdParam,
datasetData: data,
onObjectIdChange: handleCurrentObjectIdChange,
});
const stateUpdateStrategy = useTableEditingStateDashcardUpdateStrategy(
dashcardId,
cardId,
);
const editingScope = useMemo(() => {
return { "dashcard-id": dashcardId };
}, [dashcardId]);
const {
isInserting,
isDeleting,
isUpdating,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowUpdateBulk,
handleRowDelete,
handleRowDeleteBulk,
} = useTableCRUD({
tableId,
scope: editingScope,
datasetData: data,
stateUpdateStrategy,
});
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
useTableEditingUndoRedo({
tableId,
scope: editingScope,
stateUpdateStrategy,
});
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
visualizationSettings,
);
const { hasCreateAction, 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 {
tableActions,
handleTableActionRun,
selectedTableActionState,
handleExecuteActionModalClose,
} = useTableActionsExecute({
actionsVizSettings: visualizationSettings?.[
"editableTable.enabledActions"
] as TableActionDisplaySettings[] | undefined,
datasetData: data,
});
const { rowSelection, selectedRowIndices, setRowSelection } =
useEditingTableRowSelection();
const {
isDeleteBulkRequested,
requestDeleteBulk,
cancelDeleteBulk,
onDeleteBulkConfirmation,
} = useTableBulkDeleteConfirmation({
handleRowDeleteBulk,
selectedRowIndices,
setRowSelection,
});
const isActionExecuteModalOpen = !!selectedTableActionState;
const { getColumnSortDirection } = useTableSorting({
question,
});
const [
isBulkEditingRequested,
{ open: requestBulkEditing, close: closeBulkEditing },
] = useDisclosure();
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"
>
<Text fw="bold">{title}</Text>
{!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={openEditRowModal}
columnsConfig={columnsConfig}
getColumnSortDirection={getColumnSortDirection}
rowActions={tableActions}
onActionRun={handleTableActionRun}
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>
</>
)}
<EditingBaseRowModal
modalState={modalState}
onClose={closeModal}
hasDeleteAction={hasDeleteAction}
onEdit={handleRowUpdate}
onRowCreate={handleRowCreate}
onRowDelete={handleRowDelete}
datasetColumns={data.cols}
currentRowData={
modalState.rowIndex !== undefined
? data.rows[modalState.rowIndex]
: undefined
}
fieldMetadataMap={tableFieldMetadataMap}
isLoading={isInserting}
columnsConfig={columnsConfig}
/>
<EditBulkRowsModal
opened={isBulkEditingRequested}
datasetColumns={data.cols}
fieldMetadataMap={tableFieldMetadataMap}
hasDeleteAction={hasDeleteAction}
columnsConfig={columnsConfig}
onClose={closeBulkEditing}
onEdit={handleRowUpdateBulk}
onDelete={handleRowDeleteBulk}
isDeleting={isDeleting}
selectedRowIndices={selectedRowIndices}
setRowSelection={setRowSelection}
/>
<Modal
isOpen={isActionExecuteModalOpen}
onClose={handleExecuteActionModalClose}
>
{selectedTableActionState && (
<TableActionExecuteModalContent
actionId={selectedTableActionState.actionId}
initialValues={selectedTableActionState.rowData}
actionOverrides={selectedTableActionState.actionOverrides}
onClose={handleExecuteActionModalClose}
/>
)}
</Modal>
<DeleteBulkRowConfirmationModal
opened={isDeleteBulkRequested}
rowCount={selectedRowIndices.length}
isLoading={isDeleting}
onConfirm={onDeleteBulkConfirmation}
onClose={cancelDeleteBulk}
/>
{withLeaveUnsavedConfirmation && (
<UnsavedLeaveConfirmationModal
isUpdating={isUpdating}
isDeleting={isDeleting}
isInserting={isInserting}
/>
)}
</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,293 @@
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/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 S from "./EditTableData.module.css";
import { EditTableDataGrid } from "./EditTableDataGrid";
import { EditTableDataHeader } from "./EditTableDataHeader";
import { EditTableDataOverlay } from "./EditTableDataOverlay";
import { DeleteBulkRowConfirmationModal } from "./modals/DeleteBulkRowConfirmationModal";
import { EditBulkRowsModal } from "./modals/EditBulkRowsModal";
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
import { UnsavedLeaveConfirmationModal } from "./modals/UnsavedLeaveConfirmationModal";
import { useTableBulkDeleteConfirmation } from "./modals/use-table-bulk-delete-confirmation";
import { useTableEditingModalControllerWithObjectId } from "./modals/use-table-modal-with-object-id";
import { getTableEditPathname } from "./url";
import { useStandaloneTableQuery } from "./use-standalone-table-query";
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 {
state: modalState,
openCreateRowModal,
openEditRowModal,
closeModal,
} = useTableEditingModalControllerWithObjectId({
currentObjectId: objectIdParam,
datasetData,
onObjectIdChange: handleCurrentObjectIdChange,
});
const stateUpdateStrategy =
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
const editingScope = useMemo(() => {
return { "table-id": tableId };
}, [tableId]);
const {
isInserting,
isDeleting,
isUpdating,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowUpdateBulk,
handleRowDelete,
handleRowDeleteBulk,
} = useTableCRUD({
tableId,
scope: editingScope,
datasetData,
stateUpdateStrategy,
});
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
useTableEditingUndoRedo({
tableId,
scope: editingScope,
stateUpdateStrategy,
});
const { rowSelection, selectedRowIndices, setRowSelection } =
useEditingTableRowSelection();
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={openEditRowModal}
onRowSelectionChange={setRowSelection}
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>
<EditingBaseRowModal
modalState={modalState}
onClose={closeModal}
onEdit={handleRowUpdate}
onRowCreate={handleRowCreate}
onRowDelete={handleRowDelete}
datasetColumns={datasetData.cols}
currentRowData={
modalState.rowIndex !== undefined
? datasetData.rows[modalState.rowIndex]
: undefined
}
fieldMetadataMap={tableFieldMetadataMap}
isLoading={isInserting}
hasDeleteAction
/>
<EditBulkRowsModal
opened={isBulkEditingRequested}
datasetColumns={datasetData.cols}
fieldMetadataMap={tableFieldMetadataMap}
onClose={closeBulkEditing}
onEdit={handleRowUpdateBulk}
onDelete={handleRowDeleteBulk}
isDeleting={isDeleting}
selectedRowIndices={selectedRowIndices}
setRowSelection={setRowSelection}
hasDeleteAction
/>
<DeleteBulkRowConfirmationModal
opened={isDeleteBulkRequested}
rowCount={selectedRowIndices.length}
isLoading={isDeleting}
onConfirm={onDeleteBulkConfirmation}
onClose={cancelDeleteBulk}
/>
<UnsavedLeaveConfirmationModal
isUpdating={isUpdating}
isDeleting={isDeleting}
isInserting={isInserting}
isLocationAllowed={handleIsLeaveLocationAllowed}
/>
</>
);
};

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,258 @@
import type { OnChangeFn, Row, RowSelectionState } from "@tanstack/react-table";
import type React from "react";
import { useCallback, useMemo } from "react";
import { Ellipsified } from "metabase/core/components/Ellipsified";
import {
type ColumnOptions,
DataGrid,
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 { isPK } from "metabase-lib/v1/types/utils/isa";
import type {
DatasetColumn,
DatasetData,
Field,
FieldWithMetadata,
RowPkValue,
RowValue,
RowValues,
UpdateCellValueHandlerParams,
WritebackAction,
} from "metabase-types/api";
import { canEditField } from "../../helpers";
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 } 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<RowPkValue, true>;
rowActions?: WritebackAction[];
onActionRun?: (action: WritebackAction, row: Row<RowValues>) => void;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onColumnSort?: (field: Field) => void;
};
export const EditTableDataGrid = ({
data,
fieldMetadataMap,
onCellValueUpdate,
onRowExpandClick,
columnsConfig,
getColumnSortDirection,
cellsWithFailedUpdatesMap,
rowActions,
onActionRun,
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 pkColumnIndex = cols.findIndex(isPK);
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 rowPkValue = rowData[pkColumnIndex] as RowPkValue;
const cellUniqKey = getCellUniqKey(rowPkValue, 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();
const tableProps = useDataGridInstance({
data: rows,
rowId,
columnOrder,
columnSizingMap,
columnsOptions,
columnVisibility,
columnPinning: { left: [ROW_SELECT_COLUMN_ID, ROW_ID_COLUMN_ID] },
enableRowSelection: true,
rowSelection,
onRowSelectionChange,
columnRowSelectOptions: columnRowSelectOptions,
rowActionsColumn:
rowActions?.length && onActionRun
? { actions: rowActions, onActionRun }
: 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,75 @@
import type { CellContext } from "@tanstack/react-table";
import { useCallback } from "react";
import type {
DatasetColumn,
FieldWithMetadata,
RowValue,
RowValues,
UpdateCellValueHandlerParams,
} from "metabase-types/api";
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,303 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { t } from "ttag";
import { useSearchFieldValuesQuery } from "metabase/api";
import { useDebouncedValue } from "metabase/hooks/use-debounced-value";
import { getFieldOptions } from "metabase/querying/filters/components/FilterValuePicker/utils";
import {
Box,
Combobox,
Icon,
Input,
Loader,
type SelectOption,
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 getSelectedLabelText({
value: value,
label: remappedValue,
});
}
// Fallback to the raw value instead of a label
return value;
}
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,
}}
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,28 @@
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}
withCreateNew={false}
/>
);
};

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";

View File

@@ -0,0 +1,33 @@
import type { MantineSize, TextInputProps } from "metabase/ui";
import type {
DatasetColumn,
FieldWithMetadata,
RowValue,
} from "metabase-types/api";
export type EditingBodyCellConditionalStylesNames =
| "wrapper"
| "textInputElement"
| "selectTextInputElement"
| "dateTextInputElement";
export interface EditingBodyPrimitiveProps {
withTextarea?: boolean;
autoFocus?: boolean;
datasetColumn: DatasetColumn;
field?: FieldWithMetadata;
initialValue: RowValue;
inputProps?: {
size?: MantineSize;
variant?: TextInputProps["variant"];
placeholder?: string;
disabled?: boolean;
error?: TextInputProps["error"];
rightSection?: TextInputProps["rightSection"];
rightSectionPointerEvents?: TextInputProps["rightSectionPointerEvents"];
};
classNames?: { [key in EditingBodyCellConditionalStylesNames]?: string };
onChangeValue?: (value: RowValue) => unknown;
onSubmit: (value: RowValue) => unknown;
onCancel: () => unknown;
}

View File

@@ -0,0 +1,49 @@
import { msgid, ngettext, t } from "ttag";
import { Button, Group, Modal } from "metabase/ui";
type DeleteBulkRowConfirmationModalProps = {
opened: boolean;
rowCount: number;
isLoading?: boolean;
onConfirm: () => void;
onClose: () => void;
};
export function DeleteBulkRowConfirmationModal({
opened,
rowCount,
isLoading,
onConfirm,
onClose,
}: DeleteBulkRowConfirmationModalProps) {
return (
<Modal
size="md"
title={ngettext(
msgid`Delete ${rowCount} record?`,
`Delete ${rowCount} records?`,
rowCount,
)}
opened={opened}
onClose={onClose}
>
<Group justify="flex-end" mt="xl">
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button
variant="filled"
color="danger"
onClick={onConfirm}
loading={isLoading}
>
{ngettext(
msgid`Delete ${rowCount} record`,
`Delete ${rowCount} records`,
rowCount,
)}
</Button>
</Group>
</Modal>
);
}

View File

@@ -0,0 +1,27 @@
import { t } from "ttag";
import { Button, Group, Modal } from "metabase/ui";
type DeleteRowConfirmationModalProps = {
onConfirm: () => void;
onCancel: () => void;
};
export function DeleteRowConfirmationModal({
onConfirm,
onCancel,
}: DeleteRowConfirmationModalProps) {
return (
<Modal size="md" title={t`Delete this record?`} opened onClose={onCancel}>
<Group justify="flex-end" mt="xl">
<Button variant="subtle" onClick={onCancel}>
{t`Cancel`}
</Button>
<Button
variant="filled"
color="danger"
onClick={onConfirm}
>{t`Delete record`}</Button>
</Group>
</Modal>
);
}

View File

@@ -0,0 +1,253 @@
import { useDisclosure } from "@mantine/hooks";
import type { RowSelectionState } from "@tanstack/react-table";
import cx from "classnames";
import { Fragment, useCallback, useEffect, useState } from "react";
import { msgid, ngettext, t } from "ttag";
import { noop } from "underscore";
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
import { ActionIcon, Group, Icon, Modal, Text, rem } from "metabase/ui";
import { canEditField } from "metabase-enterprise/data_editing/helpers";
import type {
DatasetColumn,
FieldWithMetadata,
RowValue,
Table,
UpdatedRowBulkHandlerParams,
} from "metabase-types/api";
import { EditingBodyCellConditional } from "../inputs";
import type { EditableTableColumnConfig } from "../use-editable-column-config";
import { DeleteBulkRowConfirmationModal } from "./DeleteBulkRowConfirmationModal";
import S from "./EditingBaseRowModal.module.css";
import { useEditingModalOrderedVisibleDatasetColumns } from "./use-editing-modal-ordered-dataset-columns";
interface EditBulkRowsModalProps {
opened: boolean;
datasetColumns: DatasetColumn[];
datasetTable?: Table;
onClose: () => void;
onEdit: (data: UpdatedRowBulkHandlerParams) => Promise<boolean>;
onDelete: (rowIndices: number[]) => Promise<boolean>;
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
hasDeleteAction: boolean;
isDeleting?: boolean;
columnsConfig?: EditableTableColumnConfig;
selectedRowIndices: number[];
setRowSelection: (state: RowSelectionState) => void;
}
export function EditBulkRowsModal({
opened,
datasetColumns,
onClose,
onEdit,
onDelete,
isDeleting,
fieldMetadataMap,
hasDeleteAction,
columnsConfig,
selectedRowIndices,
setRowSelection,
}: EditBulkRowsModalProps) {
// We need editing state to track null values for columns that were cleared
const [editingState, setEditingState] = useState<Record<string, RowValue>>(
{},
);
// Clear editing state on modal close
useEffect(() => {
setEditingState({});
}, [opened]);
const [
isDeleteRequested,
{ open: requestDeletion, close: closeDeletionModal },
] = useDisclosure();
// Columns might be reordered to match the order in `columnsConfig`
const orderedVisibleDatasetColumns =
useEditingModalOrderedVisibleDatasetColumns(datasetColumns, columnsConfig);
const handleValueEdit = useCallback(
async (key: string, value: RowValue, forceUpdate = false) => {
// Skip update if the value is empty and the value was not changed before
const valueWasModifiedBefore = key in editingState;
if (!forceUpdate && !value && !valueWasModifiedBefore) {
return;
}
const result = await onEdit({
updatedData: { [key]: value },
rowIndices: selectedRowIndices,
});
if (result) {
setEditingState((prev) => ({ ...prev, [key]: value }));
}
},
[onEdit, selectedRowIndices, setEditingState, editingState],
);
const handleDeleteConfirmation = useCallback(async () => {
const result = await onDelete(selectedRowIndices);
if (result) {
setRowSelection({});
onClose();
closeDeletionModal();
}
}, [
onDelete,
selectedRowIndices,
closeDeletionModal,
onClose,
setRowSelection,
]);
if (isDeleteRequested) {
return (
<DeleteBulkRowConfirmationModal
opened={true}
onClose={closeDeletionModal}
rowCount={selectedRowIndices.length}
isLoading={isDeleting}
onConfirm={handleDeleteConfirmation}
/>
);
}
return (
<Modal.Root opened={opened} onClose={onClose}>
<Modal.Overlay />
<Modal.Content>
<Modal.Header px="xl" pb="0" className={S.modalHeader}>
<Modal.Title>
{ngettext(
msgid`Editing ${selectedRowIndices.length} record`,
`Editing ${selectedRowIndices.length} records`,
selectedRowIndices.length,
)}
</Modal.Title>
<Group
gap="xs"
mr={rem(-5) /* aligns cross with modal right padding */}
>
{hasDeleteAction && (
<ActionIcon variant="subtle" onClick={requestDeletion}>
<Icon name="trash" />
</ActionIcon>
)}
<ActionIcon variant="subtle" onClick={onClose}>
<Icon name="close" />
</ActionIcon>
</Group>
</Modal.Header>
<Modal.Body
px="xl"
py="lg"
className={cx(S.modalBody, S.modalBodyEditing)}
>
{orderedVisibleDatasetColumns.map((column) => {
const field = fieldMetadataMap?.[column.name];
return (
<Fragment key={column.id}>
<Group h="2.5rem" align="center">
<Icon
className={S.modalBodyColumn}
name={
column.semantic_type
? FIELD_SEMANTIC_TYPES_MAP[column.semantic_type].icon
: "string"
}
/>
<Text className={S.modalBodyColumn}>
{column.display_name}
{field?.database_required && (
<Text component="span" c="error">
*
</Text>
)}
</Text>
</Group>
<BulkEditingInput
datasetColumn={column}
field={field}
onSubmitValue={handleValueEdit}
isValueCleared={editingState[column.name] === null}
isValueModified={column.name in editingState}
columnsConfig={columnsConfig}
/>
</Fragment>
);
})}
</Modal.Body>
</Modal.Content>
</Modal.Root>
);
}
type BulkEditingInputProps = {
datasetColumn: DatasetColumn;
field?: FieldWithMetadata;
onSubmitValue: (key: string, value: RowValue, forceUpdate?: boolean) => void;
columnsConfig?: EditableTableColumnConfig;
isValueCleared?: boolean;
isValueModified?: boolean;
};
function BulkEditingInput({
datasetColumn,
field,
onSubmitValue,
columnsConfig,
isValueCleared,
isValueModified,
}: BulkEditingInputProps) {
const disabled =
columnsConfig?.isColumnReadonly(datasetColumn.name) || !canEditField(field);
const handleValueClear = useCallback(() => {
onSubmitValue(datasetColumn.name, null, true);
}, [onSubmitValue, datasetColumn.name]);
const handleValueSubmit = useCallback(
(value: RowValue) => {
onSubmitValue(datasetColumn.name, value);
},
[onSubmitValue, datasetColumn.name],
);
const placeholder = isValueCleared
? "NULL"
: !isValueModified
? t`(Unchanged)`
: undefined;
return (
<EditingBodyCellConditional
autoFocus={false}
datasetColumn={datasetColumn}
field={field}
initialValue={null}
onCancel={noop}
onSubmit={handleValueSubmit}
inputProps={{
placeholder,
disabled,
rightSectionPointerEvents: "all",
rightSection: !disabled && !isValueCleared && (
<Icon
name="close"
color="var(--mb-color-text-light)"
onClick={handleValueClear}
onMouseDown={(event) => event.stopPropagation()}
/>
),
}}
withTextarea
/>
);
}

View File

@@ -0,0 +1,35 @@
.modalHeader {
background-color: var(--mb-color-background);
border-bottom: 1px solid var(--mb-color-border);
height: rem(80);
padding-top: 0;
padding-bottom: 0;
align-items: center;
}
.modalBody {
background-color: var(--mb-base-color-orion-5);
display: grid;
grid-template-columns: 45% auto;
row-gap: rem(24);
max-height: calc(
100vh - (var(--modal-y-offset) * 2) - rem(80) - rem(72)
/* header + footer */
);
overflow-y: scroll;
}
.modalBodyEditing {
max-height: calc(100vh - (var(--modal-y-offset) * 2) - rem(80) /* header */);
}
.modalBodyColumn {
align-self: center;
}
.modalFooter {
background-color: var(--mb-color-background);
border-top: 1px solid var(--mb-color-border);
height: rem(72);
align-items: center;
}

View File

@@ -0,0 +1,307 @@
import { useDisclosure } from "@mantine/hooks";
import cx from "classnames";
import { useFormik } from "formik";
import { Fragment, useCallback, useEffect, useMemo } from "react";
import { t } from "ttag";
import { noop } from "underscore";
import { FIELD_SEMANTIC_TYPES_MAP } from "metabase/lib/core";
import {
ActionIcon,
Button,
Flex,
Group,
Icon,
Modal,
Text,
rem,
} from "metabase/ui";
import type {
DatasetColumn,
FieldWithMetadata,
RowValue,
RowValues,
Table,
UpdatedRowHandlerParams,
} from "metabase-types/api";
import { EditingBodyCellConditional } from "../inputs";
import type { EditableTableColumnConfig } from "../use-editable-column-config";
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
import S from "./EditingBaseRowModal.module.css";
import { useEditingModalOrderedVisibleDatasetColumns } from "./use-editing-modal-ordered-dataset-columns";
import type { TableEditingModalState } from "./use-table-modal";
import { TableEditingModalAction } from "./use-table-modal";
interface EditingBaseRowModalProps {
datasetColumns: DatasetColumn[];
datasetTable?: Table;
modalState: TableEditingModalState;
onClose: () => void;
onEdit: (data: UpdatedRowHandlerParams) => Promise<boolean>;
onRowCreate: (data: Record<string, RowValue>) => Promise<boolean>;
onRowDelete: (rowIndex: number) => Promise<boolean>;
currentRowData?: RowValues;
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
hasDeleteAction: boolean;
isLoading?: boolean;
columnsConfig?: EditableTableColumnConfig;
}
type EditingFormValues = Record<string, RowValue>;
export function EditingBaseRowModal({
datasetColumns,
modalState,
onClose,
onEdit,
onRowCreate,
onRowDelete,
currentRowData,
fieldMetadataMap,
hasDeleteAction,
isLoading,
columnsConfig,
}: EditingBaseRowModalProps) {
const isEditingMode = !!currentRowData;
const [
isDeleteRequested,
{ open: requestDeletion, close: closeDeletionModal },
] = useDisclosure();
const validateForm = useCallback(
(values: EditingFormValues) => {
const errors: Record<string, string> = {};
datasetColumns.forEach((column) => {
const field = fieldMetadataMap?.[column.name];
const isRequired = field?.database_required;
if (isRequired && !values[column.name]) {
errors[column.name] = t`This column is required`;
}
});
return errors;
},
[fieldMetadataMap, datasetColumns],
);
const onSubmit = useCallback(
async (values: EditingFormValues) => {
const success = await onRowCreate(values);
if (success) {
onClose();
}
},
[onClose, onRowCreate],
);
const {
isValid,
resetForm,
setFieldValue,
handleSubmit,
validateForm: revalidateForm,
} = useFormik({
initialValues: {} as EditingFormValues,
onSubmit,
validate: validateForm,
validateOnMount: true,
});
// Clear new row data when modal is opened
useEffect(() => {
if (modalState.action === TableEditingModalAction.Create) {
resetForm();
revalidateForm();
}
}, [modalState.action, resetForm, revalidateForm]);
const handleValueEdit = useCallback(
(key: string, value: RowValue) => {
if (modalState.rowIndex != null && isEditingMode) {
onEdit({
rowIndex: modalState.rowIndex,
updatedData: {
[key]: value,
},
});
}
},
[isEditingMode, modalState.rowIndex, onEdit],
);
const handleDeleteConfirmation = useCallback(async () => {
if (modalState.rowIndex !== undefined) {
closeDeletionModal();
onClose();
await onRowDelete(modalState.rowIndex);
}
}, [closeDeletionModal, onRowDelete, modalState.rowIndex, onClose]);
// Columns might be reordered to match the order in `columnsConfig`
const orderedVisibleDatasetColumns =
useEditingModalOrderedVisibleDatasetColumns(datasetColumns, columnsConfig);
// We can't use `currentRowData` in case
// when colums are reordered due to `columnsConfig`
const currentRowDataMap = useMemo(() => {
if (!currentRowData) {
return {};
}
return datasetColumns.reduce(
(acc, column, index) => ({
...acc,
[column.name]: currentRowData[index],
}),
{} as Record<string, RowValue>,
);
}, [currentRowData, datasetColumns]);
if (isDeleteRequested) {
return (
<DeleteRowConfirmationModal
onCancel={closeDeletionModal}
onConfirm={handleDeleteConfirmation}
/>
);
}
return (
<Modal.Root opened={modalState.action !== null} onClose={onClose}>
<Modal.Overlay />
<Modal.Content>
<form onSubmit={handleSubmit}>
<Modal.Header px="xl" pb="0" className={S.modalHeader}>
<Modal.Title>
{isEditingMode ? t`Edit record` : t`Create a new record`}
</Modal.Title>
<Group
gap="xs"
mr={rem(-5) /* aligns cross with modal right padding */}
>
{isEditingMode && hasDeleteAction && (
<ActionIcon variant="subtle" onClick={requestDeletion}>
<Icon name="trash" />
</ActionIcon>
)}
<ActionIcon variant="subtle" onClick={onClose}>
<Icon name="close" />
</ActionIcon>
</Group>
</Modal.Header>
<Modal.Body
px="xl"
py="lg"
className={cx(S.modalBody, { [S.modalBodyEditing]: isEditingMode })}
>
{orderedVisibleDatasetColumns.map((column) => {
const field = fieldMetadataMap?.[column.name];
return (
<Fragment key={column.id}>
<Group h="2.5rem" align="center">
<Icon
className={S.modalBodyColumn}
name={
column.semantic_type
? FIELD_SEMANTIC_TYPES_MAP[column.semantic_type].icon
: "string"
}
/>
<Text className={S.modalBodyColumn}>
{column.display_name}
{field?.database_required && (
<Text component="span" c="error">
*
</Text>
)}
</Text>
</Group>
<ModalEditingInput
isEditingMode={isEditingMode}
initialValue={currentRowDataMap[column.name] ?? null}
datasetColumn={column}
field={field}
onSubmitValue={handleValueEdit}
onChangeValue={setFieldValue}
disabled={columnsConfig?.isColumnReadonly(column.name)}
/>
</Fragment>
);
})}
</Modal.Body>
{!isEditingMode && (
<Flex px="xl" className={S.modalFooter} gap="lg" justify="flex-end">
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button
disabled={isLoading || !isValid}
variant="filled"
type="submit"
>{t`Create new record`}</Button>
</Flex>
)}
</form>
</Modal.Content>
</Modal.Root>
);
}
type EditingInputWithEventsProps = {
isEditingMode: boolean;
initialValue?: RowValue;
datasetColumn: DatasetColumn;
field?: FieldWithMetadata;
onSubmitValue: (key: string, value: RowValue) => void;
onChangeValue: (key: string, value: RowValue) => void;
disabled?: boolean;
};
function ModalEditingInput({
isEditingMode,
initialValue,
datasetColumn,
field,
onSubmitValue,
onChangeValue,
disabled,
}: EditingInputWithEventsProps) {
// The difference is that in editing mode we handle change only when value is ready (e.g. on blur)
// whereas in creating mode we handle change immediately to update the form state
const handleValueSubmit = useCallback(
(value: RowValue) => {
if (isEditingMode) {
onSubmitValue(datasetColumn.name, value);
}
},
[isEditingMode, datasetColumn.name, onSubmitValue],
);
const handleValueChange = useCallback(
(value: RowValue) => {
if (!isEditingMode) {
onChangeValue(datasetColumn.name, value);
}
},
[isEditingMode, datasetColumn.name, onChangeValue],
);
return (
<EditingBodyCellConditional
autoFocus={false}
datasetColumn={datasetColumn}
field={field}
initialValue={initialValue ?? null}
onCancel={noop}
onSubmit={handleValueSubmit}
onChangeValue={handleValueChange}
inputProps={{ disabled }}
withTextarea
/>
);
}

View File

@@ -0,0 +1,60 @@
import type { Location } from "history";
import {
type InjectedRouter,
type Route,
type RouterProps,
withRouter,
} from "react-router";
import { t } from "ttag";
import useBeforeUnload from "metabase/hooks/use-before-unload";
import { useConfirmRouteLeaveModal } from "metabase/hooks/use-confirm-route-leave-modal";
import { Button, Group, Modal, Stack, Text } from "metabase/ui";
type UnsavedLeaveConfirmationModalProps = {
router: RouterProps;
isUpdating: boolean;
isDeleting: boolean;
isInserting: boolean;
isLocationAllowed?: (location: Location | undefined) => boolean;
};
export const UnsavedLeaveConfirmationModal = withRouter(
({
router,
isUpdating,
isDeleting,
isInserting,
isLocationAllowed,
}: UnsavedLeaveConfirmationModalProps) => {
const isLoading = isUpdating || isDeleting || isInserting;
const routes = router.routes as Route[];
const currentRoute = routes[routes.length - 1];
useBeforeUnload(isLoading);
const { opened, close, confirm } = useConfirmRouteLeaveModal({
isEnabled: isLoading,
router: router as InjectedRouter,
route: currentRoute,
isLocationAllowed,
});
return (
<Modal
size="md"
title={t`Unsaved changes`}
opened={opened}
onClose={close}
>
<Stack gap="xl">
<Text>{t`Leaving this page may result in losing your changes.`}</Text>
<Group justify="flex-end">
<Button variant="filled" color="danger" onClick={confirm}>
{t`Leave anyway`}
</Button>
</Group>
</Stack>
</Modal>
);
},
);

View File

@@ -0,0 +1,23 @@
import { useMemo } from "react";
import type { DatasetColumn } from "metabase-types/api";
import type { EditableTableColumnConfig } from "../use-editable-column-config";
export function useEditingModalOrderedVisibleDatasetColumns(
datasetColumns: DatasetColumn[],
columnsConfig?: EditableTableColumnConfig,
) {
return useMemo(() => {
if (!columnsConfig?.columnOrder.length) {
return datasetColumns;
}
return columnsConfig.columnOrder
.map((name) => datasetColumns.find((it) => it.name === name))
.filter(
(it): it is DatasetColumn =>
it !== undefined && !columnsConfig.isColumnHidden(it.name),
);
}, [columnsConfig, datasetColumns]);
}

View File

@@ -0,0 +1,42 @@
import { useDisclosure } from "@mantine/hooks";
import type { RowSelectionState } from "@tanstack/react-table";
import { useCallback } from "react";
type UseTableBulkDeleteConfirmationProps = {
selectedRowIndices: number[];
setRowSelection: (state: RowSelectionState) => void;
handleRowDeleteBulk: (rowIndices: number[]) => Promise<boolean>;
};
export function useTableBulkDeleteConfirmation({
selectedRowIndices,
setRowSelection,
handleRowDeleteBulk,
}: UseTableBulkDeleteConfirmationProps) {
const [
isDeleteBulkRequested,
{ open: requestDeleteBulk, close: cancelDeleteBulk },
] = useDisclosure(false);
const handleDeleteBulkConfirmation = useCallback(async () => {
const success = await handleRowDeleteBulk(selectedRowIndices);
if (success) {
setRowSelection({});
}
cancelDeleteBulk();
}, [
cancelDeleteBulk,
handleRowDeleteBulk,
selectedRowIndices,
setRowSelection,
]);
return {
isDeleteBulkRequested,
requestDeleteBulk,
cancelDeleteBulk,
onDeleteBulkConfirmation: handleDeleteBulkConfirmation,
};
}

View File

@@ -0,0 +1,101 @@
import { useCallback, useEffect } from "react";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type { DatasetData } from "metabase-types/api";
import {
type TableEditingModalController,
useTableEditingModalController,
} from "./use-table-modal";
/**
* This is a special object ID used to indicate that the create row modal
* should be opened. It's not a real object ID, but rather a placeholder.
* We use it to keep the logic simple.
*/
const OBJECT_ID_CREATE_ROW = "create";
type UseTableEditingModalControllerWithObjectIdProps = {
currentObjectId?: string;
datasetData: DatasetData | null | undefined;
onObjectIdChange: (objectId?: string) => void;
};
/**
* In contrast to the `useTableEditingModalController`, this hook relies on the
* `currentObjectId` prop to determine which modal to open and when. It's designed
* for editing modals with direct URL access, where the `currentObjectId` is passed
* as a prop. This allows to keep the URL in sync with the modal state as well as
* to support history navigation.
*
* Basically opening a modal triggers a URL change, which triggers `useEffect` hook
* to open the modal based on the current object ID.
*/
export function useTableEditingModalControllerWithObjectId({
currentObjectId,
datasetData,
onObjectIdChange,
}: UseTableEditingModalControllerWithObjectIdProps): TableEditingModalController {
const { state, openCreateRowModal, openEditRowModal, closeModal } =
useTableEditingModalController();
useEffect(() => {
switch (currentObjectId) {
case OBJECT_ID_CREATE_ROW:
openCreateRowModal();
break;
case undefined:
closeModal();
break;
default:
if (datasetData) {
const pkColumnIndex = datasetData.cols.findIndex(isPK);
const rowIndex = datasetData.rows.findIndex(
(row) =>
row[pkColumnIndex] === currentObjectId ||
row[pkColumnIndex] === Number(currentObjectId),
);
if (rowIndex !== -1) {
openEditRowModal(rowIndex);
}
}
}
}, [
currentObjectId,
datasetData,
openCreateRowModal,
openEditRowModal,
closeModal,
]);
const handleOpenEditRowModal = useCallback(
(rowIndex: number) => {
if (!datasetData) {
return;
}
const pkColumnIndex = datasetData.cols.findIndex(isPK);
const objectId = datasetData.rows[rowIndex][pkColumnIndex];
onObjectIdChange(objectId?.toString());
},
[datasetData, onObjectIdChange],
);
const handleOpenCreateRowModal = useCallback(() => {
onObjectIdChange(OBJECT_ID_CREATE_ROW);
}, [onObjectIdChange]);
const handleCloseModal = useCallback(() => {
onObjectIdChange(undefined);
}, [onObjectIdChange]);
return {
state,
openCreateRowModal: handleOpenCreateRowModal,
openEditRowModal: handleOpenEditRowModal,
closeModal: handleCloseModal,
};
}

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