Compare commits

...

234 Commits

Author SHA1 Message Date
Ngoc Khuat
fbd43c7a32 add a test 2025-05-09 16:29:44 +07:00
Ngoc Khuat
93b4ed7c92 a simple queue for system event 2025-05-09 16:22:37 +07: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
431 changed files with 21117 additions and 2774 deletions

View File

@@ -584,6 +584,8 @@
metabase.test/with-user-in-groups clojure.core/let
metabase.test.gentest/defgentest clojure.test/deftest
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

View File

@@ -35,7 +35,7 @@
{:metabase/modules
{actions
{:api #{metabase.actions.api metabase.actions.core}
:uses #{analytics api driver legacy-mbql lib model-persistence models query-processor search settings util}}
:uses #{analytics api driver events legacy-mbql lib model-persistence models query-processor search settings util}}
activity-feed
{:api #{metabase.activity-feed.api metabase.activity-feed.init}
@@ -285,9 +285,8 @@
util}}
events
{:api #{metabase.events.core
metabase.events.init}
:uses #{channel driver models util view-log}}
{:api #{metabase.events.core metabase.events.init}
:uses #{api config channel driver models notification premium-features util view-log}}
formatter
{:api #{metabase.formatter}
@@ -456,6 +455,7 @@
driver
events
models
lib
permissions
premium-features
settings
@@ -463,6 +463,7 @@
request
task
task-history
types
util}}
permissions
@@ -788,15 +789,21 @@
enterprise/core
{:api #{metabase-enterprise.core.init}
:uses :any}
:uses #{enterprise/advanced-config enterprise/cache enterprise/enhancements enterprise/data-editing enterprise/stale}}
enterprise/dashboard-subscription-filters
{:api #{}
:uses #{premium-features}}
enterprise/data-editing
{:api #{metabase-enterprise.data-editing.api}
:uses #{api actions util}}
{: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}}
enterprise/data-editing-public
{:api #{}
:uses #{api actions enterprise/data-editing driver events lib query-processor upload util}}
enterprise/database-routing
{:api #{metabase-enterprise.database-routing.api}

View File

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

@@ -143,3 +143,4 @@ llm-payloads
.aider*
/config.yml
CLAUDE.local.md
.claude/settings.local.json

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.1295"} ; Honey SQL 2. SQL generation from Clojure data maps
@@ -60,8 +60,7 @@
{: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
@@ -841,7 +840,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"
metabase-enterprise.data-editing.notification-test
metabase.dashboard-subscription-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

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

@@ -18,6 +18,7 @@ export * from "./e2e-email-helpers";
export * from "./e2e-embedding-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

@@ -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,96 @@
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("Editing People").should("be.visible");
cy.findByTestId("table-root").should("be.visible");
});
cy.findByTestId("edit-table-data-root")
.findByLabelText("Back to 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

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

@@ -8,5 +8,6 @@
(:require
[metabase-enterprise.advanced-config.init]
[metabase-enterprise.cache.init]
[metabase-enterprise.data-editing.init]
[metabase-enterprise.enhancements.init]
[metabase-enterprise.stale.init]))

View File

@@ -1,38 +1,310 @@
(ns metabase-enterprise.data-editing.api
(:require
[clojure.set :as set]
[metabase-enterprise.data-editing.data-editing :as data-editing]
[metabase-enterprise.data-editing.undo :as undo]
[metabase.actions.core :as actions]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.driver :as driver]
[metabase.models.field-values :as field-values]
[metabase.util :as u]
[metabase.util.i18n :as i18n :refer [tru]]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
[toucan2.core :as t2])
(:import
(clojure.lang ExceptionInfo)))
(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)
;; TODO consider moving this down somewhere generic, like in handle-effects!*
(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 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)))
(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 (data-editing/select-table-pk-fields table-id)]
(data-editing/query-db-rows table-id pk-fields rows)))
(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} :map]]]
(check-permissions)
(let [rows' (data-editing/apply-coercions table-id rows)
scope (or scope {:table-id table-id})
res (data-editing/insert! api/*current-user-id* scope table-id rows')]
{:created-rows (invalidate-and-present! table-id (:created-rows res))}))
(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 scope]} :- [:map
[:rows [:sequential {:min 1} :map]]
;; TODO make this non-optional in the future
[:scope {:optional true} :map]]]
(check-permissions)
(if (empty? rows)
{:updated []}
(let [user-id api/*current-user-id*
scope (or scope {:table-id table-id})
rows (data-editing/apply-coercions table-id rows)
rows (map :after (data-editing/perform-bulk-action! :table.row/update user-id scope table-id rows))]
{:updated (invalidate-and-present! table-id 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} :map]]]
(check-permissions)
(let [user-id api/*current-user-id*
scope (or scope {:table-id table-id})]
(data-editing/perform-bulk-action! :table.row/delete user-id scope table-id 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}))
(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))
(api.macros/defendpoint :post "/undo"
"Undo the last change you made.
For now only supports tables, but in the future will support editables for sure.
Maybe actions, workflows, etc.
Could even generalize to things like edits to dashboard definitions themselves."
[_
_
{:keys [table-id scope no-op]}] :- [:map
;; deprecated, this will be replaced by scope
[:table-id ms/PositiveInt]
;; TODO make this non-optional in the future
[:scope {:optional true} :map]
[:no-op {:optional true} ms/BooleanValue]]
(check-permissions)
(let [user-id api/*current-user-id*
scope (or scope {:table-id table-id})]
(if no-op
{:batch_num (undo/next-batch-num :undo user-id scope)}
;; IDEA encapsulate this in an action
;; IDEA use generic action calling API instead of having this endpoint
(try
{:result (undo/undo! user-id scope)}
(catch ExceptionInfo e
(throw (translate-undo-error e)))))))
(api.macros/defendpoint :post "/redo"
"Redo the last change you made.
For now only supports tables, but in the future will support editables for sure.
Maybe actions, workflows, etc.
Could even generalize to things like edits to dashboard definitions themselves."
[_
_
{:keys [table-id scope no-op]}] :- [:map
;; deprecated, this will be replaced by scope
[:table-id ms/PositiveInt]
;; TODO: make this non-optional in the future
[:scope {:optional true} :map]
[:no-op {:optional true} ms/BooleanValue]]
(check-permissions)
(let [user-id api/*current-user-id*
scope (or scope {:table-id table-id})]
(if no-op
{:batch_num (undo/next-batch-num :redo user-id scope)}
;; IDEA encapsulate this in an action
;; IDEA use generic action calling API instead of having this endpoint
;; TODO translate errors to http codes
(try
{:result (undo/redo! user-id scope)}
(catch ExceptionInfo e
(throw (translate-undo-error e)))))))
(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)
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?)))))
(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]]]
(let [action (-> (actions/select-action :id (parse-long action-id) :archived false)
(t2/hydrate :creator)
api/read-check)
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)
[row] (data-editing/query-db-rows table-id pk-fields [pk])
_ (api/check-404 row)
row-params (->> (:parameters action)
(keep (fn [{:keys [id slug]}]
(when (contains? field-names (or slug id))
[id (row (keyword (or slug id)))])))
(into {}))
param-id (u/index-by (some-fn :slug :id) :id (:parameters action))
provided (update-keys params #(api/check-400 (param-id (name %)) "Unexpected parameter provided"))]
(actions/execute-action! action (merge row-params provided))))
(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,8 @@
(ns metabase-enterprise.data-editing.core
(:require
[metabase-enterprise.data-editing.data-editing :as data-editing]
[potemkin :as p]))
(p/import-vars
[data-editing
insert!])

View File

@@ -0,0 +1,192 @@
(ns metabase-enterprise.data-editing.data-editing
(:require
[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]
[nano-id.core :as nano-id]
[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 k identity))))]
(for [row input-rows]
(m/map-kv-vals coerce row))))
(defn perform-bulk-action!
"Operates on rows in the database, supply an action-kw: :table.row/create, :table.row/update, :table.row/delete.
The input `rows` is given different semantics depending on the action type, see actions/perform-action!."
[action-kw user-id scope table-id rows & {:keys [existing-context]}]
;; TODO make this work for multi instances by using the metabase_cluster_lock table, or something similar
;; https://github.com/metabase/metabase/pull/56173/files
(locking #'perform-bulk-action!
(let [db-id (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))]
(->> (actions/perform-action!
action-kw
scope
(for [row rows]
{:database db-id, :table-id table-id, :row row})
{:policy :data-editing
:existing-context (if existing-context
(assoc existing-context :user-id user-id)
(let [iid (nano-id/nano-id)]
{:invocation-id iid
:invocation-stack [[:grid/edit iid]]
:user-id user-id
:scope scope}))})
:effects
(filter (comp #{:effect/row.modified} first))
(map second)))))
(defn insert!
"Inserts rows into the table, recording their creation as an event. Returns the inserted records.
Expects rows that are acceptable directly by [[actions/perform-action!]]. If casts or reversing coercion strategy
are required, that work must be done before calling this function."
[user-id scope table-id rows]
(api/check-500 user-id)
(let [res (perform-bulk-action! :table.row/create user-id scope table-id rows)]
{:created-rows (map :after res)}))
(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 #{"undo"} namespace 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
:args {:table_id table-id
:db_id db-id
:timestamp (t/zoned-date-time (t/zone-id "UTC"))}}))))))

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,3 @@
(ns metabase-enterprise.data-editing.init
(:require
[metabase-enterprise.data-editing.events]))

View File

@@ -0,0 +1,232 @@
(ns metabase-enterprise.data-editing.undo
(:require
[clojure.walk :as walk]
[metabase-enterprise.data-editing.data-editing :as data-editing]
[metabase.models.interface :as mi]
[metabase.util :as u]
[methodical.core :as methodical]
[nano-id.core :as nano-id]
[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- 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
(->> (or scope "unknown") (walk/postwalk #(if-not (map? %) % (apply sorted-map (apply concat %)))) pr-str)))
(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})
(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 user-id scope batch]
(let [undo? (= :undo undo-or-redo)
action-kw (keyword "undo" (name undo-or-redo))]
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
:let [rows (batch->rows undo? sub-batch)
iid (nano-id/nano-id)
opts {:existing-context {:invocation_id iid
:invocation-stack [[action-kw iid]]}}]]
(case (if undo? (invert category) category)
:create (try (data-editing/perform-bulk-action! :table.row/create user-id scope table-id rows opts)
(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 (data-editing/perform-bulk-action! :table.row/update user-id scope table-id rows opts)
:delete (data-editing/perform-bulk-action! :table.row/delete user-id scope table-id rows opts)))))
(defn- undo*! [undo-or-redo 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 user-id scope batch)
(t2/update! :model/Undo
{:batch_num (:batch_num (first batch))}
{:undone undo?})
(->> (for [[table-id sub-batch] (u/group-by :table_id batch)]
[table-id (map vector
(map (comp (if undo? invert identity) categorize) sub-batch)
(batch->rows undo? sub-batch))])
(into {}))))))
(defn undo!
"Rollback the given user's last change to the given table."
[user-id scope]
(undo*! :undo user-id scope))
(defn redo!
"Rollback the given user's last change to the given table."
[user-id scope]
(undo*! :redo user-id scope))

View File

@@ -0,0 +1,33 @@
(ns metabase-enterprise.data-editing-public.api
(:require
[metabase-enterprise.data-editing.core :as data-editing]
[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.")
;; TODO just call the action directly once we have it, get rid of this `insert!` method.
{:created (count (:created-rows (data-editing/insert! (:creator_id hook)
{:webhook-id (:id hook)}
(:table_id hook)
rows)))}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."
(api.macros/ns-handler *ns*))

View File

@@ -62,6 +62,7 @@
"Notification"
"NotificationCard"
"NotificationSubscription"
"NotificationSystemEvent"
"NotificationHandler"
"NotificationRecipient"
"Permissions"
@@ -86,6 +87,7 @@
"Secret"
"Session"
"TaskHistory"
"Undo"
"User"
"UserKeyValue"
"UserParameterValue"

View File

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

View File

@@ -1,13 +1,20 @@
(ns metabase-enterprise.data-editing.api-test
(:require
[clojure.set :as set]
[clojure.test :refer :all]
[metabase-enterprise.data-editing.api :as data-editing.api]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[metabase.driver :as driver]
[metabase.driver.sql-jdbc :as sql-jdbc]
[metabase.models.field-values :as field-values]
[metabase.query-processor :as qp]
[metabase.sync.core :as sync]
[metabase.test :as mt]
[metabase.util :as u]
[metabase.util.json :as json]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn- table-rows [table-id]
(mt/rows
(qp/process-query
@@ -18,74 +25,666 @@
(defn- table-url [table-id]
(format "ee/data-editing/table/%d" table-id))
(use-fixtures :each
(fn [f]
(mt/with-dynamic-fn-redefs [data-editing.api/require-authz? (constantly true)]
(f))))
(deftest feature-flag-required-test
(mt/with-premium-features #{}
(let [url (table-url 1)]
(let [url (data-editing.tu/table-url 1)]
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :post 402 url))
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :put 402 url))
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :delete 402 url)))))
(mt/assert-has-premium-feature-error "Editing Table Data" (mt/user-http-request :crowberto :post 402 (str url "/delete"))))))
(deftest table-operations-test
(mt/with-premium-features #{:table-data-editing}
(mt/with-empty-h2-app-db
(let [db (t2/select-one :model/Database (mt/id))
driver :h2
table-name (str "temp_table_" (u/lower-case-en (random-uuid)))]
(mt/test-drivers #{:h2 :postgres}
(with-open [table-ref (data-editing.tu/open-test-table!)]
(let [table-id @table-ref
url (data-editing.tu/table-url table-id)]
(data-editing.tu/toggle-data-editing-enabled! true)
(testing "Initially the table is empty"
(is (= [] (table-rows table-id))))
(testing "POST should insert new rows"
(is (= {:created-rows [{:id 1 :name "Pidgey" :song "Car alarms"}
{:id 2 :name "Spearow" :song "Hold music"}
{:id 3 :name "Farfetch'd" :song "The land of lisp"}]}
(mt/user-http-request :crowberto :post 200 url
{:rows [{:name "Pidgey" :song "Car alarms"}
{:name "Spearow" :song "Hold music"}
{:name "Farfetch'd" :song "The land of lisp"}]})))
(is (= [[1 "Pidgey" "Car alarms"]
[2 "Spearow" "Hold music"]
[3 "Farfetch'd" "The land of lisp"]]
(table-rows table-id))))
(testing "PUT should update the relevant rows and columns"
(is (= #{{:id 1, :name "Pidgey", :song "Join us now and share the software"}
{:id 2, :name "Speacolumn", :song "Hold music"}}
(set
(:updated
(mt/user-http-request :crowberto :put 200 url
{:rows [{:id 1 :song "Join us now and share the software"}
{:id 2 :name "Speacolumn"}]})))))
(is (= #{[1 "Pidgey" "Join us now and share the software"]
[2 "Speacolumn" "Hold music"]
[3 "Farfetch'd" "The land of lisp"]}
(set (table-rows table-id)))))
(testing "DELETE should remove the corresponding rows"
(is (= {:success true}
(mt/user-http-request :crowberto :post 200 (str url "/delete")
{:rows [{:id 1}
{:id 2}]})))
(is (= [[3 "Farfetch'd" "The land of lisp"]]
(table-rows table-id)))))))))
(deftest editing-allowed-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(testing "40x returned if user/database not configured for editing"
(let [test-endpoints
(fn [flags]
(with-open [table-ref (data-editing.tu/open-test-table!)]
(let [actions-enabled (:a flags)
editing-enabled (:d flags)
superuser (:s flags)
url (data-editing.tu/table-url @table-ref)
settings {:database-enable-table-editing (boolean editing-enabled)
:database-enable-actions (boolean actions-enabled)}
_ (data-editing.tu/alter-appdb-settings! merge settings)
user (if superuser :crowberto :rasta)
req mt/user-http-request-full-response
post-response
(req user :post url {:rows [{:name "Pidgey" :song "Car alarms"}]})
put-response
(req user :put url {:rows [{:id 1 :song "Join us now and share the software"}]})
del-response
(req user :post (str url "/delete") {:rows [{:id 1}]})]
{:settings settings
:user user
:responses {:create post-response
:update put-response
:delete del-response}})))
error-or-ok
(fn [{:keys [status body]}]
(if (<= 200 status 299)
:ok
[(:message body body) status]))
;; Shorthand config notation
;; :a == action-editing should not affect result
;; :d == data-editing only allowed to edit if editing enabled
;; :s == super-user only allowed to edit if a superuser
tests
[#{:a} ["You don't have permissions to do that." 403]
#{:d} ["You don't have permissions to do that." 403]
#{:a :d} ["You don't have permissions to do that." 403]
#{:s} ["Data editing is not enabled." 400]
#{:s :a} ["Data editing is not enabled." 400]
#{:s :d} :ok
#{:s :a :d} :ok]]
(doseq [[flags expected] (partition 2 tests)
:let [{:keys [settings user responses]} (test-endpoints flags)]
[verb response] responses]
(testing (format "%s user: %s, settings: %s" verb user settings)
(is (= expected (error-or-ok response))))))))))
(deftest create-table-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(let [run-example
(fn [flags req-body]
(let [{table-name-prefix :name} req-body
table-name (str table-name-prefix "_" (System/currentTimeMillis))
req-body' (u/update-if-exists req-body :name (constantly table-name))
db-id (mt/id)
driver driver/*driver*
editing-enabled (:d flags)
superuser (:s flags)
_ (data-editing.tu/toggle-data-editing-enabled! editing-enabled)
user (if superuser :crowberto :rasta)
url (format "ee/data-editing/database/%d/table" db-id)
res (delay (mt/user-http-request-full-response user :post url req-body'))
cleanup! #(try (driver/drop-table! driver db-id table-name) (catch Exception _))
describe-table
(fn []
(-> (driver/describe-table driver (t2/select-one :model/Database db-id) {:name table-name})
(update :name {table-name table-name-prefix})
(update :fields #(sort-by :name (for [f %] (select-keys f [:name :base-type :pk?]))))))]
(try
(if (<= 200 (:status @res) 299)
(merge
{:status 200}
(describe-table))
(:status @res))
(finally
(cleanup!)))))]
(are [flags req-body expected]
(= expected (run-example flags req-body))
#{:s :d}
{}
400
#{:s :d}
{:name "a"}
400
#{:s :d}
{:name "a"
:columns [[{:name "id", :type "int"}]]}
400
#{:s :d}
{:name "a"
:columns [{:name "id", :type "int"}
{:name "name", :type "int"}]
:primary_key ["id"]}
;; =>
{:status 200
:name "a"
:fields [{:name "id"
:base-type :type/BigInteger
:pk? true}
{:name "name"
:base-type :type/BigInteger}]}
#{:s :d}
{:name "a"
:columns [{:name "id", :type "not-a-type"}]
:primary_key ["id"]}
;; =>
400
;; escaped quotes are not allowed for now
#{:s :d}
{:name "a\""
:columns [{:name "id", :type "int"}]
:primary_key ["id"]}
400
#{:s :d}
{:name "a`"
:columns [{:name "id", :type "int"}]
:primary_key ["id"]}
400
;; underscores, dashes, spaces allowed
#{:s :d}
{:name "a_b1 -"
:columns [{:name "id", :type "int"}]
:primary_key ["id"]}
;; =>
{:status 200
:name "a_b1 -"
:fields [{:name "id"
:base-type :type/BigInteger
:pk? true}]}
;; if not admin, denied
#{:d}
{:name "a"
:columns [{:name "id", :type "int"}]
:primary_key ["id"]}
403
;; data editing disabled, denied
#{:s}
{:name "a"
:columns [{:name "id", :type "int"}]
:primary_key ["id"]}
400
;; compound pk
#{:s :d}
{:name "a"
:columns [{:name "id_p1", :type "int"}
{:name "id_p2", :type "int"}]
:primary_key ["id_p1" "id_p2"]}
;; =>
{:status 200
:name "a"
:fields [{:name "id_p1"
:base-type :type/BigInteger
:pk? true}
{:name "id_p2"
:base-type :type/BigInteger
:pk? true}]})))))
(deftest create-table-auto-inc-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/toggle-data-editing-enabled! true)
(let [db-id (mt/id)
url (format "ee/data-editing/database/%d/table" db-id)
user :crowberto
table-name (str "test_table_" (System/currentTimeMillis))
req-body {:name table-name
:columns [{:name "id", :type "auto_incrementing_int_pk"}
{:name "n", :type "int"}]
:primary_key ["id"]}
_ (mt/user-http-request user :post 200 url req-body)
db (t2/select-one :model/Database db-id)
table-id (data-editing.tu/sync-new-table! db table-name)
create! #(mt/user-http-request user :post 200 (table-url table-id) {:rows %})]
(create! [{:n 1} {:n 2}])
(is (= [[1 1] [2 2]] (table-rows table-id)))))))
(deftest coercion-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/toggle-data-editing-enabled! true)
(let [user :crowberto
req mt/user-http-request
create!
#(req user :post (data-editing.tu/table-url %1) {:rows %2})
update!
#(req user :put (data-editing.tu/table-url %1) {:rows %2})
always-lossy
#{:Coercion/UNIXNanoSeconds->DateTime
:Coercion/UNIXMicroSeconds->DateTime
:Coercion/ISO8601->Date
:Coercion/ISO8601->Time}
driver-lossy
(case driver/*driver*
:postgres #{:Coercion/UNIXMilliSeconds->DateTime}
#{})
lossy? (set/union always-lossy driver-lossy)
do-test
(fn [t coercion-strategy input expected]
(testing (str t " " coercion-strategy " " input)
(with-open [table (data-editing.tu/open-test-table!
{:id 'auto-inc-type
:o [t :null]}
{:primary-key [:id]})]
(let [table-id @table
table-name-kw (t2/select-one-fn (comp keyword :name) [:model/Table :name] table-id)
field-id (t2/select-one-fn :id [:model/Field :id] :table_id table-id :name "o")
driver driver/*driver*
get-qp-state (fn [] (map #(zipmap [:id :o] %) (table-rows table-id)))
get-db-state (fn [] (sql-jdbc/query driver (mt/id) {:select [:*] :from [table-name-kw]}))]
(t2/update! :model/Field field-id {:coercion_strategy coercion-strategy})
(testing "create"
(let [row {:o input}
{returned-state :created-rows} (create! table-id [row])
qp-state (get-qp-state)
_ (is (= 1 (count returned-state)))]
(when-not (lossy? coercion-strategy)
(is (= qp-state returned-state) "we should return the same coerced output that table/$table-id/data would return")
(is (= input (:o (first qp-state))) "the qp value should be the same as the input"))
(is (= expected (:o (first (get-db-state)))))))
(testing "update"
(let [[{id :id}] (:created-rows (create! table-id [{:o nil}]))
_ (is (some? id))
{returned-state :updated} (update! table-id [{:id id, :o input}])
[qp-row] (filter (comp #{id} :id) (get-qp-state))]
(is (= 1 (count returned-state)))
(is (some? qp-row))
(when-not (lossy? coercion-strategy)
(is (= [qp-row] returned-state))
(is (= input (:o qp-row))))
(is (= expected (:o (first (get-db-state)))))))))))]
;; type coercion input database
(->> [:text nil "a" "a"
:text :Coercion/YYYYMMDDHHMMSSString->Temporal "2025-03-25T14:34:00Z" "20250325143400"
:text :Coercion/ISO8601->DateTime "2025-03-25T14:34:42.314Z" "2025-03-25T14:34:42.314Z"
:text :Coercion/ISO8601->Date "2025-03-25T00:00:00Z" "2025-03-25"
:text :Coercion/ISO8601->Time "1999-04-05T14:34:42Z" "14:34:42"
;; note fractional seconds in input, remains undefined for Seconds
:int :Coercion/UNIXSeconds->DateTime "2025-03-25T14:34:42Z" (quot (inst-ms #inst "2025-03-25T14:34:42Z") 1000)
:bigint :Coercion/UNIXMilliSeconds->DateTime "2025-03-25T14:34:42.314Z" (inst-ms #inst "2025-03-25T14:34:42.314Z")
;; note fractional secs beyond millis are discarded (lossy)
:bigint :Coercion/UNIXMicroSeconds->DateTime "2025-03-25T14:34:42.314121Z" (* (inst-ms #inst "2025-03-25T14:34:42.314Z") 1000)
:bigint :Coercion/UNIXNanoSeconds->DateTime "2025-03-25T14:34:42.3141212Z" (* (inst-ms #inst "2025-03-25T14:34:42.314Z") 1000000)
;; nil safe
:text :Coercion/YYYYMMDDHHMMSSString->Temporal nil nil
;; seconds component does not work properly here, lost by qp output, bug in existing code?
#_#_#_#_:text :Coercion/YYYYMMDDHHMMSSString->Temporal "2025-03-25T14:34:42Z" "20250325143442"]
(partition 4)
(run! #(apply do-test %)))))))
(deftest webhook-creation-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(with-open [test-table (data-editing.tu/open-test-table!)]
(let [url "ee/data-editing/webhook"
req #(mt/user-http-request-full-response %1 :post url %2)
status (comp :status req)
table-id @test-table
not-a-table-id Long/MAX_VALUE]
(testing "auth fail"
(is (= 403 (status :rasta {})))
(is (= 403 (status :rasta {:table-id table-id})) "no information leakage"))
(testing "creates token"
(let [token (:token (:body (req :crowberto {:table-id table-id})))]
(is (string? token))
(testing "token in database"
(is (some? (t2/select-one :table_webhook_token :token token))))
(testing "new token if called again"
(is (not= token (:token (req :crowberto {:table-id table-id}))))
(testing "table does not exist"
(is (= 404 (status :crowberto {:table-id not-a-table-id}))))))))))))
(deftest webhook-list-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(with-open [test-table1 (data-editing.tu/open-test-table!)
test-table2 (data-editing.tu/open-test-table!)]
(let [url "ee/data-editing/webhook"
req #(mt/user-http-request-full-response %1 :get url :table-id %2)
create-url "ee/data-editing/webhook"
create #(:body (mt/user-http-request-full-response :crowberto :post create-url {:table-id %}))
status (comp :status req)
table-id1 @test-table1
table-id2 @test-table2
not-a-table-id Long/MAX_VALUE]
(testing "auth fail"
(is (= 403 (status :rasta table-id1)))
(is (= 403 (status :rasta not-a-table-id)) "no information leakage"))
(testing "table does not exist"
(is (= 404 (status :crowberto not-a-table-id))))
(testing "no tokens"
(is (= [] (:tokens (:body (req :crowberto table-id1))))))
(testing "n tokens"
(let [{token1 :token} (create table-id1)
{token2 :token} (create table-id2)
{token3 :token} (create table-id1)
table1-res (:body (req :crowberto table-id1))
table2-res (:body (req :crowberto table-id2))
table1-tokens (map :token (:tokens table1-res))
table2-tokens (map :token (:tokens table2-res))]
(is (= {token1 1 token3 1} (frequencies table1-tokens)))
(is (= [token2] table2-tokens)))))))))
(deftest webhook-delete-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(with-open [test-table (data-editing.tu/open-test-table!)]
(let [url #(format "ee/data-editing/webhook/%s" %)
req #(mt/user-http-request-full-response %1 :delete (url %2) {})
create-url "ee/data-editing/webhook"
create #(:body (mt/user-http-request-full-response :crowberto :post create-url {:table-id %}))
list-url "ee/data-editing/webhook"
list-tokens #(:body (mt/user-http-request-full-response :crowberto :get list-url :table-id %))
status (comp :status req)
table-id @test-table
{token :token} (create table-id)
not-a-token (str (random-uuid))]
(testing "auth fail"
(is (= 403 (status :rasta token)))
(is (= 403 (status :rasta not-a-token)) "no information leakage"))
(testing "token does not exist"
(is (= 404 (status :crowberto not-a-token))))
(testing "token does exist"
(is (some #{token} (map :token (:tokens (list-tokens table-id)))))
(is (= 200 (status :crowberto token)))
(is (not-any? #{token} (map :token (:tokens (list-tokens table-id))))))
(testing "token does not exist when deleted"
(is (= 404 (status :crowberto token)))))))))
(deftest webhook-ingest-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/toggle-data-editing-enabled! true)
(with-open [test-table (data-editing.tu/open-test-table!
{:id [:int]
:v [:text]}
{:primary-key [:id]})]
(let [url #(format "ee/data-editing-public/webhook/%s/data" %)
req #(mt/client-full-response
:post (url %1)
{:request-options {:body (.getBytes (json/encode %2))}})
status (comp :status req)
result (comp
(fn [req]
(is (= 200 (:status req)))
(:body req))
req)
create-url "ee/data-editing/webhook"
create #(:body (mt/user-http-request-full-response :crowberto :post create-url {:table-id %}))
delete-url #(format "ee/data-editing/webhook/%s" %)
delete #(mt/user-http-request :crowberto :delete (delete-url %))
table-id @test-table
{token :token} (create table-id)
not-a-token (str (random-uuid))]
(testing "token does not exist"
(is (= 404 (status not-a-token [{:v "foo"}]))))
(testing "empty rows"
(are [input code]
(= code (status token input))
nil 400
{} 400
[] 400
[{}] 400))
(testing "one row in array"
(is (= {:created 1} (result token [{:id 1, :v "a"}])))
(is (= [[1 "a"]] (table-rows table-id))))
(testing "multiple rows in array"
(is (= {:created 2} (result token [{:id 2, :v "b"} {:id 3, :v "c"}])))
(is (= [[1 "a"] [2 "b"] [3 "c"]] (table-rows table-id))))
(testing "missing pk"
(is (= 400 (status token [{:v "d"}]))))
(testing "insert collision"
(is (= 400 (status token [{:id 1, :v "a"}])))
(testing "partial failure"
(let [rows-before (table-rows table-id)]
(is (= 400 (status token [{:id 4, :v "d"} {:id 1, :v "a"}])))
(is (= rows-before (table-rows table-id))))))
(testing "wrong columns"
(is (= 400 (status token [{:id 1, :not_a_column "a"}]))))
(testing "data editing disabled"
(try
(data-editing.tu/toggle-data-editing-enabled! false)
(is (= 400 (status token [{:id 4, :v "d"}])))
(data-editing.tu/toggle-data-editing-enabled! true)
(is (= {:created 1} (result token [{:id 4, :v "d"}])))
(finally
(data-editing.tu/toggle-data-editing-enabled! true))))
(testing "token deleted"
(delete token)
(is (= 404 (status token [{:id 5, :v "e"}])))))))))
;; It would be nice to have for-all config/inputs type tests verifying
;; insert behaviour is same as the POST data-editing/table inserts (collision, violation error, event)
(deftest field-values-invalidated-test
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/toggle-data-editing-enabled! true)
(with-open [table (data-editing.tu/open-test-table! {:id 'auto-inc-type, :n [:text]} {:primary-key [:id]})]
(let [table-id @table
url (data-editing.tu/table-url table-id)
field-id (t2/select-one-fn :id :model/Field :table_id table-id :name "n")
_ (t2/update! :model/Field {:id field-id} {:semantic_type "type/Category"})
field-values #(vec (:values (field-values/get-latest-full-field-values field-id)))
create! #(mt/user-http-request :crowberto :post 200 url {:rows %})
update! #(mt/user-http-request :crowberto :put 200 url {:rows %})]
(is (= [] (field-values)))
(create! [{:n "a"}])
;; It's async
(Thread/sleep 10)
(is (= ["a"] (field-values)))
(create! [{:n "b"} {:n "c"}])
;; It's async
(Thread/sleep 10)
(is (= ["a" "b" "c"] (field-values)))
(update! [{:id 2, :n "d"}])
;; It's async
(Thread/sleep 10)
(is (= ["a" "c" "d"] (field-values)))
(create! [{:n "a"}])
(update! [{:id 1, :n "e"}])
;; It's async
(Thread/sleep 10)
(is (= ["a" "c" "d" "e"] (field-values))))))))
(deftest get-row-action-test
(let [url #(format "ee/data-editing/row-action/%s" %)
action-api #(mt/user-http-request :crowberto :get 200 (format "action/%s" %))]
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(testing "no dashcard"
(mt/with-temp [:model/Card model {:type :model}
:model/Action action {:type :query
:name "test_action"
:model_id (:id model)}]
(testing "not specified"
(is (= 400 (:status (mt/user-http-request-full-response :crowberto :get (url (:id action)))))))
(testing "specified but does not exist"
(is (= 404 (:status (mt/user-http-request-full-response :crowberto :get (url (:id action)) :dashcard-id 9999999)))))))
(testing "no action"
(mt/with-temp [:model/Dashboard dash {}
:model/DashboardCard dashcard {:dashboard_id (:id dash)}]
(is (= 404 (:status (mt/user-http-request-full-response :crowberto :get (url 99999999) :dashcard-id (:id dashcard)))))
(testing "no dashcard still results in 400"
(is (= 400 (:status (mt/user-http-request-full-response :crowberto :get (url 9999999))))))))
(testing "everything exists, no params defined"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Table table {}
:model/Card model {:type :model
:table_id (:id table)}
:model/Action action {:type :query
:name "test_action"
:model_id (:id model)}
:model/Dashboard dash {}
:model/DashboardCard dashcard {:dashboard_id (:id dash)
:card_id (:id model)}]
(testing "no access"
(is (= 403 (:status (mt/user-http-request-full-response :rasta :get (url (:id action)) :dashcard-id (:id dashcard))))))
(testing "action access"
(is (= {:status 200
:body (action-api (:id action))}
(-> (mt/user-http-request-full-response :crowberto :get (url (:id action)) :dashcard-id (:id dashcard))
(select-keys [:status
:body]))))))))
(testing "parameters defined"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Table table {}
:model/Field _a {:table_id (:id table)
:name "a"}
:model/Field _b {:table_id (:id table)
:name "b"}
:model/Field _c {:table_id (:id table)
:name "c"}
:model/Card model {:type :model
:table_id (:id table)}
:model/Action action {:type :query
:name "test_action"
:model_id (:id model)
:parameters [{:slug "a"}
{:slug "e"}
{:slug "c"}
{:slug "d"}]}
:model/Dashboard dash {}
:model/DashboardCard dashcard {:dashboard_id (:id dash)
:card_id (:id model)}]
(let [{:keys [status, body]} (mt/user-http-request-full-response :crowberto :get (url (:id action)) :dashcard-id (:id dashcard))]
(is (= 200 status))
(is (= ["e" "d"]
(->> body
:parameters
(map :slug))))))))))))
(deftest row-action-execute-test
(let [url #(format "ee/data-editing/row-action/%s/execute" %)
req #(apply mt/user-http-request-full-response
(:user %)
:post
(url (:action-id %))
(:body %)
(mapcat identity (:query-params %)))]
(mt/with-premium-features #{:table-data-editing}
(mt/test-drivers #{:h2 :postgres}
(data-editing.tu/toggle-data-editing-enabled! true)
(mt/with-actions-enabled
(try
(let [_ (driver/create-table! driver
(mt/id)
table-name
{:id (driver/upload-type->database-type driver :metabase.upload/auto-incrementing-int-pk)
:name [:text]
:song [:text]}
{:primary-key [:id]})
table (sync/create-table! db
{:name table-name
:schema nil
:display_name table-name})
_ (sync/sync-fields-for-table! db table)
table-id (:id table)
url (table-url table-id)]
(testing "no dashcard"
(mt/with-temp [:model/Card model {:type :model}
:model/Action action {:type :query
:name "test_action"
:model_id (:id model)}]
(testing "not specified"
(is (= 400 (:status (req {:user :crowberto, :action-id (:id action), :body {:pk {}, :params {}}})))))
(testing "specified but does not exist"
(is (= 404 (:status (req {:user :crowberto
:action-id (:id action)
:body {:pk {}, :params {}}
:query-params {:dashcard-id 999999}})))))))
(testing "no action"
(mt/with-temp [:model/Dashboard dash {}
:model/DashboardCard dashcard {:dashboard_id (:id dash)}]
(is (= 404 (:status (req {:user :crowberto
:post 999999
:body {:pk {}, :params {}}
:query-params {:dashcard-id (:id dashcard)}}))))
(testing "no dashcard still results in 400"
(is (= 400 (:status (req {:user :crowberto, :post 999999, :body {:pk {}, :params {}}})))))))
(mt/with-non-admin-groups-no-root-collection-perms
(with-open [test-table (data-editing.tu/open-test-table! {:id 'auto-inc-type
:name [:text]
:status [:text]}
{:primary-key [:id]})]
(mt/with-temp [:model/Card model {:type :model
:table_id @test-table
:database_id (mt/id)
:dataset_query {:database (mt/id)
:type :query
:query {:source-table @test-table}}}
:model/Action action {:type :implicit
:name "update"
:model_id (:id model)
:parameters [{:id "a"
:name "Id"
:slug "id"}
{:id "b"
:name "Name"
:slug "name"}
{:id "c"
:name "Status"
:slug "status"}]}
(testing "Initially the table is empty"
(is (= [] (table-rows table-id))))
(testing "POST should insert new rows"
(is (= {:created-rows [{:id 1 :name "Pidgey" :song "Car alarms"}
{:id 2 :name "Spearow" :song "Hold music"}
{:id 3 :name "Farfetch'd" :song "The land of lisp"}]}
(mt/user-http-request :crowberto :post 200 url
{:rows [{:name "Pidgey" :song "Car alarms"}
{:name "Spearow" :song "Hold music"}
{:name "Farfetch'd" :song "The land of lisp"}]})))
(is (= [[1 "Pidgey" "Car alarms"]
[2 "Spearow" "Hold music"]
[3 "Farfetch'd" "The land of lisp"]]
(table-rows table-id))))
(testing "PUT should update the relevant rows and columns"
(is (= {:rows-updated 2}
(mt/user-http-request :crowberto :put 200 url
{:rows [{:id 1 :song "Join us now and share the software"}
{:id 2 :name "Speacolumn"}]})))
(is (= [[1 "Pidgey" "Join us now and share the software"]
[2 "Speacolumn" "Hold music"]
[3 "Farfetch'd" "The land of lisp"]]
(table-rows table-id))))
(testing "DELETE should remove the corresponding rows"
(is (= {:success true}
(mt/user-http-request :crowberto :delete 200 url
{:rows [{:id 1}
{:id 2}]})))
(is (= [[3 "Farfetch'd" "The land of lisp"]]
(table-rows table-id)))))
(finally
(try
(driver/drop-table! driver (mt/id) table-name)
(catch Exception _)))))))))
:model/ImplicitAction _ {:action_id (:id action)
:kind "row/update"}
:model/Dashboard dash {}
:model/DashboardCard dashcard {:dashboard_id (:id dash)
:card_id (:id model)}]
(testing "no access to the model"
(is (= 403 (:status (req {:user :rasta
:action-id (:id action)
:body {:pk {:id 1}
:params {:status "approved"}}
:query-params {:dashcard-id (:id dashcard)}})))))
(testing "row does not exist, action not executed"
(is (= 404 (:status (req {:user :crowberto
:action-id (:id action)
:body {:pk {:id 1}
:params {:status "approved"}}
:query-params {:dashcard-id (:id dashcard)}})))))
(testing "row exists, action executed"
(mt/user-http-request :crowberto :post 200 (data-editing.tu/table-url @test-table)
{:rows [{:name "Widgets", :status "waiting"}]})
(is (= {:status 200
:body {:rows-updated 1}}
(-> (req {:user :crowberto
:action-id (:id action)
:body {:pk {:id 1}
:params {:status "approved"}}
:query-params {:dashcard-id (:id dashcard)}})
(select-keys [:status :body])))))))))))))

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,407 @@
(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.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 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 "*Crowberto Corv has created a row for CATEGORIES*\n*Created row:*\n• ID : 76\n• NAME : New Category"}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has a new row"
:body [{"Crowberto Corv has created a row for CATEGORIES" true
"NAME: New Category" true}]}
(mt/summarize-multipart-single-email
email
#"Crowberto Corv has created a row for CATEGORIES"
#"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 "*Crowberto Corv has updated a row from CATEGORIES*\n*Update:*\n• ID : 1\n• NAME : Updated Category"}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has been updated"
:body [{"Crowberto Corv has updated a row in CATEGORIES" true
"NAME: Updated Category" true}]}
(mt/summarize-multipart-single-email
email
#"Crowberto Corv has updated a row in CATEGORIES"
#"NAME: Updated Category"))))
: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 "*Crowberto Corv has deleted a row from CATEGORIES*\n*Deleted row:*\n• ID : 1\n• NAME : African"}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has a row deleted"
:body [{"Crowberto Corv has deleted a row from CATEGORIES" true
"NAME: African" true}]}
(mt/summarize-multipart-single-email
email
#"Crowberto Corv has deleted a row from CATEGORIES"
#"NAME: African"))))
:channel/http (fn [[req :as reqs]]
(is (= 1 (count reqs)))
(is (=? {:body (mt/malli=? :map)} req)))}))
(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: ID,ADDRESS,EMAIL,PASSWORD,NAME,CITY,LONGITUDE,STATE,SOURCE,BIRTH_DATE,ZIP,LATITUDE,CREATED_AT,")
: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: ADDRESS,BIRTH_DATE,CITY,CREATED_AT,EMAIL,ID,LATITUDE,LONGITUDE,NAME,PASSWORD,SOURCE,STATE,ZIP,")
: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 "*Crowberto Corv has created a row for CATEGORIES*\n*Created row:*\n• ID : 76\n• NAME : New Category"}}]
:channel "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has a new row"
:body [{"Crowberto Corv has created a row for CATEGORIES" true
"NAME: New Category" true}]}
(mt/summarize-multipart-single-email
email
#"Crowberto Corv has created a row for CATEGORIES"
#"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
(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 %)))])}
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
{:payload_type :notification/system-event
:payload {:event_name :event/row.created
:table_id (mt/id :orders)}
:creator_id (mt/user->id :crowberto)})))))
(deftest example-payload-row-update-test
(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 %)))])}
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
{:payload_type :notification/system-event
:payload {:event_name :event/row.updated
:table_id (mt/id :orders)}
:creator_id (mt/user->id :crowberto)})))))
(deftest example-payload-row-delete-test
(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 %)))])}
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
{:payload_type :notification/system-event
:payload {:event_name :event/row.deleted
:table_id (mt/id :orders)}
:creator_id (mt/user->id :crowberto)})))))
(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-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"}
: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 (=? {:message "Value does not match schema"
:data {:error (mt/malli=? :map)}}
(mt/user-http-request :crowberto :post 500 "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,385 @@
(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]
(:batch_num (mt/user-http-request user-id :post 200 (str "/ee/data-editing/" (name undo-or-redo))
{:scope scope
:no-op true})))
(defn- fast-reload-response [response]
(if-let [table-id->diffs (:result response)]
(update-vals table-id->diffs #(mapv (fn [diff] (update (vec diff) 0 keyword)) %))
response))
(defn- undo-via-api! [user-id scope]
(fast-reload-response
(mt/user-http-request user-id :post "/ee/data-editing/undo" {:scope scope})))
(defn- redo-via-api! [user-id scope]
(fast-reload-response
(mt/user-http-request user-id :post "/ee/data-editing/redo" {:scope scope})))
(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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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 (= {table-id [[:create {: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 (= {table-id [[:update {: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 (= {table-id [[:update {: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 (= {table-id [[:delete {: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)
test-scope {:shared true}]
(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
test-scope
{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
test-scope
{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)
test-scope
{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)
test-scope
{(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

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

@@ -8,6 +8,7 @@ export * from "./metabot";
export * from "./gdrive";
export * from "./db-routing";
export * from "./query-validation";
export * from "./table-data-edit";
export * from "./scim";
export * from "./tags";
export * from "./upload-management";

View File

@@ -0,0 +1,69 @@
import type {
TableDeleteRowsRequest,
TableDeleteRowsResponse,
TableInsertRowsRequest,
TableInsertRowsResponse,
TableUndoRedoRequest,
TableUndoRedoResponse,
TableUpdateRowsRequest,
TableUpdateRowsResponse,
} from "metabase-enterprise/data_editing/tables/types";
import { EnterpriseApi } from "./api";
export const tableDataEditApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
insertTableRows: builder.mutation<
TableInsertRowsResponse,
TableInsertRowsRequest
>({
query: ({ tableId, rows, scope }) => ({
method: "POST",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows, scope },
}),
}),
updateTableRows: builder.mutation<
TableUpdateRowsResponse,
TableUpdateRowsRequest
>({
query: ({ tableId, rows, scope }) => ({
method: "PUT",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows, scope },
}),
}),
deleteTableRows: builder.mutation<
TableDeleteRowsResponse,
TableDeleteRowsRequest
>({
query: ({ tableId, rows, scope }) => ({
method: "POST",
url: `/api/ee/data-editing/table/${tableId}/delete`,
body: { rows, scope },
}),
}),
tableUndo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
query: ({ tableId, scope, noOp }) => ({
method: "POST",
url: `/api/ee/data-editing/undo`,
body: { "table-id": tableId, scope, "no-op": noOp },
}),
}),
tableRedo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
query: ({ tableId, scope, noOp }) => ({
method: "POST",
url: `/api/ee/data-editing/redo`,
body: { "table-id": tableId, scope, "no-op": noOp },
}),
}),
}),
});
export const {
useInsertTableRowsMutation,
useUpdateTableRowsMutation,
useDeleteTableRowsMutation,
useTableUndoMutation,
useTableRedoMutation,
} = tableDataEditApi;

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,628 @@
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 = requestBody?.handlers
? requestBody.handlers
.map((h) => h.channel_type)
.filter((type): type is NotificationChannelType => !!type)
: [];
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
? {
payload_type: requestBody.payload_type,
payload: requestBody.payload,
creator_id: user.id,
}
: 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
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}
>
{isEditMode ? t`Update` : t`Create`}
</ButtonWithStatus>
</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}
confirmButtonPrimary={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,764 @@
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 {
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,
NotificationTriggerEvent,
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: "" }}
/>
</>
)}
</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,
conditions,
logicalOperator,
eventType,
);
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[2]
: 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[3] 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[3] 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,
conditions?: ConditionRow[],
logicalOperator?: LogicalOperator,
eventType?: NotificationTriggerEvent,
): ConditionalAlertExpression {
const baseExpression: ConditionalAlertExpression = [
"=",
["context", "table_id"],
tableId,
];
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",
"row_change",
eventType === "event/row.deleted" ? "before" : "after",
];
if (validConditions.length === 1) {
const condition = validConditions[0];
return [
"and",
baseExpression,
[
condition.operator as ComparisonOperator,
[...payloadPath, condition.columnName],
condition.value,
] as ConditionalAlertExpression,
];
}
return [
"and",
baseExpression,
[
// @ts-expect-error Write proper typing for Expression
logicalOperator,
...validConditions.map(
(condition) =>
[
condition.operator as ComparisonOperator,
[...payloadPath, condition.columnName],
condition.value,
] as ConditionalAlertExpression,
),
],
];
}
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[2]);
}
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,56 @@
.root {
position: absolute;
top: 0;
right: 0;
height: 100%;
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 {
overflow-y: auto;
flex-grow: 1;
padding: 0;
}
.previewStack {
background: var(--mb-color-bg-white);
width: 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;
}

View File

@@ -0,0 +1,110 @@
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" h="100%" gap="md" className={S.root}>
<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 && (
<Text c="error">
{t`Error during generation of template preview. Check your template.
We will provide better error messages soon 🙏.`}
{/* TODO: Un-comment this line when BE return human-readable error without stack trace */}
{/* {JSON.stringify(error)} */}
</Text>
)}
{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>
<Box>
{htmlContent ? (
<div dangerouslySetInnerHTML={{ __html: htmlContent }} />
) : (
<Text c="text-medium">{t`(No body content)`}</Text>
)}
</Box>
</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,185 @@
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
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,305 @@
import cx from "classnames";
import { useCallback, useMemo } from "react";
import { push } from "react-router-redux";
import { useLocation } from "react-use";
import { t } from "ttag";
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 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,
DatasetData,
VisualizationSettings,
} from "metabase-types/api";
import S from "./EditTableData.module.css";
import { EditTableDataGrid } from "./EditTableDataGrid";
import { EditTableDataOverlay } from "./EditTableDataOverlay";
import { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
import { useTableEditingModalControllerWithObjectId } from "./modals/use-table-modal-with-object-id";
import { useEditableTableColumnConfigFromVisualizationSettings } from "./use-editable-column-config";
import { useTableActions } from "./use-table-actions";
import { useTableCRUD } from "./use-table-crud";
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?: VisualizationSettings;
question: Question;
};
export const EditTableDashcardVisualization = ({
title,
dashcardId,
cardId,
tableId,
data,
className,
visualizationSettings,
question,
}: 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,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowDelete,
} = 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 } = useTableActions(
visualizationSettings,
);
const { getColumnSortDirection } = useTableSorting({
question,
});
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>
<Group gap="sm" align="center">
<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>
)}
{hasCreateAction && (
<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}
/>
</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}
/>
</Stack>
);
};
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,88 @@
.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;
}
}
.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);
}
.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);
}
.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,209 @@
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 { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
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 { 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);
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,
});
// console.log({ datasetData });
const stateUpdateStrategy =
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
const editingScope = useMemo(() => {
return { "table-id": tableId };
}, [tableId]);
const {
isInserting,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowDelete,
} = useTableCRUD({
tableId,
scope: editingScope,
datasetData,
stateUpdateStrategy,
});
const { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel } =
useTableEditingUndoRedo({
tableId,
scope: editingScope,
stateUpdateStrategy,
});
useMount(() => {
dispatch(closeNavbar());
});
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
table={table}
question={fakeTableQuestion}
isLoading={isFetching}
isUndoLoading={isUndoLoading}
isRedoLoading={isRedoLoading}
onCreate={openCreateRowModal}
onQuestionChange={handleQuestionChange}
refetchTableDataQuery={refetch}
onUndo={undo}
onRedo={redo}
/>
)}
{isDatabaseTableEditingEnabled(database) ? (
<>
<Box pos="relative" className={S.gridWrapper}>
<EditTableDataOverlay
show={shouldDisableTable}
message={currentActionLabel ?? ""}
/>
<EditTableDataGrid
data={datasetData}
fieldMetadataMap={tableFieldMetadataMap}
cellsWithFailedUpdatesMap={cellsWithFailedUpdatesMap}
onCellValueUpdate={handleCellValueUpdate}
onRowExpandClick={openEditRowModal}
/>
</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
/>
</>
);
};

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,212 @@
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 { 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,
FieldWithMetadata,
RowValue,
RowValues,
} from "metabase-types/api";
import { canEditField } from "../../helpers";
import type { RowPkValue, UpdateCellValueHandlerParams } from "../types";
import S from "./EditTableData.module.css";
import { EditingBodyCellWrapper } from "./EditingBodyCell";
import type { EditableTableColumnConfig } from "./use-editable-column-config";
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>;
};
export const EditTableDataGrid = ({
data,
fieldMetadataMap,
onCellValueUpdate,
onRowExpandClick,
columnsConfig,
getColumnSortDirection,
cellsWithFailedUpdatesMap,
}: EditTableDataGridProps) => {
const { cols, rows } = data;
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
useTableEditing();
const columnOrder = useMemo(
() =>
columnsConfig ? columnsConfig.columnOrder : 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 tableProps = useDataGridInstance({
data: rows,
rowId,
columnOrder,
columnSizingMap,
columnsOptions,
columnVisibility,
});
const handleCellClick = 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],
);
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={handleCellClick}
/>
);
};

