Compare commits

...

155 Commits

Author SHA1 Message Date
Ngoc Khuat
0dc8112eec docds 2025-04-22 13:44:53 +07:00
Ngoc Khuat
efe6139a6e don't use keyword to access stuffs 2025-04-21 17:50:16 +07:00
Ngoc Khuat
9c45fafa28 collection operators 2025-04-21 17:44:58 +07: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
372 changed files with 14591 additions and 1285 deletions

View File

@@ -34,7 +34,7 @@
{:metabase/modules
{actions
{:api #{metabase.actions.api metabase.actions.core}
:uses #{analytics api driver legacy-mbql lib model-persistence models query-processor search util}}
:uses #{analytics api driver events legacy-mbql lib model-persistence models query-processor search util}}
activity-feed
{:api #{metabase.activity-feed.api metabase.activity-feed.init}
@@ -211,7 +211,7 @@
events
{:api #{metabase.events metabase.events.init metabase.events.notification}
:uses #{api channel driver models notification premium-features util}}
:uses #{api config channel driver models notification premium-features util}}
formatter
{:api #{metabase.formatter}
@@ -392,6 +392,7 @@
driver
events
models
lib
permissions
premium-features
public-settings
@@ -681,7 +682,7 @@
enterprise/core
{:api #{metabase-enterprise.core.init}
:uses #{enterprise/enhancements}}
:uses #{enterprise/enhancements enterprise/data-editing}}
enterprise/dashboard-subscription-filters
{:api #{}
@@ -772,7 +773,11 @@
enterprise/data-editing
{:api #{}
:uses #{api actions util}}
:uses #{api actions driver events lib models query-processor upload util}}
enterprise/data-editing-public
{:api #{}
:uses #{api actions enterprise/data-editing driver events lib query-processor upload util}}
enterprise/task
{:api #{metabase-enterprise.task.truncate-audit-tables}

View File

@@ -57,11 +57,10 @@
com.ibm.icu/icu4j {:mvn/version "76.1"} ; Used to transliterate Unicode characters into Latin characters
com.mchange/c3p0 {:mvn/version "0.10.1"} ; DB connection pool
com.snowplowanalytics/snowplow-java-tracker ^:antq/exclude ; 2+ depend on apache httpclient 5.x, but saml and clj-http depend on 4.x
{:mvn/version "1.0.1"} ; Snowplow analytics
{:mvn/version "1.0.1"} ; Snowplow analytics
com.taoensso/nippy {:mvn/version "3.4.2"} ; 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.18.0"} ; Apache Commons -- useful IO util fns
commons-net/commons-net {:mvn/version "3.11.1"} ; Apache Commons -- useful network utils. Transitive dep of Snowplow, pinned due to CVE-2021-37533
@@ -86,7 +85,7 @@
:git/url "https://github.com/eerohele/pp"}
io.github.metabase/macaw {:mvn/version "0.2.25"} ; Parse native SQL queries
io.github.resilience4j/resilience4j-retry ^:antq/exclude ; 2.x do not support Java 8
{:mvn/version "1.7.1"} ; Support for retrying operations
{:mvn/version "1.7.1"} ; Support for retrying operations
io.prometheus/simpleclient_hotspot {:mvn/version "0.16.0"} ; prometheus jvm collector
io.prometheus/simpleclient_jetty {:mvn/version "0.16.0"} ; prometheus jetty collector
javax.servlet/servlet-api {:mvn/version "2.5"} ; used by ring's multipart-params (file upload)
@@ -841,7 +840,9 @@
;; clj -X:dev:ee:ee-dev:test:test/notification
:test/notification
{:exec-args {:only ["test/metabase/notification"
"test/metabase/channel"]}}
"test/metabase/channel"
metabase.events.notification-test
metabase-enterprise.data-editing.notification-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

@@ -0,0 +1,97 @@
import { SAMPLE_DB_ID } from "e2e/support/cypress_data";
const { H } = cy;
describe("scenarios > embedding > dashboard parameters", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.setTokenFeatures("all");
H.setTableEditingEnabledForDB(SAMPLE_DB_ID);
cy.intercept("GET", "/api/database").as("getDatabases");
cy.intercept("GET", "/api/table/*").as("getTable");
});
it("should allow to open table data page", () => {
openDatabaseTable("People");
cy.wait("@getTable");
cy.findByTestId("main-navbar-root").should("not.be.visible");
cy.findByTestId("table-data-view-root")
.should("be.visible")
.within(() => {
cy.findByText("People").should("be.visible");
cy.findByText("Explore").should("be.visible");
cy.findByText("Edit").should("be.visible");
cy.findByLabelText("Refresh").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

@@ -11,6 +11,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]
@@ -66,6 +67,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

@@ -6,4 +6,5 @@
See https://metaboat.slack.com/archives/CKZEMT1MJ/p1736556522733279 for rationale behind this pattern."
(:require
[metabase-enterprise.data-editing.init]
[metabase-enterprise.enhancements.init]))

View File

@@ -1,38 +1,249 @@
(ns metabase-enterprise.data-editing.api
(:require
[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.upload :as-alias upload]
[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)
(defn- invalidate-field-values! [table-id rows]
(let [field-name-xf (comp (mapcat keys)
(distinct)
(map name)
(map u/lower-case-en))
field-names (into #{} field-name-xf rows)
fields (when (seq field-names)
(t2/select :model/Field
:table_id table-id
:name [:in field-names]
:has_field_values [:in ["list" "auto-list"]]))]
(run! field-values/create-or-update-full-field-values! fields)))
(defn require-authz?
"Temporary hack to have auth be off by default, only on if MB_DATA_EDITING_AUTHZ=true.
Remove once auth policy is fixed for dashboards."
[]
(= "true" (System/getenv "MB_DATA_EDITING_AUTHZ")))
(defn- check-permissions []
(when (require-authz?)
(api/check-superuser)))
(api.macros/defendpoint :post "/table/:table-id"
"Insert row(s) into the given table."
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
{}
{:keys [rows]}]
(perform-bulk-action! :bulk/create table-id rows))
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
(check-permissions)
(let [rows' (data-editing/apply-coercions table-id rows)
res (data-editing/insert! api/*current-user-id* table-id rows')
pk-fields (data-editing/select-table-pk-fields table-id)
;; actions code does not return coerced values
;; right now the FE works off qp outputs, which coerce output row data
;; still feels messy, revisit this
pks->db-row (data-editing/query-db-rows table-id pk-fields (map #(update-keys % keyword) (:created-rows res)))]
(invalidate-field-values! table-id rows')
{:created-rows (vals pks->db-row)}))
(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]} :- [:map [:rows [:sequential {:min 1} :map]]]]
(check-permissions)
(if (empty? rows)
{:updated []}
(let [rows' (data-editing/apply-coercions table-id rows)
pk-fields (data-editing/select-table-pk-fields table-id)
pks->db-row (data-editing/query-db-rows table-id pk-fields rows')
user-id api/*current-user-id*]
;; If this fails, we probably have another bug like the type-mismatch issue we had here:
;; https://linear.app/metabase/issue/WRK-281/undo-deletes-a-record-instead-of-reverting-the-edits
(assert (every? (fn [row] (get pks->db-row (data-editing/get-row-pks pk-fields row))) rows')
"Able to look up the existing values of these rows, for system events")
(data-editing/perform-bulk-action! :bulk/update table-id rows')
;; TODO this should also become a subscription to the "data written" system event
(let [row-pk->old-new-values (->> (for [row rows']
(let [pks (data-editing/get-row-pks pk-fields row)]
[pks [(get pks->db-row pks)
row]]))
(into {}))]
(undo/track-change! user-id {table-id row-pk->old-new-values}))
(api.macros/defendpoint :delete "/table/:table-id"
(invalidate-field-values! table-id rows')
{:updated (vals (data-editing/query-db-rows table-id pk-fields rows'))})))
;; This is a POST instead of DELETE as not all web proxies pass on the body of DELETE requests.
(api.macros/defendpoint :post "/table/:table-id/delete"
"Delete row(s) from the given table"
[{:keys [table-id]} :- [:map [:table-id ms/PositiveInt]]
{}
{:keys [rows]}]
(perform-bulk-action! :bulk/delete table-id rows))
{:keys [rows]} :- [:map [:rows [:sequential {:min 1} :map]]]]
(check-permissions)
(let [pk-fields (data-editing/select-table-pk-fields table-id)
pks->db-rows (data-editing/query-db-rows table-id pk-fields rows)
;; If this fails, we probably have another bug like the type-mismatch issue we had here:
;; https://linear.app/metabase/issue/WRK-281/undo-deletes-a-record-instead-of-reverting-the-edits
_ (assert (every? (fn [row] (get pks->db-rows (data-editing/get-row-pks pk-fields row))) rows)
"Able to look up the existing values of these rows, for system events")
res (data-editing/perform-bulk-action! :bulk/delete table-id rows)
user-id api/*current-user-id*
row-pk->old-new-values (->> (for [row rows]
(let [pks (data-editing/get-row-pks pk-fields row)]
[pks [(get pks->db-rows pks) nil]]))
(into {}))]
;; TODO this should also become a subscription to the "data written" system event
(undo/track-change! user-id {table-id row-pk->old-new-values})
res))
;; 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" ::upload/auto-incrementing-int-pk
"boolean" ::upload/boolean
"int" ::upload/int
"float" ::upload/float
"varchar255" ::upload/varchar-255
"text" ::upload/text
"date" ::upload/date
"datetime" ::upload/datetime
"offset_datetime" ::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 future will support editables for sure.
Maybe actions, workflows, etc.
Could even generalize to things like edits to dashboard definitions themselves."
[_
_
{:keys [table-id no-op]}] :- [:map
[:table-id ms/PositiveInt]
[:no-op {:optional true} ms/BooleanValue]]
(check-permissions)
(api/check-404 (t2/select-one-pk :model/Table table-id))
(let [user-id api/*current-user-id*]
(if no-op
{:batch_num (undo/next-batch-num true user-id table-id)}
;; IDEA encapsulate this in an action
;; IDEA use generic action calling API instead of having this endpoint
(try
{:result (undo/undo! user-id table-id)}
(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 future will support editables for sure.
Maybe actions, workflows, etc.
Could even generalize to things like edits to dashboard definitions themselves."
[_
_
{:keys [table-id no-op]}] :- [:map
[:table-id ms/PositiveInt]
[:no-op {:optional true} ms/BooleanValue]]
(check-permissions)
(api/check-404 (t2/select-one :model/Table table-id))
(let [user-id api/*current-user-id*]
(if no-op
{:batch_num (undo/next-batch-num false user-id table-id)}
;; 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 table-id)}
(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)}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."

View File

@@ -0,0 +1,50 @@
(ns metabase-enterprise.data-editing.coerce
(:import
(clojure.lang BigInt)
(java.time ZonedDateTime)
(java.time.format DateTimeFormatter)))
(set! *warn-on-reflection* true)
(defn- json-zdt->unix-millis ^long [s]
(-> s ZonedDateTime/parse .toInstant .toEpochMilli))
(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 [formatter (DateTimeFormatter/ofPattern "yyyyMMddHHmmSS")]
(defn- json-zdt->yyyymmddhhmmss [s]
(-> s ZonedDateTime/parse (.format formatter))))
;; 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))
(def input-coercion-fn
"Maps a coercion strategy to a function from input JSON object to the action representation (to be supplied to JDBC).
Assumes the input JSON object is valid for the coercion strategy, and can fit within the bounds of the target column."
{:Coercion/UNIXSeconds->DateTime #'json-zdt->unix-seconds
:Coercion/UNIXMilliSeconds->DateTime #'json-zdt->unix-millis
:Coercion/UNIXMicroSeconds->DateTime #'json-zdt->unix-micros
:Coercion/UNIXNanoSeconds->DateTime #'json-zdt->unix-nanos
:Coercion/YYYYMMDDHHMMSSString->Temporal #'json-zdt->yyyymmddhhmmss
:Coercion/ISO8601->DateTime identity
:Coercion/ISO8601->Date #'json-zdt->date
:Coercion/ISO8601->Time #'json-zdt->time})

View File

@@ -0,0 +1,176 @@
(ns metabase-enterprise.data-editing.data-editing
(:require
[medley.core :as m]
[metabase-enterprise.data-editing.coerce :as data-editing.coerce]
[metabase.actions.core :as actions]
[metabase.api.common :as api]
[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]
[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))
(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)
(select-keys 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
(m/index-by #(get-row-pks pk-fields %))))))))
(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)
(data-editing.coerce/input-coercion-fn 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: :bulk/create, :bulk/update, :bulk/delete.
The input `rows` is given different semantics depending on the action type, see actions/perform-action!."
[action-kw table-id rows]
;; TODO make this work for multi instances by using the metabase_cluster_lock table
;; https://github.com/metabase/metabase/pull/56173/files
(locking #'perform-bulk-action!
(actions/perform-with-system-events!
action-kw
{:database (api/check-404 (t2/select-one-fn :db_id [:model/Table :db_id] table-id))
:table-id table-id
:arg rows}
{:policy :data-editing})))
(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 table-id rows]
;; TODO make sure we're always passed a user, and remove this fallback
;; hard to make a business case for anonymous lemur's inserting data
(let [user-id (or user-id (t2/select-one-pk :model/User :is_superuser true))
res (perform-bulk-action! :bulk/create table-id rows)
pk-cols (t2/select-fn-vec :name [:model/Field :name] :table_id table-id :semantic_type :type/PK)
row-pk->old-new-values (->> (for [row (:created-rows res)]
(let [pks (zipmap pk-cols (map row pk-cols))]
[pks [nil row]]))
(into {}))]
;; TODO this should also become a subscription to the "data written" system event
;; TODO Circular reference will be fixed when we remove the hacks from this method.
;; We'll actually delete this whole method, it'll just become an :editable/insert action invocation.
((requiring-resolve 'metabase-enterprise.data-editing.undo/track-change!) user-id {table-id row-pk->old-new-values})
res))
(defn qry-context
"TODO remove this hacky glue"
[args-map]
;; These assumptions about the shape of args-map are not generally true
(let [table-id (:table-id args-map) ;
pk-fields (when table-id (select-table-pk-fields table-id))
rows (:arg args-map)]
(when (and table-id pk-fields (seq rows))
{:table-id table-id
:pk-fields pk-fields
:rows rows})))
(defn query-previous-rows
"TODO fix all this hackery"
[action-kw {:keys [table-id pk-fields rows]}]
(case action-kw
(:bulk/update :bulk/delete)
(query-db-rows table-id pk-fields rows)
:bulk/create
{}
;; action does not relate to row updates
(throw (ex-info "See, this doesn't make sense" {:dumb :hack}))))
(defn query-latest-rows
"TODO fix all this hackery"
[action-kw {:keys [table-id pk-fields rows]} result]
(case action-kw
:bulk/delete
{}
:bulk/update
(query-db-rows table-id pk-fields rows)
:bulk/create
(->> (for [row (:created-rows result)]
[(query-db-rows table-id pk-fields [row])
(update-keys row keyword)])
(into {}))
;; action does not relate to row updates
(throw (ex-info "See, this doesn't make sense" {:dumb :hack}))))

View File

@@ -0,0 +1,16 @@
(ns metabase-enterprise.data-editing.events
(:require
[metabase.events.notification :as events.notification]))
(derive ::event :metabase/event)
(derive :event/rows.created ::event)
(derive :event/rows.updated ::event)
(derive :event/rows.deleted ::event)
(doseq [event [:event/rows.created
:event/rows.updated
:event/rows.deleted]]
(defmethod events.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,171 @@
(ns metabase-enterprise.data-editing.undo
(:require
[metabase-enterprise.data-editing.data-editing :as data-editing]
[metabase.models.interface :as mi]
[metabase.util :as u]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(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?))
;; We must enforce the following invariant for the data in the Undo table:
;;
;; undone` is monotonic in `batch_num`, for each `table_id`, `row_pk` combination.
;; i.e., it can only change from `false` to `true` for any logical "cell", as `batch_num` increases.
(defn- next-batch [undo? user-id table-id]
;; 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]
[:= table-id :table_id]
(if undo?
[:not :undone]
:undone)]}]))
(defn track-change!
"Insert some snapshot data based on edits made to the given table."
[user-id table-id->row-pk->old-new-values]
(t2/insert!
:model/Undo
(for [[table-id table-updates] table-id->row-pk->old-new-values
[row-pk [old new]] table-updates]
{:batch_num [:+ [:inline 1] [:coalesce {:from [:data_edit_undo_chain] :select [[[:max :batch_num]]]} 0]]
:table_id table-id
:user_id user-id
:row_pk row-pk
:raw_before old
:raw_after new}))
;; TODO delete any orphaned or conflicting "undone" changes (keep in sync with conflict? if we fix false positives)
;; We do this in multiple statements because:
;; - it's much simpler
;; - typically there is only one table, and this is more efficient in that case
(doseq [[table-id table-updates] table-id->row-pk->old-new-values
:let [row-pks (keys table-updates)]]
;; TODO This is the wrong batch number
;; - we need to scope ourselves just to these row-pks
;; - we want to look across all users
;; - we can't rely on `undone` being monotonic if we're searching across multiple row-pks
(when-let [{:keys [batch_num]} (first (next-batch true user-id table-id))]
(t2/delete! :model/Undo
:table_id table-id
:row_pk [:in row-pks]
:batch_num [:> batch_num]
:undone true))))
(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? user-id table-id]
(:batch_num (first (next-batch undo? user-id table-id))))
;; 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? batch]
(doseq [[[table-id category] sub-batch] (u/group-by (juxt :table_id categorize) batch)
:let [rows (batch->rows undo? sub-batch)]]
(case (if undo? (invert category) category)
:create (try (data-editing/perform-bulk-action! :bulk/create table-id rows)
(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! :bulk/update table-id rows)
:delete (data-editing/perform-bulk-action! :bulk/delete table-id rows))))
(defn- undo*! [undo? user-id table-id]
(let [batch (next-batch undo? user-id table-id)]
(cond
(not (seq batch)) (throw (ex-info (if undo?
"No previous versions found"
"No subsequent versions found")
{:error :undo/none
:user-id user-id
:table-id table-id}))
(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
:table-id table-id}))
:else
(do (update-table-data! undo? 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 table-id]
(undo*! true user-id table-id))
(defn redo!
"Rollback the given user's last change to the given table."
[user-id table-id]
(undo*! false user-id table-id))

View File

@@ -0,0 +1,30 @@
(ns metabase-enterprise.data-editing-public.api
(:require
[metabase-enterprise.data-editing.data-editing :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) (:table_id hook) rows)))}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/data-editing routes."
(api.macros/ns-handler *ns*))

View File

@@ -65,6 +65,7 @@
"Notification"
"NotificationCard"
"NotificationSubscription"
"NotificationSystemEvent"
"NotificationHandler"
"NotificationRecipient"
"Permissions"
@@ -90,6 +91,7 @@
"Session"
"TablePrivileges"
"TaskHistory"
"Undo"
"User"
"UserKeyValue"
"UserParameterValue"

View File

@@ -66,6 +66,7 @@
:sso_jwt true
:sso_ldap true
:sso_saml true
:table_data_editing false
:upload_management false
: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,504 @@
(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))))
(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 "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"}]})))
(testing "Initially the table is empty"
(is (= [] (table-rows table-id))))
(is (= [[1 "Pidgey" "Car alarms"]
[2 "Spearow" "Hold music"]
[3 "Farfetch'd" "The land of lisp"]]
(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"}]})))
(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" "Car alarms"]
[2 "Spearow" "Hold music"]
[3 "Farfetch'd" "The land of lisp"]]
(table-rows table-id))))
(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 "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"}]})))
(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)))))))))
(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))))
(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
(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)))))
post-response
(req user :post url {:rows [{:name "Pidgey" :song "Car alarms"}]})
(finally
(try
(driver/drop-table! driver (mt/id) table-name)
(catch Exception _)))))))))
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")
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"}])
(is (= ["a"] (field-values)))
(create! [{:n "b"} {:n "c"}])
(is (= ["a" "b" "c"] (field-values)))
(update! [{:id 2, :n "d"}])
(is (= ["a" "c" "d"] (field-values)))
(create! [{:n "a"}])
(update! [{:id 1, :n "e"}])
(is (= ["a" "c" "d" "e"] (field-values))))))))

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,183 @@
(ns metabase-enterprise.data-editing.notification-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.data-editing.test-util :as data-editing.tu]
[metabase.events.notification :as events.notification]
[metabase.notification.test-util :as notification.tu]
[metabase.test :as mt]))
(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!
[event request-fn channel-type->assert-fns]
(data-editing.tu/with-temp-test-db!
(mt/with-temp [:model/Channel chn {:type :channel/http}]
(notification.tu/with-system-event-notification!
[_notification {:notification-system-event {:event_name event
:table_id (mt/id :categories)}
: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))]
(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/rows.created
(fn []
(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 (=? {:attachments [{: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-id "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has a new row"
:message [{"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/rows.updated
(fn []
(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 (=? {:attachments [{:blocks
[{:type "section",
:text
{:type "mrkdwn",
:text "*Crowberto Corv has updated a row from CATEGORIES*\n*Update:*\n• NAME : Updated Category"}}]}]
:channel-id "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has been updated"
:message [{"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/rows.deleted
(fn []
(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 (=? {:attachments [{: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-id "#test-pulse"}
message)))
:channel/email (fn [[email :as emails]]
(is (= 1 (count emails)))
(is (=? {:subject "Table CATEGORIES has a row deleted"
:message [{"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 filter-notifications-test
(testing "getting table notifications will return only notifications of a table and action"
(doseq [event [:event/rows.created
:event/rows.updated
:event/rows.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 (=? {: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 1 :name "orders"}
:records [{:row {:ID 1 :STATUS "approved"} :changes {:STATUS {:before "pending" :after "approved"}}}]
:settings {}}
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
{:payload_type :notification/system-event
:payload {:event_name :event/rows.created}
:creator_id (mt/user->id :crowberto)})))))
(deftest example-payload-row-update-test
(is (=? {: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 1 :name "orders"}
:records [{:row {:ID 1 :STATUS "approved"} :changes {:STATUS {:before "pending" :after "approved"}}}]
:settings {}}
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
{:payload_type :notification/system-event
:payload {:event_name :event/rows.updated}
:creator_id (mt/user->id :crowberto)})))))
(deftest example-payload-row-delete-test
(is (=? {: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 1 :name "orders"}
:records [{:row {:ID 1 :STATUS "approved"} :changes {:STATUS {:before "pending" :after "approved"}}}]
:settings {}}
(:payload (mt/user-http-request :crowberto :post 200 "notification/payload"
{:payload_type :notification/system-event
:payload {:event_name :event/rows.deleted}
:creator_id (mt/user->id :crowberto)})))))

View File

@@ -0,0 +1,90 @@
(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 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,230 @@
(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])
(:import
(clojure.lang ExceptionInfo)))
(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}})))
;; TODO use actual mutations to create the history (TODO subscribe to the relevant events)
(defn- write-sequence! [table-id pk states]
(loop [prior nil
states states]
(when (seq states)
(let [[user-id value] (first states)]
(undo/track-change! user-id {table-id {pk [prior 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)]
(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 (undo/next-batch-num true user-id table-id))
(is (not (undo/next-batch-num false user-id table-id)))
(is (= {table-id [[:create {:id 1, :name "Snorkmaiden", :favourite_food "orc"}]]}
(undo/undo! user-id table-id)))
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
(is (undo/next-batch-num true user-id table-id))
(is (undo/next-batch-num false user-id table-id))
(is (= {table-id [[:update {:id 1, :name "Snorkmaiden", :favourite_food "pork"}]]}
(undo/undo! user-id table-id)))
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
(is (undo/next-batch-num true user-id table-id))
(is (undo/next-batch-num false user-id table-id))
;; This doesn't tell the FE which rows to hide
(is (= {table-id [[:delete {:id 1}]]}
(undo/undo! user-id table-id)))
(is (= [] (table-rows table-id)))
(is (not (undo/next-batch-num true user-id table-id)))
(is (undo/next-batch-num false user-id table-id))
(is (thrown-with-msg?
ExceptionInfo
#"No previous versions found"
(undo/undo! user-id table-id)))
(is (not (undo/next-batch-num true user-id table-id)))
(is (undo/next-batch-num false user-id table-id))
(is (= {table-id [[:create {:id 1, :name "Snorkmaiden", :favourite_food "pork"}]]}
(undo/redo! user-id table-id)))
(is (= [[1 "Snorkmaiden" "pork"]] (table-rows table-id)))
(is (undo/next-batch-num true user-id table-id))
(is (undo/next-batch-num false user-id table-id))
(is (= {table-id [[:update {:id 1, :name "Snorkmaiden", :favourite_food "orc"}]]}
(undo/redo! user-id table-id)))
(is (= [[1 "Snorkmaiden" "orc"]] (table-rows table-id)))
(is (undo/next-batch-num true user-id table-id))
(is (undo/next-batch-num false user-id table-id))
(is (= {table-id [[:delete {:id 1}]]}
(undo/redo! user-id table-id)))
(is (= [] (table-rows table-id)))
(is (undo/next-batch-num true user-id table-id))
(is (not (undo/next-batch-num false user-id table-id)))
(is (thrown-with-msg?
ExceptionInfo
#"No subsequent versions found"
(undo/redo! user-id table-id)))
(is (undo/next-batch-num true user-id table-id))
(is (not (undo/next-batch-num false user-id table-id)))))))))
;; 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)]
(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 (undo/next-batch-num true user-2 table-id))
(is (not (undo/next-batch-num false user-2 table-id)))
(is (thrown-with-msg?
ExceptionInfo
#"Blocked by other changes"
(undo/undo! user-2 table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (not (undo/next-batch-num false user-1 table-id)))
(is (= {table-id [[:create {:id 2, :name "Moominswole", :power 9001}]]}
(undo/undo! user-1 table-id)))
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (thrown-with-msg?
ExceptionInfo
#"Blocked by other changes"
(undo/undo! user-1 table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (undo/next-batch-num true user-2 table-id))
(is (not (undo/next-batch-num false user-2 table-id)))
(is (= {table-id [[:update {:id 2, :name "Moomintroll", :power 9001}]]}
(undo/undo! user-2 table-id)))
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (= {table-id [[:update {:id 2, :name "Moomintroll", :power 3}]]}
(undo/undo! user-1 table-id)))
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (= {table-id [[:delete {:id 2}]]}
(undo/undo! user-1 table-id)))
(is (= [] (table-rows table-id)))
(is (not (undo/next-batch-num true user-1 table-id)))
(is (undo/next-batch-num false user-1 table-id))
(is (thrown-with-msg?
ExceptionInfo
#"No previous versions found"
(undo/undo! user-1 table-id)))
(is (not (undo/next-batch-num true user-1 table-id)))
(is (undo/next-batch-num false user-1 table-id))
(is (= {table-id [[:create {:id 2, :name "Moomintroll", :power 3}]]}
(undo/redo! user-1 table-id)))
(is (= [[2 "Moomintroll" 3]] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (= {table-id [[:update {:id 2, :name "Moomintroll", :power 9001}]]}
(undo/redo! user-1 table-id)))
(is (= [[2 "Moomintroll" 9001]] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (= {table-id [[:update {:id 2, :name "Moominswole", :power 9001}]]}
(undo/redo! user-2 table-id)))
(is (= [[2 "Moominswole" 9001]] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (undo/next-batch-num false user-1 table-id))
(is (= {table-id [[:delete {:id 2}]]}
(undo/redo! user-1 table-id)))
(is (= [] (table-rows table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (not (undo/next-batch-num false user-1 table-id)))
(is (thrown-with-msg?
ExceptionInfo
#"No subsequent versions found"
(undo/redo! user-1 table-id)))
(is (undo/next-batch-num true user-2 table-id))
(is (not (undo/next-batch-num false user-2 table-id)))
(is (thrown-with-msg?
ExceptionInfo
#"No subsequent versions found"
(undo/redo! user-2 table-id)))
(is (undo/next-batch-num true user-1 table-id))
(is (not (undo/next-batch-num false user-1 table-id)))))))))

View File

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

View File

@@ -56,6 +56,7 @@
:model/Notification
:model/NotificationCard
:model/NotificationSubscription
:model/NotificationSystemEvent
:model/NotificationHandler
:model/NotificationRecipient
:model/ParameterCard
@@ -82,6 +83,7 @@
:model/TablePrivileges
: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

@@ -3,5 +3,6 @@ export * from "./billing-info";
export * from "./gdrive";
export * from "./db-routing";
export * from "./query-validation";
export * from "./table-data-edit";
export * from "./scim";
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 }) => ({
method: "POST",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows },
}),
}),
updateTableRows: builder.mutation<
TableUpdateRowsResponse,
TableUpdateRowsRequest
>({
query: ({ tableId, rows }) => ({
method: "PUT",
url: `/api/ee/data-editing/table/${tableId}`,
body: { rows },
}),
}),
deleteTableRows: builder.mutation<
TableDeleteRowsResponse,
TableDeleteRowsRequest
>({
query: ({ tableId, rows }) => ({
method: "POST",
url: `/api/ee/data-editing/table/${tableId}/delete`,
body: { rows },
}),
}),
tableUndo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
query: ({ tableId, noOp }) => ({
method: "POST",
url: `/api/ee/data-editing/undo`,
body: { "table-id": tableId, "no-op": noOp },
}),
}),
tableRedo: builder.mutation<TableUndoRedoResponse, TableUndoRedoRequest>({
query: ({ tableId, noOp }) => ({
method: "POST",
url: `/api/ee/data-editing/redo`,
body: { "table-id": tableId, "no-op": noOp },
}),
}),
}),
});
export const {
useInsertTableRowsMutation,
useUpdateTableRowsMutation,
useDeleteTableRowsMutation,
useTableUndoMutation,
useTableRedoMutation,
} = tableDataEditApi;

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,183 @@
import cx from "classnames";
import { t } from "ttag";
import {
ActionIcon,
Box,
Button,
Flex,
Group,
Icon,
Loader,
Stack,
Text,
rem,
} from "metabase/ui";
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 { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
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 = {
dashcardId: number;
cardId: number;
tableId: ConcreteTableId;
data: DatasetData;
className?: string;
visualizationSettings?: VisualizationSettings;
question: Question;
};
export const EditTableDashcardVisualization = ({
dashcardId,
cardId,
tableId,
data,
className,
visualizationSettings,
question,
}: EditTableDashcardVisualizationProps) => {
const stateUpdateStrategy = useTableEditingStateDashcardUpdateStrategy(
dashcardId,
cardId,
);
const {
isCreateRowModalOpen,
expandedRowIndex,
isInserting,
closeCreateRowModal,
tableFieldMetadataMap,
handleRowCreate,
handleCellValueUpdate,
handleExpandedRowDelete,
handleModalOpenAndExpandedRow,
} = useTableCRUD({ tableId, datasetData: data, stateUpdateStrategy });
const { undo, redo, isUndoLoading, isRedoLoading } = useTableEditingUndoRedo({
tableId,
stateUpdateStrategy,
});
const columnsConfig = useEditableTableColumnConfigFromVisualizationSettings(
visualizationSettings,
);
const { hasCreateAction, hasDeleteAction } = useTableActions(
visualizationSettings,
);
const { getColumnSortDirection } = useTableSorting({
question,
});
return (
<Stack className={cx(S.container, className)} gap={0}>
<Box pos="relative" className={S.gridWrapper}>
<EditTableDataGrid
data={data}
fieldMetadataMap={tableFieldMetadataMap}
onCellValueUpdate={handleCellValueUpdate}
onRowExpandClick={handleModalOpenAndExpandedRow}
columnsConfig={columnsConfig}
getColumnSortDirection={getColumnSortDirection}
/>
</Box>
<Flex
p="xs"
px="1rem"
justify="space-between"
align="center"
className={S.gridFooterDashcardVisualization}
>
<Group
gap="xs"
mih={
rem(30) /* to avoid jumping when "New record" button is disabled */
}
>
<ActionIcon onClick={undo} disabled={isUndoLoading || isRedoLoading}>
{isUndoLoading ? (
<Loader size="xs" />
) : (
<Icon
name="undo"
tooltip={t`Undo changes`}
c="var(--mb-color-brand)"
/>
)}
</ActionIcon>
<ActionIcon onClick={redo} disabled={isUndoLoading || isRedoLoading}>
{isRedoLoading ? (
<Loader size="xs" />
) : (
<Icon
name="redo"
tooltip={t`Redo changes`}
c="var(--mb-color-brand)"
/>
)}
</ActionIcon>
{hasCreateAction && (
<Button
variant="subtle"
size="xs"
fz="sm"
leftSection={<Icon name="add" />}
onClick={() => handleModalOpenAndExpandedRow()}
>{t`New record`}</Button>
)}
</Group>
<Text fz="sm" fw="bold">
{getEditTableRowCountMessage(data)}
</Text>
</Flex>
<EditingBaseRowModal
opened={isCreateRowModalOpen}
hasDeleteAction={hasDeleteAction}
onClose={closeCreateRowModal}
onEdit={handleCellValueUpdate}
onRowCreate={handleRowCreate}
onRowDelete={handleExpandedRowDelete}
datasetColumns={data.cols}
currentRowIndex={expandedRowIndex}
currentRowData={
expandedRowIndex !== undefined
? data.rows[expandedRowIndex]
: 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)}`;
}

View File

@@ -0,0 +1,77 @@
.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 .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;
}

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,165 @@
import type { Location } from "history";
import { useMemo } from "react";
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 { EditingBaseRowModal } from "./modals/EditingBaseRowModal";
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;
};
location: Location<{ filter?: string }>;
};
export const EditTableDataContainer = ({
params: { dbId: dbIdParam, tableId: tableIdParam },
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 stateUpdateStrategy =
useTableEditingStateApiUpdateStrategy(fakeTableQuery);
const {
isCreateRowModalOpen,
expandedRowIndex,
isInserting,
closeCreateRowModal,
tableFieldMetadataMap,
handleRowCreate,
handleCellValueUpdate,
handleExpandedRowDelete,
handleModalOpenAndExpandedRow,
} = useTableCRUD({ tableId, datasetData, stateUpdateStrategy });
const { undo, redo, isUndoLoading, isRedoLoading } = useTableEditingUndoRedo({
tableId,
stateUpdateStrategy,
});
useMount(() => {
dispatch(closeNavbar());
});
if (!database || isLoading || !fakeTableQuestion) {
// TODO: show loader
return null;
}
if (!rawDatasetResult || !datasetData || !tableFieldMetadataMap) {
// TODO: show error
return null;
}
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={handleModalOpenAndExpandedRow}
onQuestionChange={handleQuestionChange}
refetchTableDataQuery={refetch}
onUndo={undo}
onRedo={redo}
/>
)}
{isDatabaseTableEditingEnabled(database) ? (
<>
<Box pos="relative" className={S.gridWrapper}>
<EditTableDataGrid
data={datasetData}
fieldMetadataMap={tableFieldMetadataMap}
onCellValueUpdate={handleCellValueUpdate}
onRowExpandClick={handleModalOpenAndExpandedRow}
/>
</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
opened={isCreateRowModalOpen}
onClose={closeCreateRowModal}
onEdit={handleCellValueUpdate}
onRowCreate={handleRowCreate}
onRowDelete={handleExpandedRowDelete}
datasetColumns={datasetData.cols}
currentRowIndex={expandedRowIndex}
currentRowData={
expandedRowIndex !== undefined
? datasetData.rows[expandedRowIndex]
: 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,223 @@
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 type {
DatasetColumn,
DatasetData,
FieldWithMetadata,
RowValue,
RowValues,
} from "metabase-types/api";
import { canEditField } from "../../helpers";
import type { UpdatedRowCellsHandlerParams } 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";
type EditTableDataGridProps = {
data: DatasetData;
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
onCellValueUpdate: (params: UpdatedRowCellsHandlerParams) => void;
onRowExpandClick: (rowIndex: number) => void;
columnsConfig?: EditableTableColumnConfig;
getColumnSortDirection?: (
column: DatasetColumn,
) => OrderByDirection | undefined;
};
export const EditTableDataGrid = ({
data,
fieldMetadataMap,
onCellValueUpdate,
onRowExpandClick,
columnsConfig,
getColumnSortDirection,
}: EditTableDataGridProps) => {
const { cols, rows } = data;
const { editingCellId, onCellClickToEdit, onCellEditCancel } =
useTableEditing();
const columnOrder = useMemo(
() =>
columnsConfig
? columnsConfig.filter((it) => it.enabled).map(({ name }) => name)
: cols.map(({ name }) => name),
[cols, columnsConfig],
);
const columnVisibility = useMemo(
() =>
columnsConfig
? columnsConfig.reduce(
(acc, { name, enabled }) => ({
...acc,
[name]: enabled,
}),
{},
)
: undefined,
[columnsConfig],
);
// if columnsConfig is not provided, all columns are editable and columnsConfiguredForEditing is undefined
const columnsConfiguredForEditing = useMemo(() => {
if (!columnsConfig) {
return undefined;
}
return new Set(
columnsConfig.filter((it) => it.editable).map(({ name }) => name),
);
}, [columnsConfig]);
const columnSizingMap = useMemo(() => ({}), []);
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
return cols.map((column, columnIndex) => {
const isEditableColumn =
!columnsConfiguredForEditing ||
columnsConfiguredForEditing.has(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,
};
if (!isEditableColumn) {
options.getCellClassName = () => S.readonlyCell;
}
return options;
});
}, [
cols,
getColumnSortDirection,
fieldMetadataMap,
onCellValueUpdate,
onCellEditCancel,
editingCellId,
columnsConfiguredForEditing,
]);
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 (
columnsConfiguredForEditing &&
!columnsConfiguredForEditing.has(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,
columnsConfiguredForEditing,
],
);
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,129 @@
import { useDisclosure } from "@mantine/hooks";
import { useMemo } from "react";
import { t } from "ttag";
import { TableNotificationsTrigger } from "metabase/notifications/modals";
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,
Loader,
Stack,
Title,
} from "metabase/ui";
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);
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}
>{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"
disabled={isUndoLoading || isRedoLoading}
>
{isUndoLoading ? (
<Loader size="xs" color="currentColor" />
) : (
<Icon name="undo" tooltip={t`Undo changes`} />
)}
</ActionIcon>
<ActionIcon
onClick={onRedo}
size="lg"
disabled={isUndoLoading || isRedoLoading}
>
{isRedoLoading ? (
<Loader size="xs" color="currentColor" />
) : (
<Icon name="redo" tooltip={t`Redo changes`} />
)}
</ActionIcon>
</Flex>
</Group>
</Flex>
<QuestionFiltersHeader
expanded={areFiltersExpanded}
question={question}
updateQuestion={onQuestionChange}
/>
</Stack>
);
};

View File

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

View File

@@ -0,0 +1,75 @@
import type { CellContext } from "@tanstack/react-table";
import { useCallback } from "react";
import type {
DatasetColumn,
FieldWithMetadata,
RowValue,
RowValues,
} from "metabase-types/api";
import type { UpdatedRowCellsHandlerParams } 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: UpdatedRowCellsHandlerParams) => 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,
});
}
// Hide the editing cell after submitting the value
onCellEditCancel();
},
[columnName, onCellEditCancel, onCellValueUpdate, rowIndex, initialValue],
);
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 @@
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,281 @@
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 { 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) {
// Type safety, should always be present
if (value in optionValueToOptionMap) {
return getSelectedLabelText(optionValueToOptionMap[value]);
}
return value;
}
return null;
}, [
isLoading,
value,
optionValueToOptionMap,
inputProps?.placeholder,
getSelectedLabelText,
]);
const isNullable = field?.database_is_nullable;
const shouldDisplayClearButton = isNullable && !!value;
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,308 @@
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 { UpdatedRowCellsHandlerParams } from "../../types";
import { EditingBodyCellConditional } from "../inputs";
import type { EditableTableColumnConfig } from "../use-editable-column-config";
import { DeleteRowConfirmationModal } from "./DeleteRowConfirmationModal";
import S from "./EditingBaseRowModal.module.css";
interface EditingBaseRowModalProps {
datasetColumns: DatasetColumn[];
datasetTable?: Table;
onClose: () => void;
onEdit: (data: UpdatedRowCellsHandlerParams) => void;
onRowCreate: (data: Record<string, RowValue>) => void;
onRowDelete: (rowIndex: number) => void;
opened: boolean;
currentRowIndex?: number;
currentRowData?: RowValues;
fieldMetadataMap: Record<FieldWithMetadata["name"], FieldWithMetadata>;
hasDeleteAction: boolean;
isLoading?: boolean;
columnsConfig?: EditableTableColumnConfig;
}
type EditingFormValues = Record<string, RowValue>;
export function EditingBaseRowModal({
datasetColumns,
onClose,
onEdit,
onRowCreate,
onRowDelete,
opened,
currentRowIndex,
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 {
isValid,
resetForm,
setFieldValue,
handleSubmit,
errors,
validateForm: revalidateForm,
} = useFormik({
initialValues: {} as EditingFormValues,
onSubmit: onRowCreate,
validate: validateForm,
validateOnMount: true,
});
// Clear new row data when modal is opened
useEffect(() => {
if (opened) {
resetForm();
revalidateForm();
}
}, [opened, resetForm, revalidateForm]);
const handleValueEdit = useCallback(
(key: string, value: RowValue) => {
if (currentRowIndex != null && isEditingMode) {
onEdit({
rowIndex: currentRowIndex,
updatedData: {
[key]: value,
},
});
}
},
[isEditingMode, currentRowIndex, onEdit],
);
const handleDeleteConfirmation = useCallback(() => {
if (currentRowIndex !== undefined) {
onRowDelete(currentRowIndex);
closeDeletionModal();
}
}, [currentRowIndex, closeDeletionModal, onRowDelete]);
const disabledColumnNames = useMemo(() => {
if (!columnsConfig) {
return undefined;
}
return new Set(
columnsConfig.filter((it) => !it.editable).map(({ name }) => name),
);
}, [columnsConfig]);
// Columns might be reordered to match the order in `columnsConfig`
const orderedDatasetColumns = useMemo(() => {
if (!columnsConfig) {
return datasetColumns;
}
return columnsConfig
.filter((it) => it.enabled)
.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={opened} 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={disabledColumnNames?.has(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,39 @@
import { useMemo } from "react";
import type {
DashCardVisualizationSettings,
VisualizationSettings,
} from "metabase-types/api";
export type EditableTableColumnConfig = {
name: string;
enabled: boolean;
editable: 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 enableAllEditableColumns = editableColumns.length === 0;
const editableColumnsSet = new Set(editableColumns);
return columnSettings.map((column) => ({
name: column.name,
enabled: column.enabled,
editable: enableAllEditableColumns || editableColumnsSet.has(column.name),
}));
}, [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,200 @@
import { useDisclosure } from "@mantine/hooks";
import { useCallback, useMemo, useState } from "react";
import { t } from "ttag";
import { useGetTableQueryMetadataQuery } from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
import { addUndo } from "metabase/redux/undo";
import {
useDeleteTableRowsMutation,
useInsertTableRowsMutation,
useUpdateTableRowsMutation,
} from "metabase-enterprise/api";
import type { UpdatedRowCellsHandlerParams } from "metabase-enterprise/data_editing/tables/types";
import { isPK } from "metabase-lib/v1/types/utils/isa";
import type {
ConcreteTableId,
DatasetData,
FieldWithMetadata,
RowValue,
} from "metabase-types/api";
import type { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
export const useTableCRUD = ({
tableId,
datasetData,
stateUpdateStrategy,
}: {
tableId: ConcreteTableId;
datasetData: DatasetData | null | undefined;
stateUpdateStrategy: TableEditingStateUpdateStrategy;
}) => {
const [
isCreateRowModalOpen,
{ open: openCreateRowModal, close: closeCreateRowModal },
] = useDisclosure(false);
const [expandedRowIndex, setExpandedRowIndex] = useState<
number | undefined
>();
const dispatch = useDispatch();
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 displayErrorIfExists = useCallback(
(error: any) => {
if (error) {
dispatch(
addUndo({
icon: "warning",
toastColor: "error",
message: error?.data?.errors?.[0].error ?? t`An error occurred`,
}),
);
}
},
[dispatch],
);
const handleCellValueUpdate = useCallback(
async ({ updatedData, rowIndex }: UpdatedRowCellsHandlerParams) => {
if (!datasetData) {
console.warn(
"Failed to update table data - no data is loaded for a table",
);
return;
}
const columns = datasetData.cols;
const rowData = datasetData.rows[rowIndex];
const pkColumnIndex = columns.findIndex(isPK);
const pkColumn = columns[pkColumnIndex];
const rowPkValue = rowData[pkColumnIndex];
const updatedRowWithPk = {
...updatedData,
[pkColumn.name]: rowPkValue,
};
const response = await updateTableRows({
tableId: tableId,
rows: [updatedRowWithPk],
});
stateUpdateStrategy.onRowsUpdated(response.data?.updated);
displayErrorIfExists(response.error);
},
[
datasetData,
updateTableRows,
tableId,
displayErrorIfExists,
stateUpdateStrategy,
],
);
const handleRowCreate = useCallback(
async (data: Record<string, RowValue>) => {
const response = await insertTableRows({
tableId: tableId,
rows: [data],
});
displayErrorIfExists(response.error);
if (!response.error) {
closeCreateRowModal();
stateUpdateStrategy.onRowsCreated(response.data?.["created-rows"]);
}
},
[
insertTableRows,
tableId,
displayErrorIfExists,
closeCreateRowModal,
stateUpdateStrategy,
],
);
const handleExpandedRowDelete = useCallback(
async (rowIndex: number) => {
if (!datasetData) {
console.warn(
"Failed to update table data - no data is loaded for a table",
);
return;
}
const columns = datasetData.cols;
const rowData = datasetData.rows[rowIndex];
const pkColumnIndex = columns.findIndex(isPK);
const pkColumn = columns[pkColumnIndex];
const rowPkValue = rowData[pkColumnIndex];
closeCreateRowModal();
const rows = [{ [pkColumn.name]: rowPkValue }];
const response = await deleteTableRows({
rows,
tableId: tableId,
});
if (response.data?.success) {
stateUpdateStrategy.onRowsDeleted(rows);
}
displayErrorIfExists(response.error);
},
[
datasetData,
closeCreateRowModal,
deleteTableRows,
tableId,
displayErrorIfExists,
stateUpdateStrategy,
],
);
const handleModalOpenAndExpandedRow = useCallback(
(rowIndex?: number) => {
setExpandedRowIndex(rowIndex);
openCreateRowModal();
},
[openCreateRowModal],
);
return {
isCreateRowModalOpen,
expandedRowIndex,
isInserting,
closeCreateRowModal,
tableFieldMetadataMap,
handleRowCreate,
handleCellValueUpdate,
handleExpandedRowDelete,
handleModalOpenAndExpandedRow,
};
};

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,112 @@
import type { ThunkDispatch, UnknownAction } from "@reduxjs/toolkit";
import { useCallback, useMemo } from "react";
import { datasetApi } from "metabase/api";
import { useDispatch } from "metabase/lib/redux";
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(
adhocQuery: 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>[]) => {
if (!rows || !adhocQuery) {
return;
}
dispatch(
datasetApi.util.updateQueryData(
"getAdhocQuery",
adhocQuery,
({ data }) => {
data.rows.push(
...mapDataEditingRowObjectsToRowValues(rows, data.cols),
);
},
),
);
},
[dispatch, adhocQuery],
);
const onRowsUpdated = useCallback(
(rows?: Record<string, RowValue>[]) => {
if (!rows || !adhocQuery) {
return;
}
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];
}
}
}
}
},
),
);
},
[dispatch, adhocQuery],
);
const onRowsDeleted = useCallback(
(rows?: Record<string, RowValue>[]) => {
if (!rows || !adhocQuery) {
return;
}
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, adhocQuery],
);
return useMemo(
() => ({
onRowsCreated,
onRowsUpdated,
onRowsDeleted,
}),
[onRowsCreated, onRowsUpdated, onRowsDeleted],
);
}

View File

@@ -0,0 +1,122 @@
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 || !rows) {
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 || !rows) {
return;
}
const pkColumnIndex = cardData.data.cols.findIndex(isPK);
const pkColumnName = cardData.data.cols[pkColumnIndex].name;
const primaryKeyToUpdatedRowObjectMap =
createPrimaryKeyToUpdatedRowObjectMap(pkColumnName, rows);
dispatch(
updateCardData(cardId, dashcardId, {
...cardData,
data: {
...cardData.data,
rows: cardData.data.rows.map((row) => {
const updatedRowObject = primaryKeyToUpdatedRowObjectMap.get(
row[pkColumnIndex],
);
if (updatedRowObject) {
return row.map((value, index) => {
const columnName = cardData.data.cols[index].name;
if (columnName in updatedRowObject) {
return updatedRowObject[columnName];
}
return value;
});
}
return row;
}),
},
}),
);
},
[cardData, dashcardId, cardId, dispatch],
);
const onRowsDeleted = useCallback(
(rows?: Record<string, RowValue>[]) => {
if (!cardData || !rows) {
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,23 @@
import type { DatasetColumn, RowValue } from "metabase-types/api";
type DataEditingRow = Record<string, RowValue>;
export interface TableEditingStateUpdateStrategy {
onRowsCreated: (rows?: DataEditingRow[]) => void;
onRowsUpdated: (rows?: DataEditingRow[]) => 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,100 @@
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 { TableEditingStateUpdateStrategy } from "./use-table-state-update-strategy";
type UseTableEditingUndoRedoProps = {
tableId: ConcreteTableId;
stateUpdateStrategy: TableEditingStateUpdateStrategy;
};
export function useTableEditingUndoRedo({
tableId,
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 });
handleResponse("undo", response);
}, [undoMutation, tableId, handleResponse]);
const redo = useCallback(async () => {
const response = await redoMutation({ tableId });
handleResponse("redo", response);
}, [redoMutation, tableId, handleResponse]);
return { undo, redo, isUndoLoading, isRedoLoading };
}

View File

@@ -0,0 +1,15 @@
import { b64hash_to_utf8, utf8_to_b64url } from "metabase/lib/encoding";
import type { Filter } from "metabase-types/api";
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;
};

View File

@@ -0,0 +1,52 @@
import type {
ConcreteTableId,
DatasetColumn,
RowValue,
} from "metabase-types/api";
export type RowCellsWithPkValue = Record<DatasetColumn["name"], RowValue>;
export type TableInsertRowsRequest = {
tableId: ConcreteTableId;
rows: RowCellsWithPkValue[];
};
export type TableInsertRowsResponse = {
"created-rows": Record<string, RowValue>[];
};
export type TableUpdateRowsRequest = {
tableId: ConcreteTableId;
rows: RowCellsWithPkValue[];
};
export type TableUpdateRowsResponse = { updated: Record<string, RowValue>[] };
export type TableDeleteRowsRequest = {
tableId: ConcreteTableId;
rows: RowCellsWithPkValue[];
};
export type TableDeleteRowsResponse = { success: boolean };
export type UpdatedRowCellsHandlerParams = {
updatedData: Record<DatasetColumn["name"], RowValue>;
rowIndex: number;
};
export type TableUndoRedoRequest = {
tableId: ConcreteTableId;
/**
* When true, the API will only return the batch number of the next undo operation
* without actually performing the undo. This is useful for checking if an undo operation
* is available before attempting it.
*/
noOp?: boolean;
};
export type TableOperation = [string, Record<string, RowValue>];
export type TableUndoRedoResponse = {
batch_num?: number;
result?: Record<ConcreteTableId, TableOperation[]>;
};

View File

@@ -0,0 +1,16 @@
.container {
height: 100%;
width: 100%;
overflow: hidden;
}
.gridWrapper {
overflow: hidden;
flex-grow: 1;
}
.gridRoot {
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-sm);
overflow: hidden;
}

View File

@@ -0,0 +1,130 @@
import { useMemo } from "react";
import { useMount } from "react-use";
import { t } from "ttag";
import { useGetTableDataQuery, useGetTableQuery } from "metabase/api";
import Link from "metabase/core/components/Link";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { TableNotificationsTrigger } from "metabase/notifications/modals";
import { closeNavbar } from "metabase/redux/app";
import { getMetadata } from "metabase/selectors/metadata";
import { getUserIsAdmin } from "metabase/selectors/user";
import {
Box,
Button,
Flex,
Group,
Icon,
Stack,
Text,
Title,
} from "metabase/ui";
import Question from "metabase-lib/v1/Question";
import { getRowCountMessage } from "metabase-lib/v1/queries/utils/row-count";
import * as ML_Urls from "metabase-lib/v1/urls";
import S from "./BrowseTableData.module.css";
import { BrowseTableDataGrid } from "./BrowseTableDataGrid";
type BrowseTableDataProps = {
params: {
dbId: string;
tableId: string;
};
};
export const BrowseTableData = ({
params: { dbId: dbIdParam, tableId: tableIdParam },
}: BrowseTableDataProps) => {
const tableId = parseInt(tableIdParam, 10);
const databaseId = parseInt(dbIdParam, 10);
const dispatch = useDispatch();
const metadata = useSelector(getMetadata);
const isAdmin = useSelector(getUserIsAdmin);
const { data: table } = useGetTableQuery({ id: tableId });
const { data: datasetData } = useGetTableDataQuery({
tableId,
});
const editUrl = `/browse/databases/${databaseId}/tables/${tableId}/edit`;
const exploreUrl = useMemo(() => {
const question = Question.create({
databaseId,
tableId,
metadata,
}).setDefaultDisplay();
return ML_Urls.getUrl(question);
}, [databaseId, metadata, tableId]);
useMount(() => {
dispatch(closeNavbar());
});
if (!table || !datasetData) {
// TODO: add loading and error handling
// TODO: add loading state on data refresh
return null;
}
return (
<Stack className={S.container} gap={0} data-testid="table-data-view-root">
<Group
justify="space-between"
align="center"
px="1.5rem"
py="1rem"
bg="var(--mb-color-background)"
>
<Group gap="sm">
<Icon
name="table"
color="var(--mb-color-text-secondary)"
size="1.5rem"
/>
<Title>{table.display_name}</Title>
<Box mt="0.4rem">
<Icon name="info_filled" color="var(--mb-color-text-secondary)" />
</Box>
</Group>
<Group gap="md">
<Button
leftSection={<Icon name="insight" />}
component={Link}
to={exploreUrl}
>{t`Explore`}</Button>
{isAdmin && (
<Button
leftSection={<Icon name="pencil" />}
component={Link}
to={editUrl}
>{t`Edit`}</Button>
)}
<Flex gap="xs">
<TableNotificationsTrigger tableId={tableId} />
</Flex>
</Group>
</Group>
<Box className={S.gridWrapper} p="1.5rem 1.5rem 0.5rem">
<BrowseTableDataGrid data={datasetData} />
</Box>
<Flex
py="0.5rem"
px="1.5rem"
h="2.5rem"
justify="flex-end"
align="center"
>
<Text fw="bold" size="md" c="inherit" component="span">
{getRowCountMessage(datasetData)}
</Text>
</Flex>
</Stack>
);
};

View File

@@ -0,0 +1,57 @@
import { useMemo } from "react";
import {
type ColumnOptions,
DataGrid,
useDataGridInstance,
} from "metabase/data-grid";
import { formatValue } from "metabase/lib/formatting/value";
import { extractRemappedColumns } from "metabase/visualizations";
import type { Dataset, RowValue, RowValues } from "metabase-types/api";
import S from "./BrowseTableData.module.css";
type BrowseTableDataGridProps = {
data: Dataset;
};
export const BrowseTableDataGrid = ({ data }: BrowseTableDataGridProps) => {
const { cols, rows } = useMemo(
() => extractRemappedColumns(data.data),
[data.data],
);
const columnOrder = useMemo(() => cols.map(({ name }) => name), [cols]);
const columnSizingMap = useMemo(() => ({}), []);
const columnsOptions: ColumnOptions<RowValues, RowValue>[] = useMemo(() => {
return cols.map((column, columnIndex) => {
const options: ColumnOptions<RowValues, RowValue> = {
id: column.name,
name: column.display_name,
accessorFn: (row: RowValues) => row[columnIndex],
formatter: (value) => formatValue(value, { column }),
wrap: false,
};
return options;
});
}, [cols]);
const tableProps = useDataGridInstance({
data: rows,
columnOrder,
columnSizingMap,
columnsOptions,
});
return (
<DataGrid
classNames={{
root: S.gridRoot,
}}
{...tableProps}
/>
);
};

View File

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

View File

@@ -36,3 +36,4 @@ import "./resource_downloads";
import "./user_provisioning";
import "./clean_up";
import "./troubleshooting";
import "./data_editing";

View File

@@ -1,6 +1,7 @@
import * as ML from "cljs/metabase.lib.js";
import * as ML_MetadataCalculation from "cljs/metabase.lib.metadata.calculation";
import type {
Field as ApiField,
CardId,
CardType,
DatabaseId,
@@ -183,7 +184,6 @@ export function tableOrCardMetadata(
): CardMetadata | TableMetadata {
return ML.table_or_card_metadata(queryOrMetadataProvider, tableID);
}
export function visibleColumns(
query: Query,
stageIndex: number,
@@ -201,7 +201,7 @@ export function returnedColumns(
export function fromLegacyColumn(
query: Query,
stageIndex: number,
columnOrField: DatasetColumn | Field,
columnOrField: DatasetColumn | Field | ApiField,
): ColumnMetadata {
return ML.legacy_column__GT_metadata(query, stageIndex, columnOrField);
}

View File

@@ -0,0 +1,20 @@
import { msgid, ngettext, t } from "ttag";
import { formatNumber } from "metabase/lib/formatting/numbers";
import { HARD_ROW_LIMIT } from "metabase-lib/v1/queries/utils/index";
import type { Dataset } from "metabase-types/api";
export const formatRowCount = (count: number) => {
const countString = formatNumber(count);
return ngettext(msgid`${countString} row`, `${countString} rows`, count);
};
export function getRowCountMessage(result: Dataset): string {
if (result.data.rows_truncated > 0) {
return t`Showing first ${formatRowCount(result.row_count)}`;
}
if (result.row_count === HARD_ROW_LIMIT) {
return t`Showing first ${HARD_ROW_LIMIT} rows`;
}
return t`Showing ${formatRowCount(result.row_count)}`;
}

View File

@@ -16,7 +16,7 @@ import type { PaginationRequest, PaginationResponse } from "./pagination";
import type { Parameter } from "./parameters";
import type { DatasetQuery, FieldReference, PublicDatasetQuery } from "./query";
import type { CollectionEssentials } from "./search";
import type { Table, TableId } from "./table";
import type { ConcreteTableId, Table, TableId } from "./table";
import type { UserInfo } from "./user";
import type { CardDisplayType, VisualizationDisplay } from "./visualization";
import type { SmartScalarComparison } from "./visualization-settings";
@@ -66,6 +66,8 @@ export interface Card<Q extends DatasetQuery = DatasetQuery>
cache_ttl: number | null;
based_on_upload?: TableId | null; // table id of upload table, if any
table_id?: ConcreteTableId | null; // table id for editable table
archived: boolean;
creator?: CreatorInfo;
@@ -236,6 +238,7 @@ export type VisualizationSettings = {
// Table
"table.columns"?: TableColumnOrderSetting[];
// Keys here can be modern (returned by `getColumnKey`) or legacy (`getLegacyColumnKey`).
// Use `getColumnSettings` which checks for both keys.
column_settings?: Record<string, ColumnSettings>;

View File

@@ -12,15 +12,13 @@ import type {
ParameterId,
ParameterTarget,
Table,
TableColumnOrderSetting,
UserId,
VirtualCardDisplay,
WritebackActionId,
} from "metabase-types/api";
import type {
ActionDisplayType,
WritebackAction,
WritebackActionId,
} from "./actions";
import type { ActionDisplayType, WritebackAction } from "./actions";
import type { Card, CardId, VisualizationSettings } from "./card";
import type { Dataset } from "./dataset";
import type { ModerationReview } from "./moderation";
@@ -101,10 +99,20 @@ export type DashboardCardLayoutAttrs = {
size_y: number;
};
export type EditableTableRowActionDisplaySetting = {
id: WritebackActionId | "row/create" | "row/delete";
enabled: boolean;
};
export type DashCardVisualizationSettings = {
[key: string]: unknown;
virtual_card?: VirtualCard;
iframe?: string;
// "table-editable" specific settings
"table.columns"?: TableColumnOrderSetting[];
"table.editableColumns"?: string[]; // list of column names
"editableTable.enabledActions"?: EditableTableRowActionDisplaySetting[];
};
export type BaseDashboardCard = DashboardCardLayoutAttrs & {
@@ -117,6 +125,7 @@ export type BaseDashboardCard = DashboardCardLayoutAttrs & {
entity_id: BaseEntityId;
visualization_settings?: DashCardVisualizationSettings;
justAdded?: boolean;
isAdded?: boolean;
created_at: string;
updated_at: string;
};

View File

@@ -121,6 +121,15 @@ export interface Field {
updated_at: string;
}
export interface FieldWithMetadata extends Field {
database_default?: string;
database_indexed: boolean;
database_is_auto_increment: boolean;
database_is_generated: boolean;
database_is_nullable: boolean;
database_required: boolean;
}
export interface FieldFormattingSettings {
currency?: string;
}
@@ -155,7 +164,8 @@ export interface GetFieldValuesResponse {
export interface SearchFieldValuesRequest {
fieldId: FieldId;
searchFieldId: FieldId;
value: string;
// If value is not provided, the endpoint will return all values with limit
value?: string;
limit: number;
}

View File

@@ -1,15 +1,16 @@
import type {
Notification,
AlertNotification,
NotificationCronSubscription,
NotificationHandlerEmail,
NotificationHandlerSlack,
NotificationRecipientUser,
TableNotification,
} from "metabase-types/api";
import { createMockUserInfo } from "metabase-types/api/mocks/user";
export const createMockNotification = (
opts?: Partial<Notification>,
): Notification => ({
export const createMockAlertNotification = (
opts?: Partial<AlertNotification>,
): AlertNotification => ({
payload_id: 7,
payload: {
id: 7,
@@ -98,9 +99,28 @@ export const createMockNotificationCronSubscription = (
id: 10,
notification_id: 10,
type: "notification-subscription/cron",
event_name: null,
created_at: "2025-01-07T18:40:47.245205+03:00",
cron_schedule: "0 0 9 * * ?",
ui_display_type: "cron/builder",
...opts,
});
export const createMockTableNotification = (
opts?: Partial<TableNotification>,
): TableNotification => ({
id: 1,
payload_id: null,
payload_type: "notification/system-event",
payload: {
event_name: "event/rows.created",
table_id: 42,
},
creator: createMockUserInfo(),
creator_id: 3,
handlers: [createMockNotificationHandlerEmail()],
created_at: "2025-01-07T18:40:47.245205+03:00",
updated_at: "2025-01-07T18:40:47.245205+03:00",
active: true,
condition: ["=", ["value"], true],
...opts,
});

View File

@@ -124,6 +124,7 @@ export const createMockTokenFeatures = (
query_reference_validation: false,
serialization: false,
cache_preemptive: false,
table_data_editing: false,
database_routing: false,
...opts,
});

View File

@@ -80,6 +80,7 @@ export type EmailChannelSpec = ChannelSpec & {
recipients: ChannelSpecRecipients;
};
export type NotificationChannelKey = "email" | "slack" | "http";
export interface ChannelApiResponse {
channels: {
email?: EmailChannelSpec;

View File

@@ -1,6 +1,7 @@
import type { Card, CardId } from "metabase-types/api/card";
import type { Channel } from "metabase-types/api/notification-channels";
import type { PaginationRequest } from "metabase-types/api/pagination";
import type { Table, TableId } from "metabase-types/api/table";
import type { UserId, UserInfo } from "metabase-types/api/user";
export type NotificationId = number;
@@ -10,6 +11,16 @@ export type NotificationCardSendCondition =
| "goal_below"
| "has_result";
// The SystemEvent values that can trigger notifications.
export type NotificationTriggerEvent =
| "event/rows.created"
| "event/rows.updated"
| "event/rows.deleted";
type NotificationPayloadType =
| "notification/card"
| "notification/system-event";
//#region Payload union type
type NotificationCardPayload = {
payload_type: "notification/card";
@@ -26,7 +37,15 @@ type NotificationCardPayload = {
payload_id?: number;
};
type NotificationPayload = NotificationCardPayload; // will be populated with more variants later on
type NotificationSystemEventPayload = {
payload_type: "notification/system-event";
payload: {
event_name: NotificationTriggerEvent;
table_id: TableId;
table?: Table; // hydrated on the BE
};
payload_id: null;
};
//#endregion
@@ -60,12 +79,24 @@ export type NotificationRecipient =
| NotificationRecipientUser
| NotificationRecipientRawValue;
export type TemplateDetails = {
type: string;
subject?: string;
body: string;
};
export type ChannelTemplate = {
name: string;
channel_type: string;
details: TemplateDetails;
};
type NotificationHandlerBase = {
notification_id?: NotificationId;
template_id?: number | null;
channel_id?: number | null;
channel?: Channel | null;
template?: unknown | null; // TODO: hydrated template
template?: ChannelTemplate | null; // TODO: hydrated template
active?: boolean;
id?: number;
@@ -105,9 +136,8 @@ export type NotificationHandler =
export type ScheduleDisplayType = "cron/builder" | "cron/raw" | null;
export type NotificationCronSubscription = {
type: "notification-subscription/cron";
event_name: null;
cron_schedule: string;
ui_display_type: ScheduleDisplayType;
ui_display_type?: ScheduleDisplayType | null;
// only for existing notifications
id?: number;
@@ -116,6 +146,8 @@ export type NotificationCronSubscription = {
updated_at?: string;
};
export type NotificationSubscription = NotificationCronSubscription;
//#endregion
export interface ListNotificationsRequest extends PaginationRequest {
@@ -125,6 +157,8 @@ export interface ListNotificationsRequest extends PaginationRequest {
creator_or_recipient_id?: UserId;
card_id?: CardId;
permission_group_id?: number;
table_id?: TableId;
payload_type?: NotificationPayloadType;
}
export type CreateAlertNotificationRequest = NotificationCardPayload & {
@@ -132,7 +166,14 @@ export type CreateAlertNotificationRequest = NotificationCardPayload & {
subscriptions: NotificationCronSubscription[];
};
export type CreateNotificationRequest = CreateAlertNotificationRequest; // will be populated with more variants later on
export type CreateTableNotificationRequest = NotificationSystemEventPayload & {
handlers: NotificationHandler[];
condition: Condition;
};
export type CreateNotificationRequest =
| CreateAlertNotificationRequest
| CreateTableNotificationRequest;
export type UpdateAlertNotificationRequest = NotificationCardPayload & {
id: NotificationId;
@@ -141,18 +182,57 @@ export type UpdateAlertNotificationRequest = NotificationCardPayload & {
subscriptions: NotificationCronSubscription[];
};
export type UpdateNotificationRequest = UpdateAlertNotificationRequest; // will be populated with more variants later on
export type Notification = NotificationPayload & {
export type UpdateTableNotificationRequest = NotificationSystemEventPayload & {
id: NotificationId;
active: boolean;
handlers: NotificationHandler[];
subscriptions: NotificationCronSubscription[];
condition: Condition;
};
export type UpdateNotificationRequest =
| UpdateAlertNotificationRequest
| UpdateTableNotificationRequest;
export type GetNotificationPayloadExampleRequest = {
payload_type: NotificationPayloadType;
payload: {
event_name: NotificationTriggerEvent;
};
creator_id: UserId;
};
export type GetNotificationPayloadExampleResponse = {
payload: any;
schema: any;
};
type BaseNotification = {
id: NotificationId;
active: boolean;
creator_id: UserId;
creator: UserInfo;
handlers: NotificationHandler[];
updated_at?: string;
created_at?: string;
};
export type AlertNotification = BaseNotification &
NotificationCardPayload & { subscriptions: NotificationCronSubscription[] };
export type TableNotification = BaseNotification &
NotificationSystemEventPayload & {
condition: Condition;
};
export type Notification = AlertNotification | TableNotification;
// Initial schema for conditional expression.
// Will be updated later.
type Operator = "=" | ">" | "<" | ">=" | "<=" | "!=" | "and" | "or";
type Path = Array<string>;
type Value = string | number | boolean;
type Expression = [Operator, Path | Expression, Value | Expression];
type Condition = Expression;

View File

@@ -206,6 +206,7 @@ export const tokenFeatures = [
"cache_granular_controls",
"disable_password_login",
"content_verification",
// "data_editing", // TODO[WRK]: enable this check after this feature token is added on the BE
"embedding",
"embedding_sdk",
"hosting",
@@ -228,6 +229,7 @@ export const tokenFeatures = [
"collection_cleanup",
"query_reference_validation",
"cache_preemptive",
"table_data_editing",
"database_routing",
] as const;

View File

@@ -1,3 +1,5 @@
import type { DatasetData } from "metabase-types/api/dataset";
import type { Card, CardType } from "./card";
import type { Database, DatabaseId, InitialSyncStatus } from "./database";
import type { Field, FieldDimensionOption, FieldId } from "./field";
@@ -129,3 +131,28 @@ export interface DeleteUploadTableRequest {
tableId: TableId;
"archive-cards"?: boolean;
}
export interface GetTableDataRequest {
tableId: TableId;
}
export type TableData = {
data: DatasetData;
database_id: DatabaseId;
table_id: TableId;
row_count: number;
running_time: number;
error?:
| string
| {
status: number; // HTTP status code
data?: string;
};
error_type?: string;
error_is_curated?: boolean;
status?: string;
/** In milliseconds */
average_execution_time?: number;
/** A date in ISO 8601 format */
started_at?: string;
};

View File

@@ -17,6 +17,7 @@ export const isVirtualCardDisplayType = (
const cardDisplayTypes = [
"table",
"table-editable",
"bar",
"line",
"pie",

View File

@@ -18,7 +18,9 @@ export type DashboardSidebarName =
| "editParameter"
| "settings"
| "sharing"
| "info";
| "info"
| "addEditableTable"
| "configureEditableTable";
interface BaseSidebarState {
name?: DashboardSidebarName;
@@ -99,6 +101,7 @@ export interface DashboardState {
dashcards: Record<DashCardId, StoreDashcard>;
dashcardData: DashCardDataMap;
editingDashcardDataOverride: DashCardDataMap;
parameterValues: Record<ParameterId, ParameterValueOrArray>;
draftParameterValues: Record<ParameterId, ParameterValueOrArray | null>;
@@ -107,6 +110,7 @@ export interface DashboardState {
loadingControls: DashboardLoadingControls;
editingDashboard: Dashboard | null;
isAddParameterPopoverOpen: boolean;
isNavigatingBackToDashboard: boolean;

View File

@@ -8,6 +8,7 @@ export const createMockDashboardState = (
dashboards: {},
dashcards: {},
dashcardData: {},
editingDashcardDataOverride: {},
parameterValues: {},
draftParameterValues: {},
loadingDashCards: {

View File

@@ -4,6 +4,7 @@ const PREFIX = `/account/notifications`;
const TYPE_MAP = {
"question-notification": "alert",
"table-notification": "alert",
pulse: "pulse",
};

View File

@@ -9,7 +9,11 @@ import {
NotificationMessage,
} from "metabase/account/notifications/components/NotificationCard/DashboardNotificationCard.styled";
import { formatCreatorMessage } from "metabase/account/notifications/components/NotificationCard/utils";
import type { QuestionNotificationListItem } from "metabase/account/notifications/types";
import type {
QuestionNotificationListItem,
TableNotificationListItem,
} from "metabase/account/notifications/types";
import { isTableNotification } from "metabase/api/notification";
import Link from "metabase/core/components/Link/Link";
import {
canArchive,
@@ -17,36 +21,38 @@ import {
formatTitle,
getNotificationEnabledChannelsMap,
} from "metabase/lib/notifications";
import * as Urls from "metabase/lib/urls";
import { Group, Icon } from "metabase/ui";
import type { User } from "metabase-types/api";
type NotificationCardProps = {
listItem: QuestionNotificationListItem;
type NotificationCardProps<
T extends QuestionNotificationListItem | TableNotificationListItem,
> = {
listItem: T;
user: User;
onUnsubscribe: (listItem: QuestionNotificationListItem) => void;
onArchive: (listItem: QuestionNotificationListItem) => void;
onUnsubscribe: (listItem: T) => void;
onArchive: (listItem: T) => void;
isEditable: boolean;
entityLink: string;
};
export const NotificationCard = ({
export const NotificationCard = <
T extends QuestionNotificationListItem | TableNotificationListItem,
>({
listItem,
user,
isEditable,
onUnsubscribe,
onArchive,
}: NotificationCardProps): JSX.Element => {
entityLink,
}: NotificationCardProps<T>): JSX.Element => {
const { item } = listItem;
const hasArchive = canArchive(listItem.item, user);
const entityLink = Urls.question({
id: item.payload.card_id,
card_id: item.payload.card_id,
});
const hasArchive = canArchive(item, user);
const enabledChannelsMap = getNotificationEnabledChannelsMap(item);
const subscription = item.subscriptions[0];
// Handle different notification types
const isTable = isTableNotification(item);
const subscription = isTable ? null : item.subscriptions?.[0];
const onUnsubscribeClick = useCallback(() => {
onUnsubscribe(listItem);
@@ -73,10 +79,11 @@ export const NotificationCard = ({
<Icon name="webhook" size={16} />
)}
<Group gap="0.25rem" align="center" c="text-medium">
{subscription && (
<span>{formatNotificationSchedule(subscription)}</span>
)}
{" · "}
{subscription &&
subscription.type === "notification-subscription/cron" && (
<span>{formatNotificationSchedule(subscription)}</span>
)}
{!isTable && " · "}
{<span>{formatCreatorMessage(item, user.id)}</span>}
</Group>
</Group>

View File

@@ -1,26 +1,65 @@
import { fireEvent, screen } from "@testing-library/react";
import { renderWithTheme } from "__support__/ui";
import type { QuestionNotificationListItem } from "metabase/account/notifications/types";
import type { Notification } from "metabase-types/api";
import type {
AlertNotification,
NotificationTriggerEvent,
} from "metabase-types/api";
import {
createMockNotification,
createMockAlertNotification,
createMockNotificationCronSubscription,
createMockNotificationHandlerEmail,
createMockNotificationHandlerSlack,
createMockNotificationRecipientUser,
createMockTable,
createMockUser,
} from "metabase-types/api/mocks";
import type {
QuestionNotificationListItem,
TableNotificationListItem,
} from "../../types";
import { NotificationCard } from "./NotificationCard";
const getQuestionAlertItem = (
opts?: Partial<Notification>,
opts?: Partial<AlertNotification>,
): QuestionNotificationListItem => ({
item: createMockNotification(opts),
item: createMockAlertNotification(opts),
type: "question-notification",
});
const getTableNotificationItem = (
event: NotificationTriggerEvent,
tableName = "Sample Table",
): TableNotificationListItem => {
const mockTable = createMockTable({
id: 42,
display_name: tableName,
});
return {
item: {
id: 123,
active: true,
creator_id: 1,
creator: createMockUser(),
handlers: [createMockNotificationHandlerEmail()],
created_at: "2025-01-07T12:00:00Z",
updated_at: "2025-01-07T12:00:00Z",
payload_type: "notification/system-event",
payload: {
event_name: event,
table_id: mockTable.id,
table: mockTable,
},
payload_id: null,
condition: ["=", ["field", "id"], 1],
} as any, // Cast to any to allow adding the table property
type: "table-notification",
};
};
describe("NotificationCard", () => {
it("should render a question alert", () => {
const alert = getQuestionAlertItem();
@@ -33,6 +72,7 @@ describe("NotificationCard", () => {
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
@@ -56,6 +96,7 @@ describe("NotificationCard", () => {
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
@@ -80,6 +121,7 @@ describe("NotificationCard", () => {
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
@@ -103,6 +145,7 @@ describe("NotificationCard", () => {
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
@@ -124,6 +167,7 @@ describe("NotificationCard", () => {
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
@@ -158,6 +202,7 @@ describe("NotificationCard", () => {
isEditable
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
entityLink={"/"}
/>,
);
@@ -166,6 +211,91 @@ describe("NotificationCard", () => {
expect(onArchive).not.toHaveBeenCalled();
});
it("should render a table notification with 'rows created' event", () => {
const tableNotification = getTableNotificationItem("event/rows.created");
const user = createMockUser();
renderWithTheme(
<NotificationCard
listItem={tableNotification}
user={user}
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
expect(
screen.getByText("Sample Table table - Rows created"),
).toBeInTheDocument();
expect(screen.getByLabelText("mail icon")).toBeInTheDocument();
expect(
screen.getByText("Created by you on January 7, 2025"),
).toBeInTheDocument();
});
it("should render a table notification with 'rows updated' event", () => {
const tableNotification = getTableNotificationItem("event/rows.updated");
const user = createMockUser();
renderWithTheme(
<NotificationCard
listItem={tableNotification}
user={user}
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
expect(
screen.getByText("Sample Table table - Rows updated"),
).toBeInTheDocument();
});
it("should render a table notification with 'rows deleted' event", () => {
const tableNotification = getTableNotificationItem("event/rows.updated");
const user = createMockUser();
renderWithTheme(
<NotificationCard
listItem={tableNotification}
user={user}
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
expect(
screen.getByText("Sample Table table - Rows deleted"),
).toBeInTheDocument();
});
it("should render a table notification with custom table name", () => {
const tableNotification = getTableNotificationItem(
"event/rows.created",
"Orders",
);
const user = createMockUser();
renderWithTheme(
<NotificationCard
listItem={tableNotification}
user={user}
isEditable
onArchive={jest.fn()}
onUnsubscribe={jest.fn()}
entityLink={"/"}
/>,
);
expect(screen.getByText("Orders table - Rows created")).toBeInTheDocument();
});
it("should unsubscribe when user is the creator and subscribed with another user", () => {
const creator = createMockUser({ id: 1 });
const recipient = createMockUser({ id: 2 });
@@ -196,6 +326,7 @@ describe("NotificationCard", () => {
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
isEditable
entityLink={"/"}
/>,
);
@@ -217,6 +348,7 @@ describe("NotificationCard", () => {
isEditable={false}
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
entityLink={"/"}
/>,
);
@@ -236,6 +368,7 @@ describe("NotificationCard", () => {
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
isEditable
entityLink={"/"}
/>,
);
@@ -269,6 +402,7 @@ describe("NotificationCard", () => {
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
isEditable
entityLink={"/"}
/>,
);

View File

@@ -3,6 +3,7 @@ import { t } from "ttag";
import type { NotificationListItem } from "metabase/account/notifications/types";
import { TextButton } from "metabase/components/Button.styled";
import * as Urls from "metabase/lib/urls";
import type { User } from "metabase-types/api";
import {
@@ -49,27 +50,53 @@ export const NotificationList = ({
{t`Not seeing one here?`}
</TextButton>
</NotificationHeader>
{listItems.map((listItem) =>
listItem.type === "pulse" ? (
<DashboardNotificationCard
key={`${listItem.type}-${listItem.item.id}`}
listItem={listItem}
user={user}
isEditable={canManageSubscriptions}
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
/>
) : (
<NotificationCard
key={`${listItem.type}-${listItem.item.id}`}
listItem={listItem}
user={user}
isEditable={canManageSubscriptions}
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
/>
),
)}
{listItems.map((listItem) => {
switch (listItem.type) {
case "pulse":
return (
<DashboardNotificationCard
key={`${listItem.type}-${listItem.item.id}`}
listItem={listItem}
user={user}
isEditable={canManageSubscriptions}
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
/>
);
case "table-notification": {
const payload = listItem.item.payload;
return (
<NotificationCard
key={`${listItem.type}-${listItem.item.id}`}
listItem={listItem}
user={user}
isEditable={canManageSubscriptions}
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
entityLink={Urls.tableView(
payload.table!.db_id,
payload.table_id,
)}
/>
);
}
case "question-notification":
return (
<NotificationCard
key={`${listItem.type}-${listItem.item.id}`}
listItem={listItem}
user={user}
isEditable={canManageSubscriptions}
onUnsubscribe={onUnsubscribe}
onArchive={onArchive}
entityLink={Urls.question({
id: listItem.item.payload.card_id,
card_id: listItem.item.payload.card_id,
})}
/>
);
}
})}
{children}
</div>
);

View File

@@ -8,7 +8,7 @@ import {
} from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
import { useDispatch } from "metabase/lib/redux";
import { DeleteAlertConfirmModal } from "metabase/notifications/modals/DeleteAlertConfirmModal";
import { DeleteConfirmModal } from "metabase/notifications/modals/shared/DeleteConfirmModal";
import { addUndo } from "metabase/redux/undo";
import type { Notification } from "metabase-types/api";
@@ -65,7 +65,7 @@ export const DeleteAlertModal = ({
<LoadingAndErrorWrapper loading={isLoading} error={error}>
{() =>
notification ? (
<DeleteAlertConfirmModal
<DeleteConfirmModal
title={
hasUnsubscribed
? t`You’re unsubscribed. Delete this alert as well?`

View File

@@ -2,7 +2,11 @@ import type { JSX, ReactNode } from "react";
import { useMemo } from "react";
import type { NotificationListItem } from "metabase/account/notifications/types";
import { skipToken, useListNotificationsQuery } from "metabase/api";
import {
isTableNotification,
skipToken,
useListNotificationsQuery,
} from "metabase/api";
import Pulses from "metabase/entities/pulses";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { parseTimestamp } from "metabase/lib/time-dayjs";
@@ -33,7 +37,7 @@ const NotificationsAppInner = ({
const dispatch = useDispatch();
const { data: questionNotifications = [] } = useListNotificationsQuery(
const { data: configuredNotifications = [] } = useListNotificationsQuery(
user
? {
creator_or_recipient_id: user.id,
@@ -44,10 +48,18 @@ const NotificationsAppInner = ({
const items = useMemo(() => {
const combinedItems: NotificationListItem[] = [
...questionNotifications.map((alert) => ({
item: alert,
type: "question-notification" as const,
})),
...configuredNotifications.map((alert) => {
if (isTableNotification(alert)) {
return {
item: alert,
type: "table-notification" as const,
};
}
return {
item: alert,
type: "question-notification" as const,
};
}),
...pulses.map((pulse) => ({
item: pulse,
type: "pulse" as const,
@@ -59,7 +71,7 @@ const NotificationsAppInner = ({
parseTimestamp(b.item.created_at).unix() -
parseTimestamp(a.item.created_at).unix(),
);
}, [pulses, questionNotifications]);
}, [pulses, configuredNotifications]);
const onHelp = () => dispatch(navigateToHelp());
const onUnsubscribe = ({ item, type }: NotificationListItem) =>

View File

@@ -8,7 +8,7 @@ import {
} from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/components/LoadingAndErrorWrapper";
import { useDispatch, useSelector } from "metabase/lib/redux";
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/UnsubscribeConfirmModal";
import { UnsubscribeConfirmModal } from "metabase/notifications/modals/shared/UnsubscribeConfirmModal";
import { addUndo } from "metabase/redux/undo";
import { getUser } from "metabase/selectors/user";
import type { Notification, User } from "metabase-types/api";

View File

@@ -1,4 +1,8 @@
import type { Alert, Notification } from "metabase-types/api";
import type {
Alert,
AlertNotification,
TableNotification,
} from "metabase-types/api";
export type DashboardAlertListItem = {
item: Alert;
@@ -6,10 +10,16 @@ export type DashboardAlertListItem = {
};
export type QuestionNotificationListItem = {
item: Notification;
item: AlertNotification;
type: "question-notification";
};
export type TableNotificationListItem = {
item: TableNotification;
type: "table-notification";
};
export type NotificationListItem =
| DashboardAlertListItem
| QuestionNotificationListItem;
| QuestionNotificationListItem
| TableNotificationListItem;

View File

@@ -0,0 +1,70 @@
import { useState } from "react";
import { t } from "ttag";
import { hasFeature } from "metabase/admin/databases/utils";
import Toggle from "metabase/core/components/Toggle";
import { getResponseErrorMessage } from "metabase/lib/errors";
import { PLUGIN_DATA_EDITING } from "metabase/plugins";
import { Box, Flex } from "metabase/ui";
import type { Database, DatabaseData, DatabaseId } from "metabase-types/api";
import { Description, Error, Label } from "./DatabaseFeatureComponents";
import { DatabaseInfoSection } from "./DatabaseInfoSection";
export function DatabaseTableEditingSection({
database,
updateDatabase,
}: {
database: Database;
updateDatabase: (
database: { id: DatabaseId } & Partial<DatabaseData>,
) => Promise<void>;
}) {
const isEditingDatabase = !!database.id;
const showTableEditingSection =
isEditingDatabase &&
hasFeature(database, "actions") &&
PLUGIN_DATA_EDITING.isEnabled();
const [error, setError] = useState<string | null>(null);
const handleToggle = async (enabled: boolean) => {
try {
setError(null);
await updateDatabase({
id: database.id,
settings: { "database-enable-table-editing": enabled },
});
} catch (err) {
setError(getResponseErrorMessage(err) || t`An error occurred`);
}
};
if (!showTableEditingSection) {
return null;
}
return (
<DatabaseInfoSection
name={t`Editable table data`}
description={t`Allow the data within tables of this database to be edited by Admin users.`}
data-testid="database-table-editing-section"
>
<Flex align="center" justify="space-between" mb="xs">
<Label htmlFor="table-editing-toggle">{t`Editable tables`}</Label>
<Toggle
id="table-editing-toggle"
value={PLUGIN_DATA_EDITING.isDatabaseTableEditingEnabled(database)}
onChange={handleToggle}
/>
</Flex>
<Box maw="22.5rem">
{error ? <Error>{error}</Error> : null}
<Description>
{t`Your database connection will need Write permissions.`}
</Description>
</Box>
</DatabaseInfoSection>
);
}

View File

@@ -27,6 +27,7 @@ import type {
import { DatabaseConnectionInfoSection } from "../components/DatabaseConnectionInfoSection";
import { DatabaseDangerZoneSection } from "../components/DatabaseDangerZoneSection";
import { DatabaseModelFeaturesSection } from "../components/DatabaseModelFeaturesSection";
import { DatabaseTableEditingSection } from "../components/DatabaseTableEditingSection";
import { ExistingDatabaseHeader } from "../components/ExistingDatabaseHeader";
import { NewDatabasePermissionsModal } from "../components/NewDatabasePermissionsModal";
import { deleteDatabase, updateDatabase } from "../database";
@@ -116,6 +117,11 @@ function DatabaseEditAppInner({
updateDatabase={updateDatabase}
/>
<DatabaseTableEditingSection
database={database}
updateDatabase={updateDatabase}
/>
<PLUGIN_DB_ROUTING.DatabaseRoutingSection
database={database}
/>

View File

@@ -1,8 +1,14 @@
import { useMemo } from "react";
import type {
AlertNotification,
CreateNotificationRequest,
GetNotificationPayloadExampleRequest,
GetNotificationPayloadExampleResponse,
ListNotificationsRequest,
Notification,
NotificationId,
TableNotification,
UpdateNotificationRequest,
} from "metabase-types/api/notification";
@@ -87,6 +93,16 @@ export const notificationApi = Api.injectEndpoints({
body,
}),
}),
getNotificationPayloadExample: builder.mutation<
GetNotificationPayloadExampleResponse,
GetNotificationPayloadExampleRequest
>({
query: (body) => ({
method: "POST",
url: `/api/notification/payload`,
body,
}),
}),
}),
});
@@ -101,4 +117,37 @@ export const {
useUpdateNotificationMutation,
useUnsubscribeFromNotificationMutation,
useSendUnsavedNotificationMutation,
useGetNotificationPayloadExampleMutation,
} = notificationApi;
export const useTableNotificationsQuery = (
params: Parameters<typeof useListNotificationsQuery>[0],
) => {
const { data, ...rest } = useListNotificationsQuery(params);
return {
data: useMemo(() => data?.filter(isTableNotification), [data]),
...rest,
};
};
export const useAlertNotificationsQuery = (
params: Parameters<typeof useListNotificationsQuery>[0],
) => {
const { data, ...rest } = useListNotificationsQuery(params);
return {
data: useMemo(() => data?.filter(isAlertNotification), [data]),
...rest,
};
};
export const isTableNotification = (
notification: Notification,
): notification is TableNotification => {
return notification.payload_type === "notification/system-event";
};
export const isAlertNotification = (
notification: Notification,
): notification is AlertNotification => {
return notification.payload_type === "notification/card";
};

View File

@@ -1,8 +1,11 @@
import type {
Field,
FieldWithMetadata,
GetTableDataRequest,
GetTableQueryMetadataRequest,
GetTableRequest,
Table,
TableData,
TableId,
TableListQuery,
UpdateTableFieldsOrderRequest,
@@ -20,6 +23,10 @@ import {
tag,
} from "./tags";
interface TableWithFieldMetadata extends Table {
fields: FieldWithMetadata[];
}
export const tableApi = Api.injectEndpoints({
endpoints: (builder) => ({
listTables: builder.query<Table[], TableListQuery | void>({
@@ -37,7 +44,10 @@ export const tableApi = Api.injectEndpoints({
}),
providesTags: (table) => (table ? provideTableTags(table) : []),
}),
getTableQueryMetadata: builder.query<Table, GetTableQueryMetadataRequest>({
getTableQueryMetadata: builder.query<
TableWithFieldMetadata,
GetTableQueryMetadataRequest
>({
query: ({ id, ...params }) => ({
method: "GET",
url: `/api/table/${id}/query_metadata`,
@@ -52,6 +62,12 @@ export const tableApi = Api.injectEndpoints({
}),
providesTags: [listTag("field")],
}),
getTableData: builder.query<TableData, GetTableDataRequest>({
query: ({ tableId }) => ({
method: "GET",
url: `/api/table/${tableId}/data`,
}),
}),
updateTable: builder.mutation<Table, UpdateTableRequest>({
query: ({ id, ...body }) => ({
method: "PUT",
@@ -115,6 +131,7 @@ export const {
useGetTableQuery,
useGetTableQueryMetadataQuery,
useLazyListTableForeignKeysQuery,
useGetTableDataQuery,
useUpdateTableMutation,
useUpdateTableListMutation,
useUpdateTableFieldsOrderMutation,

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