View File

@@ -0,0 +1,125 @@
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,
Title,
} from "metabase/ui";
import { TableNotificationsTrigger } from "metabase-enterprise/data_editing/alerts/TableNotificationsModals/TableNotificationsTrigger/TableNotificationsTrigger";
import type Question from "metabase-lib/v1/Question";
import type Table from "metabase-lib/v1/metadata/Table";
import type { Table as ApiTable } from "metabase-types/api";
import { EditTableDataBackButton } from "./EditTableDataBackButton";
import { EditTableDataFilterButton } from "./EditTableDataFilterButton";
interface EditTableDataHeaderProps {
table: Table | ApiTable;
question: Question;
isLoading: boolean;
isUndoLoading: boolean;
isRedoLoading: boolean;
onCreate: () => void;
onQuestionChange: (newQuestion: Question) => void;
refetchTableDataQuery: () => void;
onUndo: () => void;
onRedo: () => void;
}
export const EditTableDataHeader = ({
table,
question,
isLoading,
isUndoLoading,
isRedoLoading,
onCreate,
onQuestionChange,
refetchTableDataQuery,
onUndo,
onRedo,
}: 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}>
<Flex
p="lg"
data-testid="table-data-view-header"
bd="1px solid var(--mb-color-border)"
justify="space-between"
>
<Group gap="sm">
<EditTableDataBackButton table={table} />
<Title>{t`Editing ${table.display_name}`}</Title>
</Group>
<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>
<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>
</Flex>
<QuestionFiltersHeader
expanded={areFiltersExpanded}
question={question}
updateQuestion={onQuestionChange}
/>
</Stack>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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()}
{...inputProps}
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()}
/>
)
}
>
{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,64 @@
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 type { EditingBodyPrimitiveProps } from "./types";
export const EditingBodyCellConditional = (
props: EditingBodyPrimitiveProps,
) => {
const { datasetColumn: column, field, inputProps } = props;
const disabled = !canEditField(field);
const placeholder = field?.database_default
? `<${field.database_default}>`
: field?.database_is_nullable
? t`Optional`
: t`Required`;
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 }}
/>
);
}
return (
<EditingBodyCellBasicInput
{...props}
inputProps={{ placeholder, disabled, ...inputProps }}
/>
);
};

View File

@@ -0,0 +1,82 @@
import { type KeyboardEvent, useCallback, useState } from "react";
import {
DEFAULT_DATE_STYLE,
DEFAULT_TIME_STYLE,
} from "metabase/lib/formatting/datetime-utils";
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 initialDateValue = initialValue
? new Date(initialValue?.toString())
: null;
const valueFormat = isDateTime ? DEFAULT_DATETIME_STYLE : DEFAULT_DATE_STYLE;
const [value, setValue] = useState<Date | null>(initialDateValue);
const [isFocused, setFocused] = useState(false);
const handleChange = useCallback(
(value: Date | null) => {
setValue(value);
onChangeValue?.(value ? value.toISOString() : null);
},
[onChangeValue],
);
const handleFocus = useCallback(() => {
setFocused(true);
}, [setFocused]);
const handleBlur = useCallback(() => {
setFocused(false);
onSubmit(value ? value.toISOString() : null);
}, [value, onSubmit]);
const handleKeyUp = useCallback(
(event: KeyboardEvent) => {
if (event.key === "Escape") {
onCancel();
} else if (event.key === "Enter") {
onSubmit(value ? value.toISOString() : null);
}
},
[value, onCancel, onSubmit],
);
return (
<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}
/>
);
};

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,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,30 @@
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 {
autoFocus?: boolean;
datasetColumn: DatasetColumn;
field?: FieldWithMetadata;
initialValue: RowValue;
inputProps?: {
size?: MantineSize;
variant?: TextInputProps["variant"];
placeholder?: string;
disabled?: boolean;
error?: TextInputProps["error"];
};
classNames?: { [key in EditingBodyCellConditionalStylesNames]?: string };
onChangeValue?: (value: RowValue) => unknown;
onSubmit: (value: RowValue) => unknown;
onCancel: () => unknown;
}

View File

@@ -0,0 +1,30 @@
import { t } from "ttag";
import { Button, Group, Modal, Stack, Text } 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}>
<Stack>
<Text>{t`This action can not be undone.`}</Text>
<Group justify="flex-end">
<Button variant="subtle" onClick={onCancel}>
{t`Cancel`}
</Button>
<Button
variant="filled"
color="danger"
onClick={onConfirm}
>{t`Delete record`}</Button>
</Group>
</Stack>
</Modal>
);
}

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: rem(32) auto 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,309 @@
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,
} from "metabase-types/api";
import type { UpdatedRowHandlerParams } from "../../types";
import { EditingBodyCellConditional } from "../inputs";
import type { EditableTableColumnConfig } from "../use-editable-column-config";
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
import S from "./EditingBaseRowModal.module.css";
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,
errors,
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 orderedDatasetColumns = useMemo(() => {
if (!columnsConfig) {
return datasetColumns;
}
return columnsConfig.columnOrder
.map((name) => datasetColumns.find((it) => it.name === name))
.filter((it): it is DatasetColumn => it !== undefined);
}, [columnsConfig, datasetColumns]);
// 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 })}
>
{orderedDatasetColumns.map((column) => {
const field = fieldMetadataMap?.[column.name];
return (
<Fragment key={column.id}>
<Icon
className={S.modalBodyColumn}
name={
column.semantic_type
? FIELD_SEMANTIC_TYPES_MAP[column.semantic_type].icon
: "string"
}
/>
<Text className={S.modalBodyColumn}>
{column.display_name}
</Text>
<ModalEditingInput
isEditingMode={isEditingMode}
initialValue={currentRowDataMap[column.name] ?? null}
datasetColumn={column}
field={field}
onSubmitValue={handleValueEdit}
onChangeValue={setFieldValue}
error={!isEditingMode && !!errors[column.name]}
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;
error?: boolean;
disabled?: boolean;
};
function ModalEditingInput({
isEditingMode,
initialValue,
datasetColumn,
field,
onSubmitValue,
onChangeValue,
error,
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={{ error, disabled }}
/>
);
}

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,
};
}

View File

@@ -0,0 +1,55 @@
import { useCallback, useState } from "react";
export enum TableEditingModalAction {
Create = "create",
Edit = "edit",
}
export type TableEditingModalState = {
action: TableEditingModalAction | null;
rowIndex?: number;
};
export type TableEditingModalController = {
state: TableEditingModalState;
openCreateRowModal: () => void;
openEditRowModal: (rowIndex: number) => void;
closeModal: () => void;
};
/**
* Simple controller to manage the state of the table editing modal.
* See `useTableEditingModalControllerWithObjectId` for a more complex
* implementation that relies on the `currentObjectId` prop.
*/
export function useTableEditingModalController(): TableEditingModalController {
const [modalState, setModalState] = useState<TableEditingModalState>({
action: null,
rowIndex: undefined, // TODO: replace with row: Record<string, RowValue>
});
const handleOpenCreateRowModal = useCallback(() => {
setModalState({
action: TableEditingModalAction.Create,
rowIndex: undefined,
});
}, []);
const handleOpenEditRowModal = useCallback((rowIndex: number) => {
setModalState({ action: TableEditingModalAction.Edit, rowIndex });
}, []);
const handleCloseModal = useCallback(() => {
setModalState((state) => ({
...state,
action: null,
}));
}, []);
return {
state: modalState,
openCreateRowModal: handleOpenCreateRowModal,
openEditRowModal: handleOpenEditRowModal,
closeModal: handleCloseModal,
};
}

View File

@@ -0,0 +1,9 @@
export function getTableEditPathname(
databaseId: number,
tableId: number,
objectId?: string,
): string {
return `/browse/databases/${databaseId}/tables/${tableId}/edit${
objectId ? `/${objectId}` : ""
}`;
}

View File

@@ -0,0 +1,60 @@
import { useMemo } from "react";
import type {
DashCardVisualizationSettings,
VisualizationSettings,
} from "metabase-types/api";
export type EditableTableColumnConfig = {
columnOrder: string[];
columnVisibilityMap: Record<string, boolean>;
isColumnReadonly: (columnName: string) => boolean;
isColumnHidden: (columnName: string) => boolean;
};
export function useEditableTableColumnConfigFromVisualizationSettings(
visualizationSettings?: VisualizationSettings & DashCardVisualizationSettings,
): EditableTableColumnConfig | undefined {
return useMemo(() => {
if (!visualizationSettings) {
return undefined;
}
const columnSettings = visualizationSettings["table.columns"] ?? [];
const editableColumns =
visualizationSettings["table.editableColumns"] ?? [];
if (columnSettings.length === 0) {
return undefined;
}
const isAllColumnsEditable = editableColumns.length === 0;
const editableColumnSet = new Set(editableColumns);
const columnOrder: string[] = [];
const columnVisibilityMap: Record<string, boolean> = {};
const readonlyColumnSet = new Set<string>();
const hiddenColumnSet = new Set<string>();
for (const column of columnSettings) {
columnOrder.push(column.name);
columnVisibilityMap[column.name] = column.enabled;
if (!column.enabled) {
hiddenColumnSet.add(column.name);
}
if (!isAllColumnsEditable && !editableColumnSet.has(column.name)) {
readonlyColumnSet.add(column.name);
}
}
return {
columnOrder,
columnVisibilityMap,
isColumnHidden: (columnName: string) => hiddenColumnSet.has(columnName),
isColumnReadonly: (columnName: string) =>
readonlyColumnSet.has(columnName),
};
}, [visualizationSettings]);
}

View File

@@ -0,0 +1,91 @@
import type { Location } from "history";
import { useCallback, useEffect, useMemo } from "react";
import { push } from "react-router-redux";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { loadMetadataForTable } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import {
deserializeTableFilter,
serializeTableFilter,
} from "metabase-enterprise/data_editing/tables/edit/utils";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type { StructuredDatasetQuery } from "metabase-types/api";
type StandaloneTableQueryProps = {
tableId: number;
databaseId: number;
location: Location<{ filter?: string }>;
};
export const useStandaloneTableQuery = ({
tableId,
databaseId,
location,
}: StandaloneTableQueryProps) => {
const filterQueryParam = useMemo(() => {
return location.query?.filter
? deserializeTableFilter(location.query.filter)
: null;
}, [location.query.filter]);
const metadata = useSelector(getMetadata);
const dispatch = useDispatch();
const table = metadata.table(tableId);
useEffect(() => {
dispatch(loadMetadataForTable(tableId));
}, [dispatch, tableId]);
const fakeTableQuestion = useMemo(() => {
if (table) {
let question = Question.create({ databaseId, tableId, metadata });
if (filterQueryParam) {
const legacyQuery = Lib.toLegacyQuery(
question.query(),
) as StructuredDatasetQuery;
legacyQuery.query.filter = filterQueryParam;
question = question.setDatasetQuery(legacyQuery);
}
return question;
}
}, [databaseId, filterQueryParam, metadata, tableId, table]);
const handleQuestionChange = useCallback(
(newQuestion: Question) => {
const legacyQuery = Lib.toLegacyQuery(
newQuestion.query(),
) as StructuredDatasetQuery;
const newFilterMbql = legacyQuery.query.filter;
if (newFilterMbql) {
const searchParams = new URLSearchParams();
searchParams.append("filter", serializeTableFilter(newFilterMbql));
// don't set filter param if it is empty
dispatch(
push(`${window.location.pathname}?${searchParams.toString()}`),
);
} else {
dispatch(push(window.location.pathname));
}
},
[dispatch],
);
const fakeTableQuery = useMemo(() => {
if (fakeTableQuestion) {
return Lib.toLegacyQuery(fakeTableQuestion.query());
}
}, [fakeTableQuestion]);
return {
fakeTableQuestion,
fakeTableQuery,
table,
handleQuestionChange,
};
};

View File

@@ -0,0 +1,23 @@
import type {
DashCardVisualizationSettings,
VisualizationSettings,
} from "metabase-types/api";
export const useTableActions = (
visualizationSettings?: VisualizationSettings & DashCardVisualizationSettings,
) => {
const hasCreateAction =
visualizationSettings?.["editableTable.enabledActions"]?.find(
({ id }) => id === "row/create",
)?.enabled || false;
const hasDeleteAction =
visualizationSettings?.["editableTable.enabledActions"]?.find(
({ id }) => id === "row/delete",
)?.enabled || false;
return {
hasCreateAction,
hasDeleteAction,
};
};

View File

@@ -0,0 +1,102 @@
import { useCallback, useMemo, useState } from "react";
import { useDispatch } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import type { CellUniqKey, RowPkValue } from "../types";
import { ErrorUpdateToast } from "./ErrorUpdateToast";
import type { OptimisticUpdatePatchResult } from "./use-table-state-update-strategy";
import { getCellUniqKey, getUpdateApiErrorMessage } from "./utils";
export const useTableCrudOptimisticUpdate = () => {
const [cellsWithFailedUpdatesMap, setCellsWithFailedUpdatesMap] = useState<
Record<CellUniqKey, number>
>({});
const dispatch = useDispatch();
const remappedCellsWithFailedUpdatesMap = useMemo(
() =>
Object.keys(cellsWithFailedUpdatesMap).reduce(
(result, key) => {
result[key] = true;
return result;
},
{} as Record<CellUniqKey, true>,
),
[cellsWithFailedUpdatesMap],
);
const handleCellValueUpdateError = useCallback(
(
error: unknown,
cellUpdateContext: {
rowPkValue: RowPkValue;
columnName: string;
patchResult: OptimisticUpdatePatchResult | undefined;
},
) => {
const { columnName, rowPkValue, patchResult } = cellUpdateContext;
const cellUniqKey = getCellUniqKey(rowPkValue, columnName);
patchResult?.revert();
dispatch(
addUndo({
toastColor: "bg-black",
icon: null,
renderChildren: () => <ErrorUpdateToast error={error} />,
timeout: null, // removes automatic toast hide
undo: false,
onDismiss: () => {
setCellsWithFailedUpdatesMap((prevState) => {
const currentFailedUpdatesCount = prevState[cellUniqKey];
const newCount = currentFailedUpdatesCount - 1;
const newMap = { ...prevState };
if (newCount === 0) {
delete newMap[cellUniqKey];
} else {
newMap[cellUniqKey] = newCount;
}
return newMap;
});
},
}),
);
const currentFailedUpdatesCount =
cellsWithFailedUpdatesMap[cellUniqKey] || 0;
setCellsWithFailedUpdatesMap({
...cellsWithFailedUpdatesMap,
[cellUniqKey]: currentFailedUpdatesCount + 1,
});
},
[cellsWithFailedUpdatesMap, dispatch],
);
const handleGenericUpdateError = useCallback(
(error: unknown) => {
if (error) {
dispatch(
addUndo({
icon: "warning",
toastColor: "error",
message: getUpdateApiErrorMessage(error),
}),
);
}
},
[dispatch],
);
return {
cellsWithFailedUpdatesMap: remappedCellsWithFailedUpdatesMap,
handleCellValueUpdateError,
handleGenericUpdateError,
};
};

View File

@@ -0,0 +1,245 @@
import { useCallback, useMemo } from "react";
import { useGetTableQueryMetadataQuery } from "metabase/api";
import {
useDeleteTableRowsMutation,
useInsertTableRowsMutation,
useUpdateTableRowsMutation,
} from "metabase-enterprise/api";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type {
ConcreteTableId,
DatasetData,
FieldWithMetadata,
RowValue,
} from "metabase-types/api";
import type {
RowPkValue,
TableEditingScope,
UpdateCellValueHandlerParams,
UpdatedRowHandlerParams,
} from "../types";
import { useTableCrudOptimisticUpdate } from "./use-table-crud-optimistic-update";
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
import { getRowPkKeyValue } from "./utils";
export const useTableCRUD = ({
tableId,
scope,
datasetData,
stateUpdateStrategy,
}: {
tableId: ConcreteTableId;
scope?: TableEditingScope;
datasetData: DatasetData | null | undefined;
stateUpdateStrategy: TableEditingStateUpdateStrategy;
}) => {
const {
cellsWithFailedUpdatesMap,
handleCellValueUpdateError,
handleGenericUpdateError,
} = useTableCrudOptimisticUpdate();
const [deleteTableRows] = useDeleteTableRowsMutation();
const [updateTableRows] = useUpdateTableRowsMutation();
const [insertTableRows, { isLoading: isInserting }] =
useInsertTableRowsMutation();
const { data: tableMetadata } = useGetTableQueryMetadataQuery({
id: tableId,
});
const tableFieldMetadataMap = useMemo(() => {
return (
tableMetadata?.fields?.reduce(
(acc, item) => ({
...acc,
[item.name]: item,
}),
{} as Record<FieldWithMetadata["name"], FieldWithMetadata>,
) || {}
);
}, [tableMetadata]);
const handleCellValueUpdate = useCallback(
async ({
updatedData,
rowIndex,
columnName,
}: UpdateCellValueHandlerParams): Promise<boolean> => {
// mostly the same as "handleRowUpdate", but has optimistic update and special error handling
if (!datasetData) {
console.warn(
"Failed to update table data - no data is loaded for a table",
);
return false;
}
const pkRecord = getRowPkKeyValue(datasetData, rowIndex);
const updatedRowWithPk = {
...updatedData,
...pkRecord,
};
const rowPkValue = Object.values(pkRecord)[0] as RowPkValue;
const patchResult = stateUpdateStrategy.onRowsUpdated([updatedRowWithPk]);
try {
const response = await updateTableRows({
tableId: tableId,
rows: [updatedRowWithPk],
});
if (response.error) {
handleCellValueUpdateError(response.error, {
columnName,
rowPkValue,
patchResult: patchResult || undefined,
});
}
if (response.data?.updated) {
stateUpdateStrategy.onRowsUpdated(response.data.updated);
}
return !response.error;
} catch (e) {
handleCellValueUpdateError(e, {
columnName,
rowPkValue,
patchResult: patchResult || undefined,
});
return false;
}
},
[
datasetData,
handleCellValueUpdateError,
stateUpdateStrategy,
tableId,
updateTableRows,
],
);
const handleRowUpdate = useCallback(
async ({ updatedData, rowIndex }: UpdatedRowHandlerParams) => {
if (!datasetData) {
console.warn(
"Failed to update table data - no data is loaded for a table",
);
return false;
}
const pkRecord = getRowPkKeyValue(datasetData, rowIndex);
const updatedRowWithPk = {
...updatedData,
...pkRecord,
};
const response = await updateTableRows({
tableId: tableId,
rows: [updatedRowWithPk],
scope,
});
if (!response.error && response.data) {
stateUpdateStrategy.onRowsUpdated(response.data.updated);
} else {
handleGenericUpdateError(response.error);
}
return !response.error;
},
[
datasetData,
updateTableRows,
tableId,
scope,
handleGenericUpdateError,
stateUpdateStrategy,
],
);
const handleRowCreate = useCallback(
async (data: Record<string, RowValue>): Promise<boolean> => {
const response = await insertTableRows({
tableId: tableId,
rows: [data],
scope,
});
if (!response.error && response.data) {
stateUpdateStrategy.onRowsCreated(response.data["created-rows"]);
} else {
handleGenericUpdateError(response.error);
}
return !response.error;
},
[
insertTableRows,
tableId,
handleGenericUpdateError,
scope,
stateUpdateStrategy,
],
);
const handleRowDelete = useCallback(
async (rowIndex: number) => {
if (!datasetData) {
console.warn(
"Failed to update table data - no data is loaded for a table",
);
return false;
}
const columns = datasetData.cols;
const rowData = datasetData.rows[rowIndex];
const pkColumnIndex = columns.findIndex(isPK);
const pkColumn = columns[pkColumnIndex];
const rowPkValue = rowData[pkColumnIndex];
const rows = [{ [pkColumn.name]: rowPkValue }];
const response = await deleteTableRows({
rows,
tableId: tableId,
scope,
});
if (response.data?.success) {
stateUpdateStrategy.onRowsDeleted(rows);
}
if (response.error) {
handleGenericUpdateError(response.error);
}
return !response.error;
},
[
datasetData,
deleteTableRows,
tableId,
scope,
stateUpdateStrategy,
handleGenericUpdateError,
],
);
return {
isInserting,
tableFieldMetadataMap,
cellsWithFailedUpdatesMap,
handleCellValueUpdate,
handleRowCreate,
handleRowUpdate,
handleRowDelete,
};
};

View File

@@ -0,0 +1,23 @@
import { useCallback, useState } from "react";
import type { DataGridCellId } from "metabase/data-grid";
export const useTableEditing = () => {
const [editingCellId, setEditingCellId] = useState<DataGridCellId | null>(
null,
);
const onCellClickToEdit = useCallback((cellId: DataGridCellId) => {
setEditingCellId(cellId);
}, []);
const onCellEditCancel = useCallback(() => {
setEditingCellId(null);
}, []);
return {
onCellClickToEdit,
onCellEditCancel,
editingCellId,
};
};

View File

@@ -0,0 +1,33 @@
import * as Lib from "metabase-lib";
import type Question from "metabase-lib/v1/Question";
import type Field from "metabase-lib/v1/metadata/Field";
import type { DatasetColumn } from "metabase-types/api";
export const useTableSorting = ({ question }: { question: Question }) => {
const getColumnSortDirection = (columnOrField: DatasetColumn | Field) => {
if (!question || !columnOrField) {
return;
}
const query = question.query();
const stageIndex = -1;
const column = Lib.findMatchingColumn(
query,
stageIndex,
Lib.fromLegacyColumn(query, stageIndex, columnOrField),
Lib.orderableColumns(query, stageIndex),
);
if (column != null) {
const columnInfo = Lib.displayInfo(query, stageIndex, column);
if (columnInfo.orderByPosition != null) {
const orderBys = Lib.orderBys(query, stageIndex);
const orderBy = orderBys[columnInfo.orderByPosition];
const orderByInfo = Lib.displayInfo(query, stageIndex, orderBy);
return orderByInfo.direction;
}
}
};
return { getColumnSortDirection };
};

View File

@@ -0,0 +1,111 @@
import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit";
import { useCallback, useMemo } from "react";
import { datasetApi } from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
import { checkNotNull } from "metabase/lib/types";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type { DatasetQuery, RowValue } from "metabase-types/api";
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
import {
createPrimaryKeyToUpdatedRowObjectMap,
mapDataEditingRowObjectsToRowValues,
} from "./use-table-state-update-strategy";
export function useTableEditingStateApiUpdateStrategy(
nullableAdhocQuery: DatasetQuery | undefined,
): TableEditingStateUpdateStrategy {
// "metabase-api" state is not typed, so we need to use the `any` type
const dispatch = useDispatch() as ThunkDispatch<any, any, UnknownAction>;
const onRowsCreated = useCallback(
(rows: Record<string, RowValue>[]) => {
const adhocQuery = checkNotNull(nullableAdhocQuery);
dispatch(
datasetApi.util.updateQueryData(
"getAdhocQuery",
adhocQuery,
({ data }) => {
data.rows.push(
...mapDataEditingRowObjectsToRowValues(rows, data.cols),
);
},
),
);
},
[dispatch, nullableAdhocQuery],
);
const onRowsUpdated = useCallback(
(rows: Record<string, RowValue>[]) => {
const adhocQuery = checkNotNull(nullableAdhocQuery);
const patchResult = dispatch(
datasetApi.util.updateQueryData(
"getAdhocQuery",
adhocQuery,
({ data }) => {
const pkColumnIndex = data.cols.findIndex(isPK);
const pkColumnName = data.cols[pkColumnIndex].name;
const primaryKeyToUpdatedRowObjectMap =
createPrimaryKeyToUpdatedRowObjectMap(pkColumnName, rows);
for (const rowArray of data.rows) {
const updatedRowObject = primaryKeyToUpdatedRowObjectMap.get(
rowArray[pkColumnIndex],
);
if (updatedRowObject) {
for (let i = 0; i < data.cols.length; i++) {
const columnName = data.cols[i].name;
if (columnName in updatedRowObject) {
rowArray[i] = updatedRowObject[columnName];
}
}
}
}
},
),
);
return {
revert: patchResult.undo,
};
},
[dispatch, nullableAdhocQuery],
);
const onRowsDeleted = useCallback(
(rows: Record<string, RowValue>[]) => {
const adhocQuery = checkNotNull(nullableAdhocQuery);
dispatch(
datasetApi.util.updateQueryData(
"getAdhocQuery",
adhocQuery,
({ data }) => {
const pkColumnIndex = data.cols.findIndex(isPK);
const pkColumnName = data.cols[pkColumnIndex].name;
const deletedPKs = new Set(rows.map((row) => row[pkColumnName]));
data.rows = data.rows.filter(
(row) => !deletedPKs.has(row[pkColumnIndex]),
);
},
),
);
},
[dispatch, nullableAdhocQuery],
);
return useMemo(
() => ({
onRowsCreated,
onRowsUpdated,
onRowsDeleted,
}),
[onRowsCreated, onRowsUpdated, onRowsDeleted],
);
}

View File

@@ -0,0 +1,147 @@
import { useCallback, useMemo } from "react";
import { updateCardData } from "metabase/dashboard/actions/data-fetching";
import { getDashcardData } from "metabase/dashboard/selectors";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type { RowValue } from "metabase-types/api";
import {
type TableEditingStateUpdateStrategy,
createPrimaryKeyToUpdatedRowObjectMap,
mapDataEditingRowObjectsToRowValues,
} from "./use-table-state-update-strategy";
export function useTableEditingStateDashcardUpdateStrategy(
dashcardId: number,
cardId: number,
): TableEditingStateUpdateStrategy {
const dispatch = useDispatch();
const cardData = useSelector(
(state) => getDashcardData(state, dashcardId)[cardId],
);
const onRowsCreated = useCallback(
(rows: Record<string, RowValue>[]) => {
if (!cardData) {
return;
}
dispatch(
updateCardData(cardId, dashcardId, {
...cardData,
data: {
...cardData.data,
rows: [
...cardData.data.rows,
...mapDataEditingRowObjectsToRowValues(rows, cardData.data.cols),
],
},
}),
);
},
[cardData, dashcardId, cardId, dispatch],
);
const onRowsUpdated = useCallback(
(rows: Record<string, RowValue>[]) => {
if (!cardData) {
return;
}
const pkColumnIndex = cardData.data.cols.findIndex(isPK);
const pkColumnName = cardData.data.cols[pkColumnIndex].name;
const primaryKeyToUpdatedRowObjectMap =
createPrimaryKeyToUpdatedRowObjectMap(pkColumnName, rows);
const originalRowsPkMap: Map<RowValue, RowValue[]> = new Map();
dispatch(
updateCardData(cardId, dashcardId, {
...cardData,
data: {
...cardData.data,
rows: cardData.data.rows.map((row) => {
const rowPkValue = row[pkColumnIndex];
const updatedRowObject =
primaryKeyToUpdatedRowObjectMap.get(rowPkValue);
if (updatedRowObject) {
originalRowsPkMap.set(rowPkValue, row);
const updatedRow = row.map((value, index) => {
const columnName = cardData.data.cols[index].name;
if (columnName in updatedRowObject) {
return updatedRowObject[columnName];
}
return value;
});
primaryKeyToUpdatedRowObjectMap.delete(rowPkValue);
return updatedRow;
}
return row;
}),
},
}),
);
return {
revert: () =>
dispatch(
updateCardData(cardId, dashcardId, {
...cardData,
data: {
...cardData.data,
rows: cardData.data.rows.map((row) => {
const rowPkValue = row[pkColumnIndex];
const originalRowObject = originalRowsPkMap.get(rowPkValue);
return originalRowObject || row;
}),
},
}),
),
};
},
[cardData, dashcardId, cardId, dispatch],
);
const onRowsDeleted = useCallback(
(rows: Record<string, RowValue>[]) => {
if (!cardData) {
return;
}
const pkColumnIndex = cardData.data.cols.findIndex(isPK);
const pkColumnName = cardData.data.cols[pkColumnIndex].name;
const deletedPKs = new Set(rows.map((row) => row[pkColumnName]));
dispatch(
updateCardData(cardId, dashcardId, {
...cardData,
data: {
...cardData.data,
rows: cardData.data.rows.filter(
(row) => !deletedPKs.has(row[pkColumnIndex]),
),
},
}),
);
},
[cardData, dashcardId, cardId, dispatch],
);
return useMemo(
() => ({
onRowsCreated,
onRowsUpdated,
onRowsDeleted,
}),
[onRowsCreated, onRowsUpdated, onRowsDeleted],
);
}

View File

@@ -0,0 +1,27 @@
import type { DatasetColumn, RowValue } from "metabase-types/api";
type DataEditingRow = Record<string, RowValue>;
export type OptimisticUpdatePatchResult = {
revert: () => void;
};
export interface TableEditingStateUpdateStrategy {
onRowsCreated: (rows: DataEditingRow[]) => void;
onRowsUpdated: (rows: DataEditingRow[]) => OptimisticUpdatePatchResult | void;
onRowsDeleted: (rows: DataEditingRow[]) => void;
}
export function mapDataEditingRowObjectsToRowValues(
rows: Record<string, RowValue>[],
columns: DatasetColumn[],
) {
return rows.map((row) => columns.map((column) => row[column.name]));
}
export function createPrimaryKeyToUpdatedRowObjectMap(
pkColumnName: string,
rows: Record<string, RowValue>[],
) {
return new Map(rows.map((row) => [row[pkColumnName], row]));
}

View File

@@ -0,0 +1,110 @@
import { useCallback } from "react";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import {
useTableRedoMutation,
useTableUndoMutation,
} from "metabase-enterprise/api/table-data-edit";
import type { ConcreteTableId, RowValue } from "metabase-types/api";
import type { TableEditingScope } from "../types";
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
type UseTableEditingUndoRedoProps = {
tableId: ConcreteTableId;
scope?: TableEditingScope;
stateUpdateStrategy: TableEditingStateUpdateStrategy;
};
export function useTableEditingUndoRedo({
tableId,
scope,
stateUpdateStrategy,
}: UseTableEditingUndoRedoProps) {
const [undoMutation, { isLoading: isUndoLoading }] = useTableUndoMutation();
const [redoMutation, { isLoading: isRedoLoading }] = useTableRedoMutation();
const dispatch = useDispatch();
const handleResponse = useCallback(
(
operationName: "undo" | "redo",
response:
| Awaited<ReturnType<typeof undoMutation>>
| Awaited<ReturnType<typeof redoMutation>>,
) => {
if (response.error) {
dispatch(
addUndo({
icon: "warning",
toastColor: "error",
message:
operationName === "undo"
? t`Unable to perform undo operation`
: t`Unable to perform redo operation`,
}),
);
} else if (!response.data) {
dispatch(
addUndo({
icon: "warning",
toastColor: "warning",
message:
operationName === "undo"
? t`Nothing to undo`
: t`Nothing to redo`,
}),
);
} else if (response.data?.result?.[tableId]) {
const operations = response.data?.result?.[tableId].reduce(
(acc, [operationType, row]) => {
if (operationType === "create") {
acc.created.push(row);
} else if (operationType === "update") {
acc.updated.push(row);
} else if (operationType === "delete") {
acc.deleted.push(row);
}
return acc;
},
{
created: [] as Record<string, RowValue>[],
updated: [] as Record<string, RowValue>[],
deleted: [] as Record<string, RowValue>[],
},
);
if (operations.created.length > 0) {
stateUpdateStrategy.onRowsCreated(operations.created);
}
if (operations.updated.length > 0) {
stateUpdateStrategy.onRowsUpdated(operations.updated);
}
if (operations.deleted.length > 0) {
stateUpdateStrategy.onRowsDeleted(operations.deleted);
}
}
},
[dispatch, stateUpdateStrategy, tableId],
);
const undo = useCallback(async () => {
const response = await undoMutation({ tableId, scope });
handleResponse("undo", response);
}, [undoMutation, tableId, scope, handleResponse]);
const redo = useCallback(async () => {
const response = await redoMutation({ tableId, scope });
handleResponse("redo", response);
}, [redoMutation, tableId, scope, handleResponse]);
const currentActionLabel = isUndoLoading
? t`Undoing actions...`
: isRedoLoading
? t`Redoing actions...`
: undefined;
return { undo, redo, isUndoLoading, isRedoLoading, currentActionLabel };
}

View File

@@ -0,0 +1,66 @@
import { t } from "ttag";
import { b64hash_to_utf8, utf8_to_b64url } from "metabase/lib/encoding";
import type { GenericErrorResponse } from "metabase/lib/errors";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type { DatasetData, Filter } from "metabase-types/api";
import type { CellUniqKey, RowPkValue } from "../types";
export const serializeTableFilter = (filterMbql: Filter): string => {
return utf8_to_b64url(JSON.stringify(filterMbql));
};
export const deserializeTableFilter = (filterParam: string): Filter | null => {
const maybeFilter = JSON.parse(b64hash_to_utf8(filterParam));
// simple and hacky way to test if param is a valid filter
return Array.isArray(maybeFilter) && typeof maybeFilter[0] === "string"
? (maybeFilter as Filter)
: null;
};
export const getRowPkKeyValue = (
datasetData: DatasetData,
rowIndex: number,
) => {
const columns = datasetData.cols;
const rowData = datasetData.rows[rowIndex];
const pkColumnIndex = columns.findIndex(isPK);
const pkColumn = columns[pkColumnIndex];
const rowPkValue = rowData[pkColumnIndex];
return { [pkColumn.name]: rowPkValue };
};
export const getUpdateApiErrorMessage = (
error: GenericErrorResponse | unknown,
): string => {
const maybeError = error as GenericErrorResponse;
if (typeof maybeError.data === "string") {
return maybeError.data;
}
if (
Array.isArray(maybeError.data?.errors) &&
"error" in maybeError.data?.errors[0]
) {
return maybeError.data.errors[0].error;
}
if (Array.isArray(maybeError.errors) && "error" in maybeError.errors[0]) {
return maybeError.errors[0].error;
}
return t`Unknown error`;
};
export const getCellUniqKey = (
rowPkValue: RowPkValue,
columnName: string,
): CellUniqKey => {
// DataGrid uses rowIndex + column name key, which is not unique, so we have to use pk value
return `${rowPkValue}_${columnName}`;
};

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