Compare commits

...

240 Commits

Author SHA1 Message Date
Nathan Voxland
2935aefc64 only include tables you can use for native query builder 2025-08-12 23:09:01 -05:00
Nathan Voxland
0ecddc9870 Merge remote-tracking branch 'origin/feature/reports' into document-sql 2025-08-12 19:48:05 -05:00
Aleksandr Lesnenko
7ebb0f0000 fix pivot query editing in documents 2025-08-12 20:56:35 -03:00
Denis Berezin
25ce9c5dcf Reports fix fe ci (#62046)
* Fix some CI issues

* Fix some CI issues

* Fix typecheck issues

* Fix lint

* Fix unit tests

* Fix e2e tests
2025-08-13 02:48:09 +03:00
Edward Paget
14a343de12 feat: check for name length 2025-08-12 16:06:07 -05:00
Brad Anderson
ec0c2dba8b Reverts probably-accidental change 2025-08-12 17:02:25 -04:00
Brad Anderson
39afe7fff5 UXW-451 Adds some light dataset error handling 2025-08-12 16:42:00 -04:00
Edward Paget
ab958737a9 fix: remove extraneous precondition migration 2025-08-12 15:33:11 -05:00
Brad Anderson
45a0c85158 UXW-472 Adds ArchivedEntityBanner for documents 2025-08-12 15:49:51 -04:00
Nick Fitzpatrick
d2479a5b95 bury shame 2025-08-12 15:48:11 -04:00
Nick Fitzpatrick
6d13f02bf3 Move, Archive, and Bookmark in documents page 2025-08-12 15:46:27 -04:00
Brad Anderson
3b8161ca75 Fix issue where "Delete permanently" errors out instead of deleting 2025-08-12 15:05:20 -04:00
Brad Anderson
93047a3e2f Updates Document type with newly added properties 2025-08-12 15:04:14 -04:00
Edward Paget
2b902a4ce1 feat: return can_restore, can_delete for docs 2025-08-12 13:43:04 -05:00
eliotmetabase
e03e528f09 Update menu items (#62072)
* Update commands and remove descriptions

* Removes unused code

---------

Co-authored-by: Brad Anderson <brad@metabase.com>
2025-08-12 11:09:29 -07:00
Brad Anderson
6f74d26021 Fix issue where metabot nodes sometimes yield a false positive in hasUnsavedChanges because they updated their own attributes 2025-08-12 13:56:01 -04:00
Nick Fitzpatrick
cb9d5e33e2 more e2e tests 2025-08-12 13:36:11 -04:00
Brad Anderson
1ddff04bf6 Changes how mention text is referenced in the documents metabot prompt serializer. It uses a cache in the documents slice now. It was using a node attribute before but that was hacky and it messed with the "is dirty" calculation. 2025-08-12 13:04:09 -04:00
Edward Paget
640b530563 fix: casing in tests 2025-08-12 11:16:12 -05:00
Nathan Voxland
952a52974c Able to generate sql charts 2025-08-12 11:05:44 -05:00
Edward Paget
915abf29eb fix: token features in recent views test 2025-08-12 10:50:22 -05:00
Edward Paget
67eceb653a fix: whitespace exclusion path 2025-08-12 10:17:59 -05:00
Edward Paget
11260e4d72 fix: whitespace and round-trip serdes test 2025-08-12 10:15:33 -05:00
Edward Paget
11d3fb22d2 Merge branch 'master' into feature/reports 2025-08-12 09:55:58 -05:00
Edward Paget
2627574bce feat: add documents to recents 2025-08-12 09:51:56 -05:00
Brad Anderson
53fcff39ef Updates "Created by Metabot" line based on design/user feedback 2025-08-12 10:38:55 -04:00
Aleksandr Lesnenko
8783c54480 improve state management UXW-463 UXW-450 2025-08-11 22:12:02 -03:00
Brad Anderson
9f8331cb63 UXW-462 Fix issue where you can write markdown in metabot blocks 2025-08-11 18:37:46 -04:00
Aleksandr Lesnenko
65bbe6429e feedback on the command 2025-08-11 19:21:19 -03:00
Aleksandr Lesnenko
7b367b6c0f refactor 2025-08-11 19:21:19 -03:00
Brad Anderson
c328d7f7e0 UXW-462 Fix issue where sometimes custom tiptap node tries to re-render too quickly resulting in an error 2025-08-11 18:09:44 -04:00
Brad Anderson
08cec1d1f5 UXW-462 Adds database picker in metabot block 2025-08-11 18:00:31 -04:00
Nick Fitzpatrick
5c489b9c95 Markdown Loki Test 2025-08-11 17:44:02 -04:00
Aleksandr Lesnenko
4a079f203a bump padding 2025-08-11 18:20:52 -03:00
Aleksandr Lesnenko
1fc9390906 updated styles 2025-08-11 18:15:18 -03:00
Edward Paget
3164d5d866 fix: kondo warning on unused var 2025-08-11 16:13:22 -05:00
Edward Paget
71d915b7d0 feat: support serdes on documents (#62032) 2025-08-11 16:04:13 -05:00
Edward Paget
bbe95ee859 fixup! pinning reconcilation with documents 2025-08-11 15:26:37 -05:00
Edward Paget
6542c95758 feat: support pinning documents 2025-08-11 14:51:17 -05:00
Aleksandr Lesnenko
88ef82938d fix command 2025-08-11 13:23:46 -03:00
Brad Anderson
9477b72420 Fix issue where charts sometimes don't get added via metabot when inserted in the middle of a document 2025-08-11 11:09:51 -04:00
Aleksandr Lesnenko
38bd513835 fix command search 2025-08-09 15:25:14 -03:00
Aleksandr Lesnenko
e4c92ff6e2 placeholder 2025-08-08 18:46:12 -03:00
Aleksandr Lesnenko
4cbb2297a5 mention + command feedback 2025-08-08 18:30:14 -03:00
Edward Paget
54ebe17696 fix: handle inadvertent documents in oss tests 2025-08-08 16:18:44 -05:00
Edward Paget
41674dd1de fix: final in-place search test fixes 2025-08-08 15:12:03 -05:00
Edward Paget
9595d9493d fix: more in-place search fixes for documents 2025-08-08 14:47:04 -05:00
Edward Paget
3c3a8dd127 fix: set type to :question dashboard_id to nil 2025-08-08 14:31:46 -05:00
Edward Paget
4373216910 fix: migration order 2025-08-08 14:25:58 -05:00
Edward Paget
efbfc502f1 fix: in_place search fixes and test fixes 2025-08-08 14:17:42 -05:00
Edward Paget
9bd2243573 fix: emit documentment read on api get 2025-08-08 13:21:52 -05:00
Edward Paget
9c2838341e feat: documents in search
Also supports view_counts and last_viewed_at for documents
2025-08-08 13:21:51 -05:00
Edward Paget
a60e02dd1e fix: remove documents token hack 2025-08-08 13:21:51 -05:00
Edward Paget
92e9d6360d fix: set null on collection cascade for documents
mirrors behavior for dashboards and cards
2025-08-08 13:21:51 -05:00
Nick Fitzpatrick
c6f35e0393 initial e2e tests 2025-08-08 14:58:13 -03:00
Aleksandr Lesnenko
89b4bbe920 fix metadata fetching 2025-08-08 14:29:51 -03:00
Aleksandr Lesnenko
0ab1d4ccb6 no emojis and markdown downloading 2025-08-08 12:44:25 -03:00
Aleksandr Lesnenko
ea64672746 drop emojis 2025-08-08 12:44:25 -03:00
Brad Anderson
b9a446a933 Localizes "Created with Metabot" text 2025-08-08 10:21:15 -04:00
Brad Anderson
7bd57d1d66 MetabotEmbed polish:
* Prevents prompt from highlighting when you hit "Run" (potentially bringing up the editor bubble menu).
* Prevents useEffect hook from running way more than it needs to.
* Adds margin below MetabotEmbed.
* Hitting escape with nothing in the textarea will now remove the MetabotEmbed block.
* Makes layout less janky when Metabot responds with a long error message.
2025-08-08 09:55:47 -04:00
Aleksandr Lesnenko
dfeafc9489 do not show bubble menu on disallowed nodes 2025-08-08 01:42:49 -03:00
Aleksandr Lesnenko
9f0c6d1e2e fix replace question 2025-08-08 01:40:50 -03:00
Aleksandr Lesnenko
e716d1d253 clean 2025-08-08 01:37:28 -03:00
Aleksandr Lesnenko
f7c8221468 improved mention and command 2025-08-07 22:10:42 -03:00
Brad Anderson
4135955236 UXW-461 Save button always shows up for new documents (and user has necessary permissions) 2025-08-07 16:24:52 -04:00
Brad Anderson
b2a0c144ba UXW-461 Prevents jump when Save button appears/disappears. Also adds subtle fade-in transition when it appears. 2025-08-07 16:13:11 -04:00
Brad Anderson
9a56a9e96b UXW-459 Fix issues with LeaveRouteConfirmModal and new documents 2025-08-07 15:25:57 -04:00
Nick Fitzpatrick
436840eac9 hide object detail row in document tables 2025-08-07 16:18:07 -03:00
Brad Anderson
ab0bd1b8ff UXW-459 Fix issue where creating a new document when starting from another document overwrites the existing document (and shows previous document's metadata in new document) 2025-08-07 14:27:13 -04:00
Nick Fitzpatrick
c800fb73d0 Handle URL slugs, remove version code from document page, handle 404 and 403 2025-08-07 14:43:28 -03:00
Brad Anderson
0463d74f76 UXW-453 Fix 404 when clicking draft card title 2025-08-07 13:36:34 -04:00
Nick Fitzpatrick
f8924beba5 add title to untitled docs 2025-08-07 13:54:45 -03:00
Edward Paget
92eb7e62a1 fix: add entity id column to documents 2025-08-07 11:27:27 -05:00
Brad Anderson
6351f1c4ef Replaces hard-coded metabot-generated card title with title from the /generate-content response (or translates a default title if none provided) 2025-08-07 12:25:19 -04:00
Edward Paget
eb8f2d91d0 fix: foreign key indexes 2025-08-07 10:34:07 -05:00
Aleksandr Lesnenko
8c5adc5f85 fixed drills & query editing for metabot cards 2025-08-07 10:37:14 -04:00
Brad Anderson
a792b1706e Updates MetabotEmbed to use most up-to-date api contract 2025-08-07 10:37:14 -04:00
Brad Anderson
268352d02b Stubs out the necessary updates to make MetabotEmbed work with an UnsavedCard from the BE rather than a full Card 2025-08-07 10:37:14 -04:00
Brad Anderson
acafad1b6a Updates MetabotEmbed to use most up-to-date api contract 2025-08-07 10:37:14 -04:00
Brad Anderson
4f95b0ebb7 Updates MetabotEmbed "stop generating" functionality to work with regular (non-streaming) api call 2025-08-07 10:37:14 -04:00
Brad Anderson
92017cfe7c Updates MetabotEmbed to always use draft cards 2025-08-07 10:37:14 -04:00
Brad Anderson
9ee59951ae Updates MetabotEmbed to work with upcoming BE changes 2025-08-07 10:37:14 -04:00
Nick Fitzpatrick
403bf4eafd Show tooltip with precise time in document metadata 2025-08-07 11:12:24 -03:00
Nick Fitzpatrick
010af9dc10 undo moves should work once archived is boolean 2025-08-07 11:12:24 -03:00
Edward Paget
8c1e179da7 fix: bad var reference in tests 2025-08-07 08:36:37 -05:00
Aleksandr Lesnenko
40368f85fb l10n 2025-08-06 22:29:48 -03:00
Aleksandr Lesnenko
978e12c2d6 swap icon 2025-08-06 22:26:38 -03:00
Aleksandr Lesnenko
ea4c347006 UXW-416: replace question should replace only 1 question 2025-08-06 19:57:52 -03:00
Aleksandr Lesnenko
5556654506 UXW-400: formatting floating menu 2025-08-06 19:19:34 -03:00
Nathan Voxland
4ea6c16f19 Merge remote-tracking branch 'origin/feature/reports' into feature/reports 2025-08-06 16:48:59 -05:00
Nathan Voxland
6620040235 New metabot documents API 2025-08-06 16:48:47 -05:00
Edward Paget
f809d7747c fix: module sort 2025-08-06 16:36:22 -05:00
Edward Paget
181e8f31d2 fix: tests and kondos 2025-08-06 16:21:47 -05:00
Edward Paget
83bd4ba534 Merge branch 'master' into feature/reports 2025-08-06 15:50:29 -05:00
Edward Paget
f770d21cdf fix: for OSS tests 2025-08-06 15:48:48 -05:00
Edward Paget
f6f6b2d3a9 fix: ordering of migrations for mysql 2025-08-06 15:36:19 -05:00
Edward Paget
745ab32bde fix: tests and lints 2025-08-06 15:33:44 -05:00
Nick Fitzpatrick
6760658aec App Bar Breadcrumbs for documents 2025-08-06 17:18:47 -03:00
Edward Paget
bad6bccac2 fix: require non-blank string for name 2025-08-06 15:03:50 -05:00
Edward Paget
7448009633 feat: add document delete endpoint 2025-08-06 14:59:33 -05:00
Edward Paget
7418a137c8 fix: always add documents to bookmark lookup 2025-08-06 14:42:37 -05:00
Edward Paget
40269ca9c0 fix: add perms info to documents in collections 2025-08-06 14:41:31 -05:00
Edward Paget
42bee66fba fix: hide documents in collection items if not EE 2025-08-06 14:05:14 -05:00
Edward Paget
76ee0b6f8c feat: allow documents to be bookmarked 2025-08-06 13:52:35 -05:00
Aleksandr Lesnenko
912aca8811 fix UXW-412 again 2025-08-06 15:13:33 -03:00
Brad Anderson
2e9f07ede2 Fix issue where submitting empty metabot prompt (by hitting "Run" or cmd+enter) makes it spin forever 2025-08-06 14:00:11 -04:00
Nick Fitzpatrick
9568b9956d Document Collection Item Caching 2025-08-06 14:23:13 -03:00
Edward Paget
80867e1335 feat: allow documents to be archived 2025-08-06 11:49:50 -05:00
Brad Anderson
7894f1e45e Fix issue where temporary implementation of metabot block can't create questions 2025-08-06 11:20:10 -04:00
Nick Fitzpatrick
fd4a621603 document entity 2025-08-06 12:15:28 -03:00
Edward Paget
0486d547d3 feat: include revision info in collection tree res 2025-08-06 09:08:37 -05:00
Edward Paget
ec7163091a feat: revision history for documents 2025-08-06 08:57:32 -05:00
Aleksandr Lesnenko
287e496a1b wip but working sql query editor in documents 2025-08-05 23:28:00 -03:00
Aleksandr Lesnenko
2ac44861b5 Persist cards when saving document (#61818)
* chore: remove document version model

* persist cards only when saving documents

* chore: remove in_document type

* feat: return document creator

* feat: save cards with parameters pass in as cards

in POST and PUT requests to documents

* chore: exclude doc cards from where shouldn't be

search, recents, collection items

* chore: fixup search and card tests

* contract

* feat: update document ast with new card ids

* fix: report -> document in collection api

* fix: ws

* feat: clone cards not associated with document

* chore: add tests for document permissions

* fix contract

* fetch root collection

* update message

* must be working now

* fix sidebar

---------

Co-authored-by: Edward Paget <ed.paget@gmail.com>
2025-08-05 19:59:20 -03:00
Denis Berezin
47d994182c Merge branch 'master' into feature/reports
# Conflicts:
#	frontend/src/metabase/redux/app.ts
2025-08-06 00:18:15 +03:00
Denis Berezin
17435642ef Fix some CI issues 2025-08-06 00:03:50 +03:00
Denis Berezin
a917ade608 Merge branch 'master' into feature/reports
# Conflicts:
#	resources/migrations/056_update_migrations.yaml
#	test/metabase/test/util/namespace.clj
2025-08-05 22:30:13 +03:00
Nick Fitzpatrick
1799892d0b Document Metadata 2025-08-05 14:45:26 -03:00
Brad Anderson
4f7c22ed53 Adds subtle animation to "Working on it..." message 2025-08-04 17:34:07 -04:00
Brad Anderson
ba806688b6 Fix issues with multiple in-flight requests 2025-08-04 17:17:48 -04:00
Brad Anderson
18898a5fdf Tweaks metabot block styling, wires up "stop generating" functionality 2025-08-04 17:00:10 -04:00
Brad Anderson
1ebe336a00 UXW-404 Improves image formatting 2025-08-04 13:05:36 -04:00
Aleksandr Lesnenko
36790affcf auto focus on title on new documents 2025-08-04 13:08:36 -03:00
Aleksandr Lesnenko
a15154cb84 fix layout 2025-08-04 11:51:08 -03:00
Aleksandr Lesnenko
095cae88af fix links to tables 2025-08-01 22:20:08 -03:00
Aleksandr Lesnenko
5314f9f976 missing import 2025-08-01 22:10:37 -03:00
Aleksandr Lesnenko
7189344bfd paste smartlinks 2025-08-01 22:01:48 -03:00
Aleksandr Lesnenko
11b74e0e4b split mention vs command, allow mentioning other entities 2025-08-01 21:29:55 -03:00
Aleksandr Lesnenko
c5862c278d mention all entities 2025-08-01 20:47:30 -03:00
Aleksandr Lesnenko
0af4b1c0de uxw-366: Navigation back to the report from a drill through can be difficult - can we add the back button like for dashboards 2025-08-01 20:18:35 -03:00
Aleksandr Lesnenko
4cd96fa6a2 fix embedding dashboard-only cards to documents 2025-08-01 19:30:19 -03:00
Aleksandr Lesnenko
b47ccb6123 fix FE usage of documents api 2025-08-01 18:43:27 -03:00
Edward Paget
5271f4092d chore: rename everything to document (#61743)
strip out snapshotting code
2025-08-01 15:04:32 -05:00
Aleksandr Lesnenko
c06ea7d391 do not leak prosemirror styles into charts 2025-08-01 14:17:20 -03:00
Edward Paget
e2e2e5fdf3 keep report cards and collections in sync (#61686) 2025-08-01 10:04:50 -05:00
Ryan Laurie
329b903a8b update metabot question creation parsing 2025-08-01 07:14:18 -06:00
Aleksandr Lesnenko
82acc79f82 fe: reports -> documents 2025-07-31 21:58:28 -03:00
Aleksandr Lesnenko
240862340a icons 2025-07-31 21:00:14 -03:00
Nick Fitzpatrick
7849e0394c cleaning up emoji ui a bit 2025-07-31 19:10:19 -03:00
Nick Fitzpatrick
8fa264cb17 gross emojis 2025-07-31 18:08:02 -03:00
Aleksandr Lesnenko
ca4744dc08 why scroll to bottom when opened 2025-07-31 17:51:25 -03:00
Aleksandr Lesnenko
78e9dda8dd jumping layout when loading 2025-07-31 17:51:25 -03:00
Ryan Laurie
b36f00cf80 stop header bouncing 2025-07-31 14:42:55 -06:00
Nick Fitzpatrick
3f24ba5687 disable metabot sidebar on mob+b 2025-07-31 17:40:18 -03:00
Aleksandr Lesnenko
d7dc44feb2 always clone non in_report cards 2025-07-31 17:34:38 -03:00
Aleksandr Lesnenko
0450ba6a67 always create a card clone when embedding 2025-07-31 17:34:38 -03:00
Aleksandr Lesnenko
b30ecea5ca cleanup 2025-07-31 17:34:38 -03:00
Nick Fitzpatrick
fc8ca9b602 Print Report 2025-07-31 17:01:10 -03:00
Luiz Arakaki
68366d7d37 remove trigger for uberjar 2025-07-31 15:20:36 -03:00
Luiz Arakaki
63a94c8788 run build and docker uberjar on push 2025-07-31 14:47:46 -03:00
Ryan Laurie
10bd9d68ce Insert into reports from metabot (#61642)
* insert from metabot

* Metabot plugin and hook

* make stuff nicer

* cleanup

* no more snapshots
2025-07-31 11:19:18 -06:00
Aleksandr Lesnenko
7221915503 polish 2025-07-31 11:59:02 -03:00
Aleksandr Lesnenko
4b57f82089 saving new report should not show LeaveRouteConfirmModal 2025-07-30 20:19:56 -03:00
Aleksandr Lesnenko
b8d2ac9ebc fix backend not starting 2025-07-30 20:00:53 -03:00
Aleksandr Lesnenko
00e3499af8 remove replace question, drop refresh question 2025-07-30 19:29:04 -03:00
Aleksandr Lesnenko
d81a470a57 drop flushSync error 2025-07-30 19:28:58 -03:00
Aleksandr Lesnenko
c329272406 send used card ids 2025-07-30 19:28:49 -03:00
Aleksandr Lesnenko
75ec895750 less redux 2025-07-30 19:28:42 -03:00
Aleksandr Lesnenko
60ca78c873 remove snapshots 2025-07-30 19:28:35 -03:00
Edward Paget
45ee83044e chore: add enterprise card test 2025-07-30 08:37:43 -05:00
Aleksandr Lesnenko
c43a1c5ca5 switch to prosemirror ast 2025-07-29 23:02:07 -03:00
Aleksandr Lesnenko
a7930e7395 better markdown serde 2025-07-29 22:16:50 -03:00
Edward Paget
ad58a25fc6 feat: associate cards with reports in create 2025-07-29 16:30:49 -05:00
Edward Paget
7c5f3f11db feat: associate in_report cards w/ report_document 2025-07-29 15:04:47 -05:00
Denis Berezin
cff5e706f9 Ditch overall sidebar for now (#61523)
* Remove list of cards sidebar

* Some type fixes

* Review fixes
2025-07-29 01:24:14 +03:00
Nick Fitzpatrick
f889695914 remove references to static questions 2025-07-28 13:28:00 -03:00
Aleksandr Lesnenko
db61438bd8 cleanup 2025-07-28 03:10:45 -03:00
Ryan Laurie
6191275795 more leave confirmation 2025-07-25 15:24:18 -06:00
Ryan Laurie
319cf701dc allow saving in personal collections and our analytics 2025-07-25 15:10:29 -06:00
Brad Anderson
0995bf3526 Fixes issue where dropping images into reports doesn't work because the events are getting swallowed by react-dnd 2025-07-25 13:01:25 -04:00
Aleksandr Lesnenko
00d8e50082 fix lists 2025-07-25 11:39:47 -04:00
Nick Fitzpatrick
606416ba23 Fixing UX jank with Code Jank 2025-07-25 11:42:09 -03:00
John Swanson
86a55c97d9 fix startup more 2025-07-25 05:40:18 -07:00
John Swanson
850afa8366 fix startup
@nvoxland i *think* this was a stray and not necessary for your metabot
changes but we can add it back along with `dev/src/api/routes.clj` if
i'm wrong!
2025-07-25 05:26:58 -07:00
Nick Fitzpatrick
0f1eb823d3 adding column blocks and colmns 2025-07-25 09:05:25 -03:00
Nathan Voxland
64448e927d Added ability to ask metabot about the report you are on 2025-07-24 23:23:27 -05:00
Aleksandr Lesnenko
f2a3859e32 fix editing viz settings when multiple copied entries 2025-07-24 22:34:08 -04:00
Aleksandr Lesnenko
2c85824963 saved images should render as images 2025-07-24 20:36:42 -04:00
Aleksandr Lesnenko
27420b5cb9 fix excessive space 2025-07-24 20:33:37 -04:00
Aleksandr Lesnenko
c3e67fd73e can write 2025-07-24 20:14:06 -04:00
Aleksandr Lesnenko
18ab9af893 hack in_report card type to return just question 2025-07-24 18:18:36 -04:00
Ryan Laurie
d3469efb09 frontend readonly support 2025-07-24 15:58:12 -06:00
Brad Anderson
e00d09cc37 Adds ability to add images via markdown to reports 2025-07-24 17:00:47 -04:00
Ryan Laurie
ef602ae2d0 show and save reports in collections 2025-07-24 14:52:38 -06:00
Aleksandr Lesnenko
65c5af5155 refresh snapshot 2025-07-24 16:34:58 -04:00
Aleksandr Lesnenko
eb0e823d1e title click opens question 2025-07-24 16:28:17 -04:00
Aleksandr Lesnenko
c6eeb8e6ba drill-through 2025-07-24 16:06:52 -04:00
John Swanson
af496e167d check perms on snapshot read/write
to read a snapshot, you need:

- read perms on the collection its report is in, if it has a report
attached, OR

- to be the person who created the snapshot in the first place.

to write a snapshot, you need to be a superuser.
2025-07-24 12:16:36 -07:00
John Swanson
48199ff2f7 add reports to collection items
we'll need to update the frontend to request `report` models, and then
handle them correctly there (with a special icon and links). For now
this won't have any effect, since the FE is selecting specific models
and that doesn't include reports.
2025-07-24 12:16:36 -07:00
Brad Anderson
83ca395ccf Allows links to be pasted into reports 2025-07-24 15:08:45 -04:00
Ryan Laurie
bd83d0691b keyboard shortcut for saving 2025-07-24 13:02:05 -06:00
Ryan Laurie
5b2fa90fdb fix new report navigation 2025-07-24 12:19:43 -06:00
Nick Fitzpatrick
436341ab76 Viz Settings for Static Questions 2025-07-24 14:11:43 -03:00
John Swanson
aabc1d165f Enforce permissions on reports
Add collection_id to reports, optionally allow setting it in API
endpoints (POST and PUT), enforce perms as follows:

- POST and PUT require superuser

- GET endpoints require read perms on the container collection
2025-07-24 09:44:57 -07:00
Edward Paget
a4747ed263 Add snapshotted timestamp 2025-07-24 11:35:21 -05:00
Ryan Laurie
cb5f72cad4 prevent losing unsaved changes 2025-07-24 10:28:54 -06:00
Brad Anderson
6c0b2d81ad UXW-9 Adds smart links. Changes "insert chart" mechanism from "@" to "starting with /" so @'s can be used for aforementioned smart links. 2025-07-24 12:26:13 -04:00
Edward Paget
e0f1ecee2d exclude cards from recent views 2025-07-24 10:11:43 -05:00
Edward Paget
6b8124421a Add in_report card type;
make sure snapshotted cards have it set, exclude it from the collection
view and entity picker.

update the report editor to use the returned card id from the snapshot endpoint
2025-07-24 09:50:37 -05:00
Aleksandr Lesnenko
df462d07d5 ensure question selected 2025-07-23 22:29:19 -04:00
Aleksandr Lesnenko
4f908179ec there has to be something to save to show the save button 2025-07-23 22:27:01 -04:00
Aleksandr Lesnenko
9cbdac4de4 deselect question after embedding so you can type further 2025-07-23 22:19:12 -04:00
Aleksandr Lesnenko
0b4b4dfced padding is too huge 2025-07-23 22:13:19 -04:00
Aleksandr Lesnenko
19f4980a7e fix-hack magical new lines 2025-07-23 22:10:10 -04:00
Aleksandr Lesnenko
38e9f21dfb debloat report page, fix used content sidebar 2025-07-23 21:51:46 -04:00
Aleksandr Lesnenko
2775a7e069 sidebar button 2025-07-23 21:33:24 -04:00
Aleksandr Lesnenko
d92773df06 use snapshots, fix a few bugs, change viz settings sidebar behavior 2025-07-23 21:16:01 -04:00
Ryan Laurie
023864e72f correct ci skip label check (#61370) 2025-07-23 16:52:43 -06:00
Ryan Laurie
9c479154a5 versions frontend 2025-07-23 15:58:42 -06:00
Nathan Voxland
747781ee29 Renamed duplicate constraints in migrations 2025-07-23 16:44:53 -05:00
Nathan Voxland
31503487b3 Merge remote-tracking branch 'origin/feature/reports' into feature/reports 2025-07-23 16:09:37 -05:00
Nathan Voxland
9c7f08c2ae Added ?version argument to GET report-id 2025-07-23 16:09:24 -05:00
Nick Fitzpatrick
8693547be1 Static Quesiton Node 2025-07-23 17:57:43 -03:00
Edward Paget
29786d318c Replace runs with snapshoting endpoints 2025-07-23 15:33:05 -05:00
Nathan Voxland
9307c56d7b Added versions and reports API 2025-07-23 12:12:01 -05:00
Edward Paget
2583528cb3 Convert get run endpoints to stream 2025-07-23 09:53:21 -05:00
Edward Paget
33edd890a3 Working save and fetch of run data 2025-07-23 09:13:08 -05:00
Edward Paget
1e8dbe446b Towards working save of runs 2025-07-23 09:13:08 -05:00
Ryan Laurie
ce0e349f21 picard_facepalm 2025-07-22 16:59:54 -06:00
Ryan Laurie
fc5a6469ba saving fixes 2025-07-22 16:42:51 -06:00
Aleksandr Lesnenko
8523bf0412 ui 2025-07-22 18:11:00 -04:00
Aleksandr Lesnenko
13d11d33cd used content 2025-07-22 18:04:05 -04:00
Aleksandr Lesnenko
bb15f10b44 select chart type 2025-07-22 17:59:07 -04:00
Aleksandr Lesnenko
f9241dd461 show viz settings sidebar for embedded questions (#61341)
* show viz settings sidebar for embedded questions

* some redux

* ensure sidebar open

* settings sidebar edits charts viz settings
2025-07-22 17:27:28 -04:00
Nathan Voxland
a4a135903b Added report crud operations 2025-07-22 16:25:19 -05:00
Brad Anderson
67d31cea38 UXW-20 Adds “Browse all” option to SearchResults and RecentsList dropdowns 2025-07-22 17:20:07 -04:00
Ryan Laurie
b7e77ac43c add reports API Frontend 2025-07-22 13:41:57 -06:00
Kyle Doherty
89fd0b5c2b text fix 2025-07-22 14:58:17 -04:00
Kyle Doherty
5339aaa1ca layout and styling tweaks 2025-07-22 14:58:17 -04:00
Edward Paget
809896ee0a fixup! names on the migrations 2025-07-22 13:26:56 -05:00
Edward Paget
aae3788116 fix: remove namespace test const 2025-07-22 13:07:55 -05:00
Edward Paget
d743daaf20 feat: add migration and models for reports (#61300)
* feat: add migration and models for reports

* chore: add test for pivot card query caching

* Change column name markdown_docuemnt -> document

Add a content_type column to the table

* feat: add report run table and fk on cards

* feat: report run api start

* feat: run report api skeleton fixes for migration issues

* fixup! make qp run work
2025-07-22 12:46:09 -05:00
Aleksandr Lesnenko
853d9b0cf0 collapse sidebar in the reports view 2025-07-22 13:42:52 -04:00
Aleksandr Lesnenko
4e88f438e9 reorganize question embed node files 2025-07-22 13:01:35 -04:00
Ryan Laurie
5b6d472c96 cleanup some llm garbage 2025-07-22 10:38:00 -06:00
Ryan Laurie
60373ead57 add markdown exports
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/reports/components/ReportPage.tsx
2025-07-22 10:01:30 -06:00
Ryan Laurie
a4a927799d Enable CI skipping on PRs (#61307)
* dont run tests on pr with ci skip

* static viz too

* label instead of title
2025-07-22 09:59:24 -06:00
Aleksandr Lesnenko
8e75cd74f4 Report editing (#61290)
* basic tiptap

* new menu item

* vibecoded embedding

* lint, prettier

* layout

* refactor vibecoded layout

* ui

* useless placeholder

* fix selection

* lint

* ui

* ui

* ux

* ui

* experimental, possibly revert: notebook editor
2025-07-22 11:52:14 -04:00
Aleksandr Lesnenko
8252df95f2 actually tsx 2025-07-21 12:47:54 -04:00
Aleksandr Lesnenko
1cd6a43417 report route 2025-07-21 12:18:23 -04:00
254 changed files with 58846 additions and 722 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

View File

@@ -8,6 +8,7 @@ const { CSS_CONFIG } = require("../frontend/build/shared/rspack/css-config");
const mainAppStories = [
"../frontend/**/*.mdx",
"../frontend/**/*.stories.@(js|jsx|ts|tsx)",
"../enterprise/frontend/**/*.stories.@(js|jsx|ts|tsx)",
];
const config: StorybookConfig = {

View File

@@ -630,7 +630,8 @@
"src/metabase/util/currency\\.cljc$"
"test/metabase/channel/render/png_test.clj"
"#.+#$"
"\\.transit\\.json$"]}}
"\\.transit\\.json$"
"enterprise/frontend/src/metabase-enterprise/documents/components/Editor/data/data\\.json$"]}}
;; clojure -X:dev:ee:ee-dev:test:cloverage
:cloverage

View File

@@ -0,0 +1,31 @@
import type {
Document,
DocumentContent,
RegularCollectionId,
} from "metabase-types/api";
export const createDocument = ({
name,
collection_id,
document,
alias,
}: {
name: string;
collection_id?: RegularCollectionId | null;
document: DocumentContent;
alias?: string;
}): Cypress.Chainable<Cypress.Response<Document>> => {
cy.log(`Create a collection: ${name}`);
return cy
.request("POST", "/api/ee/document", {
name,
collection_id,
document,
})
.then((response) => {
if (alias) {
cy.wrap(response.body.id).as(alias);
}
});
};

View File

@@ -10,6 +10,7 @@ export { createDashboard } from "./createDashboard";
export type { DashboardDetails } from "./createDashboard";
export { createDashboardWithQuestions } from "./createDashboardWithQuestions";
export { createDashboardWithTabs } from "./createDashboardWithTabs";
export { createDocument } from "./createDocument";
export { createModerationReview } from "./createModerationReview";
export { createNativeQuestion } from "./createNativeQuestion";
export type { NativeQuestionDetails } from "./createNativeQuestion";

View File

@@ -45,7 +45,12 @@ export function getPersonalCollectionName(
export function openCollectionItemMenu(item: string, index = 0) {
// eslint-disable-next-line no-unsafe-element-filtering
cy.findAllByText(item).eq(index).closest("tr").icon("ellipsis").click();
cy.findByTestId("collection-table")
.findAllByText(item)
.eq(index)
.closest("tr")
.icon("ellipsis")
.click();
}
export const getPinnedSection = () => {

View File

@@ -0,0 +1,40 @@
export const documentContent = () => cy.findByTestId("document-content");
export const documentFormattingMenu = () =>
cy.findByTestId("document-formatting-menu");
export const addToDocument = (text: string, newLine: boolean = true) => {
cy.realType(text);
if (newLine) {
cy.realType("{enter}");
}
};
export const documentSuggestionDialog = () =>
cy.findByRole("dialog", { name: "Mention Dialog" });
export const documentSuggestionItem = (name: RegExp | string) =>
documentSuggestionDialog().findByRole("option", { name });
export const commandSuggestionDialog = () =>
cy.findByRole("dialog", { name: "Command Dialog" });
export const commandSuggestionItem = (name: RegExp | string) =>
commandSuggestionDialog().findByRole("option", { name });
export const getDocumentCard = (name: string) =>
documentContent()
.findAllByTestId("document-card-embed")
.contains('[data-testid="document-card-embed"]', name);
export const assertDocumentCardVizType = (name: string, type: string) =>
getDocumentCard(name).find(`[data-viz-ui-name=${type}]`);
export const getDocumentSidebar = () =>
cy.findByTestId("document-card-sidebar");
export const openDocumentCardMenu = (name: string) => {
getDocumentCard(name)
.findByRole("button", { name: /ellipsis/ })
.click();
};

View File

@@ -155,8 +155,8 @@ export function removeSummaryGroupingField({
index = 0,
}: {
field: string;
stage: number;
index: number;
stage?: number;
index?: number;
}) {
getNotebookStep("summarize", { stage, index })
.findByTestId("breakout-step")

View File

@@ -14,6 +14,7 @@ export * from "./e2e-dashboard-visualizer-helpers";
export * from "./e2e-database-metadata-helpers";
export * from "./e2e-datamodel-helpers";
export * from "./e2e-dimension-list-helpers";
export * from "./e2e-document-helpers";
export * from "./e2e-downloads-helpers";
export * from "./e2e-dragndrop-helpers";
export * from "./e2e-element-visibility-helpers";

View File

@@ -903,9 +903,9 @@ describe("scenarios > collection defaults", () => {
cy.log("single move");
cy.findByTestId("collection-table").within(() => {
H.openCollectionItemMenu("Outer collection 1");
});
cy.wait("@getCollectionItems");
H.openCollectionItemMenu("Outer collection 1");
H.popover().findByText("Move").click();

View File

@@ -0,0 +1,494 @@
import {
ORDERS_BY_YEAR_QUESTION_ID,
ORDERS_QUESTION_ID,
} from "e2e/support/cypress_sample_instance_data";
import {
ACCOUNTS_COUNT_BY_CREATED_AT,
ORDERS_COUNT_BY_PRODUCT_CATEGORY,
PRODUCTS_AVERAGE_BY_CATEGORY,
PRODUCTS_COUNT_BY_CATEGORY_PIE,
} from "e2e/support/test-visualizer-data";
const { H } = cy;
describe("documents", () => {
beforeEach(() => {
H.restore();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
});
it("should allow you to create a new document from the new button and save", () => {
cy.visit("/");
H.newButton("Document").click();
cy.findByRole("textbox", { name: "Document Title" })
.should("be.focused")
.type("Test Document");
H.documentContent().type("This is a paragraph\nAnd this is another");
cy.findByRole("button", { name: "Save" }).click();
H.entityPickerModalTab("Collections").click();
H.entityPickerModalItem(0, "Our analytics").should(
"have.attr",
"data-active",
"true",
);
H.entityPickerModalItem(1, "First collection").click();
H.entityPickerModal().findByRole("button", { name: "Select" }).click();
H.appBar()
.findByRole("link", { name: /First collection/ })
.click();
H.collectionTable()
.findByRole("link", { name: "Test Document" })
.should("exist");
cy.log("Document Management");
H.openCollectionItemMenu("Test Document");
H.popover().findByText("Move").click();
H.entityPickerModalTab("Collections").click();
H.entityPickerModalItem(0, "Our analytics")
.should("have.attr", "data-active", "true")
.click();
H.entityPickerModal().findByRole("button", { name: "Move" }).click();
H.openNavigationSidebar();
H.navigationSidebar().findByText("Our analytics").click();
H.openCollectionItemMenu("Test Document");
H.popover().findByText("Bookmark").click();
H.navigationSidebar()
.findByRole("tab", { name: "Bookmarks" })
.findByText("Test Document")
.click();
cy.location("pathname").should("equal", "/document/1-test-document");
H.appBar()
.findByRole("link", { name: /Our analytics/ })
.click();
H.openCollectionItemMenu("Test Document");
H.popover().findByText("Move to trash").click();
H.openNavigationSidebar();
H.navigationSidebar().findByText("Trash").click();
H.getUnpinnedSection().findByText("Test Document").should("exist");
});
describe("document editing", () => {
describe("Document with content", () => {
beforeEach(() => {
H.createDocument({
name: "Bar Document",
document: {
content: [
{
type: "paragraph",
content: [
{
type: "text",
text: "Lorem Ipsum and some more words",
},
],
},
{
type: "cardEmbed",
attrs: {
id: ORDERS_QUESTION_ID,
name: null,
},
},
{
type: "paragraph",
},
],
type: "doc",
},
collection_id: null,
alias: "documentId",
});
});
it("read only access", () => {
cy.signIn("readonly");
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
H.documentContent()
.findByRole("textbox")
.should("have.attr", "contenteditable", "false");
H.openDocumentCardMenu("Orders");
H.popover().findAllByRole("menuitem").should("be.disabled");
});
it("no access", () => {
cy.signIn("nocollection");
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
cy.findByRole("status").should(
"contain.text",
"Sorry, you don’t have permission to see that.",
);
});
it("not found", () => {
cy.get("@documentId").then((id) => cy.visit(`/document/${id + 1}`));
H.main().within(() => {
cy.findByText("We're a little lost...").should("exist");
cy.findByText("The page you asked for couldn't be found.");
});
});
});
});
describe("Empty Document", () => {
beforeEach(() => {
H.createDocument({
name: "Foo Document",
document: {
content: [],
type: "doc",
},
collection_id: null,
alias: "documentId",
}).then((data) => {
cy.visit(`/document/${data.body.id}`);
});
});
it("should support typing with a markdown syntax", () => {
H.documentContent().click();
H.addToDocument("# This is a heading level 1");
H.addToDocument("## This is a heading level 2");
H.addToDocument("### This is a heading level 3");
H.addToDocument("#### This is a heading level 4");
H.addToDocument("**Some Bold Text**");
H.addToDocument("*Some Italic Text*");
H.addToDocument("Lets start an unordered list");
H.addToDocument("- First Item");
H.addToDocument("Second Item");
// New Line to break out of the list;
H.addToDocument("");
H.addToDocument("Lets start an ordered list");
H.addToDocument("1. First Ordered Item");
H.addToDocument("Second Ordered Item");
// New Line to break out of the list;
H.addToDocument("");
H.addToDocument("http://metabase.com");
H.addToDocument("We can also add `inline code blocks` to paragraphs");
H.addToDocument("```");
H.addToDocument("Or add whole code blocks");
//Break out of the code block
H.addToDocument("\n");
H.documentContent().within(() => {
cy.findByRole("heading", {
name: "This is a heading level 1",
}).should("exist");
cy.findByRole("heading", {
name: "This is a heading level 2",
}).should("exist");
cy.findByRole("heading", {
name: "This is a heading level 3",
}).should("exist");
cy.findByRole("heading", {
name: "This is a heading level 4",
}).should("exist");
cy.findByRole("strong").should("contain.text", "Some Bold Text");
cy.findByRole("emphasis").should("contain.text", "Some Italic Text");
cy.findAllByRole("list").should("have.length", 2);
cy.findAllByRole("listitem")
.should("contain.text", "First Item")
.should("contain.text", "Second Item")
.should("contain.text", "First Ordered Item")
.should("contain.text", "Second Ordered Item");
cy.findByRole("link", { name: "http://metabase.com" }).should("exist");
cy.findAllByRole("code").contains("inline code blocks").should("exist");
cy.findAllByRole("code")
.contains("Or add whole code blocks")
.should("exist");
});
it("should support formatting via floating menu", () => {
const content = "Some text to play with";
const formatTests = [
{
button: /text_bold/,
role: "strong",
},
{
button: /text_italic/,
role: "emphasis",
},
{
button: /text_strike/,
role: "paragraph", // figure out what to do here
},
{
button: /format_code/,
role: "code",
},
{
button: /H1/,
role: "heading",
},
{
button: /H2/,
role: "heading",
},
{
button: /^list/,
role: "list",
},
{
button: /ordered_list/,
role: "list",
},
{
button: /quote/,
role: "blockquote",
},
{
button: /code_block/,
role: "code",
revert: false,
},
];
const assertUnformatted = () =>
H.documentContent()
// Converting to a heading currently adds a newline, which generates a new paragraph
.findAllByRole("paragraph")
.eq(0)
.should("contain.text", content);
H.documentContent().click();
H.addToDocument(content, false);
cy.realPress(["Shift", "{home}"]);
H.documentFormattingMenu().should("exist");
formatTests.forEach(({ button, role, revert = true }) => {
H.documentFormattingMenu()
.findByRole("button", { name: button })
.click();
H.documentContent().findByRole(role).should("contain.text", content);
if (revert) {
H.documentFormattingMenu()
.findByRole("button", { name: button })
.click();
assertUnformatted();
}
});
});
});
describe("Card Embeds", () => {
beforeEach(() => {
H.createQuestion(PRODUCTS_AVERAGE_BY_CATEGORY);
H.createQuestion(ACCOUNTS_COUNT_BY_CREATED_AT);
// Need to get this one to simulate recent activity
H.createQuestion(PRODUCTS_COUNT_BY_CATEGORY_PIE).then(
({ body: { id } }) => cy.request(`/api/card/${id}`),
);
H.createDashboard({
name: "Fancy Dashboard",
}).then(({ body: { id } }) => {
H.createQuestion({
...ORDERS_COUNT_BY_PRODUCT_CATEGORY,
dashboard_id: id,
});
});
});
it("should support adding cards and updating viz settings", () => {
H.documentContent().click();
H.addToDocument("/", false);
cy.log("search via type");
H.addToDocument("Accounts", false);
H.commandSuggestionDialog().should(
"contain.text",
ACCOUNTS_COUNT_BY_CREATED_AT.name,
);
cy.realPress("{downarrow}");
H.addToDocument("\n", false);
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).should("exist");
cy.realPress("{downarrow}");
cy.log("via recents");
H.addToDocument("/", false);
H.commandSuggestionItem("Question").click();
H.commandSuggestionItem(
new RegExp(RegExp.escape(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)),
).click();
H.getDocumentCard(PRODUCTS_COUNT_BY_CATEGORY_PIE.name).should("exist");
cy.realPress("{downarrow}");
cy.log("via entity picker");
H.addToDocument("/", false);
H.commandSuggestionItem("Question").click();
H.commandSuggestionItem(/Browse all/).click();
H.entityPickerModalTab("Questions").click();
H.entityPickerModalItem(1, PRODUCTS_AVERAGE_BY_CATEGORY.name).click();
H.getDocumentCard(PRODUCTS_AVERAGE_BY_CATEGORY.name).should("exist");
cy.realPress("{downarrow}");
cy.log("dashboard question via entity picker");
H.addToDocument("/", false);
H.commandSuggestionItem("Question").click();
H.commandSuggestionItem(/Browse all/).click();
H.entityPickerModalTab("Questions").click();
H.entityPickerModalItem(1, "Fancy Dashboard").click();
H.entityPickerModalItem(
2,
ORDERS_COUNT_BY_PRODUCT_CATEGORY.name,
).click();
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
"exist",
);
cy.log("change a cards display type");
H.openDocumentCardMenu(ACCOUNTS_COUNT_BY_CREATED_AT.name);
H.popover().findByText("Edit Visualization").click();
H.getDocumentSidebar().findByRole("button", { name: /Bar/i }).click();
cy.findByRole("menu", { name: /Bar/i }).findByText("Line").click();
H.getDocumentSidebar().within(() => {
cy.findByText("Axes").click();
cy.findByDisplayValue("Created At: Month").clear().type("Foo Axes");
});
H.assertDocumentCardVizType(ACCOUNTS_COUNT_BY_CREATED_AT.name, "Line");
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).findByText(
"Foo Axes",
);
H.getDocumentSidebar().findByRole("button", { name: /close/ }).click();
// Edit the Query. Assert on the number of breakouts
H.openDocumentCardMenu(PRODUCTS_COUNT_BY_CATEGORY_PIE.name);
H.getDocumentCard(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)
.findByRole("list")
.findAllByRole("listitem")
.should("have.length", 4);
H.popover().findByText("Edit Query").click();
H.removeSummaryGroupingField({ field: "Category" });
H.addSummaryGroupingField({ field: "Price" });
H.modal().findByRole("button", { name: "Save and use" }).click();
H.getDocumentCard(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)
.findByRole("list")
.findAllByRole("listitem")
.should("have.length", 7);
//Replace Card
H.openDocumentCardMenu(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name);
H.popover().findByText("Replace").click();
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
"not.exist",
);
H.commandSuggestionDialog().should("exist");
H.addToDocument("Orders", false);
cy.realPress("{enter}");
H.getDocumentCard("Orders").should("exist");
});
it("should copy an added card on save", () => {
cy.intercept({
method: "PUT",
path: "/api/ee/document/*",
}).as("documentUpdate");
cy.intercept({
method: "GET",
path: "/api/ee/document/*",
}).as("documentGet");
//initial load
cy.wait("@documentGet");
H.documentContent().click();
H.addToDocument("/ord", false);
H.commandSuggestionDialog()
.should("contain.text", "Orders, Count, Grouped by Created At (year)")
.should("contain.text", "Orders, Count")
.should("contain.text", "Orders Model");
H.commandSuggestionItem(
/Orders, Count, Grouped by Created At \(year\)/,
).click();
//Adding a new line
H.addToDocument("");
H.addToDocument("Adding a static link: /", false);
H.commandSuggestionItem(/Link to\.\.\./).click();
H.addToDocument("Ord", false);
H.commandSuggestionItem(/Orders, Count$/).click();
H.addToDocument(" And continue typing", false);
H.documentContent().within(() => {
cy.findAllByTestId("document-card-embed")
.should("have.length", 1)
.should(
"contain.text",
"Orders, Count, Grouped by Created At (year)",
);
cy.findByRole("link", { name: /Orders, Count$/ }).should("exist");
});
cy.findByRole("button", { name: "Save" }).click();
cy.wait("@documentUpdate");
cy.wait("@documentGet");
cy.findByTestId("document-card-embed")
.findByText("Orders, Count, Grouped by Created At (year)")
.click();
cy.location("pathname").should(
"not.include",
ORDERS_BY_YEAR_QUESTION_ID.toString(),
);
});
});
});
});

View File

@@ -18,6 +18,7 @@
[metabase-enterprise.content-verification.api.routes]
[metabase-enterprise.database-replication.api :as database-replication.api]
[metabase-enterprise.database-routing.api]
[metabase-enterprise.documents.api]
[metabase-enterprise.email.api]
[metabase-enterprise.gsheets.api :as gsheets.api]
[metabase-enterprise.llm.api]
@@ -49,6 +50,7 @@
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
:llm-autodescription (deferred-tru "LLM Auto-description")
:metabot-v3 (deferred-tru "MetaBot")
:documents (deferred-tru "Documents")
:scim (deferred-tru "SCIM configuration")
:serialization (deferred-tru "Serialization")
:table-data-editing (deferred-tru "Table Data Editing")
@@ -98,6 +100,7 @@
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)

View File

@@ -0,0 +1,10 @@
(ns metabase-enterprise.bookmarks.models.bookmark
"Enterprise DocumentBookmark model. Provides bookmarking functionality for documents."
(:require
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/DocumentBookmark [_model] :document_bookmark)
(doto :model/DocumentBookmark
(derive :metabase/model))

View File

@@ -11,6 +11,7 @@
[metabase-enterprise.audit-app.init]
[metabase-enterprise.cache.init]
[metabase-enterprise.database-replication.init]
[metabase-enterprise.documents.init]
[metabase-enterprise.gsheets.init]
[metabase-enterprise.llm.init]
[metabase-enterprise.metabot-v3.init]

View File

@@ -0,0 +1,10 @@
(ns metabase-enterprise.documents.api
"`/api/ee/document/` routes"
(:require
[metabase-enterprise.documents.api.document]
[metabase.api.util.handlers :as handlers]))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(handlers/routes
metabase-enterprise.documents.api.document/routes))

View File

@@ -0,0 +1,244 @@
(ns metabase-enterprise.documents.api.document
"`/api/ee/document/` routes"
(:require
[metabase-enterprise.documents.models.document :as m.document]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.collections.models.collection :as collection]
[metabase.events.core :as events]
[metabase.models.interface :as mi]
[metabase.queries.core :as card]
[metabase.query-permissions.core :as query-perms]
[metabase.util :as u]
[metabase.util.i18n :refer [tru]]
[metabase.util.malli :as mu]
[metabase.util.malli.schema :as ms]
[toucan2.core :as t2]))
(def ^:private CardCreateSchema
"Schema for creating a new card - simplified version to avoid circular dependencies"
[:map
[:name ms/NonBlankString]
[:dataset_query ms/Map]
[:entity_id {:optional true} [:maybe ms/NonBlankString]]
[:parameters {:optional true} [:maybe [:sequential ms/Map]]]
[:parameter_mappings {:optional true} [:maybe [:sequential ms/Map]]]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:display ms/NonBlankString]
[:visualization_settings ms/Map]
[:result_metadata {:optional true} [:maybe [:sequential ms/Map]]]
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]])
(def ^:private DocumentCreateOptions
[:map
[:name m.document/DocumentName]
[:document :any]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
[:cards {:optional true} [:maybe [:map-of [:int {:max -1}] CardCreateSchema]]]])
(def ^:private DocumentUpdateOptions
[:map
[:name {:optional true} m.document/DocumentName]
[:document {:optional true} :any]
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
[:cards {:optional true} [:maybe [:map-of :int CardCreateSchema]]]
[:archived {:optional true} [:maybe :boolean]]])
(defn- create-card!
"Checks that the query is runnable by the current user then saves"
[{query :dataset_query :as card} creator]
(query-perms/check-run-permissions-for-query query)
(card/create-card! (assoc card :type :question :dashboard_id nil) creator))
(mu/defn- update-cards-in-ast :- [:map [:document :any]
[:content_type :string]]
[document :- [:map
[:document :any]
[:content_type :string]]
card-id-map :- [:maybe [:map-of :int ms/PositiveInt]]]
(cond-> document
(map? document)
(prose-mirror/update-ast (fn match-card-to-update [{:keys [type attrs]}]
(and (= type prose-mirror/card-embed-type)
(contains? card-id-map (:id attrs))))
(fn update-card-id [embed]
(update-in embed [:attrs :id] card-id-map)))))
(mu/defn- create-cards-for-document! :- [:map-of ms/NegativeInt ms/PositiveInt]
"Creates cards for a document from the cards map.
Returns a mapping from the original negative integer keys to the newly created card IDs.
Args:
- cards-to-create: Map of negative-int -> CardCreateSchema data
- document-id: ID of the document these cards belong to
- document-collection-id: Collection ID of the document (for inheritance)
- creator: User creating the cards
Returns:
- Map of negative-int -> actual-card-id"
[cards-to-create :- [:map-of [:int {:max -1}] CardCreateSchema]
document-id :- ms/PositiveInt
document-collection-id :- [:or :nil ms/PositiveInt]
creator :- [:map [:id ms/PositiveInt]]]
(when (seq cards-to-create)
(reduce-kv
(fn [result-map original-key card-data]
(let [;; Merge document info into card data
;; Cards inherit document's collection_id if not explicitly specified
merged-card-data (-> card-data
(assoc :document_id document-id)
(cond-> (nil? (:collection_id card-data))
(assoc :collection_id document-collection-id)))
;; Create the card using the queries core function
new-card (create-card! merged-card-data creator)]
(assoc result-map original-key (:id new-card))))
{}
cards-to-create)))
(mu/defn- clone-cards-in-document! :- [:map-of ms/PositiveInt ms/PositiveInt]
"Finds all cards in the document that are not associated with the document and clones the cards.
Args:
- document: the document model to clone cards within
Returns:
- map of old-card-id -> cloned-card-id"
[{:keys [id collection_id] :as document}]
(let [card-ids (prose-mirror/collect-ast document #(when (and (= prose-mirror/card-embed-type (:type %))
(pos? (-> % :attrs :id)))
(-> % :attrs :id)))
to-clone (when (seq card-ids)
(t2/select :model/Card {:where [:and [:in :id card-ids]
[:or [:<> :document_id id]
[:= :document_id nil]]]}))]
(reduce (fn [accum card]
(api/read-check card)
(assoc accum
(:id card)
(:id (create-card! (assoc card :document_id id :collection_id collection_id)
@api/*current-user*))))
{}
to-clone)))
(defn get-document
"Get document by id checking if the current user has permission to access and if the document exists."
[id]
(u/prog1 (api/check-404
(api/read-check
(t2/hydrate (t2/select-one :model/Document :id id) :creator :can_write :can_delete :can_restore)))
(events/publish-event! :event/document-read
{:object-id id
:user-id api/*current-user-id*})))
(api.macros/defendpoint :get "/"
"Gets existing `Documents`."
[_route-params
_query-params]
{:items (t2/hydrate (t2/select :model/Document {:where [:and
(collection/visible-collection-filter-clause)
[:= :archived false]]})
:creator :can_write)})
(api.macros/defendpoint :post "/"
"Create a new `Document`."
[_route-params
_query-params
{:keys [name document collection_id collection_position cards]} :- DocumentCreateOptions]
(collection/check-write-perms-for-collection collection_id)
(let [document-id (t2/with-transaction [_conn]
(when collection_position
(api/maybe-reconcile-collection-position! {:collection_id collection_id
:collection_position collection_position}))
(let [document-id (t2/insert-returning-pk! :model/Document {:name name
:collection_id collection_id
:collection_position collection_position
:document document
:content_type prose-mirror/prose-mirror-content-type
:creator_id api/*current-user-id*})
cards-to-update-in-ast (merge (clone-cards-in-document! {:id document-id
:collection_id collection_id
:document document
:content_type prose-mirror/prose-mirror-content-type})
(when-not (empty? cards)
(create-cards-for-document! cards document-id collection_id @api/*current-user*)))]
(when (seq cards-to-update-in-ast)
(t2/update! :model/Document :id document-id
(update-cards-in-ast
{:document document
:content_type prose-mirror/prose-mirror-content-type}
cards-to-update-in-ast)))
document-id))
created-document (get-document document-id)]
;; Publish event after successful creation
(events/publish-event! :event/document-create
{:object created-document
:user-id api/*current-user-id*})
created-document))
(api.macros/defendpoint :get "/:document-id"
"Returns an existing Document by ID."
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
(api/read-check (get-document document-id)))
(api.macros/defendpoint :put "/:document-id"
"Updates an existing `Document`."
[{:keys [document-id]} :- [:map
[:document-id ms/PositiveInt]]
_query-params
{:keys [name document collection_id collection_position cards] :as body} :- DocumentUpdateOptions]
(let [existing-document (api/check-404 (get-document document-id))]
(api/check-403 (mi/can-write? existing-document))
(when (api/column-will-change? :collection_id existing-document body)
(m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id))
;; Handle archiving logic
(let [document-updates (dissoc (api/updates-with-archived-directly existing-document body) :cards)]
(t2/with-transaction [_conn]
(when collection_position
(api/maybe-reconcile-collection-position! existing-document {:collection_id (if (contains? body :collection_id)
collection_id
(:collection_id existing-document))
:collection_position collection_position}))
(t2/update! :model/Document document-id
(cond-> document-updates
document (merge (update-cards-in-ast
{:document document
:content_type (:content_type existing-document)}
(merge
(clone-cards-in-document! (assoc existing-document :document document))
(when-not (empty? cards) (create-cards-for-document! cards document-id collection_id @api/*current-user*)))))
name (assoc :name name)
(contains? body :collection_id) (assoc :collection_id collection_id))))
(let [updated-document (get-document document-id)]
;; Publish appropriate events
(if (:archived document-updates)
(events/publish-event! :event/document-delete
{:object updated-document
:user-id api/*current-user-id*})
(events/publish-event! :event/document-update
{:object updated-document
:user-id api/*current-user-id*}))
updated-document))))
(api.macros/defendpoint :delete "/:document-id"
"Permanently deletes an archived Document."
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
(let [document (api/check-404 (t2/select-one :model/Document :id document-id))]
(api/check-403 (mi/can-write? document))
(when-not (:archived document)
(let [msg (tru "Document must be archived before it can be deleted.")]
(throw (ex-info msg {:status-code 400, :errors {:archived msg}}))))
(t2/delete! :model/Document :id document-id)
(events/publish-event! :event/document-delete
{:object document
:user-id api/*current-user-id*})
api/generic-204-no-content))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/document/` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -0,0 +1,15 @@
(ns metabase-enterprise.documents.core
(:require
[metabase-enterprise.documents.api.document]
[metabase-enterprise.documents.recent-views]
[potemkin :as p]))
(comment
metabase-enterprise.documents.api.document/keep-me
metabase-enterprise.documents.recent-views/keep-me)
(p/import-vars
[metabase-enterprise.documents.api.document
get-document]
[metabase-enterprise.documents.recent-views
select-documents-for-recents])

View File

@@ -0,0 +1,5 @@
(ns metabase-enterprise.documents.init
(:require
[metabase-enterprise.documents.recent-views]
[metabase-enterprise.documents.revisions.impl]
[metabase-enterprise.documents.view-log]))

View File

@@ -0,0 +1,220 @@
(ns metabase-enterprise.documents.models.document
(:require
[clojure.string :as str]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
[metabase.api.common :as api]
[metabase.collections.models.collection :as collection]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.search.spec :as search.spec]
[metabase.util :as u]
[metabase.util.i18n :refer [deferred-tru]]
[metabase.util.log :as log]
[metabase.util.malli :as mu]
[methodical.core :as methodical]
[toucan2.core :as t2]))
(methodical/defmethod t2/table-name :model/Document [_model] :document)
(t2/deftransforms :model/Document
{:document mi/transform-json})
(doto :model/Document
(derive :metabase/model)
(derive :perms/use-parent-collection-perms)
(derive :hook/timestamped?)
(derive :hook/entity-id))
(def DocumentName
"Validations for the name of a document"
(mu/with-api-error-message
[:and
{:error/message "invalid document name"
:json-schema {:type "string" :minLength 1 :maxLength 254}}
[:string {:min 1 :max 254}]
[:fn
{:error/message "invalid document name"}
(complement str/blank?)]]
(deferred-tru "value must be a non-blank string between 1 and 254 characters.")))
(defn validate-collection-move-permissions
"Validates that the current user has write permissions for both old and new collections
when moving a document. Uses the same permission pattern as check-allowed-to-change-collection.
Throws 403 exception if permissions are insufficient."
[old-collection-id new-collection-id]
(when old-collection-id
(collection/check-write-perms-for-collection old-collection-id))
(when new-collection-id
(collection/check-write-perms-for-collection new-collection-id))
(when new-collection-id
(api/check-400 (t2/exists? :model/Collection :id new-collection-id :archived false))))
(methodical/defmethod t2/batched-hydrate [:model/Document :creator]
"Hydrate the creator (user) of a document based on the creator_id."
[_model k documents]
(mi/instances-with-hydrated-data
documents k
#(-> (t2/select [:model/User :id :email :first_name :last_name] :id (keep :creator_id documents))
(map (juxt :id identity))
(into {}))
:creator_id {:default {}}))
(defn sync-document-cards-collection!
"Updates all cards associated with a document to match the document's collection.
If the document is archived, also archives all associated cards. "
[document-id collection-id & {:keys [archived archived-directly]}]
(let [update-map {:collection_id collection-id
:archived (boolean archived)
:archived_directly (boolean archived-directly)}]
(t2/update! :model/Card
:document_id document-id
update-map)))
(t2/define-after-update :model/Document
[{:keys [id collection_id archived archived_directly]}]
(sync-document-cards-collection! id collection_id
:archived archived
:archived-directly archived_directly))
;;; ------------------------------------------------ Serdes Hashing -------------------------------------------------
(defmethod serdes/hash-fields :model/Document
[_table]
[:name (serdes/hydrated-hash :collection) :created-at])
(defmethod serdes/make-spec "Document"
[_model-name _opts]
{:copy [:archived :archived_directly :collection_position :description :entity_id :name :view_count]
:skip [;; instance-specific stats
:last_viewed_at
;; skip until we implement serdes for documents
:document_id]
:transform {:created_at (serdes/date)
:updated_at (serdes/date)
:collection_id (serdes/fk :model/Collection)
:creator_id (serdes/fk :model/User)
:document :skip}})
;;; ----------------------------------------------- Search ----------------------------------------------------------
(search.spec/define-spec "document"
{:model :model/Document
:attrs {:archived true
:collection-id :collection_id
:creator-id :creator_id
:view-count :view_count
:created-at :created_at
:updated-at :updated_at
:last-viewed-at :last_viewed_at
:pinned [:> [:coalesce :collection_position [:inline 0]] [:inline 0]]}
:search-terms [:name]
:render-terms {:document-name :name
:document-id :id
:collection-position true}})
;;; ----------------------------------------------- Serdes Hashing -------------------------------------------------
(defmethod serdes/hash-fields :model/Document
[_table]
[:name (serdes/hydrated-hash :collection) :created-at])
;;; ---------------------------------------------- Serialization --------------------------------------------------
(def ^:private ast-model->db-model
{"card" :model/Card
"dataset" :model/Card
"table" :model/Table
"dashboard" :model/Dashboard})
(def ^:private model->serdes-model
{"card" "Card"
"dataset" "Card"
"dashboard" "Dashboard"
"table" "Table"})
(defn- id->entity-id
[{{:keys [model] :or {model "card"} :as attrs} :attrs type :type :as node}]
(let [id-key (if (= prose-mirror/smart-link-type type) :entityId :id)
id (id-key attrs)]
(if-let [db-model (t2/select-one (ast-model->db-model model) :id id)]
(assoc-in node [:attrs id-key] (mapv #(dissoc % :label) (serdes/generate-path (model->serdes-model model) db-model)))
(u/prog1 node
(log/warnf "entity_id not found for %s at id: %s" model id)))))
(defn- entity-id->id
[{:keys [attrs type] :as node}]
(let [id-key (if (= prose-mirror/smart-link-type type) :entityId :id)
id (:id (serdes/load-find-local (id-key attrs)))]
(if id
(assoc-in node [:attrs id-key] id)
(u/prog1 node
(log/warn "Model not found at path" (id-key attrs))))))
(defn- export-document-content
"Transform cardEmbed/smartLink nodes to use entity IDs instead of database IDs"
[document serdes-key _]
(serdes-key
(if (= (:content_type document) prose-mirror/prose-mirror-content-type)
(prose-mirror/update-ast
document
#(contains? #{prose-mirror/smart-link-type prose-mirror/card-embed-type} (:type %))
id->entity-id)
document)))
(defn- import-document-content
"Transform cardEmbed/smartLink nodes to use database IDs instead of entity IDs"
[document serdes-key _]
(serdes-key
(if (= (:content_type document) prose-mirror/prose-mirror-content-type)
(prose-mirror/update-ast
document
#(contains? #{prose-mirror/smart-link-type prose-mirror/card-embed-type} (:type %))
entity-id->id)
document)))
(defmethod serdes/make-spec "Document"
[_model-name _opts]
{:copy [:archived :archived_directly :content_type :entity_id :name :collection_position]
:skip [:view_count :last_viewed_at]
:transform {:created_at (serdes/date)
:updated_at (serdes/date)
:document {:export-with-context export-document-content
:import-with-context import-document-content}
:collection_id (serdes/fk :model/Collection)
:creator_id (serdes/fk :model/User)}})
(defn- document-deps
[{:keys [content_type] :as document}]
(when (= content_type prose-mirror/prose-mirror-content-type)
(set (prose-mirror/collect-ast document (fn document-deps [{:keys [type attrs]}]
(cond
(and (= prose-mirror/smart-link-type type)
(contains? model->serdes-model (:model attrs)))
(:entityId attrs)
(= prose-mirror/card-embed-type type)
(:id attrs)
:else
nil))))))
(defmethod serdes/dependencies "Document"
[{:keys [collection_id] :as document}]
(set (concat
(document-deps document)
(when collection_id #{[{:model "Collection" :id collection_id}]}))))
(defmethod serdes/descendants "Document"
[_model-name id]
(when-let [document (t2/select-one :model/Document :id id)]
(when (= prose-mirror/prose-mirror-content-type (:content_type document))
(merge
(into {}
(for [embedded-card-id (prose-mirror/card-ids document)]
{["Card" embedded-card-id] {"Document" id}}))
(into {}
(for [{model :model link-id :id} (prose-mirror/collect-ast document
#(when (= prose-mirror/smart-link-type (:type %))
(:attrs %)))
:when (contains? model->serdes-model model)]
{[(model->serdes-model model) link-id] {"Document" id}}))))))

View File

@@ -0,0 +1,64 @@
(ns metabase-enterprise.documents.prose-mirror
"Manipulate the prose mirror ast for documents"
(:require
[clojure.walk :as walk]))
(def card-embed-type
"Type of a card-embed node"
"cardEmbed")
(def smart-link-type
"Type of a smart-link node"
"smartLink")
(def prose-mirror-content-type
"The vendored 'mime-type' for documents saved using the prose-mirror ast."
"application/json+vnd.prose-mirror")
(defn- assert-prose-mirror
"Asserts the content-type is correct for the document or throw"
[{:keys [content_type]}]
(when-not (= content_type prose-mirror-content-type)
(throw (ex-info "Document does not have the prose mirror content-type"
{:content-type content_type
:status-code 400}))))
(defn update-ast
"Update a node that matches a predicate using a post-walk.
Args:
- document - a :model/Document, this will check that content type is valid for prose mirror
- predicate - a one-arg function returning true a given node should be updated
- updater - a one-arg function taking the node and returning the new node
Returns:
- the updated prose-mirror ast"
[{:keys [document] :as doc} predicate updater]
(assert-prose-mirror doc)
(assoc doc :document
(walk/postwalk (fn ast-walker
[node]
(cond-> node
(predicate node) updater))
document)))
(defn collect-ast
"Collect values from the ast lazily removes nils
Args:
- document - a :model/Document, this will check that the content-type is valid for prose mirror
- collector - a function that extracts values from a given node
Returns:
- a lazy seq of results from collector"
[{:keys [document] :as doc} collector]
(assert-prose-mirror doc)
(->> (tree-seq :content :content document)
(keep collector)))
(defn card-ids
"Get all card-ids"
[document]
(collect-ast document #(when (and (= card-embed-type (:type %))
(pos? (-> % :attrs :id)))
(-> % :attrs :id))))

View File

@@ -0,0 +1,70 @@
(ns metabase-enterprise.documents.recent-views
"Enterprise multimethod implementations for documents in recent views."
(:require
[metabase.activity-feed.core :as activity-feed]
[metabase.collections.models.collection.root :as root]
[metabase.models.interface :as mi]
[metabase.premium-features.core :refer [defenterprise]]
[toucan2.core :as t2]))
(defenterprise select-documents-for-recents
"Query to select recent document data. Returns documents with their collection information
for use in recent views display."
:feature :documents
[document-ids]
(if-not (seq document-ids)
[]
(let [documents (t2/select :model/Document
{:select [:d.id
:d.name
:d.archived
[:d.collection_id :entity-coll-id]
[:c.id :collection_id]
[:c.name :collection_name]
[:c.authority_level :collection_authority_level]]
:from [[:document :d]]
:where [:in :d.id document-ids]
:left-join [[:collection :c]
[:and
[:= :c.id :d.collection_id]
[:= :c.archived false]]]})]
documents)))
(defn- fill-parent-coll
"Fill in parent collection information for a document, following the same pattern
as other recent view implementations."
[model-object]
(if (:collection_id model-object)
{:id (:collection_id model-object)
:name (:collection_name model-object)
:authority_level (some-> (:collection_authority_level model-object) name)}
(select-keys
(root/root-collection-with-ui-details {})
[:id :name :authority_level])))
(defn- parent-collection-valid?
"Returns true when a parent collection actually exists for this document.
Follows the same pattern as cards - returns false when the collection has a
collection_id but no collection_name (indicating the collection no longer exists)."
[{:keys [collection_id entity-coll-id]}]
(not (and entity-coll-id (nil? collection_id))))
(defn- ellide-archived
"Returns the model when it's not archived.
We use this to ensure that archived models are not returned in the recent views."
[model]
(when (false? (:archived model)) model))
(defmethod activity-feed/fill-recent-view-info :document
[{:keys [_model model_id timestamp model_object]}]
(when-let [document (and
(mi/can-read? model_object)
(parent-collection-valid? model_object)
(ellide-archived model_object))]
{:id model_id
:name (:name document)
:description ""
:model :document
:can_write (mi/can-write? document)
:timestamp (str timestamp)
:parent_collection (fill-parent-coll document)}))

View File

@@ -0,0 +1,27 @@
(ns metabase-enterprise.documents.revisions.impl
"Document revision serialization implementation.
Implements Document revision serialization following the Card exclusion pattern,
excluding metadata columns while preserving content, name, and other Document-specific fields."
(:require
[metabase.revisions.core :as revisions]))
(def ^:private excluded-columns-for-document-revision
"Columns to exclude from Document revision serialization.
Excludes metadata columns (timestamps, IDs, collection_id, creator_id, etc.)
following the same pattern established for Card revisions.
The :document field contains the actual content and is preserved."
#{:id
:creator_id
:created_at
:updated_at
:view_count
:last_viewed_at
:collection_id
:collection_position})
(defmethod revisions/serialize-instance :model/Document
[_model _id instance]
(apply dissoc instance excluded-columns-for-document-revision))

View File

@@ -0,0 +1,65 @@
(ns metabase-enterprise.documents.view-log
"Integrates view logging events for documents"
(:require
[java-time.api :as t]
[metabase.activity-feed.core :as activity-feed]
[metabase.app-db.cluster-lock :as cluster-lock]
[metabase.batch-processing.core :as grouper]
[metabase.events.core :as events]
[metabase.util.log :as log]
[metabase.view-log.core :as view-log]
[methodical.core :as m]
[steffan-westcott.clj-otel.api.trace.span :as span]
[toucan2.core :as t2]))
(def ^:private document-statistics-lock
"keyword to use for locking document updates that can deadlock"
::document-statistics-lock)
(derive ::document-read :metabase/event)
(derive :event/document-read ::document-read)
(def ^:private update-document-last-viewed-at-interval-seconds 20)
(defn- update-document-last-viewed-at!* [document-id-timestamps]
(let [document-id->timestamp (update-vals (group-by :id document-id-timestamps)
(fn [xs] (apply t/max (map :timestamp xs))))]
(try
(cluster-lock/with-cluster-lock document-statistics-lock
(t2/update! :model/Document :id [:in (keys document-id->timestamp)]
{:last_viewed_at (into [:case]
(mapcat (fn [[id timestamp]]
[[:= :id id] [:greatest [:coalesce :last_viewed_at (t/offset-date-time 0)] timestamp]])
document-id->timestamp))
:updated_at :updated_at})) ;; setting last_viewed_at should not update the updated_at column
(catch Exception e
(log/error e "Failed to update document last_viewed_at")))))
(def ^:private update-document-last-viewed-at-queue
(delay (grouper/start!
#'update-document-last-viewed-at!*
:capacity 500
:interval (* update-document-last-viewed-at-interval-seconds 1000))))
(defn- update-document-last-viewed-at!
"Update the `last_viewed_at` of a document asynchronously"
[document-id]
(let [now (t/offset-date-time)]
(grouper/submit! @update-document-last-viewed-at-queue {:id document-id
:timestamp now})))
(m/defmethod events/publish-event! ::document-read
"Handle processing for the document read event. Logs the document view and increments view count."
[topic {:keys [object-id user-id] :as event}]
(span/with-span!
{:name "view-log-document-read"
:topic topic
:user-id user-id}
(try
(view-log/increment-view-counts! :model/Document object-id)
(update-document-last-viewed-at! object-id)
(view-log/record-views! (view-log/generate-view :model :model/Document event))
;; Update recent views alongside existing view log functionality
(activity-feed/update-users-recent-views! user-id :model/Document object-id :view)
(catch Throwable e
(log/warnf e "Failed to process document view event. %s" topic)))))

View File

@@ -3,6 +3,7 @@
(:require
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.api.document]
[metabase-enterprise.metabot-v3.api.metabot]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
@@ -93,4 +94,5 @@
(handlers/routes
(api.macros/ns-handler *ns* +auth)
(handlers/route-map-handler
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes})))
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes
"/document" metabase-enterprise.metabot-v3.api.document/routes})))

View File

@@ -0,0 +1,25 @@
(ns metabase-enterprise.metabot-v3.api.document
"`/api/ee/metabot-v3/document` routes"
(:require
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase.api.common :as api]
[metabase.api.macros :as api.macros]
[metabase.api.routes.common :refer [+auth]]
[metabase.util.malli.schema :as ms]))
(api.macros/defendpoint :post "/generate-content"
"Create a new piece of content to insert into the document."
[_route-params
_query-params
body :- [:map
[:instructions ms/NonBlankString]
[:references {:optional true} ms/Map]]]
(metabot-v3.client/document-generate-content {:instructions (:instructions body)
:references (:references body)
:user_id api/*current-user-id*
:conversation_id (str (random-uuid))}))
(def ^{:arglists '([request respond raise])} routes
"`/api/ee/metabot-v3/document` routes."
(api.macros/ns-handler *ns* +auth))

View File

@@ -1,12 +1,16 @@
(ns metabase-enterprise.metabot-v3.client
(:require
[buddy.core.hash :as buddy-hash]
[buddy.sign.jwt :as jwt]
[clj-http.client :as http]
[clj-time.core :as time]
[clojure.java.io :as io]
[clojure.set :as set]
[clojure.string :as str]
[malli.core :as mc]
[malli.transform :as mtx]
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
@@ -26,6 +30,18 @@
(def ^:dynamic ^:private *debug* false)
(defn get-ai-service-token
"Get the token for the AI service."
([]
(get-ai-service-token api/*current-user-id* metabot-v3.config/internal-metabot-id))
([user-id metabot-id]
(let [secret (buddy-hash/sha256 (metabot-v3.settings/site-uuid-for-metabot-tools))
claims {:user user-id
:exp (time/plus (time/now) (time/seconds (metabot-v3.settings/metabot-ai-service-token-ttl)))
:metabot-id metabot-id}]
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256}))))
(defn- ->json-bytes ^bytes [x]
(with-open [os (java.io.ByteArrayOutputStream.)
w (java.io.OutputStreamWriter. os)]
@@ -91,6 +107,9 @@
(defn- example-question-generation-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/example-question-generation/batch"))
(defn- document-generate-content-endpoint []
(str (metabot-v3.settings/ai-service-base-url) "/v1/document/generate-content"))
(mu/defn request :- ::metabot-v3.client.schema/ai-service.response
"Make a V2 request to the AI Service."
[{:keys [context messages profile-id conversation-id session-id state]}
@@ -280,6 +299,23 @@
{:request (assoc options :body body)
:response response})))))
(mu/defn document-generate-content
"Ask the AI service to generate a new node for a document."
[body :- [:map
[:instructions :string]]]
(let [url (document-generate-content-endpoint)
options (build-request-options body)
headers (merge (:headers options) {"x-metabase-session-token" (get-ai-service-token)
"x-metabase-url" (system/site-url)})
options (assoc options :headers headers)
response (post! url options)]
(if (= (:status response) 200)
(:body response)
(throw (ex-info (format "Error in request to AI service: unexpected status code: %d %s"
(:status response) (:reason-phrase response))
{:request (assoc options :body body)
:response response})))))
(def chart-analysis-schema
"Schema for chart analysis data input."
[:map

View File

@@ -1,6 +1,7 @@
(ns metabase-enterprise.metabot-v3.dummy-tools
(:require
[medley.core :as m]
[metabase-enterprise.documents.core :as documents]
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
[metabase.api.common :as api]
[metabase.legacy-mbql.normalize :as mbql.normalize]
@@ -9,11 +10,14 @@
[metabase.lib.metadata :as lib.metadata]
[metabase.lib.types.isa :as lib.types.isa]
[metabase.lib.util :as lib.util]
[metabase.models.interface :as mi]
[metabase.util :as u]
[metabase.util.humanization :as u.humanization]
[metabase.warehouse-schema.models.field-values :as field-values]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn get-current-user
"Get information about the current user."
[]
@@ -289,6 +293,55 @@
{:structured-output details}
{:output (or details "report not found")}))))
(defn get-document-details
"Get information about the document with ID `document-id`."
[{:keys [document-id]}]
(if (int? document-id)
(try
(if-let [doc (documents/get-document document-id)]
{:structured-output {:id (:id doc)
:name (:name doc)
:document (:document doc)}}
{:output "report not found"})
(catch Exception e
{:output (str "error fetching report: " (.getMessage e))}))
{:output "invalid document_id"}))
(defn- base-type->type [field]
(case field
:type/Integer "number"
:type/Float "number"
:type/Text "string"
:type/Date "date"
:type/DateTime "datetime"
:type/Time "time"
:type/Boolean "boolean"
:type/Null "null"
"string"))
(defn get-tables
"Get information about the tables in a database."
[{:keys [database-id] :or {database-id 1}}]
(if (int? database-id)
(let [db (t2/select-one [:model/Database :id :name :description :engine] database-id)
tables (t2/select [:model/Table :id :name :description]
{:where [:and
[:= :db_id database-id]
[:= :active true]
(mi/visible-filter-clause :model/Table
:id
{:user-id api/*current-user-id*
:is-superuser? api/*is-superuser?*}
{:perms/view-data :unrestricted
:perms/create-queries :query-builder-and-native})]})
columns (group-by :table_id (map #(-> %
(assoc :type (base-type->type (:base_type %)))
(dissoc :base_type))
(t2/select [:model/Field :id :table_id :name :description :base_type])))]
{:structured-output {:database db
:tables (map #(assoc % :columns (get columns (:id %) [])) tables)}})
{:output "invalid database_id"}))
(defn- execute-query
[query-id legacy-query]
(let [legacy-query (mbql.normalize/normalize legacy-query)

View File

@@ -3,7 +3,6 @@
(:require
[buddy.core.hash :as buddy-hash]
[buddy.sign.jwt :as jwt]
[clj-time.core :as time]
[clojure.set :as set]
[malli.core :as mc]
[malli.transform :as mtx]
@@ -35,14 +34,6 @@
[metabase.util.malli.registry :as mr]
[metabase.util.malli.schema :as ms]))
(defn- get-ai-service-token
[user-id metabot-id]
(let [secret (buddy-hash/sha256 (metabot-v3.settings/site-uuid-for-metabot-tools))
claims {:user user-id
:exp (time/plus (time/now) (time/seconds (metabot-v3.settings/metabot-ai-service-token-ttl)))
:metabot-id metabot-id}]
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256})))
(defn- decode-ai-service-token
[token]
(try
@@ -55,7 +46,7 @@
(defn handle-envelope
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
[{:keys [metabot-id] :as e}]
(let [session-id (get-ai-service-token api/*current-user-id* metabot-id)]
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(try
(metabot-v3.client/request (assoc e :session-id session-id))
(catch Exception ex
@@ -69,7 +60,7 @@
(defn streaming-handle-envelope
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
[{:keys [metabot-id] :as e}]
(let [session-id (get-ai-service-token api/*current-user-id* metabot-id)]
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
(metabot-v3.client/streaming-request (assoc e :session-id session-id))))
(mr/def ::bucket
@@ -540,6 +531,53 @@
[:result_columns ::columns]]]]
[:map [:output :string]]])
(mr/def ::get-document-details-arguments
[:and
[:map
[:document_id :int]]
[:map {:encode/tool-api-request
#(set/rename-keys % {:document_id :document-id})}]])
(mr/def ::get-document-details-result
[:or
[:map
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:id :int]
[:name :string]
[:document :string]]]]
[:map [:output :string]]])
(mr/def ::get-tables-arguments
[:and
[:map
[:database_id :int]]
[:map {:encode/tool-api-request
#(set/rename-keys % {:database_id :database-id})}]])
(mr/def ::get-tables-result
[:or
[:map
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:structured_output [:map
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
[:database [:map
[:id :int]
[:engine :string]
[:name :string]
[:description :string]]]
[:tables [:sequential [:map
[:id :int]
[:name :string]
[:description :string]
[:columns [:sequential [:map
[:id :int]
[:name :string]
[:description :string]
[:type :string]]]]]]]]]]
[:map [:output :string]]])
(mr/def ::get-table-details-arguments
[:and
[:map
@@ -777,6 +815,21 @@
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/get-document-details" :- [:merge ::get-document-details-result ::tool-request]
"Get information about a given report."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::get-document-details-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-document-details) :llm.log/llm->be)
(let [arguments (mc/encode ::get-document-details-arguments arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::get-document-details-result
(metabot-v3.dummy-tools/get-document-details arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/get-table-details" :- [:merge ::get-table-details-result ::tool-request]
"Get information about a given table or model."
[_route-params
@@ -792,6 +845,21 @@
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/get-tables" :- [:merge ::get-tables-result ::tool-request]
"Get information about the tables in a given database."
[_route-params
_query-params
{:keys [arguments conversation_id] :as body} :- [:merge
[:map [:arguments ::get-tables-arguments]]
::tool-request]]
(metabot-v3.context/log (assoc body :api :get-tables) :llm.log/llm->be)
(let [arguments (mc/encode ::get-tables-arguments arguments (mtx/transformer {:name :tool-api-request}))]
(doto (-> (mc/decode ::get-tables-result
(metabot-v3.dummy-tools/get-tables arguments)
(mtx/transformer {:name :tool-api-response}))
(assoc :conversation_id conversation_id))
(metabot-v3.context/log :llm.log/be->llm))))
(api.macros/defendpoint :post "/query-metric" :- [:merge ::filtering-result ::tool-request]
"Construct a query from a metric."
[_route-params

View File

@@ -25,7 +25,8 @@
keys to remove from the model so that we'll be able to successfully load it. You can remove keys in vectors using :* to
indicate that all items in that vector should have a key removed."
{"Dashboard" #{:dashcards}
"Card" #{:dashboard_id}})
"Document" #{:document}
"Card" #{:dashboard_id :document_id}})
(defn- keys-to-strip [ingested]
(let [model (-> ingested :serdes/meta last :model)]

View File

@@ -16,6 +16,7 @@
"Card"
"Collection"
"Dashboard"
"Document"
"NativeQuerySnippet"
"Timeline"])
@@ -57,6 +58,7 @@
"DatabaseRouter"
"DashboardBookmark"
"DataPermissions"
"DocumentBookmark"
"GroupTableAccessPolicy"
"HTTPAction"
"ImplicitAction"

View File

@@ -0,0 +1,79 @@
(ns metabase-enterprise.bookmarks.api-test
(:require
[clojure.test :refer :all]
[metabase.models.interface :as mi]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn bookmark-models [user-id & models]
(doseq [model models]
(cond
(mi/instance-of? :model/Card model)
(t2/insert! :model/CardBookmark
{:user_id user-id
:card_id (u/the-id model)})
(mi/instance-of? :model/Document model)
(t2/insert! :model/DocumentBookmark
{:user_id user-id
:document_id (u/the-id model)})
:else
(throw (ex-info "Unknown type" {:user-id user-id :model model})))))
(deftest document-bookmarks-test
(testing "Document bookmarks with enterprise features"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document document {:name "Test Document" :collection_id coll-id}]
(testing "can bookmark a document when enterprise enabled"
(is (= (u/the-id document)
(->> (mt/user-http-request :rasta :post 200 (str "bookmark/document/" (u/the-id document)))
:document_id))))
(testing "document appears in bookmark list"
(let [result (mt/user-http-request :rasta :get 200 "bookmark")
document-bookmark (first (filter #(= (:type %) "document") result))]
(is (some? document-bookmark))
(is (= "Test Document" (:name document-bookmark)))
(is (= (u/the-id document) (:item_id document-bookmark)))))
(testing "can delete document bookmark"
(mt/user-http-request :rasta :delete 204 (str "bookmark/document/" (u/the-id document)))
(is (empty? (filter #(= (:type %) "document")
(mt/user-http-request :rasta :get 200 "bookmark")))))
(testing "document bookmarks are included in ordering"
(mt/with-temp [:model/Card card {:name "Test Card"}]
(mt/with-model-cleanup [:model/BookmarkOrdering]
(bookmark-models (mt/user->id :rasta) document card)
(mt/user-http-request :rasta :put 204 "bookmark/ordering"
{:orderings [{:type "document" :item_id (u/the-id document)}
{:type "card" :item_id (u/the-id card)}]})
(is (= ["document" "card"]
(map :type (mt/user-http-request :rasta :get 200 "bookmark"))))))))))
(testing "Document bookmarks without enterprise features"
(mt/with-premium-features #{}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document document {:name "Test Document" :collection_id coll-id}]
(testing "cannot bookmark documents when enterprise disabled"
(mt/user-http-request :rasta :post 402 (str "bookmark/document/" (u/the-id document))))
(testing "document model not included in allowed models"
(let [response (mt/user-http-request :rasta :post 402 (str "bookmark/document/" (u/the-id document)))]
(is (=? {:message #"Documents is a paid feature.*"}
response))))))))
(deftest document-bookmarks-archived-test
(testing "Document bookmarks on archived documents"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document archived-document {:name "Archived Document"
:collection_id coll-id
:archived true}]
(bookmark-models (mt/user->id :rasta) archived-document)
(testing "archived documents don't appear in bookmark list"
(is (empty? (filter #(= (:type %) "document")
(mt/user-http-request :rasta :get 200 "bookmark")))))))))

View File

@@ -0,0 +1,294 @@
(ns metabase-enterprise.documents.api.collection-test
(:require
[clojure.test :refer :all]
[metabase.collections.models.collection :as collection]
[metabase.test :as mt]))
(deftest document-cards-do-not-appear-in-collection-items
(testing "GET /api/collection/:id/items excludes cards with document_id"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {doc-id :id} {}
:model/Card {normal-card-id :id} {:collection_id coll-id}
:model/Card _ {:collection_id coll-id
:document_id doc-id}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(testing "Normal cards and dashboards appear, but in_document cards do not"
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items"))))))))
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items?show_dashboard_questions=true"))))))))))))
(deftest document-cards-do-not-appear-in-root-items
(testing "GET /api/collection/root/items excludes cards with document_id"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {}
:model/Card {normal-card-id :id} {:collection_id nil
:name "Normal Root Card"}
:model/Card {in-document-card-id :id} {:collection_id nil
:document_id doc-id
:name "In Document Root Card"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Normal cards and dashboards in root appear, but in_document cards do not"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
(:data items))]
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items?show_dashboard_questions=true")
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
(:data items))]
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))))))
(deftest document-cards-do-not-appear-in-root-items-without-premium
(testing "GET /api/collection/root/items excludes cards with document_id when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {normal-card-id :id} {:collection_id nil
:name "Normal Root Card"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Normal cards and dashboards in root appear, no documents"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{normal-card-id dash-id} (:id %))
(:data items))]
(is (= #{[normal-card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))))))
(deftest documents-appear-in-collection-items
(testing "GET /api/collection/:id/items includes documents"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {doc-id :id} {:collection_id coll-id
:name "Test Document"}
:model/Card {card-id :id} {:collection_id coll-id}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(testing "Documents appear alongside cards and dashboards"
(is (= #{[doc-id "document"]
[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items"))))))))))))
(deftest documents-do-not-appear-in-collection-items-without-premium
(testing "GET /api/collection/:id/items excludes documents when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Card {card-id :id} {:collection_id coll-id}
:model/Document _ {:collection_id coll-id
:name "Test Document"}
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
(testing "Only cards and dashboards appear, no documents"
(is (= #{[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model)
(:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items"))))))))))))
(deftest documents-appear-in-root-items
(testing "GET /api/collection/root/items includes documents"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:collection_id nil
:name "Root Document"}
:model/Card {card-id :id} {:collection_id nil
:name "Root Card"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Documents appear alongside cards and dashboards in root"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{doc-id card-id dash-id} (:id %))
(:data items))]
(is (= #{[doc-id "document"]
[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))))))
(deftest documents-do-not-appear-in-root-items-without-premium
(testing "GET /api/collection/root/items excludes documents when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Card {card-id :id} {:collection_id nil
:name "Root Card"}
:model/Document _ {:collection_id nil
:name "Test Document"}
:model/Dashboard {dash-id :id} {:collection_id nil
:name "Root Dashboard"}]
(testing "Only cards and dashboards appear in root, no documents"
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
root-test-items (filter #(#{card-id dash-id} (:id %))
(:data items))]
(is (= #{[card-id "card"]
[dash-id "dashboard"]}
(set (map (juxt :id :model) root-test-items))))))))))
(deftest archived-documents-appear-in-trash-items
(testing "GET /api/collection/trash/items includes documents with archived_directly true"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {archived-doc-id :id} {:collection_id nil
:name "Archived Document"
:archived true
:archived_directly true}
:model/Document {normal-doc-id :id} {:collection_id nil
:name "Normal Document"
:archived_directly false}
:model/Card {archived-card-id :id} {:collection_id nil
:name "Archived Card"
:archived true
:archived_directly true}
:model/Dashboard {archived-dash-id :id} {:collection_id nil
:name "Archived Dashboard"
:archived true
:archived_directly true}]
(testing "Archived documents appear alongside other archived items in trash"
(let [items (mt/user-http-request :rasta :get 200 (format "collection/%d/items" (collection/trash-collection-id)))
trash-test-items (filter #(#{archived-doc-id normal-doc-id archived-card-id archived-dash-id} (:id %))
(:data items))]
(is (= #{[archived-doc-id "document" true true]
[archived-card-id "card" true true]
[archived-dash-id "dashboard" true true]}
(set (map (juxt :id :model :can_delete :can_restore) trash-test-items))))
(testing "Non-archived documents do not appear in trash"
(is (not (some #(= normal-doc-id (:id %)) trash-test-items))))))))))
(deftest archived-documents-do-not-appear-in-trash-items-without-premium
(testing "GET /api/collection/trash/items excludes documents when documents feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Document _ {:collection_id nil
:name "Archived Document"
:archived true
:archived_directly true}
:model/Card {archived-card-id :id} {:collection_id nil
:name "Archived Card"
:archived true
:archived_directly true}
:model/Dashboard {archived-dash-id :id} {:collection_id nil
:name "Archived Dashboard"
:archived true
:archived_directly true}]
(testing "Only archived cards and dashboards appear in trash, no documents"
(let [items (mt/user-http-request :rasta :get 200 (format "collection/%d/items" (collection/trash-collection-id)))
trash-test-items (:data items)]
(is (= #{[archived-card-id "card"]
[archived-dash-id "dashboard"]}
(set (map (juxt :id :model) trash-test-items))))))))))
(deftest document-pinning-collection-items
(testing "GET /api/collection/:id/items supports pinned_state parameter for documents"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {pinned-doc-id :id} {:collection_id coll-id
:name "Pinned Document"
:collection_position 1}
:model/Document {unpinned-doc-id :id} {:collection_id coll-id
:name "Unpinned Document"
:collection_position nil}
:model/Card {pinned-card-id :id} {:collection_id coll-id
:name "Pinned Card"
:collection_position 2}
:model/Card {unpinned-card-id :id} {:collection_id coll-id
:name "Unpinned Card"
:collection_position nil}]
(testing "pinned_state=is_pinned returns only pinned documents and cards"
(let [items (:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items?pinned_state=is_pinned")))
item-ids (set (map :id items))]
(is (contains? item-ids pinned-doc-id))
(is (contains? item-ids pinned-card-id))
(is (not (contains? item-ids unpinned-doc-id)))
(is (not (contains? item-ids unpinned-card-id)))))
(testing "pinned_state=is_not_pinned returns only unpinned documents and cards"
(let [items (:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items?pinned_state=is_not_pinned")))
item-ids (set (map :id items))]
(is (not (contains? item-ids pinned-doc-id)))
(is (not (contains? item-ids pinned-card-id)))
(is (contains? item-ids unpinned-doc-id))
(is (contains? item-ids unpinned-card-id))))
(testing "pinned_state=all returns all documents and cards"
(let [items (:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items?pinned_state=all")))
item-ids (set (map :id items))]
(is (contains? item-ids pinned-doc-id))
(is (contains? item-ids pinned-card-id))
(is (contains? item-ids unpinned-doc-id))
(is (contains? item-ids unpinned-card-id))))))))
(deftest document-pinning-root-items
(testing "GET /api/collection/root/items supports pinned_state parameter for documents"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {pinned-doc-id :id} {:collection_id nil
:name "Pinned Root Document"
:collection_position 1}
:model/Document {unpinned-doc-id :id} {:collection_id nil
:name "Unpinned Root Document"
:collection_position nil}
:model/Card {pinned-card-id :id} {:collection_id nil
:name "Pinned Root Card"
:collection_position 2}
:model/Card {unpinned-card-id :id} {:collection_id nil
:name "Unpinned Root Card"
:collection_position nil}]
(testing "pinned_state=is_pinned returns only pinned root documents and cards"
(let [items (:data (mt/user-http-request :rasta :get 200 "collection/root/items?pinned_state=is_pinned"))
test-item-ids (set (filter #{pinned-doc-id unpinned-doc-id pinned-card-id unpinned-card-id}
(map :id items)))]
(is (contains? test-item-ids pinned-doc-id))
(is (contains? test-item-ids pinned-card-id))
(is (not (contains? test-item-ids unpinned-doc-id)))
(is (not (contains? test-item-ids unpinned-card-id)))))
(testing "pinned_state=is_not_pinned returns only unpinned root documents and cards"
(let [items (:data (mt/user-http-request :rasta :get 200 "collection/root/items?pinned_state=is_not_pinned"))
test-item-ids (set (filter #{pinned-doc-id unpinned-doc-id pinned-card-id unpinned-card-id}
(map :id items)))]
(is (not (contains? test-item-ids pinned-doc-id)))
(is (not (contains? test-item-ids pinned-card-id)))
(is (contains? test-item-ids unpinned-doc-id))
(is (contains? test-item-ids unpinned-card-id))))))))
(deftest mixed-pinned-unpinned-documents-collection-view
(testing "Integration test for mixed pinned/unpinned documents in collection view"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {doc1-id :id} {:collection_id coll-id
:name "Document A"
:collection_position 1}
:model/Document _ {:collection_id coll-id
:name "Document B"
:collection_position nil}
:model/Document {doc3-id :id} {:collection_id coll-id
:name "Document C"
:collection_position 3}]
(testing "Default view includes all documents with collection_position field"
(let [items (:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items")))
docs (filter #(= "document" (:model %)) items)
doc-positions (map :collection_position docs)]
(is (= 3 (count docs)))
;; Verify collection_position is included in the response
(is (some #(= 1 %) doc-positions))
(is (some nil? doc-positions))
(is (some #(= 3 %) doc-positions))))
(testing "Pinned documents have higher collection_position values and appear before unpinned"
(let [items (:data (mt/user-http-request :rasta :get 200
(str "collection/" coll-id "/items?pinned_state=is_pinned")))
pinned-docs (filter #(= "document" (:model %)) items)]
(is (= 2 (count pinned-docs)))
;; Verify all pinned docs have non-nil collection_position
(is (every? #(some? (:collection_position %)) pinned-docs))
(is (= #{doc1-id doc3-id} (set (map :id pinned-docs))))))))))

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,68 @@
(ns backend.test.metabase-enterprise.documents.models.dashboard-card-test
(:require
[clojure.test :refer :all]
[metabase.dashboards.models.dashboard-card :as dashboard-card]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest prevent-in-report-cards-from-being-added-to-dashboards-test
(testing "Cannot add a card with document_id to dashboard"
(mt/with-temp [:model/Dashboard dashboard {}
:model/Document document {}
:model/Card normal-card {:name "Normal Card" :type :question}
:model/Card report-card {:name "Report Card" :type :question :document_id (:id document)}]
(testing "Adding a normal card works fine"
(let [result (dashboard-card/create-dashboard-cards!
[{:dashboard_id (:id dashboard)
:card_id (:id normal-card)
:size_x 4
:size_y 3
:row 0
:col 0}])]
(is (= 1 (count result)))
(is (= (:id normal-card) (:card_id (first result))))))
(testing "Adding an in_document card throws an exception"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Cards with 'document_id' cannot be added to dashboards"
(dashboard-card/create-dashboard-cards!
[{:dashboard_id (:id dashboard)
:card_id (:id report-card)
:size_x 4
:size_y 3
:row 1
:col 0}]))))))
(testing "Cannot add multiple cards if any of them has a document_id"
(mt/with-temp [:model/Dashboard dashboard {}
:model/Document document {}
:model/Card normal-card1 {:name "Normal Card 1" :type :question}
:model/Card normal-card2 {:name "Normal Card 2" :type :model}
:model/Card report-card1 {:name "Report Card" :type :question :document_id (:id document)}]
(testing "Batch creation fails if any card is in_document type"
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Cards with 'document_id' cannot be added to dashboards"
(dashboard-card/create-dashboard-cards!
[{:dashboard_id (:id dashboard)
:card_id (:id normal-card1)
:size_x 4
:size_y 3
:row 0
:col 0}
{:dashboard_id (:id dashboard)
:card_id (:id report-card1)
:size_x 4
:size_y 3
:row 0
:col 4}
{:dashboard_id (:id dashboard)
:card_id (:id normal-card2)
:size_x 4
:size_y 3
:row 0
:col 8}])))
;; Verify that no cards were added due to transaction rollback
(is (= 0 (t2/count :model/DashboardCard :dashboard_id (:id dashboard))))))))

View File

@@ -0,0 +1,726 @@
(ns metabase-enterprise.documents.models.document-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.models.document :as document]
[metabase.collections.models.collection :as collection]
[metabase.models.serialization :as serdes]
[metabase.permissions.core :as perms]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[toucan2.core :as t2]))
(use-fixtures :once (fixtures/initialize :db :test-users))
(deftest sync-document-cards-collection-matching-cards-test
(testing "should only update cards with matching document_id"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Test Document"}
:model/Card {card1-id :id} {:name "Document Card 1"
:document_id document-id
:collection_id nil
:dataset_query (mt/mbql-query venues)}
:model/Card {card2-id :id} {:name "Document Card 2"
:document_id document-id
:collection_id nil
:dataset_query (mt/mbql-query venues)}
:model/Card {other-card-id :id} {:name "Other Card"
:type :question
:collection_id nil
:dataset_query (mt/mbql-query venues)}]
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should update exactly 2 cards
(is (= 2 updated-count))
;; Verify the document cards were updated
(let [updated-card1 (t2/select-one :model/Card :id card1-id)
updated-card2 (t2/select-one :model/Card :id card2-id)
other-card (t2/select-one :model/Card :id other-card-id)]
(is (= collection-id (:collection_id updated-card1)))
(is (= collection-id (:collection_id updated-card2)))
;; Other card should not be affected
(is (nil? (:collection_id other-card))))))))
(deftest sync-document-cards-collection-nil-document-document-id-test
(testing "should not affect cards with nil document_id"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Test Document"}
:model/Card {nil-document-card-id :id} {:name "Nil Document Card"
:document_id nil
:collection_id nil
:dataset_query (mt/mbql-query venues)}]
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should update 0 cards since no cards have matching document_id
(is (= 0 updated-count))
;; Verify the card with nil document_id was not affected
(let [unchanged-card (t2/select-one :model/Card :id nil-document-card-id)]
(is (nil? (:collection_id unchanged-card))))))))
(deftest sync-document-cards-collection-empty-result-sets-test
(testing "should handle empty result sets gracefully"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
:model/Document {document-id :id} {:name "Empty Document"}]
;; No cards associated with this document
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
;; Should return 0 for no updates
(is (= 0 updated-count))))))
(deftest document-collection-sync-hook-triggers-on-collection-change-test
(testing "Hook is called when document collection_id changes"
(mt/with-temp
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/Document {document-id :id} {:collection_id old-collection-id
:name "Test Document"}
:model/Card {card1-id :id} {:name "Card 1"
:document_id document-id
:collection_id old-collection-id}
:model/Card {card2-id :id} {:name "Card 2"
:document_id document-id
:collection_id old-collection-id}]
;; Update the document's collection_id
(t2/update! :model/Document document-id {:collection_id new-collection-id})
;; Verify that associated cards were updated to match the new collection
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card1-id))))
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card2-id)))))))
(deftest document-collection-sync-hook-handles-nil-collections-test
(testing "Hook correctly handles nil collection values"
(mt/with-temp
[:model/Collection {collection-id :id} {:name "Test Collection"}
:model/Document {document-id :id} {:collection_id collection-id
:name "Test Document"}
:model/Card {card-id :id} {:name "Card"
:document_id document-id
:collection_id collection-id}]
;; Move document to no collection (nil)
(t2/update! :model/Document document-id {:collection_id nil})
;; Verify that the card's collection_id was updated to nil
(is (nil? (:collection_id (t2/select-one :model/Card :id card-id)))))))
(deftest document-collection-sync-hook-only-affects-cards-test
(testing "Hook only updates cards with matching document_id"
(mt/with-temp
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/Document {document-id :id} {:collection_id old-collection-id
:name "Test Document"}
;; Card that should be updated (correct type and document_id)
:model/Card {in-document-card-id :id} {:name "In-Document Card"
:type :question
:document_id document-id
:collection_id old-collection-id}
;; Card that should be updated (wrong type)
:model/Card {question-card-id :id} {:name "Question Card"
:type :model
:collection_id old-collection-id}
;; Card that should NOT be updated (no document_id)
:model/Card {regular-card-id :id} {:name "Regular Card"
:collection_id old-collection-id}]
;; Update the document's collection_id
(t2/update! :model/Document document-id {:collection_id new-collection-id})
;; Verify only the correct card was updated
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id in-document-card-id))))
;; Verify other cards were NOT updated
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id question-card-id))))
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id regular-card-id)))))))
(deftest personal-collection-edge-cases-test
(testing "Personal collection handling"
(binding [collection/*allow-deleting-personal-collections* true]
(mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "test@example.com"}
:model/Collection {personal-collection-id :id} {:name "Personal Collection"
:personal_owner_id user-id}
:model/Collection {regular-collection-id :id} {:name "Regular Collection"}
:model/Document {document-id :id} {:collection_id regular-collection-id
:name "Personal Test Document"}
:model/Card {card-id :id} {:name "Personal Card"
:document_id document-id
:collection_id regular-collection-id
:dataset_query (mt/mbql-query venues)}]
(testing "moving document to personal collection moves associated cards"
(mt/with-current-user user-id
;; As the personal collection owner, update should succeed
(t2/update! :model/Document document-id {:collection_id personal-collection-id})
;; Verify both document and card moved to personal collection
(is (= personal-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
(is (= personal-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))
(testing "moving document from personal collection works"
(mt/with-current-user user-id
;; Move back to regular collection
(t2/update! :model/Document document-id {:collection_id regular-collection-id})
;; Verify both document and card moved back
(is (= regular-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
(is (= regular-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))))))
(deftest validate-collection-move-permissions-allows-move-with-both-permissions-test
(testing "allows move when user has write permissions for both collections"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permissions to both collections
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
;; Should not throw any exception
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-throws-403-missing-old-permission-test
(testing "throws 403 when user lacks write permission for old collection"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission only to new collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
;; Should throw 403 exception
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-throws-403-missing-new-permission-test
(testing "throws 403 when user lacks write permission for new collection"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission only to old collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
;; Should throw 403 exception
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-throws-403-no-permissions-test
(testing "throws 403 when user lacks write permissions for both collections"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; No permissions granted
;; Should throw 403 exception
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
(deftest validate-collection-move-permissions-allows-move-from-root-test
(testing "allows move when old collection is nil (moving from root)"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {new-collection-id :id} {:name "New Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission to new collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
;; Should not throw any exception when old collection is nil
(is (true? (document/validate-collection-move-permissions nil new-collection-id))))))))
(deftest validate-collection-move-permissions-allows-move-to-root-test
(testing "allows move when new collection is nil (moving to root)"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission to old collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
;; Should not throw any exception when new collection is nil
(is (nil? (document/validate-collection-move-permissions old-collection-id nil))))))))
(deftest validate-collection-move-permissions-allows-move-both-nil-test
(testing "allows move when both collections are nil"
(mt/with-temp [:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Should not throw any exception when both collections are nil
(is (nil? (document/validate-collection-move-permissions nil nil)))))))
(deftest validate-collection-move-permissions-throws-400-nonexistent-collection-test
(testing "throws 400 when new collection does not exist"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permission to old collection
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
;; Use a non-existent collection ID
(let [non-existent-id 999999]
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
(document/validate-collection-move-permissions old-collection-id non-existent-id)))))))))
(deftest validate-collection-move-permissions-throws-400-archived-collection-test
(testing "throws 400 when new collection is archived"
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {archived-collection-id :id} {:name "Archived Collection" :archived true}
:model/User {user-id :id} {}]
(mt/with-current-user user-id
;; Grant write permissions to both collections
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) archived-collection-id)
;; Should throw 400 exception for archived collection
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid"
(document/validate-collection-move-permissions old-collection-id archived-collection-id))))))))
(deftest validate-collection-move-permissions-superuser-test
(testing "superuser can move between any collections"
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
:model/Collection {new-collection-id :id} {:name "New Collection"}]
(mt/with-current-user (mt/user->id :crowberto)
;; Superuser should be able to move without explicit permissions
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))
(deftest hydrate-document-creator-test
(testing "hydrates document creator correctly"
(mt/with-temp [:model/User {user-id :id} {:first_name "John"
:last_name "Doe"
:email "john.doe@example.com"}
:model/Document {document-id :id} {:name "Test Document"
:creator_id user-id}]
(let [hydrated-doc (t2/hydrate (t2/select-one :model/Document :id document-id) :creator)]
(testing "creator is hydrated with correct user data"
(is (some? (:creator hydrated-doc)))
(is (= user-id (get-in hydrated-doc [:creator :id])))
(is (= "John" (get-in hydrated-doc [:creator :first_name])))
(is (= "Doe" (get-in hydrated-doc [:creator :last_name])))
(is (= "john.doe@example.com" (get-in hydrated-doc [:creator :email]))))))))
(deftest hydrate-multiple-documents-creator-test
(testing "hydrates creators for multiple documents efficiently"
(mt/with-temp [:model/User {user1-id :id} {:first_name "Alice"
:last_name "Smith"
:email "alice@example.com"}
:model/User {user2-id :id} {:first_name "Bob"
:last_name "Jones"
:email "bob@example.com"}
:model/Document {doc1-id :id} {:name "Document 1"
:creator_id user1-id}
:model/Document {doc2-id :id} {:name "Document 2"
:creator_id user2-id}
:model/Document {doc3-id :id} {:name "Document 3"
:creator_id user1-id}] ; Same creator as doc1
(let [documents (t2/select :model/Document :id [:in [doc1-id doc2-id doc3-id]])
hydrated-docs (t2/hydrate documents :creator)]
(testing "all documents have their creators hydrated"
(is (= 3 (count hydrated-docs)))
(doseq [doc hydrated-docs]
(is (some? (:creator doc)))
(is (some? (get-in doc [:creator :id])))
(is (some? (get-in doc [:creator :first_name])))
(is (some? (get-in doc [:creator :last_name])))
(is (some? (get-in doc [:creator :email])))))
(testing "creators are correctly matched to documents"
(let [doc1 (first (filter #(= doc1-id (:id %)) hydrated-docs))
doc2 (first (filter #(= doc2-id (:id %)) hydrated-docs))
doc3 (first (filter #(= doc3-id (:id %)) hydrated-docs))]
(is (= user1-id (get-in doc1 [:creator :id])))
(is (= "Alice" (get-in doc1 [:creator :first_name])))
(is (= user2-id (get-in doc2 [:creator :id])))
(is (= "Bob" (get-in doc2 [:creator :first_name])))
(is (= user1-id (get-in doc3 [:creator :id])))
(is (= "Alice" (get-in doc3 [:creator :first_name])))))))))
(deftest document-collection-position-field-handling-test
(testing "Document model supports collection_position field"
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Collection"}
:model/Document {document-id :id} {:name "Positioned Document"
:collection_id collection-id
:collection_position 5}]
(let [document (t2/select-one :model/Document :id document-id)]
(testing "collection_position is stored and retrieved correctly"
(is (= 5 (:collection_position document)))))
(testing "collection_position can be updated"
(t2/update! :model/Document document-id {:collection_position 10})
(let [updated-document (t2/select-one :model/Document :id document-id)]
(is (= 10 (:collection_position updated-document)))))
(testing "collection_position can be set to nil"
(t2/update! :model/Document document-id {:collection_position nil})
(let [updated-document (t2/select-one :model/Document :id document-id)]
(is (nil? (:collection_position updated-document))))))))
;;; ------------------------------------------------- Serialization Tests -------------------------------------------
(deftest document-serdes-spec-test
(testing "Document serialization spec includes all required fields"
(let [spec (serdes/make-spec "Document" {})]
(is (= [:archived :archived_directly :content_type :entity_id :name :collection_position]
(:copy spec)))
(is (= [:view_count :last_viewed_at] (:skip spec)))
(is (contains? (:transform spec) :created_at))
(is (contains? (:transform spec) :document))
(is (contains? (:transform spec) :updated_at))
(is (contains? (:transform spec) :collection_id))
(is (contains? (:transform spec) :creator_id))
(testing "foreign key transformers are properly configured"
(is (get-in spec [:transform :collection_id ::serdes/fk]))
(is (get-in spec [:transform :creator_id ::serdes/fk]))))))
(deftest document-serdes-dependencies-test
(testing "Document dependencies method works correctly"
(testing "with collection and creator"
(let [document {:collection_id 123
:creator_id 456
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (= #{[{:model "Collection" :id 123}]}
deps))))))
(deftest document-serdes-smartlink-single-reference-test
(testing "single smartLink card reference"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 789}]
:model "card"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 789}]))
(is (contains? deps [{:model "Collection" :id 123}])))))
(deftest document-serdes-smartlink-multiple-references-test
(testing "multiple smartLink references of different types"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 789}]
:model "card"}}
{:type "smartLink"
:attrs {:entityId [{:model "Dashboard" :id 456}]
:model "dashboard"}}
{:type "smartLink"
:attrs {:entityId [{:model "Table" :id 321}]
:model "table"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 789}]))
(is (contains? deps [{:model "Dashboard" :id 456}]))
(is (contains? deps [{:model "Table" :id 321}]))
(is (contains? deps [{:model "Collection" :id 123}])))))
(deftest document-serdes-smartlink-nested-structure-test
(testing "nested smartLink in complex prose mirror structure"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text" :text "Some text"}]}
{:type "bulletList"
:content [{:type "listItem"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 999}]
:model "card"}}]}]}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 999}]))
(is (contains? deps [{:model "Collection" :id 123}])))))
(deftest document-serdes-smartlink-no-smartlinks-test
(testing "document with no smartLinks"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text" :text "Plain text only"}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (= #{[{:model "Collection" :id 123}]}
deps)))))
(deftest document-serdes-smartlink-unknown-model-test
(testing "unknown smartLink model type is ignored"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Unknown" :id 789}]
:model "unknown-model"}}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 456}]
:model "card"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 456}]))
(is (not (some #(= (:model (first %)) "unknown-model") deps))))))
(deftest document-serdes-smartlink-missing-entity-id-test
(testing "smartLink with missing entityId is ignored"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:model "card"}}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 456}]
:model "card"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 456}]))
(is (= 2 (count deps)))))) ; collection and one valid card
(deftest document-serdes-smartlink-duplicate-references-test
(testing "duplicate smartLink references are deduplicated"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 789}]
:model "card"}}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 789}]
:model "card"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 789}]))
(is (= 2 (count deps)))))) ; collection and one unique card
(deftest document-serdes-smartlink-empty-content-test
(testing "empty document content"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content []}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (= #{[{:model "Collection" :id 123}]}
deps)))))
(deftest document-serdes-smartlink-missing-model-test
(testing "smartLink with missing model is ignored"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 789}]}}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 456}]
:model "card"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 456}]))
(is (= 2 (count deps)))))) ; collection and one valid card
(deftest document-serdes-smartlink-nil-attrs-test
(testing "smartLink with nil attrs is ignored"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs nil}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 456}]
:model "card"}}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 456}]))
(is (= 2 (count deps)))))) ; collection and one valid card
(deftest document-serdes-smartlink-mixed-content-test
(testing "mix of smartLinks and other node types"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text" :text "Check out this "}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 789}]
:model "card"}}
{:type "text" :text " and this "}
{:type "smartLink"
:attrs {:entityId [{:model "Dashboard" :id 456}]
:model "dashboard"}}]}
{:type "heading"
:attrs {:level 2}
:content [{:type "text" :text "Section with table"}]}
{:type "table"
:content [{:type "tableRow"
:content [{:type "tableCell"
:content [{:type "paragraph"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Table" :id 321}]
:model "table"}}]}]}]}]}]}
:content_type "application/json+vnd.prose-mirror"
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Card" :id 789}]))
(is (contains? deps [{:model "Dashboard" :id 456}]))
(is (contains? deps [{:model "Table" :id 321}]))
(is (contains? deps [{:model "Collection" :id 123}]))
(is (= 4 (count deps)))))) ; 3 smartLinks + collection
(deftest document-serdes-smartlink-non-prose-mirror-test
(testing "non-prose-mirror content type documents don't extract smartLinks"
(let [document {:collection_id 123
:creator_id 456
:document {:type "doc"
:content [{:type "smartLink"
:attrs {:entityId [{:model "Card" :id 456}]
:model "card"}}]}
:content_type "application/json" ; Not prose-mirror
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
deps (serdes/dependencies document)]
(is (contains? deps [{:model "Collection" :id 123}])))))
(deftest document-serdes-descendants-embedded-cards-test
(testing "Document descendants includes embedded cards"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "cardEmbed"
:attrs {:id 456}}
{:type "cardEmbed"
:attrs {:id 789}}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
(is (= {["Card" 456] {"Document" document-id}
["Card" 789] {"Document" document-id}}
descendants))))))
(deftest document-serdes-descendants-smart-links-test
(testing "Document descendants includes smart links"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "smartLink"
:attrs {:id 456
:model "card"}}
{:type "smartLink"
:attrs {:id 789
:model "dashboard"}}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
(is (= {["Card" 456] {"Document" document-id}
["Dashboard" 789] {"Document" document-id}}
descendants))))))
(deftest document-serdes-descendants-mixed-content-test
(testing "Document descendants includes both embedded cards and smart links"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "cardEmbed"
:attrs {:id 111}}
{:type "smartLink"
:attrs {:id 222
:model "card"}}
{:type "smartLink"
:attrs {:id 333
:model "table"}}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
(is (= {["Card" 111] {"Document" document-id}
["Card" 222] {"Document" document-id}
["Table" 333] {"Document" document-id}}
descendants))))))
(deftest document-serdes-descendants-empty-document-test
(testing "Document descendants handles document with no embedded content"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text" :text "Plain text only"}]}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
(is (= {} descendants))))))
(deftest document-serdes-descendants-nonexistent-document-test
(testing "Document descendants returns nil for non-existent document"
(let [descendants (serdes/descendants "Document" 99999999)]
(is (nil? descendants)))))
(deftest document-serdes-descendants-unknown-smart-link-model-test
(testing "Document descendants ignores smart links with unknown model types"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "smartLink"
:attrs {:id 456
:model "card"}}
{:type "smartLink"
:attrs {:id 789
:model "unknown-model"}}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
;; Should only include the known model type
(is (= {["Card" 456] {"Document" document-id}}
descendants))
;; Should not include unknown model
(is (not (contains? descendants ["Unknown" 789])))))))
(deftest document-serdes-descendants-duplicate-references-test
(testing "Document descendants handles duplicate references correctly"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "cardEmbed"
:attrs {:id 456}}
{:type "smartLink"
:attrs {:id 456
:model "card"}}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
;; Should merge duplicate references - same card referenced twice
(is (= {["Card" 456] {"Document" document-id}}
descendants))
;; Should only have one entry for the card
(is (= 1 (count descendants)))))))
(deftest document-serdes-descendants-non-prose-mirror-test
(testing "Document descendants handles non-prose-mirror documents"
(mt/with-temp [:model/Document {document-id :id} {:document {:some "other format"}
:content_type "application/json"}] ; Not prose-mirror
(let [descendants (serdes/descendants "Document" document-id)]
(is (nil? descendants))))))
(deftest document-serdes-descendants-all-model-types-test
(testing "Document descendants correctly maps all supported smart link model types"
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
:content [{:type "smartLink"
:attrs {:id 111
:model "card"}}
{:type "smartLink"
:attrs {:id 222
:model "dashboard"}}
{:type "smartLink"
:attrs {:id 333
:model "table"}}]}
:content_type "application/json+vnd.prose-mirror"}]
(let [descendants (serdes/descendants "Document" document-id)]
(is (= {["Card" 111] {"Document" document-id}
["Dashboard" 222] {"Document" document-id}
["Table" 333] {"Document" document-id}}
descendants))
;; Verify all supported model types are included
(is (contains? descendants ["Card" 111]))
(is (contains? descendants ["Dashboard" 222]))
(is (contains? descendants ["Table" 333]))))))

View File

@@ -0,0 +1,319 @@
(ns metabase-enterprise.documents.prose-mirror-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.prose-mirror :as prose-mirror]))
(deftest ^:parallel update-ast-basic-test
(testing "updates nodes that match predicate"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "paragraph"
:content [{:type "text"
:text "World"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "text"))
updater #(assoc % :text (str (:text %) "!"))
result (prose-mirror/update-ast doc predicate updater)]
(is (= "Hello!" (get-in result [:document :content 0 :content 0 :text])))
(is (= "World!" (get-in result [:document :content 1 :content 0 :text]))))))
(deftest ^:parallel update-ast-non-matching-test
(testing "leaves non-matching nodes unchanged"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "heading"
:content [{:type "text"
:text "Title"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "paragraph"))
updater #(assoc % :modified true)
result (prose-mirror/update-ast doc predicate updater)]
(is (true? (get-in result [:document :content 0 :modified])))
(is (nil? (get-in result [:document :content 1 :modified]))))))
(deftest ^:parallel update-ast-empty-document-test
(testing "handles empty document"
(let [doc {:document {}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(= (:type %) "text")
updater #(assoc % :updated true)
result (prose-mirror/update-ast doc predicate updater)]
(is (= {} (:document result))))))
(deftest ^:parallel update-ast-nested-structures-test
(testing "handles nested structures"
(let [doc {:document {:type "doc"
:content [{:type "table"
:content [{:type "table_row"
:content [{:type "table_cell"
:content [{:type "paragraph"
:content [{:type "text"
:text "Cell"}]}]}]}]}]}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "text"))
updater #(update % :text str "-updated")
result (prose-mirror/update-ast doc predicate updater)]
(is (= "Cell-updated"
(get-in result [:document :content 0 :content 0 :content 0 :content 0 :content 0 :text]))))))
(deftest ^:parallel update-ast-all-node-types-test
(testing "predicate receives all node types including non-maps"
(let [doc {:document {:type "doc"
:content ["string-node"
42
{:type "paragraph"}]}
:content_type prose-mirror/prose-mirror-content-type}
visited-nodes (atom [])
predicate (fn [node]
(swap! visited-nodes conj node)
false)
updater identity]
(prose-mirror/update-ast doc predicate updater)
(is (some string? @visited-nodes))
(is (some number? @visited-nodes))
(is (some map? @visited-nodes)))))
(deftest ^:parallel update-ast-updater-function-test
(testing "updater function is applied correctly"
(let [doc {:document {:type "doc"
:attrs {:level 1}}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (contains? % :attrs))
updater #(update-in % [:attrs :level] inc)
result (prose-mirror/update-ast doc predicate updater)]
(is (= 2 (get-in result [:document :attrs :level]))))))
(deftest ^:parallel update-ast-invalid-content-type-test
(testing "throws exception for invalid content-type"
(let [doc {:document {:type "doc"}
:content_type "text/plain"}
predicate identity
updater identity]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Document does not have the prose mirror content-type"
(prose-mirror/update-ast doc predicate updater))))))
(deftest ^:parallel update-ast-exception-data-test
(testing "exception contains correct data"
(let [doc {:document {:type "doc"}
:content_type "application/json"}
predicate identity
updater identity]
(try
(prose-mirror/update-ast doc predicate updater)
(is false "Should have thrown exception")
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
(is (= "application/json" (:content-type data)))
(is (= 400 (:status-code data)))))))))
(deftest ^:parallel update-ast-preserve-fields-test
(testing "preserves other document fields"
(let [doc {:document {:type "doc"}
:content_type prose-mirror/prose-mirror-content-type
:id 123
:name "Test Doc"
:other-field "value"}
predicate (constantly false)
updater identity
result (prose-mirror/update-ast doc predicate updater)]
(is (= 123 (:id result)))
(is (= "Test Doc" (:name result)))
(is (= "value" (:other-field result))))))
(deftest ^:parallel update-ast-nil-document-test
(testing "handles nil document"
(let [doc {:document nil
:content_type prose-mirror/prose-mirror-content-type}
predicate identity
updater identity
result (prose-mirror/update-ast doc predicate updater)]
(is (nil? (:document result))))))
(deftest ^:parallel update-ast-multiple-updates-test
(testing "multiple updates on same node"
(let [doc {:document {:type "text" :text "hello"}
:content_type prose-mirror/prose-mirror-content-type}
predicate #(and (map? %) (= (:type %) "text"))
updater #(-> %
(update :text str "!")
(assoc :modified true))
result (prose-mirror/update-ast doc predicate updater)]
(is (= "hello!" (get-in result [:document :text])))
(is (true? (get-in result [:document :modified]))))))
(deftest ^:parallel update-ast-content-type-constant-test
(testing "works with valid prose-mirror content-type constant"
(let [doc {:document {:type "doc"}
:content_type prose-mirror/prose-mirror-content-type}
predicate (constantly false)
updater identity]
(is (some? (prose-mirror/update-ast doc predicate updater))))))
(deftest ^:parallel collect-ast-basic-test
(testing "collects values from matching nodes"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "paragraph"
:content [{:type "text"
:text "World"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["Hello" "World"] (vec result))))))
(deftest ^:parallel collect-ast-empty-results-test
(testing "returns empty sequence when no matches"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (= (:type %) "nonexistent") %)
result (prose-mirror/collect-ast doc collector)]
(is (empty? result)))))
(deftest ^:parallel collect-ast-nil-filtering-test
(testing "removes nils from results"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "Hello"}]}
{:type "heading"
:content [{:type "text"
:text "Title"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text") (= (:text %) "Hello"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["Hello"] (vec result))))))
(deftest ^:parallel collect-ast-nested-structures-test
(testing "works with nested structures"
(let [doc {:document {:type "doc"
:content [{:type "table"
:content [{:type "table_row"
:content [{:type "table_cell"
:content [{:type "paragraph"
:content [{:type "text"
:text "Cell1"}]}]}
{:type "table_cell"
:content [{:type "paragraph"
:content [{:type "text"
:text "Cell2"}]}]}]}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["Cell1" "Cell2"] (vec result))))))
(deftest ^:parallel collect-ast-traversal-order-test
(testing "collector receives all nodes in tree traversal order"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text "First"}]}
{:type "paragraph"
:content [{:type "text"
:text "Second"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
visited-types (atom [])
collector (fn [node]
(when (map? node)
(swap! visited-types conj (:type node)))
nil)]
(doall (prose-mirror/collect-ast doc collector))
(is (= ["doc" "paragraph" "text" "paragraph" "text"] @visited-types)))))
(deftest ^:parallel collect-ast-lazy-sequence-test
(testing "returns lazy sequence"
(let [doc {:document {:type "doc"
:content [{:type "text" :text "test"}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (map? %) (:type %))
result (prose-mirror/collect-ast doc collector)]
(is (instance? clojure.lang.LazySeq result)))))
(deftest ^:parallel collect-ast-empty-document-test
(testing "handles empty document"
(let [doc {:document {}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (map? %) (:type %))
result (prose-mirror/collect-ast doc collector)]
(is (empty? result)))))
(deftest ^:parallel collect-ast-nil-document-test
(testing "handles nil document"
(let [doc {:document nil
:content_type prose-mirror/prose-mirror-content-type}
collector identity
result (prose-mirror/collect-ast doc collector)]
(is (empty? result)))))
(deftest ^:parallel collect-ast-complex-objects-test
(testing "collector can return complex objects"
(let [doc {:document {:type "doc"
:content [{:type "paragraph"
:attrs {:id "p1"}
:content [{:type "text"
:text "Hello"}]}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "paragraph"))
{:id (get-in % [:attrs :id])
:type (:type %)})
result (prose-mirror/collect-ast doc collector)]
(is (= [{:id "p1" :type "paragraph"}] (vec result))))))
(deftest ^:parallel collect-ast-invalid-content-type-test
(testing "throws exception for invalid content-type"
(let [doc {:document {:type "doc"}
:content_type "text/plain"}
collector identity]
(is (thrown-with-msg?
clojure.lang.ExceptionInfo
#"Document does not have the prose mirror content-type"
(prose-mirror/collect-ast doc collector))))))
(deftest ^:parallel collect-ast-exception-data-test
(testing "exception contains correct data for collect-ast"
(let [doc {:document {:type "doc"}
:content_type "application/xml"}
collector identity]
(try
(doall (prose-mirror/collect-ast doc collector))
(is false "Should have thrown exception")
(catch clojure.lang.ExceptionInfo e
(let [data (ex-data e)]
(is (= "application/xml" (:content-type data)))
(is (= 400 (:status-code data)))))))))
(deftest ^:parallel collect-ast-card-embed-test
(testing "works with card embed nodes"
(let [doc {:document {:type "doc"
:content [{:type prose-mirror/card-embed-type
:attrs {:cardId 123}}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) prose-mirror/card-embed-type))
(get-in % [:attrs :cardId]))
result (prose-mirror/collect-ast doc collector)]
(is (= [123] (vec result))))))
(deftest ^:parallel collect-ast-order-preservation-test
(testing "preserves order of collection"
(let [doc {:document {:type "doc"
:content [{:type "text" :text "first"}
{:type "text" :text "second"}
{:type "text" :text "third"}]}
:content_type prose-mirror/prose-mirror-content-type}
collector #(when (and (map? %) (= (:type %) "text"))
(:text %))
result (prose-mirror/collect-ast doc collector)]
(is (= ["first" "second" "third"] (vec result))))))

View File

@@ -0,0 +1,204 @@
(ns metabase-enterprise.documents.recent-views-test
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.recent-views :as recent-views]
[metabase.activity-feed.core :as activity-feed]
[metabase.events.core :as events]
[metabase.test :as mt]
[toucan2.core :as t2]))
(defn- most-recent-view
[user-id model-id model-type]
(t2/select-one [:model/RecentViews :user_id :model :model_id]
:user_id user-id
:model_id model-id
:model model-type
{:order-by [[:id :desc]]}))
(deftest card-query-test
(mt/with-test-user :rasta
(testing "document cards should not be counted"
(mt/with-temp [:model/Document {doc-id :id} {}
:model/Card card-3 {:creator_id (mt/user->id :rasta)
:document_id doc-id}]
(events/publish-event! :event/card-query {:card-id (:id card-3)
:user-id (mt/user->id :rasta)
:context :question})
(is (nil? (most-recent-view (mt/user->id :rasta) (:id card-3) "card")))))))
(deftest legacy-card-read-test
(testing "in_document cards should not be counted even with context :question"
(mt/with-temp [:model/Document {doc-id :id} {}
:model/Card card {:creator_id (mt/user->id :rasta)
:document_id doc-id}]
(mt/with-test-user :rasta
(events/publish-event! :event/card-read {:object-id (:id card)
:user-id (mt/user->id :rasta)
:context :question})
(is (nil? (most-recent-view (mt/user->id :rasta) (:id card) "card")))))))
(deftest select-documents-for-recents-empty-input-test
(mt/with-premium-features #{:documents}
(testing "returns empty vector when given empty document-ids"
(is (= [] (recent-views/select-documents-for-recents [])))
(is (= [] (recent-views/select-documents-for-recents nil))))))
(deftest select-documents-for-recents-with-collection-test
(mt/with-premium-features #{:documents}
(testing "returns documents with collection information"
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"
:authority_level "official"
:archived false}
:model/Document {doc1-id :id} {:name "Document 1"
:collection_id coll-id
:archived false}
:model/Document {doc2-id :id} {:name "Document 2"
:collection_id coll-id
:archived false}]
(let [results (recent-views/select-documents-for-recents [doc1-id doc2-id])]
(is (= 2 (count results)))
;; Check first document
(let [doc1 (first (filter #(= (:id %) doc1-id) results))]
(is (= "Document 1" (:name doc1)))
(is (= false (:archived doc1)))
(is (= coll-id (:entity-coll-id doc1)))
(is (= coll-id (:collection_id doc1)))
(is (= "Test Collection" (:collection_name doc1)))
(is (= "official" (:collection_authority_level doc1))))
;; Check second document
(let [doc2 (first (filter #(= (:id %) doc2-id) results))]
(is (= "Document 2" (:name doc2)))
(is (= false (:archived doc2)))))))))
(deftest select-documents-for-recents-without-collection-test
(mt/with-premium-features #{:documents}
(testing "returns documents without collection when collection_id is nil"
(mt/with-temp [:model/Document {doc-id :id} {:name "Orphan Document"
:collection_id nil
:archived false}]
(let [results (recent-views/select-documents-for-recents [doc-id])]
(is (= 1 (count results)))
(let [doc (first results)]
(is (= "Orphan Document" (:name doc)))
(is (= false (:archived doc)))
(is (nil? (:entity-coll-id doc)))
(is (nil? (:collection_id doc)))
(is (nil? (:collection_name doc)))
(is (nil? (:collection_authority_level doc)))))))))
(deftest select-documents-for-recents-archived-collections-test
(mt/with-premium-features #{:documents}
(testing "excludes documents from archived collections via left join"
(mt/with-temp [:model/Collection {archived-coll-id :id} {:name "Archived Collection"
:authority_level "official"
:archived true}
:model/Collection {active-coll-id :id} {:name "Active Collection"
:authority_level "official"
:archived false}
:model/Document {doc1-id :id} {:name "Document in Archived Collection"
:collection_id archived-coll-id
:archived false}
:model/Document {doc2-id :id} {:name "Document in Active Collection"
:collection_id active-coll-id
:archived false}]
(let [results (recent-views/select-documents-for-recents [doc1-id doc2-id])]
(is (= 2 (count results)))
;; Document from archived collection should have nil collection info
(let [doc1 (first (filter #(= (:id %) doc1-id) results))]
(is (= "Document in Archived Collection" (:name doc1)))
(is (= archived-coll-id (:entity-coll-id doc1)))
(is (nil? (:collection_id doc1)))
(is (nil? (:collection_name doc1)))
(is (nil? (:collection_authority_level doc1))))
;; Document from active collection should have collection info
(let [doc2 (first (filter #(= (:id %) doc2-id) results))]
(is (= "Document in Active Collection" (:name doc2)))
(is (= active-coll-id (:entity-coll-id doc2)))
(is (= active-coll-id (:collection_id doc2)))
(is (= "Active Collection" (:collection_name doc2)))
(is (= "official" (:collection_authority_level doc2)))))))))
(deftest select-documents-for-recents-archived-documents-test
(mt/with-premium-features #{:documents}
(testing "includes archived documents in results"
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"
:archived false}
:model/Document {doc-id :id} {:name "Archived Document"
:collection_id coll-id
:archived true}]
(let [results (recent-views/select-documents-for-recents [doc-id])]
(is (= 1 (count results)))
(let [doc (first results)]
(is (= "Archived Document" (:name doc)))
(is (true? (:archived doc)))))))))
(deftest select-documents-for-recents-nonexistent-ids-test
(mt/with-premium-features #{:documents}
(testing "handles non-existent document IDs gracefully"
(let [results (recent-views/select-documents-for-recents [999999 888888])]
(is (= [] results))))))
(deftest select-documents-for-recents-mixed-ids-test
(mt/with-premium-features #{:documents}
(testing "handles mixed existing and non-existing document IDs"
(mt/with-temp [:model/Document {doc-id :id} {:name "Existing Document"
:archived false}]
(let [results (recent-views/select-documents-for-recents [doc-id 999999])]
(is (= 1 (count results)))
(is (= "Existing Document" (:name (first results)))))))))
(deftest select-documents-for-recents-duplicates-test
(mt/with-premium-features #{:documents}
(testing "preserves order and handles duplicates in document-ids"
(mt/with-temp [:model/Document {doc1-id :id} {:name "Document 1" :archived false}
:model/Document {doc2-id :id} {:name "Document 2" :archived false}]
(let [results (recent-views/select-documents-for-recents [doc1-id doc2-id doc1-id])]
;; Should return unique documents even if IDs are duplicated
(is (= 2 (count results)))
(is (= #{doc1-id doc2-id} (set (map :id results)))))))))
(deftest recents-api-with-documents-premium-feature-test
(testing "/recents API returns documents when :document premium feature is enabled"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document {doc-id :id} {:name "Test Document"
:collection_id coll-id
:archived false}]
;; Add document to recent views
(activity-feed/update-users-recent-views! (mt/user->id :rasta) :model/Document doc-id :view)
;; Call the API
(let [response (mt/user-http-request :rasta :get 200 "activity/recents" :context [:views])]
(is (some #(and (= (:model %) "document")
(= (:id %) doc-id)
(= (:name %) "Test Document"))
(:recents response))
"Document should be present in recents when premium feature is enabled"))))))
(deftest select-documents-for-recents-without-premium-feature-test
(testing "returns empty vector when :documents premium feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
:archived false}]
(let [results (recent-views/select-documents-for-recents [doc-id])]
(is (= [] results)
"Should return empty vector when premium feature is disabled"))))))
(deftest recents-api-without-documents-premium-feature-test
(testing "/recents API does not return documents when :document premium feature is disabled"
(mt/with-premium-features #{}
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
:model/Document {doc-id :id} {:name "Test Document"
:collection_id coll-id
:archived false}]
;; Add document to recent views
(activity-feed/update-users-recent-views! (mt/user->id :rasta) :model/Document doc-id :view)
;; Call the API
(let [response (mt/user-http-request :rasta :get 200 "activity/recents" :context [:views])]
(is (not-any? #(= (:model %) :document) (:recents response))
"No documents should be present in recents when premium feature is disabled"))))))

View File

@@ -0,0 +1,103 @@
(ns metabase-enterprise.documents.revisions.impl-test
"Unit tests for Document revision serialization and reversion functionality."
(:require
[clojure.test :refer :all]
[metabase-enterprise.documents.revisions.impl]
[metabase.revisions.models.revision :as revision]
[metabase.test :as mt]
[toucan2.core :as t2]))
(deftest serialize-instance-document-test
(testing "Document revision serialization excludes metadata fields"
(let [document {:id 123
:name "Test Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
:creator_id 456
:created_at #t "2023-01-01T00:00:00Z"
:updated_at #t "2023-01-02T00:00:00Z"
:collection_id 789
:archived false}
serialized (revision/serialize-instance :model/Document 123 document)]
(testing "includes Document-specific fields"
(is (= "Test Document" (:name serialized)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
(:document serialized)))
(is (= false (:archived serialized))))
(testing "excludes metadata fields"
(is (not (contains? serialized :id)))
(is (not (contains? serialized :creator_id)))
(is (not (contains? serialized :created_at)))
(is (not (contains? serialized :updated_at)))
(is (not (contains? serialized :collection_id)))))))
(deftest serialize-instance-document-with-id-test
(testing "Document revision serialization works with different IDs"
(let [document {:id 123
:name "Test Document"
:document {:type "doc" :content []}
:creator_id 456
:created_at #t "2023-01-01T00:00:00Z"
:updated_at #t "2023-01-02T00:00:00Z"
:collection_id 789}]
(testing "works with matching ID"
(let [serialized (revision/serialize-instance :model/Document 123 document)]
(is (= "Test Document" (:name serialized)))
(is (not (contains? serialized :id)))))
(testing "works with different ID"
(let [serialized (revision/serialize-instance :model/Document 999 document)]
(is (= "Test Document" (:name serialized)))
(is (not (contains? serialized :id))))))))
(deftest serialize-instance-document-minimal-test
(testing "Document revision serialization works with minimal document"
(let [document {:id 1
:name "Minimal Doc"
:document {:type "doc"}
:creator_id 2
:created_at #t "2023-01-01T00:00:00Z"
:updated_at #t "2023-01-01T00:00:00Z"
:collection_id nil}
serialized (revision/serialize-instance :model/Document 1 document)]
(is (= "Minimal Doc" (:name serialized)))
(is (= {:type "doc"} (:document serialized)))
(is (nil? (:collection_id serialized)) "collection_id should be excluded even when nil")
(is (not (contains? serialized :id)))
(is (not (contains? serialized :creator_id))))))
(deftest revert-to-revision-document-test
(testing "Document reversion uses default implementation"
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Original Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :crowberto)}]
(let [original-serialized (revision/serialize-instance :model/Document doc-id document)
_ (t2/update! :model/Document doc-id {:name "Updated Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}})
updated-document (t2/select-one :model/Document :id doc-id)]
(testing "document was updated"
(is (= "Updated Document" (:name updated-document)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}
(:document updated-document))))
(testing "reversion restores original state"
(revision/revert-to-revision! :model/Document doc-id (mt/user->id :crowberto) original-serialized)
(let [reverted-document (t2/select-one :model/Document :id doc-id)]
(is (= "Original Document" (:name reverted-document)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
(:document reverted-document)))
(testing "preserves metadata fields not in serialization"
(is (= doc-id (:id reverted-document)))
(is (= (mt/user->id :crowberto) (:creator_id reverted-document)))
(is (:created_at reverted-document))
(is (:updated_at reverted-document)))))))))
(deftest excluded-columns-test
(testing "excluded columns constant contains expected fields"
(let [excluded-columns @#'metabase-enterprise.documents.revisions.impl/excluded-columns-for-document-revision]
(is (contains? excluded-columns :id))
(is (contains? excluded-columns :creator_id))
(is (contains? excluded-columns :created_at))
(is (contains? excluded-columns :updated_at))
(is (contains? excluded-columns :collection_id))
(is (contains? excluded-columns :collection_position))
(is (= 8 (count excluded-columns)) "Should exclude exactly 5 metadata fields"))))

View File

@@ -0,0 +1,270 @@
(ns metabase-enterprise.documents.revisions.integration-test
"Integration tests for Document revision history including API endpoints, events, and permissions."
(:require
[clojure.string :as str]
[clojure.test :refer :all]
[metabase-enterprise.documents.revisions.impl]
[metabase.config.core :as config]
[metabase.events.core :as events]
[metabase.revisions.models.revision :as revision]
[metabase.test :as mt]
[metabase.util :as u]
[toucan2.core :as t2]))
(def ^:private rasta-revision-info
(delay {:id (mt/user->id :rasta)
:first_name "Rasta"
:last_name "Toucan"
:common_name "Rasta Toucan"}))
(defn- create-document-revision!
"Helper function to create a Document revision manually for testing."
[document-id is-creation? user]
(revision/push-revision!
{:object (t2/select-one :model/Document :id document-id)
:entity :model/Document
:id document-id
:user-id (mt/user->id user)
:is-creation? is-creation?}))
(defn- get-document-revisions
"Helper function to get revisions for a Document via API."
[document-id]
(mt/user-http-request :rasta :get 200 (format "revision/document/%d" document-id)))
(deftest document-create-event-triggers-revision-test
(testing "Document creation triggers revision creation via events"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Test Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
:creator_id (mt/user->id :crowberto)}]
(events/publish-event! :event/document-create {:object document :user-id (mt/user->id :crowberto)})
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)]
(is revision "Revision should be created")
(is (= "Document" (:model revision)))
(is (= doc-id (:model_id revision)))
(is (= (mt/user->id :crowberto) (:user_id revision)))
(is (:is_creation revision))
(is (not (:is_reversion revision)))
(testing "serialized object excludes metadata fields"
(let [object (:object revision)]
(is (= "Test Document" (:name object)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]} (:document object)))
(is (not (contains? object :id)))
(is (not (contains? object :creator_id)))
(is (not (contains? object :created_at)))
(is (not (contains? object :updated_at)))
(is (not (contains? object :collection_id))))))))))
(deftest document-update-event-triggers-revision-test
(testing "Document update triggers revision creation via events"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Original Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :crowberto)}]
(let [updated-document (assoc document
:name "Updated Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]})]
(events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)})
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id :user_id (mt/user->id :rasta))]
(is revision "Revision should be created for update")
(is (= "Document" (:model revision)))
(is (= doc-id (:model_id revision)))
(is (= (mt/user->id :rasta) (:user_id revision)))
(is (not (:is_creation revision)))
(is (not (:is_reversion revision)))
(testing "serialized object contains updated data"
(let [object (:object revision)]
(is (= "Updated Document" (:name object)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]} (:document object)))))))))))
(deftest document-revision-api-endpoints-test
(testing "Document revision API endpoints work correctly"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Initial content"}]}]}
:creator_id (mt/user->id :rasta)}]
(create-document-revision! doc-id true :rasta)
(testing "GET /api/revision/document/:id returns Document revisions"
(let [revisions (get-document-revisions doc-id)]
(is (= 1 (count revisions)))
(let [revision (first revisions)]
(is (:is_creation revision))
(is (not (:is_reversion revision)))
(is (= "created this." (:description revision)))
(is (=? @rasta-revision-info (:user revision)))
(is (= config/mb-version-string (:metabase_version revision))))))
(testing "Multiple revisions are returned in correct order"
(t2/update! :model/Document doc-id {:name "Updated Document"})
(create-document-revision! doc-id false :rasta)
(let [revisions (get-document-revisions doc-id)]
(is (= 2 (count revisions)))
(let [[latest-revision creation-revision] revisions]
(is (not (:is_creation latest-revision)))
(is (:is_creation creation-revision))
(is (= "created this." (:description creation-revision)))
(is (not= "created this." (:description latest-revision))))))))))
(deftest document-reversion-api-test
(testing "Document reversion via API works correctly"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Original Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :rasta)}]
(create-document-revision! doc-id true :rasta)
(testing "Document can be updated and reverted"
(t2/update! :model/Document doc-id {:name "Updated Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}})
(create-document-revision! doc-id false :rasta)
(let [revisions (revision/revisions :model/Document doc-id)
[_ {original-revision-id :id}] revisions]
(testing "Revert via API endpoint"
(let [revert-response (mt/user-http-request :rasta :post "revision/revert"
{:entity :document
:id doc-id
:revision_id original-revision-id})]
(is (:is_reversion revert-response))
(is (= "reverted to an earlier version." (:description revert-response)))
(is (=? @rasta-revision-info (:user revert-response)))))
(testing "Document state is restored"
(let [reverted-document (t2/select-one :model/Document :id doc-id)]
(is (= "Original Document" (:name reverted-document)))
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
(:document reverted-document)))))
(testing "Reversion creates new revision entry"
(let [final-revisions (get-document-revisions doc-id)]
(is (= 3 (count final-revisions)))
(let [latest-revision (first final-revisions)]
(is (:is_reversion latest-revision))
(is (= "reverted to an earlier version." (:description latest-revision))))))))))))
(deftest document-revision-permission-checks-test
(testing "Document revision API respects permissions"
(mt/with-premium-features #{:documents}
(mt/with-non-admin-groups-no-root-collection-perms
(mt/with-temp [:model/Collection collection {:name "Private collection"}
:model/Document document {:collection_id (u/the-id collection)
:name "Private document"
:document {:type "doc" :content []}
:creator_id (mt/user->id :crowberto)}]
(create-document-revision! (:id document) true :crowberto)
(t2/update! :model/Document (:id document) {:name "Updated Private Document"})
(create-document-revision! (:id document) false :crowberto)
(let [document-id (u/the-id document)
[_ {prev-rev-id :id}] (revision/revisions :model/Document document-id)]
(testing "User without permissions cannot access revisions"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :get 403 (format "revision/document/%d" document-id)))))
(testing "User without permissions cannot revert"
(is (= "You don't have permissions to do that."
(mt/user-http-request :rasta :post 403 "revision/revert"
{:entity :document
:id document-id
:revision_id prev-rev-id}))))))))))
(deftest document-revision-cross-boundary-integration-test
(testing "Document revisions integrate properly with OSS revision system"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Cross-boundary Document"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Enterprise content"}]}]}
:creator_id (mt/user->id :rasta)}]
(testing "Document revisions work with OSS revision/revisions function"
(create-document-revision! doc-id true :rasta)
(let [revisions (revision/revisions :model/Document doc-id)]
(is (seq revisions))
(is (= 1 (count revisions)))
(let [revision (first revisions)]
(is (= doc-id (:model_id revision)))
(is (= "Document" (:model revision))))))
(testing "Document revisions work with OSS revision API routes"
(let [revisions-response (mt/user-http-request :rasta :get (format "revision/document/%d" doc-id))]
(is (seq revisions-response))
(is (every? #(contains? % :is_creation) revisions-response))
(is (every? #(contains? % :description) revisions-response))))
(testing "Document reversion integrates with OSS revert endpoint"
(t2/update! :model/Document doc-id {:name "Modified Document"})
(create-document-revision! doc-id false :rasta)
(let [[_ {original-rev-id :id}] (revision/revisions :model/Document doc-id)
revert-response (mt/user-http-request :rasta :post "revision/revert"
{:entity :document
:id doc-id
:revision_id original-rev-id})]
(is (:is_reversion revert-response))
(let [reverted-doc (t2/select-one :model/Document :id doc-id)]
(is (= "Cross-boundary Document" (:name reverted-doc))))))))))
(deftest document-revision-event-handler-integration-test
(testing "Document revision events are published and handled correctly"
(mt/with-premium-features #{:documents}
(let [original-event events/publish-event!
events-received (atom [])]
(with-redefs [events/publish-event! (fn [topic event]
(swap! events-received conj [topic event])
(original-event topic event))]
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Event Test Document"
:document {:type "doc" :content []}
:creator_id (mt/user->id :rasta)}]
(testing "Create event triggers revision creation"
(events/publish-event! :event/document-create {:object document :user-id (mt/user->id :rasta)})
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)]
(is revision)
(is (:is_creation revision))))
(testing "Update event triggers revision creation"
(let [updated-document (assoc document :name "Updated Event Document")]
(events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)})
(let [revisions (t2/select :model/Revision :model "Document" :model_id doc-id {:order-by [[:id :desc]]})]
(is (= 2 (count revisions)))
(let [latest-revision (first revisions)]
(is (not (:is_creation latest-revision)))
(is (= "Updated Event Document" (get-in latest-revision [:object :name])))))))
(testing "Events were published correctly"
(is (>= (count @events-received) 2))
(is (some #(= :event/document-create (first %)) @events-received))
(is (some #(= :event/document-update (first %)) @events-received)))))))))
(deftest document-revision-diff-and-descriptions-test
(testing "Document revision diffs and descriptions work correctly"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Original Title"
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
:creator_id (mt/user->id :rasta)}]
(create-document-revision! doc-id true :rasta)
(testing "Name change produces correct diff and description"
(t2/update! :model/Document doc-id {:name "Updated Title"})
(create-document-revision! doc-id false :rasta)
(let [revisions (get-document-revisions doc-id)
update-revision (first revisions)]
(is (= "Original Title" (get-in update-revision [:diff :before :name])))
(is (= "Updated Title" (get-in update-revision [:diff :after :name])))
(is (str/includes? (:description update-revision) "renamed"))))
(testing "Content change produces diff"
(t2/update! :model/Document doc-id {:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "New content"}]}]}})
(create-document-revision! doc-id false :rasta)
(let [revisions (get-document-revisions doc-id)
content-revision (first revisions)]
(is (= {:content [{:content [{:text "Original content"}]}]}
(get-in content-revision [:diff :before :document])))
(is (= {:content [{:content [{:text "New content"}]}]}
(get-in content-revision [:diff :after :document])))))))))

View File

@@ -0,0 +1,176 @@
(ns metabase-enterprise.documents.search-test
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase.events.core :as events]
[metabase.permissions.core :as perms]
[metabase.permissions.models.permissions-group :as perms-group]
[metabase.search.test-util :as search.tu]
[metabase.test :as mt]
[metabase.test.fixtures :as fixtures]
[metabase.util :as u]
[toucan2.core :as t2]))
(defn- text->prose-mirror-ast
"Convert plain text to a ProseMirror AST structure."
[text]
(if (empty? text)
{:type "doc" :content []}
{:type "doc"
:content [{:type "paragraph"
:content [{:type "text"
:text text}]}]}))
(use-fixtures :once (fixtures/initialize :db :test-users))
(defn card-with-name-and-query
([]
(card-with-name-and-query (mt/random-name)))
([card-name]
{:name card-name
:display "scalar"
:dataset_query (mt/mbql-query venues)
:visualization_settings {:global {:title nil}}}))
(deftest card-search-exclusion-test
(testing "Cards with document_id are excluded from search indexing"
(let [card-name (mt/random-name)
regular-card-name (str card-name "-regular")]
(search.tu/with-temp-index-table
(mt/with-temp [:model/Document document {:name "Test Document"
:document (text->prose-mirror-ast "")}
:model/Card document-card (-> (card-with-name-and-query card-name)
(assoc :document_id (u/the-id document)))
:model/Card regular-card (card-with-name-and-query regular-card-name)]
(testing "Search API excludes document cards"
(let [results (mt/user-http-request :crowberto :get 200 "search" :q card-name :models "card")]
(is (not (some #(= (:id %) (u/the-id document-card)) (:data results)))
"Document card should not appear in search results"))
(let [regular-results (mt/user-http-request :crowberto :get 200 "search" :q regular-card-name :models "card")]
(is (some #(= (:id %) (u/the-id regular-card)) (:data regular-results))
"Regular card should appear in search results"))))))))
(deftest document-permission-filtering-test
(testing "Document search respects collection-based permissions"
(mt/with-premium-features #{:documents}
(mt/with-non-admin-groups-no-root-collection-perms
(search.tu/with-temp-index-table
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Collection {private-coll-id :id} {}
:model/Document {doc-in-public-coll :id} {:name "Public Document"
:collection_id coll-id
:document (text->prose-mirror-ast "content")}
:model/Document {doc-in-private-coll :id} {:name "Private Document"
:collection_id private-coll-id
:document (text->prose-mirror-ast "content")}
:model/Document {doc-archived :id} {:name "Archived Document"
:collection_id coll-id
:archived true
:document (text->prose-mirror-ast "content")}]
;; Give user read access to first collection only
(perms/grant-collection-read-permissions! (perms-group/all-users) coll-id)
(testing "Regular user sees only documents in accessible collections"
(let [results (mt/user-http-request :rasta :get 200 "search" :q "Document" :models "document")]
(is (= #{doc-in-public-coll}
(set (map :id (:data results))))
"Should only see document in accessible collection")))
(testing "Regular user cannot see archived documents (no write perms)"
(let [results (mt/user-http-request :rasta :get 200 "search" :q "Archived" :archived true :models "document")]
(is (empty? (:data results))
"Should not see archived document without write permissions")))
(testing "Admin can see all documents including archived"
(let [regular-results (mt/user-http-request :crowberto :get 200 "search" :q "Document" :models "document")
archived-results (mt/user-http-request :crowberto :get 200 "search" :q "Archived" :archived true :models "document")]
(is (= #{doc-in-public-coll doc-in-private-coll}
(set (map :id (:data regular-results))))
"Admin should see all non-archived documents")
(is (= #{doc-archived}
(set (map :id (:data archived-results))))
"Admin should see archived documents")))
(testing "User with write permissions can see archived documents"
;; Give user write access to first collection
(perms/grant-collection-readwrite-permissions! (perms-group/all-users) coll-id)
(let [archived-results (mt/user-http-request :rasta :get 200 "search" :q "Archived" :archived true :models "document")]
(is (= #{doc-archived}
(set (map :id (:data archived-results))))
"User with write permissions should see archived documents in accessible collections")))))))))
(deftest document-search-without-premium-feature-test
(testing "Documents are not returned from search when documents premium feature is disabled"
(mt/with-premium-features #{}
(search.tu/with-temp-index-table
(mt/with-temp [:model/Collection {coll-id :id} {}
:model/Document {doc-id :id} {:name "Test Document"
:collection_id coll-id
:document (text->prose-mirror-ast "searchable content")}
:model/Card {card-id :id} (-> (card-with-name-and-query "Test Card")
(assoc :collection_id coll-id))]
(testing "Search for documents returns empty results"
(let [results (mt/user-http-request :crowberto :get 200 "search" :q "Test Document" :models "document")]
(is (empty? (:data results))
"Should not return any documents when premium feature is disabled")))
(testing "Search for cards still works normally"
(let [results (mt/user-http-request :crowberto :get 200 "search" :q "Test Card" :models "card")]
(is (= #{card-id}
(set (map :id (:data results))))
"Should still return cards when documents feature is disabled")))
(testing "General search excludes documents but includes other models"
(let [results (mt/user-http-request :crowberto :get 200 "search" :q "Test")]
(is (some #(= (:id %) card-id) (:data results))
"Should include cards in general search")
(is (not (some #(= (:id %) doc-id) (:data results)))
"Should not include documents in general search when feature is disabled"))))))))
(deftest document-view-tracking-integration-test
(testing "Document view tracking integrates with search"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Document {doc-id :id} {:name "Viewed Document"
:document (text->prose-mirror-ast "content")
:view_count 0}]
(testing "Document has initial state"
(let [doc (t2/select-one :model/Document :id doc-id)]
(is (= 0 (:view_count doc)))
;; last_viewed_at has a default timestamp from migration, not nil
(is (some? (:last_viewed_at doc)))))
(testing "Publishing document-read event works without errors"
;; The actual view count increment is batched and asynchronous
;; So we test that the event publishes successfully
(is (some? (events/publish-event! :event/document-read
{:object-id doc-id
:user-id (mt/user->id :crowberto)}))
"Document read event should publish successfully"))
(testing "Document with higher view count appears in search"
;; Manually set view count to test search integration
(t2/update! :model/Document doc-id {:view_count 5
:last_viewed_at (t/offset-date-time)})
(search.tu/with-temp-index-table
(let [results (mt/user-http-request :crowberto :get 200 "search" {:q "Viewed" :models "document"})
doc-results (filter #(= "document" (:model %)) (:data results))]
(when (seq doc-results)
(let [doc-result (first doc-results)]
(is (= doc-id (:id doc-result)))
;; View count affects scoring but isn't directly in result
(is (some #(= "view-count" (:name %)) (:scores doc-result))))))))
(testing "Document with recent view appears in search results"
(let [recent-time (t/minus (t/offset-date-time) (t/minutes 5))]
(t2/update! :model/Document doc-id {:last_viewed_at recent-time})
(search.tu/with-temp-index-table
(let [results (mt/user-http-request :crowberto :get 200 "search" {:q "Viewed" :models "document"})
doc-results (filter #(= "document" (:model %)) (:data results))]
(when (seq doc-results)
(let [doc-result (first doc-results)]
(is (= doc-id (:id doc-result)))
;; User recency affects scoring
(is (some #(= "user-recency" (:name %)) (:scores doc-result)))))))))))))

View File

@@ -0,0 +1,149 @@
(ns metabase-enterprise.documents.view-log-test
(:require
[clojure.test :refer :all]
[java-time.api :as t]
[metabase-enterprise.documents.view-log :as documents.view-log]
[metabase.events.core :as events]
[metabase.test :as mt]
[toucan2.core :as t2]))
(set! *warn-on-reflection* true)
(defn latest-view
"Returns the most recent view for a given user and document ID"
[user-id document-id]
(t2/select-one :model/ViewLog
:user_id user-id
:model "document"
:model_id document-id
{:order-by [[:id :desc]]}))
(deftest document-read-ee-test
(mt/with-premium-features #{:audit-app :documents}
(mt/with-temp [:model/Collection collection {}
:model/User user {}
:model/Document document {:collection_id (:id collection)
:name "Test Document"
:document "{\"type\":\"doc\",\"content\":[]}"
:creator_id (:id user)}]
(testing "A basic document read event is recorded in EE"
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
(is (partial=
{:user_id (:id user)
:model "document"
:model_id (:id document)
:has_access true
:context nil}
(latest-view (:id user) (:id document))))))))
(deftest document-read-oss-no-view-logging-test
(mt/with-premium-features #{:documents} ; documents feature but not audit-app
(mt/with-temp [:model/Collection collection {}
:model/User user {}
:model/Document document {:collection_id (:id collection)
:name "Test Document"
:document "{\"type\":\"doc\",\"content\":[]}"
:creator_id (:id user)}]
(testing "A basic document read event is not recorded without audit-app"
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
(is (nil? (latest-view (:id user) (:id document)))
"view log entries should not be made without audit-app feature")))))
(deftest document-read-view-count-test
(mt/test-helpers-set-global-values!
(mt/with-temporary-setting-values [synchronous-batch-updates true]
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection collection {}
:model/User user {}
:model/Document document {:collection_id (:id collection)
:name "Test Document"
:document "{\"type\":\"doc\",\"content\":[]}"
:creator_id (:id user)
:view_count 0}]
(testing "Document read events are recorded by a document's view_count"
(is (= 0 (:view_count document))
"view_count should be 0 before the event is published")
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
(is (= 1 (t2/select-one-fn :view_count :model/Document (:id document))))
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
(is (= 2 (t2/select-one-fn :view_count :model/Document (:id document))))))))))
(deftest update-document-last-viewed-at-test
(let [now (-> (t/offset-date-time)
(.withNano 0))
one-hour-ago (t/minus now (t/hours 1))
two-hours-ago (t/minus now (t/hours 2))]
(testing "update with multiple documents of the same IDs will set timestamp to the latest"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection collection {}
:model/User user {}
:model/Document document {:collection_id (:id collection)
:name "Test Document"
:document "{\"type\":\"doc\",\"content\":[]}"
:creator_id (:id user)
:last_viewed_at two-hours-ago}]
(#'documents.view-log/update-document-last-viewed-at!* [{:id (:id document) :timestamp one-hour-ago}
{:id (:id document) :timestamp two-hours-ago}])
(is (= one-hour-ago
(-> (t2/select-one-fn :last_viewed_at :model/Document (:id document))
t/offset-date-time
(.withNano 0)))))))
(testing "if the existing last_viewed_at is greater than the updating values, do not override it"
(mt/with-premium-features #{:documents}
(mt/with-temp [:model/Collection collection {}
:model/User user {}
:model/Document document {:collection_id (:id collection)
:name "Test Document"
:document "{\"type\":\"doc\",\"content\":[]}"
:creator_id (:id user)
:last_viewed_at now}]
(#'documents.view-log/update-document-last-viewed-at!* [{:id (:id document) :timestamp one-hour-ago}])
(is (= now
(-> (t2/select-one-fn :last_viewed_at :model/Document (:id document))
t/offset-date-time
(.withNano 0)))))))))
(deftest document-event-derivation-test
(testing "Document events are properly derived from base events"
(is (isa? :metabase-enterprise.documents.view-log/document-read :metabase/event))
(is (isa? :event/document-read :metabase-enterprise.documents.view-log/document-read))))
(deftest document-read-error-handling-test
(mt/with-premium-features #{:documents}
(testing "Document read event handles missing document gracefully"
;; This should not throw an exception
(is (some? (events/publish-event! :event/document-read {:object-id 999999 :user-id (mt/user->id :rasta)}))))))
(deftest document-statistics-lock-test
(testing "Document statistics lock is properly defined"
(is (= :metabase-enterprise.documents.view-log/document-statistics-lock
@#'documents.view-log/document-statistics-lock))))
(deftest document-read-updates-recent-views-test
(mt/with-premium-features #{:audit-app :documents}
(mt/with-temp [:model/Collection collection {}
:model/User user {}
:model/Document document {:collection_id (:id collection)
:name "Test Document"
:document "{\"type\":\"doc\",\"content\":[]}"
:creator_id (:id user)}]
(testing "Document read event updates user's recent views"
;; Verify no recent views initially
(is (empty? (t2/select :model/RecentViews
:user_id (:id user)
:model "document"
:model_id (:id document))))
;; Publish document read event
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
;; Verify recent view was created
(let [recent-view (t2/select-one :model/RecentViews
:user_id (:id user)
:model "document"
:model_id (:id document))]
(is (some? recent-view) "Recent view should be created")
(is (= (:id user) (:user_id recent-view)))
(is (= "document" (:model recent-view)))
(is (= (:id document) (:model_id recent-view))))))))

View File

@@ -4,6 +4,7 @@
[malli.core :as mc]
[malli.transform :as mtx]
[medley.core :as m]
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
@@ -37,7 +38,7 @@
([] (ai-session-token :rasta (str (random-uuid))))
([metabot-id] (ai-session-token :rasta metabot-id))
([user metabot-id]
(-> user mt/user->id (#'metabot-v3.tools.api/get-ai-service-token metabot-id))))
(-> user mt/user->id (#'metabot-v3.client/get-ai-service-token metabot-id))))
(deftest create-dashboard-subscription-test
(mt/with-premium-features #{:metabot-v3}

View File

@@ -45,6 +45,7 @@
:model/DashboardBookmark
:model/DataPermissions
:model/DatabaseRouter
:model/DocumentBookmark
:model/CollectionPermissionGraphRevision
:model/DashboardCardSeries
:model/LoginHistory

View File

@@ -1832,6 +1832,70 @@
(is (= #{[{:model "Collection" :id model-eid}] [{:model "Card" :id card-eid}]}
(set (serdes/dependencies ser))))))))))
(deftest document-test
(mt/with-empty-h2-app-db!
(ts/with-temp-dpc [:model/Collection collection {}
:model/User user {:first_name "Mark"
:last_name "Knopfler"
:email "mark@direstrai.ts"}
:model/Document document {:name "Test Document"
:document {}
:content_type "application/json+vnd.prose-mirror"
:collection_id (:id collection)
:creator_id (:id user)}
:model/Card card {:name "Dependent Card"
:document_id (u/the-id document)}
:model/Card linked-card {:name "Linked Card"}
:model/Dashboard dashboard {:name "Smart Linked Dashboard"}
:model/Table table {:name "linked_table"}]
(t2/update! :model/Document :id (u/the-id document) {:document {:type "doc"
:content [{:type "cardEmbed"
:attrs {:id (u/the-id card)}}
{:type "smartLink"
:attrs {:entityId (u/the-id linked-card)
:model "card"}}
{:type "smartLink"
:attrs {:entityId (u/the-id table)
:model "table"}}
{:type "smartLink"
:attrs {:entityId (u/the-id dashboard)
:model "dashboard"}}]}})
(testing "document extraction"
(let [ser (ts/extract-one "Document" (u/the-id document))]
(is (=? {:serdes/meta [{:model "Document" :id (:entity_id document)}]
:name "Test Document"
:entity_id (:entity_id document)
:document {:type "doc"
:content [{:type "cardEmbed"
:attrs {:id [{:model "Card" :id (:entity_id card)}]}}
{:type "smartLink"
:attrs {:entityId [{:model "Card" :id (:entity_id linked-card)}]
:model "card"}}
{:type "smartLink"
:attrs {:entityId (serdes/generate-path "Table" table)
:model "table"}}
{:type "smartLink"
:attrs {:entityId [{:model "Dashboard" :id (:entity_id dashboard)}]
:model "dashboard"}}]}
:archived false
:archived_directly false
:creator_id (:email user)
:collection_id (:entity_id collection)
:content_type "application/json+vnd.prose-mirror"
:updated_at string?
:created_at string?}
ser))
(is (not (contains? ser :id)))
(testing "depends on its collection, cardEmbeds and smarkLinks "
(is (= #{[{:model "Collection" :id (:entity_id collection)}]
[{:model "Card" :id (:entity_id card)}]
[{:model "Dashboard" :id (:entity_id dashboard)}]
[{:model "Card" :id (:entity_id linked-card)}]
(serdes/generate-path "Table" table)}
(set (serdes/dependencies ser))))))))))
(deftest visualizer-dashboard-card-settings-test
(testing "visualizer settings transform entity IDs <-> card IDs"
(let [card-entity-id "WcMlLFNVcy0iO49mKW3WH"

View File

@@ -0,0 +1,54 @@
import type {
CreateDocumentRequest,
DeleteDocumentRequest,
Document,
GetDocumentRequest,
UpdateDocumentRequest,
} from "metabase-types/api";
import { EnterpriseApi } from "./api";
import { idTag, listTag } from "./tags";
export const documentApi = EnterpriseApi.injectEndpoints({
endpoints: (builder) => ({
getDocument: builder.query<Document, GetDocumentRequest>({
query: ({ id }) => ({
method: "GET",
url: `/api/ee/document/${id}`,
}),
providesTags: (result, error, { id }) =>
!error ? [idTag("document", id)] : [],
}),
createDocument: builder.mutation<Document, CreateDocumentRequest>({
query: (body) => ({
method: "POST",
url: "/api/ee/document",
body,
}),
invalidatesTags: (_, error) => (error ? [] : [listTag("document")]), // TODO: invalidate parent collection?
}),
updateDocument: builder.mutation<Document, UpdateDocumentRequest>({
query: (document) => ({
method: "PUT",
url: `/api/ee/document/${document.id}`,
body: document,
}),
invalidatesTags: (_, error, { id }) =>
!error ? [listTag("document"), idTag("document", id)] : [],
}),
deleteDocument: builder.mutation<void, DeleteDocumentRequest>({
query: (document) => ({
method: "DELETE",
url: `/api/ee/document/${document.id}`,
}),
invalidatesTags: (_, error, { id }) =>
!error ? [listTag("document"), idTag("document", id)] : [],
}),
}),
});
export const {
useGetDocumentQuery,
useCreateDocumentMutation,
useUpdateDocumentMutation,
} = documentApi;

View File

@@ -3,10 +3,11 @@ export * from "./ai-sql-generation";
export * from "./audit-info";
export * from "./api";
export * from "./billing-info";
export * from "./metabot";
export * from "./content-translation";
export * from "./gdrive";
export * from "./db-routing";
export * from "./gdrive";
export * from "./metabot";
export * from "./document";
export * from "./saml";
export * from "./scim";
export * from "./tags";

View File

@@ -1,6 +1,8 @@
import type {
DeleteSuggestedMetabotPromptRequest,
MetabotApiEntity,
MetabotDocumentNodeRequest,
MetabotDocumentNodeResponse,
MetabotEntity,
MetabotId,
MetabotInfo,
@@ -111,6 +113,16 @@ export const metabotApi = EnterpriseApi.injectEndpoints({
invalidatesTags: (_, error, metabot_id) =>
!error ? [idTag("metabot-prompt-suggestions", metabot_id)] : [],
}),
metabotDocumentNode: builder.query<
MetabotDocumentNodeResponse,
MetabotDocumentNodeRequest
>({
query: (params) => ({
method: "POST",
url: "/api/ee/metabot-v3/document/generate-content",
params,
}),
}),
}),
});
@@ -122,4 +134,5 @@ export const {
useGetSuggestedMetabotPromptsQuery,
useDeleteSuggestedMetabotPromptMutation,
useRegenerateSuggestedMetabotPromptsMutation,
useLazyMetabotDocumentNodeQuery,
} = metabotApi;

View File

@@ -6,6 +6,7 @@ export const ENTERPRISE_TAG_TYPES = [
"metabot-entities-list",
"metabot-prompt-suggestions",
"gsheets-status",
"document",
] as const;
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];

View File

@@ -0,0 +1,8 @@
import { push } from "react-router-redux";
import type { Dispatch } from "metabase-types/store";
export const navigateBackToDocument =
(documentId: number) => (dispatch: Dispatch) => {
dispatch(push(`/document/${documentId}`));
};

View File

@@ -0,0 +1,40 @@
import { t } from "ttag";
import { deletePermanently } from "metabase/archive/actions";
import { ArchivedEntityBanner } from "metabase/archive/components/ArchivedEntityBanner/ArchivedEntityBanner";
import Bookmarks from "metabase/entities/bookmarks";
import { useDispatch } from "metabase/lib/redux";
import Documents from "metabase-enterprise/entities/document";
import { useDocumentsSelector } from "../redux-utils";
import { getCurrentDocument } from "../selectors";
export const DocumentArchivedEntityBanner = () => {
const dispatch = useDispatch();
const document = useDocumentsSelector(getCurrentDocument);
if (!document) {
return null;
}
return (
<ArchivedEntityBanner
name={document.name}
entityType={t`document`}
canMove={document.can_write}
canRestore={document.can_restore}
canDelete={document.can_delete}
onUnarchive={async () => {
await dispatch(Documents.actions.setArchived(document, false));
await dispatch(Bookmarks.actions.invalidateLists());
}}
onMove={({ id }) =>
dispatch(Documents.actions.setCollection(document, { id }))
}
onDeletePermanently={() => {
const deleteAction = Documents.actions.delete({ id: document.id });
dispatch(deletePermanently(deleteAction));
}}
/>
);
};

View File

@@ -0,0 +1,66 @@
import { type HTMLAttributes, useCallback } from "react";
import { Link } from "react-router";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import { ActionIcon, type ActionIconProps, Icon, Tooltip } from "metabase/ui";
import { navigateBackToDocument } from "metabase-enterprise/documents/actions";
import { useDocumentsSelector } from "metabase-enterprise/documents/redux-utils";
import {
getDocument,
getShowNavigateBackToDocumentButton,
} from "metabase-enterprise/documents/selectors";
export type DocumentBackButtonProps = {
noLink?: boolean;
onClick?: () => void;
documentOverride?: {
id: number;
name: string;
};
} & ActionIconProps &
HTMLAttributes<HTMLButtonElement>;
export function DocumentBackButton({
noLink,
onClick,
documentOverride,
...actionIconProps
}: DocumentBackButtonProps) {
const documentState = useDocumentsSelector(getDocument);
const showButton = useDocumentsSelector(getShowNavigateBackToDocumentButton);
const document = documentOverride ?? documentState;
const dispatch = useDispatch();
const handleClick = useCallback(() => {
if (document) {
dispatch(navigateBackToDocument(document.id));
}
onClick?.();
}, [document, dispatch, onClick]);
if (!document || !showButton) {
return null;
}
const label = t`Back to ${document.name}`;
return (
<Tooltip label={label}>
<ActionIcon
variant="outline"
radius="xl"
size="2.625rem"
color="border"
aria-label={label}
onClick={handleClick}
component={noLink ? undefined : Link}
to={`/document/${document.id}`}
{...actionIconProps}
>
<Icon c="brand" name="arrow_left" />
</ActionIcon>
</Tooltip>
);
}

View File

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

View File

@@ -0,0 +1,148 @@
.documentPage {
display: flex;
flex-direction: column;
height: calc(100vh - var(--nav-sidebar-height, 60px));
background-color: var(--mb-color-bg-white);
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 1rem 0;
background-color: var(--mb-color-bg-white);
gap: 1rem;
max-width: 900px;
margin: 0 auto;
width: 100%;
}
.titleInput {
flex: 1;
max-width: 600px;
border: none;
outline: none;
background: transparent;
font-size: 2rem;
font-weight: 700;
line-height: 1.2;
color: var(--mb-color-text-dark);
font-family: inherit;
padding: 0;
margin: 0;
}
.titleInput::placeholder {
color: var(--mb-color-text-light);
opacity: 0.7;
}
.titleInput:focus {
outline: none;
}
.contentArea {
display: flex;
flex: 1;
overflow: hidden;
}
.mainContent {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
padding: 0 2rem;
}
.documentContainer {
width: 100%;
padding: 0 2rem 4rem;
min-height: 100%;
}
@media print {
body:has(.documentContainer) {
visibility: hidden;
/* side nav */
aside {
display: none;
}
}
.documentContainer {
position: absolute;
visibility: visible;
top: 0;
right: 0;
z-index: 1000;
.header {
padding: 0;
}
:global(.node-cardEmbed) {
break-inside: avoid;
}
[data-hide-on-print] {
visibility: hidden;
}
}
}
.sidebar {
width: 360px;
flex-shrink: 0;
background-color: var(--mb-color-bg-light);
border-left: 1px solid var(--mb-color-border);
overflow-y: auto;
}
.cardEmbed {
padding: 0.5rem;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s;
}
.cardEmbed:hover {
background-color: var(--mb-color-bg-medium);
}
.highlighted {
animation: highlight 2s ease-out;
}
@keyframes highlight {
0% {
background-color: var(--mb-color-brand-light);
box-shadow: 0 0 0 4px var(--mb-color-brand-light);
}
100% {
background-color: transparent;
box-shadow: none;
}
}
.metadataItem {
display: flex;
align-items: center;
gap: 0.25rem;
color: var(--mb-color-text-secondary);
}
.hidable {
visibility: hidden;
width: 0;
opacity: 0;
transition: opacity 200ms;
&.visible {
visibility: visible;
width: auto;
opacity: 1;
}
}

View File

@@ -0,0 +1,558 @@
import type { Editor as TiptapEditor } from "@tiptap/core";
import cx from "classnames";
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import type { Route } from "react-router";
import { push, replace } from "react-router-redux";
import { usePrevious, useUnmount } from "react-use";
import useBeforeUnload from "react-use/lib/useBeforeUnload";
import { t } from "ttag";
import _ from "underscore";
import {
skipToken,
useCreateBookmarkMutation,
useDeleteBookmarkMutation,
useListBookmarksQuery,
} from "metabase/api";
import { canonicalCollectionId } from "metabase/collections/utils";
import DateTime, {
getFormattedTime,
} from "metabase/common/components/DateTime";
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
import { CollectionPickerModal } from "metabase/common/components/Pickers/CollectionPicker";
import { useToast } from "metabase/common/hooks";
import { useCallbackEffect } from "metabase/common/hooks/use-callback-effect";
import { useDispatch } from "metabase/lib/redux";
import { extractEntityId } from "metabase/lib/urls";
import { setErrorPage } from "metabase/redux/app";
import {
ActionIcon,
Box,
Button,
Flex,
Icon,
Menu,
Text,
TextInput,
Tooltip,
} from "metabase/ui";
import {
useCreateDocumentMutation,
useGetDocumentQuery,
useUpdateDocumentMutation,
} from "metabase-enterprise/api";
import type {
Card,
CollectionId,
RegularCollectionId,
} from "metabase-types/api";
import {
clearDraftCards,
closeSidebar,
openVizSettingsSidebar,
resetDocuments,
setCurrentDocument,
} from "../documents.slice";
import { useDocumentState, useRegisterDocumentMetabotContext } from "../hooks";
import { useDocumentsSelector } from "../redux-utils";
import {
getDraftCards,
getSelectedEmbedIndex,
getSelectedQuestionId,
} from "../selectors";
import { DocumentArchivedEntityBanner } from "./DocumentArchivedEntityBanner";
import styles from "./DocumentPage.module.css";
import { Editor } from "./Editor";
import { EmbedQuestionSettingsSidebar } from "./EmbedQuestionSettingsSidebar";
export const DocumentPage = ({
params: { entityId },
route,
}: {
params: { entityId?: string };
location?: { query?: { version?: string } };
route: Route;
}) => {
const dispatch = useDispatch();
const selectedQuestionId = useDocumentsSelector(getSelectedQuestionId);
const selectedEmbedIndex = useDocumentsSelector(getSelectedEmbedIndex);
const draftCards = useDocumentsSelector(getDraftCards);
const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(
null,
);
const [hasUnsavedEditorChanges, setHasUnsavedEditorChanges] = useState(false);
const [createDocument, { isLoading: isCreating }] =
useCreateDocumentMutation();
const [updateDocument, { isLoading: isUpdating }] =
useUpdateDocumentMutation();
const [collectionPickerMode, setCollectionPickerMode] = useState<
"save" | "move" | null
>(null);
const [sendToast] = useToast();
const documentId = entityId === "new" ? "new" : extractEntityId(entityId);
const previousDocumentId = usePrevious(documentId);
const [isNavigationScheduled, scheduleNavigation] = useCallbackEffect();
const isNewDocument = documentId === "new";
const { data: bookmarks = [] } = useListBookmarksQuery(undefined, {
skip: isNewDocument,
});
const [createBookmark] = useCreateBookmarkMutation();
const [deleteBookmark] = useDeleteBookmarkMutation();
const isBookmarked = Boolean(
bookmarks.find(
({ type, item_id }) => type === "document" && item_id === documentId,
),
);
let {
data: documentData,
isLoading: isDocumentLoading,
error,
} = useGetDocumentQuery(
documentId && !isNewDocument ? { id: documentId } : skipToken,
);
if (documentId !== documentData?.id) {
documentData = undefined;
}
const canWrite = isNewDocument ? true : documentData?.can_write;
useEffect(() => {
if (error) {
dispatch(setErrorPage(error));
}
}, [dispatch, error]);
const {
documentTitle,
setDocumentTitle,
documentContent,
setDocumentContent,
updateCardEmbeds,
} = useDocumentState(documentData);
// This is important as it will affect collection breadcrumbs in the appbar
useUnmount(() => {
dispatch(resetDocuments());
});
useRegisterDocumentMetabotContext();
useBeforeUnload(() => {
// warn if you try to navigate away with unsaved changes
return hasUnsavedChanges();
});
// Reset state when document changes
useEffect(() => {
if (documentId !== previousDocumentId) {
setHasUnsavedEditorChanges(false);
if (isNewDocument && previousDocumentId !== "new") {
setDocumentTitle("");
setDocumentContent(null);
dispatch(resetDocuments());
}
}
}, [
documentId,
previousDocumentId,
isNewDocument,
setDocumentTitle,
setDocumentContent,
dispatch,
]);
// Reset dirty state when document content loads from API
useEffect(() => {
if (documentContent && !isNewDocument) {
setHasUnsavedEditorChanges(false);
}
}, [documentContent, isNewDocument]);
useEffect(() => {
// Set current document when document loads (includes collection_id and all other data)
if (documentData && !isNewDocument) {
dispatch(setCurrentDocument(documentData));
} else if (isNewDocument) {
dispatch(setCurrentDocument(null));
}
}, [documentData, documentId, dispatch, isNewDocument]);
const hasUnsavedChanges = useCallback(() => {
const currentTitle = documentTitle.trim();
const originalTitle = documentData?.name || "";
const titleChanged = currentTitle !== originalTitle;
// Check if there are any draft cards
const hasDraftCards = Object.keys(draftCards).length > 0;
// For new documents, show Save if there's title or editor changes or draft cards
if (isNewDocument) {
return (
currentTitle.length > 0 || hasUnsavedEditorChanges || hasDraftCards
);
}
// For existing documents, use simple change tracking
return titleChanged || hasUnsavedEditorChanges || hasDraftCards;
}, [
documentTitle,
isNewDocument,
documentData,
hasUnsavedEditorChanges,
draftCards,
]);
const isSaving = isCreating || isUpdating;
const showSaveButton = hasUnsavedChanges() && canWrite && !isSaving;
const handleContentChanged = useCallback(() => {
if (!editorInstance) {
return;
}
// Compare current content with original content
const currentContent = editorInstance.getJSON();
const originalContent = documentContent;
// For new documents, any content means changes
if (isNewDocument) {
setHasUnsavedEditorChanges(!editorInstance.isEmpty);
return;
}
// For existing documents, compare with original content
const hasChanges = !_.isEqual(currentContent, originalContent);
setHasUnsavedEditorChanges(hasChanges);
}, [editorInstance, documentContent, isNewDocument]);
const handleToggleBookmark = useCallback(() => {
if (!documentId) {
return;
}
isBookmarked
? deleteBookmark({ type: "document", id: documentId })
: createBookmark({ type: "document", id: documentId });
}, [isBookmarked, deleteBookmark, createBookmark, documentId]);
const handleSave = useCallback(
async (collectionId: RegularCollectionId | null = null) => {
if (!editorInstance || isSaving) {
return;
}
try {
const cardsToSave: Record<number, Card> = {};
const processedCardIds = new Set<number>();
editorInstance.state.doc.descendants((node: any) => {
if (node.type.name === "cardEmbed") {
const cardId = node.attrs.id;
if (!processedCardIds.has(cardId)) {
processedCardIds.add(cardId);
if (cardId < 0 && draftCards[cardId]) {
cardsToSave[cardId] = draftCards[cardId];
}
}
}
});
const documentAst = editorInstance ? editorInstance.getJSON() : null;
const name =
documentTitle ||
t`Untitled document - ${dayjs().local().format("MMMM D, YYYY")}`;
const newDocumentData: any = {
name,
document: documentAst,
cards: Object.keys(cardsToSave).length > 0 ? cardsToSave : undefined,
};
const result = await (documentData?.id
? updateDocument({ ...newDocumentData, id: documentData.id }).then(
(response) => {
if (response.data) {
scheduleNavigation(() => {
dispatch(push(`/document/${response.data.id}`));
});
}
return response.data;
},
)
: createDocument({
...newDocumentData,
collection_id: collectionId || undefined,
}).then((response) => {
if (response.data) {
scheduleNavigation(() => {
dispatch(replace(`/document/${response.data.id}`));
});
}
return response.data;
}));
if (result) {
sendToast({
message: documentData?.id ? t`Document saved` : t`Document created`,
});
dispatch(clearDraftCards());
// Mark document as clean
setHasUnsavedEditorChanges(false);
}
} catch (error) {
console.error("Failed to save document:", error);
sendToast({ message: t`Error saving document`, icon: "warning" });
}
},
[
editorInstance,
isSaving,
documentTitle,
draftCards,
documentData?.id,
updateDocument,
createDocument,
scheduleNavigation,
dispatch,
sendToast,
],
);
const handleUpdate = async (payload: {
collection_id?: CollectionId | null;
archived?: boolean;
}) => {
if (documentData?.id) {
await updateDocument({ id: documentData.id, ...payload });
setCollectionPickerMode(null);
}
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
// Save shortcut: Cmd+S (Mac) or Ctrl+S (Windows/Linux)
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
event.preventDefault();
if (!hasUnsavedChanges() || !canWrite) {
return;
}
isNewDocument ? setCollectionPickerMode("save") : handleSave();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
hasUnsavedChanges,
handleSave,
isNewDocument,
setCollectionPickerMode,
canWrite,
]);
const handleQuestionSelect = useCallback(
(cardId: number | null, embedIndex?: number | null) => {
if (
cardId !== null &&
embedIndex !== null &&
embedIndex !== undefined &&
embedIndex >= 0 &&
selectedEmbedIndex !== null
) {
// Only update the selected embed index if the sidebar is already open
dispatch(openVizSettingsSidebar({ embedIndex }));
}
},
[dispatch, selectedEmbedIndex],
);
const handlePrintDocument = useCallback(() => {
window.print();
}, []);
return (
<Box className={styles.documentPage}>
{documentData?.archived && <DocumentArchivedEntityBanner />}
<Box className={styles.contentArea}>
<Box className={styles.mainContent}>
<Box className={styles.documentContainer}>
<Box className={styles.header} mt="xl" pt="xl">
<Flex direction="column" w="100%">
<TextInput
aria-label={t`Document Title`}
autoFocus={isNewDocument}
value={documentTitle}
onChange={(event) =>
setDocumentTitle(event.currentTarget.value)
}
flex={1}
placeholder={t`New document`}
readOnly={!canWrite}
styles={{
input: {
border: "none",
padding: 0,
fontSize: "2rem",
fontWeight: "bold",
},
}}
/>
{documentData && (
<Flex gap="md">
<Text className={styles.metadataItem}>
<Icon name="person" />
{documentData.creator.common_name}{" "}
</Text>
<Tooltip
label={getFormattedTime(
documentData.updated_at,
"default",
{ local: true },
)}
>
<Text className={styles.metadataItem}>
<Icon name="clock" />
<DateTime value={documentData.updated_at} unit="day" />
</Text>
</Tooltip>
</Flex>
)}
</Flex>
<Flex gap="md" align="center">
<Box
className={cx(styles.hidable, {
[styles.visible]: showSaveButton,
})}
>
<Button
onClick={() => {
isNewDocument
? setCollectionPickerMode("save")
: handleSave();
}}
variant="filled"
data-hide-on-print
>
{t`Save`}
</Button>
</Box>
<Menu position="bottom-end">
<Menu.Target>
<ActionIcon
variant="subtle"
size="md"
aria-label={t`More options`}
data-hide-on-print
>
<Icon name="ellipsis" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Icon name="document" />}
onClick={handlePrintDocument}
>
{t`Print Document`}
</Menu.Item>
{!isNewDocument && (
<>
{canWrite && (
<Menu.Item
leftSection={<Icon name="move" />}
onClick={() => setCollectionPickerMode("move")}
>
{t`Move`}
</Menu.Item>
)}
<Menu.Item
leftSection={<Icon name={"bookmark"} />}
onClick={handleToggleBookmark}
>
{isBookmarked
? t`Remove from Bookmarks`
: t`Bookmark`}
</Menu.Item>
{canWrite && (
<>
<Menu.Divider />
<Menu.Item
leftSection={<Icon name="trash" />}
onClick={() => handleUpdate({ archived: true })}
>
{t`Move to trash`}
</Menu.Item>
</>
)}
</>
)}
</Menu.Dropdown>
</Menu>
</Flex>
</Box>
<Editor
onEditorReady={setEditorInstance}
onCardEmbedsChange={updateCardEmbeds}
onQuestionSelect={handleQuestionSelect}
initialContent={documentContent}
onContentChanged={handleContentChanged}
editable={canWrite}
isLoading={isDocumentLoading}
/>
</Box>
</Box>
{selectedQuestionId &&
selectedEmbedIndex !== null &&
editorInstance && (
<Box className={styles.sidebar} data-testid="document-card-sidebar">
<EmbedQuestionSettingsSidebar
cardId={selectedQuestionId}
onClose={() => dispatch(closeSidebar())}
editorInstance={editorInstance}
/>
</Box>
)}
{collectionPickerMode && (
<CollectionPickerModal
title={t`Where should we save this document?`}
onClose={() => setCollectionPickerMode(null)}
value={{ id: "root", model: "collection" }}
options={{
showPersonalCollections: true,
showRootCollection: true,
}}
onChange={async (collection) => {
if (collectionPickerMode === "save") {
handleSave(canonicalCollectionId(collection.id));
setCollectionPickerMode(null);
} else if (collectionPickerMode === "move") {
(await collection) &&
handleUpdate({
collection_id: canonicalCollectionId(collection.id),
});
}
}}
/>
)}
<LeaveRouteConfirmModal
// `key` remounts this modal when navigating between different documents or to a new document.
// The `route` doesn't change in that scenario which prevents the modal from closing when you confirm you want to discard your changes.
key={documentId}
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
route={route}
/>
</Box>
</Box>
);
};

View File

@@ -0,0 +1,228 @@
.editor {
height: 100%;
display: flex;
flex-direction: column;
}
.editorContent {
flex: 1;
cursor: text;
position: relative;
}
.editorContent :global(.ProseMirror) {
padding: 1.5rem 0 2rem 0;
min-height: 100%;
outline: none;
max-width: 900px;
margin: 0 auto;
color: var(--mb-color-text-dark);
font-size: 1rem;
line-height: 1.5;
}
/* Apply typography styles only to elements NOT inside .editor-style-boundary */
.editorContent
:global(.ProseMirror)
:where(:global(h1), :global(h2), :global(h3)):not(
:global(.editor-style-boundary) *
) {
font-weight: 700;
color: var(--mb-color-text-dark);
}
.editorContent
:global(.ProseMirror)
:global(h1):not(:global(.editor-style-boundary) *) {
font-size: 1.75rem;
margin: 1.5rem 0 1rem;
line-height: 1.3;
}
.editorContent
:global(.ProseMirror)
:global(h2):not(:global(.editor-style-boundary) *) {
font-size: 1.375rem;
margin: 1.25rem 0 0.75rem;
line-height: 1.3;
}
.editorContent
:global(.ProseMirror)
:global(h3):not(:global(.editor-style-boundary) *) {
font-size: 1.125rem;
margin: 1rem 0 0.5rem;
line-height: 1.4;
}
.editorContent
:global(.ProseMirror)
:global(p):not(:global(.editor-style-boundary) *) {
margin: 0 0 1rem 0;
line-height: 1.5;
font-size: 1rem;
}
.editorContent
:global(.ProseMirror)
:where(:global(ul), :global(ol)):not(:global(.editor-style-boundary) *) {
margin: 0 0 1rem 0;
padding-left: 1.5rem;
}
.editorContent
:global(.ProseMirror)
:global(ul):not(:global(.editor-style-boundary) *) {
list-style-type: disc;
}
.editorContent
:global(.ProseMirror)
:global(ol):not(:global(.editor-style-boundary) *) {
list-style-type: decimal;
}
.editorContent
:global(.ProseMirror)
:global(li):not(:global(.editor-style-boundary) *) {
margin: 0 0 0.25rem 0;
line-height: 1.5;
display: list-item;
}
.editorContent
:global(.ProseMirror)
:global(ul ul):not(:global(.editor-style-boundary) *) {
list-style-type: circle;
margin-top: 0.25rem;
}
.editorContent
:global(.ProseMirror)
:global(ul ul ul):not(:global(.editor-style-boundary) *) {
list-style-type: square;
}
.editorContent
:global(.ProseMirror)
:where(:global(li > ul), :global(li > ol)):not(
:global(.editor-style-boundary) *
) {
margin-top: 0.25rem;
}
.editorContent
:global(.ProseMirror)
:global(li p):not(:global(.editor-style-boundary) *) {
line-height: 1.5;
margin-bottom: 0.25rem;
}
.editorContent
:global(.ProseMirror)
:global(code):not(:global(.editor-style-boundary) *) {
background-color: var(--mb-base-color-orion-5);
padding: 0.125rem 0.375rem;
border-radius: var(--mantine-radius-xs);
font-family: var(--mb-default-monospace-font-family, monospace);
font-size: 0.875rem;
color: var(--mb-color-text-dark);
border: 1px solid var(--mb-base-color-orion-10);
}
.editorContent
:global(.ProseMirror)
:global(pre):not(:global(.editor-style-boundary) *) {
background-color: var(--mb-base-color-gray-10);
color: var(--mb-color-text-dark);
padding: 1rem;
border-radius: var(--mantine-radius-sm);
overflow-x: auto;
margin: 0 0 1rem 0;
font-size: 0.875rem;
border: 1px solid var(--mb-base-color-gray-20);
}
.editorContent
:global(.ProseMirror)
:global(pre code):not(:global(.editor-style-boundary) *) {
background: none;
padding: 0;
color: inherit;
font-size: inherit;
border: none;
}
.editorContent
:global(.ProseMirror)
:global(blockquote):not(:global(.editor-style-boundary) *) {
border-left: 3px solid var(--mb-base-color-orion-20);
margin: 0 0 1rem 0;
color: var(--mb-color-text-medium);
font-style: italic;
background-color: var(--mb-base-color-orion-5);
padding: 1rem;
padding-left: 1.25rem;
border-radius: var(--mantine-radius-xs);
}
.editorContent
:global(.ProseMirror)
:global(hr):not(:global(.editor-style-boundary) *) {
border: none;
border-top: 1px solid var(--mb-color-border);
margin: 1.5rem 0;
}
.editorContent :global(.ProseMirror:focus) {
outline: none;
}
.editorContent :global(p.is-empty:first-child:before) {
color: var(--mb-color-text-light);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
font-size: 1rem;
}
.img {
max-width: 80%;
display: block;
margin: 1rem auto;
&:global(.ProseMirror-selectednode) {
box-shadow: 0 0 0 1px var(--mb-color-brand);
border-radius: var(--mantine-radius-xs);
}
}
.suggestionPopup {
width: 320px;
min-height: 50px;
max-height: 400px;
overflow: hidden;
background: var(--mb-color-bg-white);
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
box-shadow: 0 2px 8px var(--mb-color-shadow);
}
.suggestionScroll {
max-height: 400px;
overflow-y: auto;
}
.suggestionMenuItem {
cursor: pointer;
background-color: transparent;
}
.suggestionMenuItem[data-selected] {
background-color: var(--mb-color-bg-light);
}
.suggestionMenuFooter {
border-top: 1px solid var(--mb-color-border);
}

View File

@@ -0,0 +1,52 @@
import type { Store } from "@reduxjs/toolkit";
import type { StoryFn } from "@storybook/react/*";
import _ from "underscore";
import { mockSettings } from "__support__/settings";
import { createMockEntitiesState } from "__support__/store";
import { Api } from "metabase/api";
import { MetabaseReduxProvider } from "metabase/lib/redux";
import { publicReducers } from "metabase/reducers-public";
import { getStore } from "metabase/store";
import type { State } from "metabase-types/store";
import { createMockState } from "metabase-types/store/mocks";
import { Editor } from "./Editor";
import Data from "./data/data.json";
const storeInitialState = createMockState({
settings: mockSettings(),
entities: createMockEntitiesState({}),
});
const publicReducerNames = Object.keys(publicReducers);
const initialState = _.pick(storeInitialState, ...publicReducerNames) as State;
const reducers = publicReducers;
const storeMiddleware = [Api.middleware];
const store = getStore(
reducers,
initialState,
storeMiddleware,
) as unknown as Store<State>;
const ReduxDecorator = (Story: StoryFn) => {
return (
<MetabaseReduxProvider store={store}>
<Story />
</MetabaseReduxProvider>
);
};
const DefaultTemplate = () => <Editor initialContent={Data.markdownTest} />;
export default {
title: "Components/Documents",
component: Editor,
decorators: [ReduxDecorator],
layout: "fullscreen",
};
export const Default = {
render: DefaultTemplate,
};

View File

@@ -0,0 +1,239 @@
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import { Placeholder } from "@tiptap/extension-placeholder";
import type { EditorState } from "@tiptap/pm/state";
import type { JSONContent, Editor as TiptapEditor } from "@tiptap/react";
import { EditorContent, useEditor } from "@tiptap/react";
import StarterKit from "@tiptap/starter-kit";
import cx from "classnames";
import type React from "react";
import { useEffect, useMemo } from "react";
import { t } from "ttag";
import { DND_IGNORE_CLASS_NAME } from "metabase/common/components/dnd";
import CS from "metabase/css/core/index.css";
import { useSelector } from "metabase/lib/redux";
import { getSetting } from "metabase/selectors/settings";
import { Box, Loader } from "metabase/ui";
import { useDocumentsStore } from "metabase-enterprise/documents/redux-utils";
import { getMentionsCache } from "metabase-enterprise/documents/selectors";
import type { DocumentsStoreState } from "metabase-enterprise/documents/types";
import { getMentionsCacheKey } from "metabase-enterprise/documents/utils/mentionsUtils";
import styles from "./Editor.module.css";
import { EditorBubbleMenu } from "./EditorBubbleMenu";
import { CardEmbed } from "./extensions/CardEmbed";
import { CommandExtension } from "./extensions/Command/CommandExtension";
import { CommandSuggestion } from "./extensions/Command/CommandSuggestion";
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
import { MentionExtension } from "./extensions/Mention/MentionExtension";
import { MentionSuggestion } from "./extensions/Mention/MentionSuggestion";
import { MetabotNode, type PromptSerializer } from "./extensions/MetabotEmbed";
import { MetabotMentionExtension } from "./extensions/MetabotMention/MetabotMentionExtension";
import { MetabotMentionSuggestion } from "./extensions/MetabotMention/MetabotSuggestion";
import { SmartLinkEmbed } from "./extensions/SmartLink";
import { Markdown } from "./extensions/markdown/index";
import { createSuggestionRenderer } from "./extensions/suggestionRenderer";
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
import type { CardEmbedRef } from "./types";
const getMetabotPromptSerializer =
(getState: () => DocumentsStoreState): PromptSerializer =>
(node) => {
const payload: ReturnType<PromptSerializer> = { instructions: "" };
return node.content.content.reduce((acc, child) => {
// Serialize @ mentions in the metabot prompt
if (child.type.name === SmartLinkEmbed.name) {
const { model, entityId } = child.attrs;
const key = getMentionsCacheKey({ model, entityId });
const value = getMentionsCache(getState())[key];
if (!value) {
return acc;
}
acc.instructions += `[${value.name}](${key})`;
if (!acc.references) {
acc.references = {};
}
acc.references[key] = value.name;
} else {
acc.instructions += child.textContent;
}
return acc;
}, payload);
};
const isMetabotBlock = (state: EditorState): boolean =>
state.selection.$head.parent.type.name === "metabot";
interface EditorProps {
onEditorReady?: (editor: TiptapEditor) => void;
onCardEmbedsChange?: (refs: CardEmbedRef[]) => void;
initialContent?: JSONContent | null;
onChange?: (content: JSONContent) => void;
onContentChanged?: () => void; // Simple dirty flag callback
onQuestionSelect?: (cardId: number | null) => void;
editable?: boolean;
isLoading?: boolean;
}
export const Editor: React.FC<EditorProps> = ({
onEditorReady,
onCardEmbedsChange,
initialContent,
onChange,
onContentChanged,
editable = true,
onQuestionSelect,
isLoading = false,
}) => {
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
const { getState } = useDocumentsStore();
const extensions = useMemo(
() => [
StarterKit,
Image.configure({
inline: false,
HTMLAttributes: {
class: styles.img,
},
}),
SmartLinkEmbed.configure({
HTMLAttributes: {
class: "smart-link",
},
siteUrl,
}),
Link.configure({
HTMLAttributes: {
class: CS.link,
},
}),
Placeholder.configure({
placeholder: t`Start writing, press "/" to open command palette, or "@" to insert a link...`,
}),
Markdown,
CardEmbed,
MetabotNode.configure({
serializePrompt: getMetabotPromptSerializer(getState),
}),
DisableMetabotSidebar,
MentionExtension.configure({
suggestion: {
allow: ({ state }) => !isMetabotBlock(state),
render: createSuggestionRenderer(MentionSuggestion),
},
}),
CommandExtension.configure({
suggestion: {
allow: ({ state }) => !isMetabotBlock(state),
render: createSuggestionRenderer(CommandSuggestion),
},
}),
MetabotMentionExtension.configure({
suggestion: {
allow: ({ state }) => isMetabotBlock(state),
render: createSuggestionRenderer(MetabotMentionSuggestion),
},
}),
],
[siteUrl, getState],
);
const editor = useEditor(
{
extensions,
content: initialContent || "",
autofocus: false,
immediatelyRender: false,
onUpdate: ({ editor }) => {
const currentContent = editor.getJSON();
onChange?.(currentContent);
// Simple content changed notification
if (!editor.isEmpty) {
onContentChanged?.();
}
},
},
[],
);
// Handle content updates when initialContent changes
useEffect(() => {
if (editor && initialContent !== undefined) {
// Use Promise.resolve() to avoid flushSync warning
Promise.resolve().then(() => {
editor.commands.setContent(initialContent || "");
});
}
}, [editor, initialContent]);
// Notify parent when editor is ready
useEffect(() => {
if (editor && onEditorReady) {
onEditorReady(editor);
}
}, [editor, onEditorReady]);
// Update editor editable state
useEffect(() => {
if (editor) {
editor.setEditable(editable);
}
}, [editor, editable]);
useCardEmbedsTracking(editor, onCardEmbedsChange);
useQuestionSelection(editor, onQuestionSelect);
if (!editor) {
return null;
}
if (isLoading) {
return (
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
<Loader />
</Box>
);
}
return (
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
<Box
className={styles.editorContent}
onClick={(e) => {
// Focus editor when clicking on empty space
const target = e.target as HTMLElement;
if (
target.classList.contains(styles.editorContent) ||
target.classList.contains("ProseMirror")
) {
const clickY = e.clientY;
const proseMirrorElement = target.querySelector(".ProseMirror");
if (proseMirrorElement) {
const proseMirrorRect =
proseMirrorElement.getBoundingClientRect();
const isClickBelowContent = clickY > proseMirrorRect.bottom;
if (isClickBelowContent) {
// Only move to end if clicking below the actual content
editor.commands.focus("end");
} else {
// Just focus without changing cursor position for clicks in padding areas
editor.commands.focus();
}
} else {
// Fallback: just focus without position change
editor.commands.focus();
}
}
}}
>
<EditorContent data-testid="document-content" editor={editor} />
<EditorBubbleMenu editor={editor} />
</Box>
</Box>
);
};

View File

@@ -0,0 +1,156 @@
/* eslint-disable import/no-unresolved */ // -- this is a valid import for Tiptap's BubbleMenu
import type { EditorState } from "@tiptap/pm/state";
import type { Editor as TiptapEditor } from "@tiptap/react";
// @ts-expect-error - BubbleMenu is a Tiptap extension that is registered through @tiptap/extension-bubble-menu
import { BubbleMenu } from "@tiptap/react/menus";
import type React from "react";
import { t } from "ttag";
import { Flex } from "metabase/ui";
import { FormatButton } from "./FormatButton";
interface EditorBubbleMenuProps {
editor: TiptapEditor;
}
export const EditorBubbleMenu: React.FC<EditorBubbleMenuProps> = ({
editor,
}) => {
return (
<BubbleMenu
editor={editor}
shouldShow={({
editor,
state,
from,
to,
}: {
editor: TiptapEditor;
state: EditorState;
from: number;
to: number;
}) => {
// Don't show bubble menu if nothing is selected
const { empty } = state.selection;
if (empty) {
return false;
}
// Don't show for code blocks
if (editor.isActive("codeBlock")) {
return false;
}
// Don't show bubble menu for certain node types
const disallowedNodes = ["cardEmbed", "metabot", "smartLink", "image"];
// Check if any disallowed node is active
for (const nodeName of disallowedNodes) {
if (editor.isActive(nodeName)) {
return false;
}
}
// Check if selection contains any disallowed nodes
let hasDisallowedNode = false;
state.doc.nodesBetween(from, to, (node) => {
if (
disallowedNodes.includes(node.type.name) ||
node.type.name === "codeBlock"
) {
hasDisallowedNode = true;
return false; // Stop traversing
}
});
return !hasDisallowedNode;
}}
>
<Flex
gap={4}
bg="white"
p="2px"
style={{
border: "1px solid var(--mb-color-border)",
borderRadius: "6px",
boxShadow: "0 2px 12px var(--mb-color-shadow)",
}}
data-testid="document-formatting-menu"
>
<FormatButton
isActive={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
tooltip={t`Bold`}
icon="text_bold"
/>
<FormatButton
isActive={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
tooltip={t`Italic`}
icon="text_italic"
/>
<FormatButton
isActive={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
tooltip={t`Strikethrough`}
icon="text_strike"
/>
<FormatButton
isActive={editor.isActive("code")}
onClick={() => editor.chain().focus().toggleCode().run()}
tooltip={t`Inline code`}
icon="format_code"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 1 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
tooltip={t`Heading 1`}
text="H1"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 2 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 2 }).run()
}
tooltip={t`Heading 2`}
text="H2"
/>
<FormatButton
isActive={editor.isActive("heading", { level: 3 })}
onClick={() =>
editor.chain().focus().toggleHeading({ level: 3 }).run()
}
tooltip={t`Heading 3`}
text="H3"
/>
<FormatButton
isActive={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
tooltip={t`Bullet list`}
icon="list"
/>
<FormatButton
isActive={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
tooltip={t`Numbered list`}
icon="ordered_list"
/>
<FormatButton
isActive={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
tooltip={t`Quote`}
icon="quote"
/>
<FormatButton
isActive={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
tooltip={t`Code block`}
icon="code_block"
/>
</Flex>
</BubbleMenu>
);
};

View File

@@ -0,0 +1,49 @@
import type React from "react";
import type { IconName } from "metabase/ui";
import { Icon, Tooltip, UnstyledButton } from "metabase/ui";
interface FormatButtonProps {
isActive: boolean;
onClick: () => void;
tooltip: string;
icon?: IconName;
text?: string;
}
export const FormatButton: React.FC<FormatButtonProps> = ({
isActive,
onClick,
tooltip,
icon,
text,
}) => {
return (
<Tooltip label={tooltip} position="top">
<UnstyledButton
style={{
width: "32px",
height: "32px",
borderRadius: "4px",
display: "flex",
alignItems: "center",
justifyContent: "center",
color: isActive
? "var(--mb-color-brand)"
: "var(--mb-color-text-dark)",
backgroundColor: isActive
? "var(--mb-color-bg-light)"
: "transparent",
fontSize: text ? "14px" : undefined,
fontWeight: text ? "bold" : undefined,
border: "none",
cursor: "pointer",
transition: "all 0.15s ease",
}}
onClick={onClick}
>
{icon ? <Icon name={icon} size={16} /> : text}
</UnstyledButton>
</Tooltip>
);
};

View File

@@ -0,0 +1,2 @@
// Class name used to prevent editor typography styles from affecting embedded content
export const EDITOR_STYLE_BOUNDARY_CLASS = "editor-style-boundary";

View File

@@ -0,0 +1,205 @@
{
"markdownTest": {
"type": "doc",
"content": [
{
"type": "heading",
"attrs": {
"level": 1
},
"content": [
{
"type": "text",
"text": "Heading 1"
}
]
},
{
"type": "heading",
"attrs": {
"level": 2
},
"content": [
{
"type": "text",
"text": "Heading 2"
}
]
},
{
"type": "heading",
"attrs": {
"level": 3
},
"content": [
{
"type": "text",
"text": "Heading 3"
}
]
},
{
"type": "heading",
"attrs": {
"level": 4
},
"content": [
{
"type": "text",
"text": "Heading 4"
}
]
},
{
"type": "heading",
"attrs": {
"level": 5
},
"content": [
{
"type": "text",
"text": "Heading 5"
}
]
},
{
"type": "heading",
"attrs": {
"level": 6
},
"content": [
{
"type": "text",
"text": "Heading 6"
}
]
},
{
"type": "paragraph"
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "This is a Paragraph. Inside of this, you can have "
},
{
"type": "text",
"marks": [
{
"type": "bold"
}
],
"text": "Bold Text"
},
{
"type": "text",
"text": ", "
},
{
"type": "text",
"marks": [
{
"type": "italic"
}
],
"text": "Italic Text"
},
{
"type": "text",
"text": ", and even text with "
},
{
"type": "text",
"marks": [
{
"type": "strike"
}
],
"text": "Strike Through."
},
{
"type": "text",
"text": " We can also have "
},
{
"type": "text",
"marks": [
{
"type": "code"
}
],
"text": "inline code"
},
{
"type": "text",
"text": " when we don't need a code block."
}
]
},
{
"type": "codeBlock",
"attrs": {
"language": null
},
"content": [
{
"type": "text",
"text": "Speaking of, this is what a code block would look like."
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "And if you need to "
},
{
"type": "text",
"marks": [
{
"type": "link",
"attrs": {
"href": "https://www.metabase.com/",
"target": "_blank",
"rel": "noopener noreferrer nofollow",
"class": "index__link___nQD8h"
}
}
],
"text": "link something"
},
{
"type": "text",
"text": ", well that is easy too."
}
]
},
{
"type": "paragraph",
"content": [
{
"type": "text",
"text": "In the words of Sasha -"
}
]
},
{
"type": "image",
"attrs": {
"src": "https://i.imgflip.com/1lccxa.jpg",
"alt": "High Quality Hold my beer Blank Meme Template",
"title": null,
"width": null,
"height": null
}
},
{
"type": "paragraph"
}
]
}
}

View File

@@ -0,0 +1,81 @@
.embedContainer {
margin: 1rem 0;
}
.embedWrapper {
display: block;
width: 100%;
}
.cardEmbed {
border: 1px solid var(--mb-color-border);
border-radius: var(--mantine-radius-md);
margin: 1rem 0;
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
cursor: pointer;
transition: border-color 0.2s ease;
display: flex;
flex-direction: column;
}
.cardEmbed.selected {
border-color: var(--mb-color-brand);
outline: none;
}
.cardEmbed * {
user-select: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
}
.questionHeader {
padding: 0.5rem 1rem;
}
.questionResults {
height: 400px;
position: relative;
}
.loadingContainer {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 1rem;
height: 100%;
justify-content: center;
}
.errorContainer {
padding: 1rem;
color: var(--mb-color-error);
}
.questionTimestamp {
padding: 0 1rem;
margin: 0 0 1rem;
font-size: 0.75rem;
display: flex;
justify-content: flex-end;
gap: 0.25rem;
}
.titleContainer {
display: flex;
align-items: center;
gap: 0.5rem;
}
.titleEditIcon {
opacity: 0;
transition: opacity 0.2s ease;
}
.titleContainer:hover .titleEditIcon {
opacity: 1;
}

View File

@@ -0,0 +1,505 @@
import { Node, mergeAttributes } from "@tiptap/core";
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import cx from "classnames";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import { push } from "react-router-redux";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Flex, Icon, Loader, Menu, Text, TextInput } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
import { getGenericErrorMessage } from "metabase/visualizations/lib/errors";
import Question from "metabase-lib/v1/Question";
import { getUrl } from "metabase-lib/v1/urls";
import type { Card, CardDisplayType, Dataset } from "metabase-types/api";
import {
loadMetadataForDocumentCard,
openVizSettingsSidebar,
setShowNavigateBackToDocumentButton,
} from "../../../../documents.slice";
import { useCardEmbedData } from "../../../../hooks/useCardEmbedData";
import {
useDocumentsDispatch,
useDocumentsSelector,
} from "../../../../redux-utils";
import { EDITOR_STYLE_BOUNDARY_CLASS } from "../../constants";
import { formatCardEmbed } from "../markdown/card-embed-format";
import styles from "./CardEmbedNode.module.css";
import { ModifyQuestionModal } from "./ModifyQuestionModal";
import { NativeQueryModal } from "./NativeQueryModal";
const getDatasetError = (dataset: Dataset) => {
if (dataset.error) {
return {
message: getGenericErrorMessage(),
icon: "warning" as const,
};
}
};
export interface CardEmbedAttributes {
id?: number;
name?: string;
class?: string;
}
export const CardEmbedNode: Node<{
HTMLAttributes: CardEmbedAttributes;
}> = Node.create({
name: "cardEmbed",
group: "block",
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
id: {
default: null,
parseHTML: (element) => {
const id = element.getAttribute("data-id");
if (id) {
return parseInt(id);
}
return null;
},
},
name: {
default: null,
parseHTML: (element) => element.getAttribute("data-name"),
},
};
},
parseHTML() {
return [
{
tag: `div[data-type="${CardEmbedNode.name}"]`,
},
];
},
renderHTML({ node, HTMLAttributes }) {
return [
"div",
mergeAttributes(
HTMLAttributes,
{
"data-type": CardEmbedNode.name,
"data-id": node.attrs.id,
"data-name": node.attrs.name,
},
this.options.HTMLAttributes,
),
formatCardEmbed(node.attrs as CardEmbedAttributes),
];
},
renderText({ node }) {
return formatCardEmbed(node.attrs as CardEmbedAttributes);
},
addNodeView() {
return () => {
const dom = document.createElement("div");
dom.setAttribute("data-type", CardEmbedNode.name);
dom.className = styles.embedContainer;
const content = document.createElement("div");
dom.appendChild(content);
return {
dom,
contentDOM: content,
};
};
},
});
export const CardEmbedComponent = memo(
({ node, updateAttributes, selected, editor, getPos }: NodeViewProps) => {
const { id, name } = node.attrs;
const dispatch = useDispatch();
const canWrite = editor.options.editable;
let embedIndex = -1;
if (editor && getPos) {
const currentPos = getPos() ?? 0;
let nodeCount = 0;
// Count cardEmbed nodes that appear before this position
editor.state.doc.descendants((node, pos) => {
if (node.type.name === "cardEmbed") {
if (pos < currentPos) {
nodeCount++;
} else if (pos === currentPos) {
embedIndex = nodeCount;
return false; // Stop traversing
}
}
});
}
// Use the custom hook for all data fetching
const {
card: cardToUse,
dataset,
isLoading,
rawSeries,
error,
} = useCardEmbedData({ id });
const metadata = useDocumentsSelector(getMetadata);
const datasetError = dataset && getDatasetError(dataset);
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editedTitle, setEditedTitle] = useState(name || "");
const titleInputRef = useRef<HTMLInputElement>(null);
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
const displayName = name || cardToUse?.name;
const isNativeQuestion = cardToUse?.dataset_query?.type === "native";
useEffect(() => {
if (isEditingTitle && titleInputRef.current) {
titleInputRef.current.focus();
titleInputRef.current.select();
}
}, [isEditingTitle]);
const handleTitleSave = () => {
const trimmedTitle = editedTitle.trim();
if (trimmedTitle && trimmedTitle !== cardToUse?.name) {
updateAttributes({ name: trimmedTitle });
} else {
updateAttributes({ name: null });
setEditedTitle("");
}
setIsEditingTitle(false);
};
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
handleTitleSave();
} else if (e.key === "Escape") {
setEditedTitle(name || "");
setIsEditingTitle(false);
}
};
const documentsDispatch = useDocumentsDispatch();
// Load metadata for the card
useEffect(() => {
if (cardToUse) {
documentsDispatch(loadMetadataForDocumentCard(cardToUse));
}
}, [cardToUse, documentsDispatch]);
const handleEditVisualizationSettings = () => {
if (embedIndex !== -1) {
dispatch(openVizSettingsSidebar({ embedIndex }));
}
};
const handleTitleClick = () => {
if (cardToUse && metadata) {
try {
dispatch(setShowNavigateBackToDocumentButton(true));
const isDraftCard = cardToUse.id < 0;
const question = new Question(
isDraftCard ? { ...cardToUse, id: null } : cardToUse,
metadata,
);
const url = getUrl(question, { includeDisplayIsLocked: true });
dispatch(push(url));
} catch (error) {
console.error("Failed to navigate to question:", error);
}
}
};
const handleReplaceQuestion = () => {
const pos = getPos();
if (typeof pos === "number") {
editor.commands.insertContentAt(
{ from: pos, to: pos + node.nodeSize },
"/",
);
editor.commands.focus();
}
};
// Handle drill-through navigation
const handleChangeCardAndRun = useCallback(
({
nextCard,
}: {
nextCard: Card;
previousCard?: Card;
objectId?: number;
}) => {
if (!metadata) {
console.warn("Metadata not available for drill-through navigation");
return;
}
try {
dispatch(setShowNavigateBackToDocumentButton(true));
// For drill-through, we need to ensure the card is treated as adhoc
// Remove the ID so getUrl creates an adhoc question URL instead of navigating to saved question
const adhocCard = { ...nextCard, id: null };
const question = new Question(adhocCard, metadata);
const url = getUrl(question, { includeDisplayIsLocked: true });
dispatch(push(url));
} catch (error) {
console.error("Failed to create question URL:", error);
// Fallback: navigate to a new question with the dataset_query
if (nextCard.dataset_query) {
dispatch(setShowNavigateBackToDocumentButton(true));
const params = new URLSearchParams();
params.set("dataset_query", JSON.stringify(nextCard.dataset_query));
dispatch(push(`/question?${params.toString()}`));
}
}
},
[dispatch, metadata],
);
if (isLoading && !cardToUse) {
return (
<NodeViewWrapper className={styles.embedWrapper}>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
>
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
<Box className={styles.titleContainer}>
<Text size="md" color="text-dark" fw={700}>
{t`Loading question...`}
</Text>
</Box>
</Flex>
</Box>
<Box className={styles.questionResults}>
<Box className={styles.loadingContainer}>
<Loader />
</Box>
</Box>
</Box>
</NodeViewWrapper>
);
}
if (error) {
return (
<NodeViewWrapper className={styles.embedWrapper}>
<Box
className={cx(styles.errorContainer, EDITOR_STYLE_BOUNDARY_CLASS)}
>
<Text color="error">{t`Failed to load question`}</Text>
</Box>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
className={styles.embedWrapper}
data-testid="document-card-embed"
>
<Box
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
[styles.selected]: selected,
})}
>
{cardToUse && (
<Box className={styles.questionHeader}>
<Flex align="center" justify="space-between" gap="0.5rem">
{isEditingTitle ? (
<TextInput
ref={titleInputRef}
value={editedTitle}
onChange={(e) => setEditedTitle(e.target.value)}
onBlur={handleTitleSave}
onKeyDown={handleTitleKeyDown}
size="md"
flex={1}
styles={{
input: {
fontWeight: 700,
fontSize: "1rem",
border: "1px solid transparent",
padding: 0,
height: "auto",
minHeight: "auto",
lineHeight: 1.55,
backgroundColor: "transparent",
"&:focus": {
border: "1px solid var(--mb-color-border)",
backgroundColor: "var(--mb-color-bg-white)",
padding: "0 0.25rem",
},
},
}}
/>
) : (
<Box className={styles.titleContainer}>
<Text
size="md"
color="text-dark"
fw={700}
onClick={handleTitleClick}
c="pointer"
>
{displayName}
</Text>
{canWrite && (
<Icon
name="pencil"
size={14}
color="var(--mb-color-text-medium)"
className={styles.titleEditIcon}
c="pointer"
onClick={(e: React.MouseEvent) => {
e.stopPropagation();
setEditedTitle(displayName);
setIsEditingTitle(true);
}}
/>
)}
</Box>
)}
{!isEditingTitle && (
<Menu withinPortal position="bottom-end">
<Menu.Target>
<Flex
component="button"
bg="transparent"
c="pointer"
p="0.25rem"
align="center"
justify="center"
style={{
border: "none",
borderRadius: "4px",
}}
onClick={(e: React.MouseEvent) => e.stopPropagation()}
>
<Icon
name="ellipsis"
size={16}
color="var(--mb-color-text-medium)"
/>
</Flex>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
onClick={handleEditVisualizationSettings}
disabled={!canWrite}
leftSection={<Icon name="palette" size={14} />}
>
{t`Edit Visualization`}
</Menu.Item>
<Menu.Item
onClick={() => setIsModifyModalOpen(true)}
disabled={!canWrite}
leftSection={
<Icon
name={isNativeQuestion ? "sql" : "notebook"}
size={14}
/>
}
>
{t`Edit Query`}
</Menu.Item>
<Menu.Item
onClick={handleReplaceQuestion}
disabled={!canWrite}
leftSection={<Icon name="refresh" size={14} />}
>
{t`Replace`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
)}
</Flex>
</Box>
)}
{rawSeries ? (
<>
<Box className={styles.questionResults}>
<Visualization
rawSeries={rawSeries}
metadata={metadata}
onChangeCardAndRun={handleChangeCardAndRun}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
isDocument={true}
showTitle={false}
error={datasetError?.message}
errorIcon={datasetError?.icon}
/>
</Box>
</>
) : (
<Box className={styles.questionResults}>
<ChartSkeleton
display={(cardToUse?.display as CardDisplayType) || "table"}
/>
</Box>
)}
</Box>
{isModifyModalOpen &&
cardToUse &&
(isNativeQuestion ? (
<NativeQueryModal
card={cardToUse}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
editor={editor}
initialDataset={dataset}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
) : (
<ModifyQuestionModal
card={cardToUse}
isOpen={isModifyModalOpen}
onClose={() => setIsModifyModalOpen(false)}
editor={editor}
onSave={(result) => {
updateAttributes({
id: result.card_id,
name: null,
});
setIsModifyModalOpen(false);
}}
/>
))}
</NodeViewWrapper>
);
},
(prevProps, nextProps) => {
return (
prevProps.node.attrs.id === nextProps.node.attrs.id &&
prevProps.node.attrs.name === nextProps.node.attrs.name &&
prevProps.selected === nextProps.selected
);
},
);
CardEmbedComponent.displayName = "CardEmbedComponent";

View File

@@ -0,0 +1,163 @@
import type { Editor } from "@tiptap/react";
import { useEffect, useMemo, useState } from "react";
import { t } from "ttag";
import _ from "underscore";
import { useDispatch, useStore } from "metabase/lib/redux";
import { Notebook } from "metabase/querying/notebook/components/Notebook";
import { loadMetadataForCard } from "metabase/questions/actions";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Button, Flex, Modal, Text } from "metabase/ui";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type { Card } from "metabase-types/api";
import {
createDraftCard,
generateDraftCardId,
loadMetadataForDocumentCard,
} from "../../../../documents.slice";
import {
useDocumentsDispatch,
useDocumentsSelector,
} from "../../../../redux-utils";
interface ModifyQuestionModalProps {
card: Card;
isOpen: boolean;
onClose: () => void;
onSave: (result: { card_id: number; name: string }) => void;
editor?: Editor;
}
export const ModifyQuestionModal = ({
card,
isOpen,
onClose,
onSave,
editor,
}: ModifyQuestionModalProps) => {
const store = useStore();
const dispatch = useDispatch();
const documentsDispatch = useDocumentsDispatch();
const metadata = useDocumentsSelector(getMetadata);
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
null,
);
// Load metadata for card
useEffect(() => {
if (isOpen && card) {
documentsDispatch(loadMetadataForDocumentCard(card));
}
}, [isOpen, card, documentsDispatch]);
const question = useMemo(() => {
if (!card || !metadata || !isOpen) {
return null;
}
const baseQuestion = new Question(card, metadata);
if (!modifiedQuestion) {
setModifiedQuestion(baseQuestion);
}
return baseQuestion;
}, [card, metadata, isOpen, modifiedQuestion]);
const handleUpdateQuestion = async (newQuestion: Question) => {
const currentDependencies = modifiedQuestion
? Lib.dependentMetadata(
modifiedQuestion.query(),
modifiedQuestion.id(),
modifiedQuestion.type(),
)
: [];
const nextDependencies = Lib.dependentMetadata(
newQuestion.query(),
newQuestion.id(),
newQuestion.type(),
);
if (!_.isEqual(currentDependencies, nextDependencies)) {
await dispatch(loadMetadataForCard(newQuestion.card()));
const freshMetadata = getMetadata(store.getState());
const questionWithFreshMetadata = new Question(
newQuestion.card(),
freshMetadata,
);
setModifiedQuestion(questionWithFreshMetadata);
} else {
setModifiedQuestion(newQuestion);
}
};
const handleSave = async () => {
if (!modifiedQuestion || !editor) {
return;
}
try {
const modifiedData = {
dataset_query: modifiedQuestion.datasetQuery(),
display: modifiedQuestion.display(),
visualization_settings:
modifiedQuestion.card().visualization_settings ?? {},
};
const newCardId = generateDraftCardId();
documentsDispatch(
createDraftCard({
originalCard: card,
modifiedData,
draftId: newCardId,
}),
);
onSave({ card_id: newCardId, name: card.name });
onClose();
} catch (error) {
console.error("Failed to save modified question:", error);
}
};
return (
<Modal
opened={isOpen}
onClose={onClose}
size="80%"
title={t`Modify question`}
padding="lg"
>
{question && modifiedQuestion ? (
<>
<Box h="70vh" style={{ overflow: "auto" }}>
<Notebook
question={modifiedQuestion}
isDirty={true}
isRunnable={true}
isResultDirty={true}
reportTimezone="UTC"
hasVisualizeButton={false}
updateQuestion={handleUpdateQuestion}
runQuestionQuery={async () => {}}
/>
</Box>
<Flex mt="lg" justify="flex-end" gap="0.5rem">
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button variant="filled" onClick={handleSave}>
{t`Save and use`}
</Button>
</Flex>
</>
) : (
<Flex h="70vh" align="center" justify="center">
<Text>{t`Failed to load question data`}</Text>
</Flex>
)}
</Modal>
);
};

View File

@@ -0,0 +1,571 @@
import type { Editor } from "@tiptap/react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { c, t } from "ttag";
import EmptyCodeResult from "assets/img/empty-states/code.svg";
import { skipToken } from "metabase/api";
import { datasetApi } from "metabase/api/dataset";
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
import { isMac } from "metabase/lib/browser";
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
import DataReference from "metabase/query_builder/components/dataref/DataReference";
import { getMetadata } from "metabase/selectors/metadata";
import { Box, Button, Flex, Loader, Modal, Stack, Text } from "metabase/ui";
import Visualization from "metabase/visualizations/components/Visualization";
import NoResultsView from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView";
import Question from "metabase-lib/v1/Question";
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
import type {
Card,
DatabaseId,
Dataset,
DatasetQuery,
} from "metabase-types/api";
import {
createDraftCard,
generateDraftCardId,
loadMetadataForDocumentCard,
} from "../../../../documents.slice";
import {
useDocumentsDispatch,
useDocumentsSelector,
} from "../../../../redux-utils";
type DataReferenceStackItem = {
type: string;
item: unknown;
};
interface NativeQueryModalProps {
card: Card;
isOpen: boolean;
onClose: () => void;
onSave: (result: { card_id: number; name: string }) => void;
editor?: Editor;
initialDataset?: Dataset;
}
const EDITOR_HEIGHT_RATIO = 0.4;
const EDITOR_MIN_HEIGHT = 200;
const EDITOR_HEIGHT_OFFSET = 80;
const MODAL_SIDEBAR_FEATURES = {
dataReference: true,
variables: false,
snippets: false,
promptInput: false,
formatQuery: false,
aiGeneration: false,
} as const;
const getRunQueryShortcut = () => (isMac() ? t`⌘ + return` : t`Ctrl + enter`);
const isFailedDataset = (dataset: Dataset | null | undefined): boolean => {
return !!(dataset?.error || dataset?.status === "failed");
};
const hasEmptyResults = (dataset: Dataset | null | undefined): boolean => {
return (
!isFailedDataset(dataset) &&
!!dataset?.data &&
Array.isArray(dataset.data.rows) &&
dataset.data.rows.length === 0
);
};
const getErrorMessage = (sqlError: any, queryError: unknown): string => {
if (sqlError?.error) {
return sqlError.error;
}
if (typeof queryError === "object" && queryError && "message" in queryError) {
return String(queryError.message);
}
return t`An error occurred while running your query. Please check your query and try again.`;
};
const QueryExecutionEmptyState = ({
hasInitialData,
}: {
hasInitialData: boolean;
}) => {
if (hasInitialData) {
return <NoResultsView />;
}
const keyboardShortcut = getRunQueryShortcut();
return (
<Flex w="100%" h="100%" align="center" justify="center">
<Stack maw="25rem" gap={0} ta="center" align="center">
<Box maw="3rem" mb="0.75rem">
<img src={EmptyCodeResult} alt="Code prompt icon" />
</Box>
<Text c="text-medium">
{c("{0} refers to the keyboard shortcut")
.jt`To run your code, click on the Run button or type ${(
<b key="shortcut">({keyboardShortcut})</b>
)}`}
</Text>
<Text c="text-medium">{t`Here's where your results will appear`}</Text>
</Stack>
</Flex>
);
};
export const NativeQueryModal = ({
card,
isOpen,
onClose,
onSave,
editor,
initialDataset,
}: NativeQueryModalProps) => {
const documentsDispatch = useDocumentsDispatch();
const metadata = useDocumentsSelector(getMetadata);
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
null,
);
const [currentQuery, setCurrentQuery] = useState<DatasetQuery | null>(null);
const [hasExecutedQuery, setHasExecutedQuery] = useState(false);
const [isShowingTemplateTagsEditor, setIsShowingTemplateTagsEditor] =
useState(false);
const [isShowingDataReference, setIsShowingDataReference] = useState(false);
const [dataReferenceStack, setDataReferenceStack] = useState<
DataReferenceStackItem[]
>([]);
const {
data: queryResult,
isLoading: isQueryRunning,
error: queryError,
refetch: refetchQuery,
} = datasetApi.useGetAdhocQueryQuery(currentQuery || skipToken, {
skip: !currentQuery,
});
const datasetToUse =
queryResult || (!hasExecutedQuery ? initialDataset : null);
const sqlError = isFailedDataset(datasetToUse) ? datasetToUse : null;
// Load metadata for card
useEffect(() => {
if (isOpen && card) {
documentsDispatch(loadMetadataForDocumentCard(card));
}
}, [isOpen, card, documentsDispatch]);
// Question initialization
const question = useMemo(() => {
if (!card || !metadata || !isOpen) {
return null;
}
const baseQuestion = new Question(card, metadata);
if (!modifiedQuestion) {
setModifiedQuestion(baseQuestion);
}
return baseQuestion;
}, [card, metadata, isOpen, modifiedQuestion]);
// Reset query execution state when modal opens
useEffect(() => {
if (isOpen) {
setCurrentQuery(null);
setHasExecutedQuery(false);
}
}, [isOpen]);
// Dataset query handler following QB patterns
const setDatasetQuery = useCallback(
async (query: NativeQuery | DatasetQuery) => {
if (!modifiedQuestion) {
return modifiedQuestion;
}
try {
let datasetQuery: DatasetQuery;
const isNativeQueryObject =
"queryText" in query && typeof query.queryText === "function";
if (isNativeQueryObject) {
// Handle NativeQuery object
const nativeQuery = query as NativeQuery;
datasetQuery = {
type: "native",
native: {
query: nativeQuery.queryText() || "",
"template-tags": nativeQuery.templateTagsMap() || {},
},
database: modifiedQuestion.databaseId(),
} as DatasetQuery;
} else {
// Handle dataset_query object (including database changes)
datasetQuery = query as DatasetQuery;
}
const newQuestion = modifiedQuestion.setDatasetQuery(datasetQuery);
setModifiedQuestion(newQuestion);
return newQuestion;
} catch (error) {
console.error("Failed to update dataset query:", error);
return modifiedQuestion;
}
},
[modifiedQuestion],
);
// Query execution handler
const handleRunQuery = useCallback(() => {
if (!modifiedQuestion) {
return;
}
const datasetQuery = modifiedQuestion.datasetQuery();
// Check if query has changed from what we currently have
const queryChanged =
!currentQuery ||
JSON.stringify(currentQuery) !== JSON.stringify(datasetQuery);
if (queryChanged) {
// Query changed, set new query which will trigger RTK Query
setCurrentQuery(datasetQuery);
} else {
// Same query, use refetch for proper loading state
refetchQuery();
}
setHasExecutedQuery(true);
}, [modifiedQuestion, currentQuery, refetchQuery]);
// Save handler
const handleSave = async () => {
if (!modifiedQuestion || !editor) {
return;
}
try {
const modifiedData = {
dataset_query: modifiedQuestion.datasetQuery(),
display: modifiedQuestion.display(),
visualization_settings:
modifiedQuestion.card().visualization_settings ?? {},
};
const newCardId = generateDraftCardId();
documentsDispatch(
createDraftCard({
originalCard: card,
modifiedData,
draftId: newCardId,
}),
);
onSave({ card_id: newCardId, name: card.name });
onClose();
} catch (error) {
console.error("Failed to save modified question:", error);
}
};
// Transform query results to visualization series following QB patterns
const rawSeries = useMemo(() => {
if (!modifiedQuestion || !datasetToUse || sqlError) {
return null;
}
try {
const data = datasetToUse.data || datasetToUse;
return [
{
card: {
...modifiedQuestion.card(),
display: "table" as const, // Force table display for results
visualization_settings: {
...modifiedQuestion.card().visualization_settings,
"table.pivot": false,
"table.columns": undefined,
},
},
data: {
rows: data.rows || [],
cols: data.cols || [],
results_metadata: data.results_metadata || [],
insights: data.insights,
rows_truncated: data.rows_truncated || 0,
},
json_query: datasetToUse.json_query,
started_at: datasetToUse.started_at || new Date().toISOString(),
running_time: datasetToUse.running_time,
row_count: datasetToUse.row_count,
status: "completed",
},
];
} catch (error) {
console.error("Error formatting series data:", error);
return null;
}
}, [modifiedQuestion, datasetToUse, sqlError]);
const modalStyles = {
content: {
height: "90vh",
maxHeight: "90vh",
display: "flex",
flexDirection: "column" as const,
},
body: {
flex: 1,
display: "flex",
flexDirection: "column" as const,
padding: 0,
minHeight: 0,
overflow: "hidden" as const,
},
};
if (!question || !modifiedQuestion) {
return (
<Modal
opened={isOpen}
onClose={onClose}
size="95%"
title={t`Edit SQL Query`}
>
<Flex h="400px" align="center" justify="center">
<Loader size="lg" />
</Flex>
</Modal>
);
}
return (
<Modal
opened={isOpen}
onClose={onClose}
size="95%"
title={t`Edit SQL Query`}
padding="lg"
styles={modalStyles}
>
<Flex h="100%" direction="column">
{/* Main content area with horizontal layout */}
<Flex flex={1} direction="row" mih={0} style={{ overflow: "hidden" }}>
{/* Left side - Editor and Results */}
<Flex
flex={1}
direction="column"
mih={0}
miw={0}
style={{ overflow: "hidden" }}
>
<Box
pos="relative"
style={{
flexShrink: 0,
borderBottom: "1px solid var(--mb-color-border)",
overflow: "hidden",
}}
>
{/* NativeQueryEditor with minimal configuration for modal context */}
{(modifiedQuestion?.legacyNativeQuery() ||
question?.legacyNativeQuery()) && (
<NativeQueryEditor
question={modifiedQuestion}
query={
modifiedQuestion?.legacyNativeQuery() ||
question?.legacyNativeQuery() ||
({} as any)
}
isNativeEditorOpen
isInitiallyOpen
hasTopBar
hasEditingSidebar
hasParametersList={false}
sidebarFeatures={MODAL_SIDEBAR_FEATURES}
viewHeight={400}
isRunnable
isRunning={isQueryRunning}
isResultDirty={false}
isShowingDataReference={isShowingDataReference}
isShowingTemplateTagsEditor={isShowingTemplateTagsEditor}
isShowingSnippetSidebar={false}
setDatasetQuery={setDatasetQuery}
runQuestionQuery={handleRunQuery}
runQuery={handleRunQuery}
toggleTemplateTagsEditor={() =>
setIsShowingTemplateTagsEditor(!isShowingTemplateTagsEditor)
}
toggleDataReference={() => {
if (!isShowingDataReference && modifiedQuestion) {
const databaseId = modifiedQuestion.databaseId();
if (databaseId) {
setDataReferenceStack([
{ type: "database", item: { id: databaseId } },
]);
}
}
setIsShowingDataReference(!isShowingDataReference);
}}
toggleSnippetSidebar={() => {}}
setNativeEditorSelectedRange={() => {}}
openDataReferenceAtQuestion={() => {}}
openSnippetModalWithSelectedText={() => {}}
insertSnippet={() => {}}
setParameterValue={() => {}}
onOpenModal={() => {}}
cancelQuery={() => {}}
closeSnippetModal={() => {}}
handleResize={() => {}}
canChangeDatabase
toggleEditor={() => {}}
onSetDatabaseId={(databaseId: DatabaseId) => {
if (modifiedQuestion) {
const updatedDatasetQuery = {
...modifiedQuestion.datasetQuery(),
database: databaseId,
};
const newQuestion =
modifiedQuestion.setDatasetQuery(updatedDatasetQuery);
setModifiedQuestion(newQuestion);
}
}}
resizable
resizableBoxProps={{
height: Math.max(
EDITOR_MIN_HEIGHT,
Math.floor(
window.innerHeight * EDITOR_HEIGHT_RATIO -
EDITOR_HEIGHT_OFFSET,
),
),
style: {
border: "none",
width: "100%",
minWidth: 0,
overflow: "hidden",
},
}}
/>
)}
</Box>
{/* Results area */}
<Flex
id="results-container"
flex={1}
direction="column"
mih={0}
pt="0.25rem"
pos="relative"
style={{ overflow: "hidden" }}
>
{!isQueryRunning && (sqlError || queryError) ? (
<ErrorMessage
type="serverError"
title={t`Query execution failed`}
message={getErrorMessage(sqlError, queryError)}
action={null}
/>
) : !isQueryRunning && hasEmptyResults(datasetToUse) ? (
<QueryExecutionEmptyState hasInitialData={!!datasetToUse} />
) : !isQueryRunning && rawSeries ? (
<Box flex={1} mih="300px">
<Visualization
rawSeries={rawSeries}
metadata={metadata}
onChangeCardAndRun={() => {}}
getExtraDataForClick={() => ({})}
isEditing={false}
isDashboard={false}
showTitle={false}
/>
</Box>
) : !isQueryRunning ? (
<QueryExecutionEmptyState hasInitialData={false} />
) : !datasetToUse ? (
<Flex h="100%" align="center" justify="center">
<Loader size="lg" />
</Flex>
) : null}
{/* Loading overlay for when rerunning queries with existing results */}
{isQueryRunning && datasetToUse && (
<Flex
pos="absolute"
top={0}
left={0}
right={0}
bottom={0}
bg="var(--mb-color-bg-white)"
align="center"
justify="center"
style={{ zIndex: 10 }}
>
<Loader size="lg" />
</Flex>
)}
</Flex>
</Flex>
{/* Data reference sidebar */}
{isShowingDataReference && modifiedQuestion && (
<Box
w="350px"
miw="350px"
bg="var(--mb-color-bg-white)"
style={{
flexShrink: 0,
borderLeft: "1px solid var(--mb-color-border)",
overflow: "auto",
}}
>
<DataReference
dataReferenceStack={dataReferenceStack}
onClose={() => setIsShowingDataReference(false)}
popDataReferenceStack={() => {
if (dataReferenceStack.length === 1) {
setIsShowingDataReference(false);
} else {
setDataReferenceStack(dataReferenceStack.slice(0, -1));
}
}}
pushDataReferenceStack={(item: DataReferenceStackItem) => {
setDataReferenceStack([...dataReferenceStack, item]);
}}
/>
</Box>
)}
</Flex>
{/* Footer with actions */}
<Flex
pos="sticky"
bottom={0}
justify="flex-end"
gap="0.5rem"
p="1rem"
bg="var(--mb-color-bg-white)"
style={{
borderTop: "1px solid var(--mb-color-border)",
flexShrink: 0,
zIndex: 10,
}}
>
<Button variant="subtle" onClick={onClose}>
{t`Cancel`}
</Button>
<Button
variant="filled"
onClick={handleSave}
disabled={!modifiedQuestion}
>
{t`Save and use`}
</Button>
</Flex>
</Flex>
</Modal>
);
};

View File

@@ -0,0 +1,11 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { CardEmbedComponent, CardEmbedNode } from "./CardEmbedNode";
export * from "./CardEmbedNode";
export const CardEmbed = CardEmbedNode.extend({
addNodeView() {
return ReactNodeViewRenderer(CardEmbedComponent);
},
});

View File

@@ -0,0 +1,23 @@
.Column {
padding: 1rem;
margin-left: 0.5rem;
margin-right: 0.5rem;
flex: 1 1 0;
min-height: 5rem;
background-color: var(--mb-color-bg-light);
border-radius: 1rem;
position: relative;
&:hover {
background-color: var(--mb-color-bg-medium);
}
}
.ColumnBlock {
padding: 0.5rem;
display: flex;
& > [data-node-view-content-react] {
display: contents;
}
}

View File

@@ -0,0 +1,120 @@
import {
Extension,
InputRule,
Node,
type NodeViewProps,
mergeAttributes,
} from "@tiptap/core";
import {
NodeViewContent,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import { Icon } from "metabase/ui";
import Styles from "./Columns.module.css";
const ColumnBlock = Node.create({
name: "columnBlock",
group: "block",
content: "column{1,}",
isolating: true,
selectable: true,
renderHTML({ HTMLAttributes }) {
const attrs = mergeAttributes(HTMLAttributes);
return ["div", attrs, 0];
},
addInputRules() {
return [
new InputRule({
find: /::columns/g,
handler: ({ state, range }) => {
state.tr.delete(range.from, range.to);
const columnBlockNode = state.schema.nodes.columnBlock.create({}, [
state.schema.nodes.column.create({}, [
state.schema.nodes.paragraph.create(),
]),
state.schema.nodes.column.create({}, [
state.schema.nodes.paragraph.create(),
]),
state.schema.nodes.column.create({}, [
state.schema.nodes.paragraph.create(),
]),
]);
state.tr.insert(range.from, columnBlockNode);
},
}),
];
},
addNodeView() {
return ReactNodeViewRenderer(ColumnBlockView);
},
});
const ColumnBlockView = () => {
return (
<NodeViewWrapper>
<NodeViewContent className={Styles.ColumnBlock} />
</NodeViewWrapper>
);
};
const Column = Node.create({
name: "column",
group: "column",
content: "(block)*",
isolating: true,
selectable: true,
renderHTML({ HTMLAttributes }) {
const attrs = mergeAttributes(HTMLAttributes);
return ["div", attrs, 0];
},
addNodeView() {
return ReactNodeViewRenderer(ColumnView, {
className: Styles.Column,
});
},
});
const ColumnView = ({ deleteNode }: NodeViewProps) => {
return (
<NodeViewWrapper>
<Icon
name="close"
pos="absolute"
top="6"
right="6"
cursor="pointer"
onClick={deleteNode}
/>
<NodeViewContent />
</NodeViewWrapper>
);
};
export const ColumnExtension = Extension.create({
name: "columnExtension",
addExtensions() {
const extensions = [];
if (this.options.column !== false) {
extensions.push(Column);
}
if (this.options.columnBlock !== false) {
extensions.push(ColumnBlock);
}
return extensions;
},
});

View File

@@ -0,0 +1,190 @@
import { type Editor, Extension, type Range } from "@tiptap/core";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
export interface CommandOptions {
suggestion: Partial<SuggestionOptions>;
}
interface CommandProps {
command?: string;
clearQuery?: boolean;
switchToLinkMode?: boolean;
switchToEmbedMode?: boolean;
selectItem?: boolean;
embedItem?: boolean;
entityId?: number;
model?: string;
}
export const CommandPluginKey = new PluginKey("command");
export const CommandExtension = Extension.create<CommandOptions>({
name: "command",
addOptions() {
return {
suggestion: {
char: "/",
pluginKey: CommandPluginKey,
allowSpaces: true,
items: ({ query: _query }: { query: string }) => [],
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: CommandProps;
}) => {
if (
props.clearQuery &&
(props.switchToLinkMode || props.switchToEmbedMode)
) {
const startPos = range.from + 1;
editor
.chain()
.focus()
.deleteRange({ from: startPos, to: range.to })
.run();
return;
}
if (props.embedItem && props.entityId && props.model) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "cardEmbed",
attrs: {
id: props.entityId,
},
})
.run();
return;
}
if (props.selectItem && props.entityId && props.model) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "smartLink",
attrs: {
entityId: props.entityId,
model: props.model,
},
})
.run();
return;
}
if (props.command === "linkTo" || props.command === "embedQuestion") {
return;
}
if (props.command === "metabot") {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "metabot",
attrs: {},
})
.run();
return;
}
switch (props.command) {
case "heading1":
editor
.chain()
.focus()
.deleteRange(range)
.toggleHeading({ level: 1 })
.run();
break;
case "heading2":
editor
.chain()
.focus()
.deleteRange(range)
.toggleHeading({ level: 2 })
.run();
break;
case "heading3":
editor
.chain()
.focus()
.deleteRange(range)
.toggleHeading({ level: 3 })
.run();
break;
case "bulletList":
editor
.chain()
.focus()
.deleteRange(range)
.toggleBulletList()
.run();
break;
case "orderedList":
editor
.chain()
.focus()
.deleteRange(range)
.toggleOrderedList()
.run();
break;
case "blockquote":
editor
.chain()
.focus()
.deleteRange(range)
.toggleBlockquote()
.run();
break;
case "codeBlock":
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
break;
}
},
selectItem: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: CommandProps;
}) => {
if (props.entityId && props.model) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "smartLink",
attrs: {
entityId: props.entityId,
model: props.model,
},
})
.run();
}
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,550 @@
import type { Editor, Range } from "@tiptap/core";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useRef,
useState,
} from "react";
import { t } from "ttag";
import {
Box,
Divider,
Group,
Icon,
type IconName,
Stack,
Text,
UnstyledButton,
} from "metabase/ui";
import type { RecentItem, SearchResult } from "metabase-types/api";
import {
MenuItemComponent,
SearchResultsFooter,
} from "../../shared/MenuComponents";
import S from "../../shared/MenuItems.module.css";
import {
LoadingSuggestionPaper,
SuggestionPaper,
} from "../../shared/SuggestionPaper";
import { EntitySearchSection } from "../shared/EntitySearchSection";
import { EMBED_SEARCH_MODELS } from "../shared/constants";
import { useEntitySearch } from "../shared/useEntitySearch";
import { useEntitySuggestions } from "../shared/useEntitySuggestions";
interface CommandSuggestionProps {
items: SearchResult[];
command: (item: CommandItem) => void;
editor: Editor;
range: Range;
query: string;
}
interface CommandItem {
command?: string;
clearQuery?: boolean;
switchToLinkMode?: boolean;
switchToEmbedMode?: boolean;
selectItem?: boolean;
embedItem?: boolean;
entityId?: number | string;
model?: string;
}
interface SuggestionRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
interface CommandOption {
icon?: IconName;
text?: string;
label: string;
command: string;
}
interface CommandSection {
title?: string;
items: CommandOption[];
}
const CommandMenuItem = forwardRef<
HTMLButtonElement,
{
option: CommandOption;
isSelected?: boolean;
onClick?: () => void;
}
>(function CommandMenuItem({ option, isSelected, onClick }, ref) {
return (
<UnstyledButton
ref={ref}
className={S.menuItem}
onClick={onClick}
role="option"
aria-selected={isSelected}
aria-label={option.label}
>
<Group gap="sm" wrap="nowrap" align="center">
{option.icon ? (
<Icon name={option.icon} size={16} color="inherit" />
) : option.text ? (
<Box
w={16}
h={16}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Text size="xs" fw={700} c="inherit">
{option.text}
</Text>
</Box>
) : null}
<Stack gap={2} style={{ flex: 1 }}>
<Text size="md" lh="lg" c="inherit">
{option.label}
</Text>
</Stack>
</Group>
</UnstyledButton>
);
});
const CommandSuggestionComponent = forwardRef<
SuggestionRef,
CommandSuggestionProps
>(function CommandSuggestionComponent({ command, editor, query }, ref) {
const [selectedIndex, setSelectedIndex] = useState(0);
const [showLinkSearch, setShowLinkSearch] = useState(false);
const [showEmbedSearch, setShowEmbedSearch] = useState(false);
const [pendingLinkMode, setPendingLinkMode] = useState(false);
const [pendingEmbedMode, setPendingEmbedMode] = useState(false);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
useEffect(() => {
if (pendingLinkMode) {
setShowLinkSearch(true);
setPendingLinkMode(false);
}
if (pendingEmbedMode) {
setShowEmbedSearch(true);
setPendingEmbedMode(false);
}
}, [pendingLinkMode, pendingEmbedMode]);
const effectiveQuery = query;
const allCommandSections: CommandSection[] = useMemo(
() => [
{
items: [
{
icon: "metabot",
label: t`Ask Metabot`,
command: "metabot",
},
{
icon: "lineandbar",
label: t`Chart`,
command: "embedQuestion",
},
{
icon: "link",
label: t`Link`,
command: "linkTo",
},
],
},
{
title: t`Formatting`,
items: [
{
text: "H1",
label: t`Heading 1`,
command: "heading1",
},
{
text: "H2",
label: t`Heading 2`,
command: "heading2",
},
{
text: "H3",
label: t`Heading 3`,
command: "heading3",
},
{
icon: "list",
label: t`Bullet list`,
command: "bulletList",
},
{
icon: "ordered_list",
label: t`Numbered list`,
command: "orderedList",
},
{
icon: "quote",
label: t`Quote`,
command: "blockquote",
},
{
icon: "code_block",
label: t`Code block`,
command: "codeBlock",
},
],
},
],
[],
);
const allCommandOptions = useMemo(
() => allCommandSections.flatMap((section) => section.items),
[allCommandSections],
);
// Filter command options based on query when not in link search mode
const commandOptions = useMemo(() => {
if (showLinkSearch || showEmbedSearch) {
return allCommandOptions;
}
if (!query) {
return allCommandOptions;
}
const lowerQuery = query.toLowerCase();
return allCommandOptions.filter((option) =>
option.label.toLowerCase().includes(lowerQuery),
);
}, [allCommandOptions, query, showLinkSearch, showEmbedSearch]);
const onSelectLinkEntity = useCallback(
(item: { id: number | string; model: string }) => {
if (showEmbedSearch) {
command({
embedItem: true,
entityId: item.id,
model: item.model,
});
} else {
command({
selectItem: true,
entityId: item.id,
model: item.model,
});
}
},
[command, showEmbedSearch],
);
const executeCommand = (commandName: string) => {
if (commandName === "linkTo") {
setPendingLinkMode(true);
command({
clearQuery: true,
switchToLinkMode: true,
});
return;
}
if (commandName === "embedQuestion") {
setPendingEmbedMode(true);
command({
clearQuery: true,
switchToEmbedMode: true,
});
return;
}
if (commandName === "metabot") {
command({
command: "metabot",
});
return;
}
command({
command: commandName,
});
};
// Use shared entity suggestions for link/embed mode and browse all functionality
const entitySuggestions = useEntitySuggestions({
query: effectiveQuery,
editor,
onSelectEntity: onSelectLinkEntity,
enabled: showLinkSearch || showEmbedSearch || !!query,
searchModels: showEmbedSearch ? EMBED_SEARCH_MODELS : undefined,
});
const {
menuItems: linkMenuItems,
isLoading: isLinkSearchLoading,
searchResults,
selectedIndex: entitySelectedIndex,
modal: entityModal,
handlers: entityHandlers,
} = entitySuggestions;
const { menuItems: searchMenuItems } = useEntitySearch({
query,
onSelectRecent: useCallback(
(item: RecentItem) => {
command({
embedItem: true,
entityId: item.id,
model: item.model,
});
},
[command],
),
onSelectSearchResult: useCallback(
(item: SearchResult) => {
command({
embedItem: true,
entityId: item.id,
model: item.model,
});
},
[command],
),
enabled: !showLinkSearch && !showEmbedSearch && !!query,
searchModels: EMBED_SEARCH_MODELS,
});
const currentItems = useMemo(() => {
if (showLinkSearch || showEmbedSearch) {
return linkMenuItems;
}
// When searching in command mode, combine search results with matching commands
if (query && searchMenuItems.length > 0) {
// Show search results (questions) followed by matching commands
return [...searchMenuItems, ...commandOptions];
}
return commandOptions;
}, [
showLinkSearch,
showEmbedSearch,
linkMenuItems,
query,
searchMenuItems,
commandOptions,
]);
let totalItems = currentItems.length;
if (showLinkSearch || showEmbedSearch) {
totalItems = linkMenuItems.length + 1;
} else if (currentItems.length === 0 && query) {
totalItems = 1; // Just the browse all footer
}
const selectItem = (index: number) => {
if (showLinkSearch || showEmbedSearch) {
entityHandlers.selectItem(index);
} else {
// When searching in command mode, handle both entity results and commands
if (query && searchMenuItems.length > 0) {
if (index < searchMenuItems.length) {
searchMenuItems[index].action();
} else {
const commandIndex = index - searchMenuItems.length;
if (commandIndex < commandOptions.length) {
executeCommand(commandOptions[commandIndex].command);
}
}
} else if (currentItems.length === 0 && query && index === 0) {
// Handle browse all when no results
// Switch to embed mode so selected questions get embedded
setShowEmbedSearch(true);
entityHandlers.openModal();
} else {
if (index < commandOptions.length) {
executeCommand(commandOptions[index].command);
}
}
}
};
const upHandler = () => {
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
};
const downHandler = () => {
setSelectedIndex((prev) => (prev + 1) % totalItems);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [
currentItems.length,
showLinkSearch,
showEmbedSearch,
searchMenuItems.length,
]);
useEffect(() => {
const selectedItem = itemRefs.current[selectedIndex];
if (selectedItem) {
selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
}
}, [selectedIndex]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (showLinkSearch || showEmbedSearch) {
return entityHandlers.onKeyDown({ event });
}
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
if ((showLinkSearch || showEmbedSearch) && isLinkSearchLoading) {
return <LoadingSuggestionPaper aria-label={t`Command Dialog`} />;
}
return (
<SuggestionPaper aria-label={t`Command Dialog`}>
{showLinkSearch || showEmbedSearch ? (
<EntitySearchSection
menuItems={linkMenuItems}
selectedIndex={entitySelectedIndex}
onItemSelect={entityHandlers.selectItem}
onFooterClick={entityHandlers.openModal}
query={effectiveQuery}
searchResults={searchResults}
modal={entityModal}
onModalSelect={entityHandlers.handleModalSelect}
onModalClose={entityHandlers.handleModalClose}
/>
) : (
<>
{commandOptions.length > 0 ||
(query && searchMenuItems.length > 0) ? (
<>
{query && searchMenuItems.length > 0 ? (
// When searching, show question search results first, then matching commands
<>
{searchMenuItems.map((item, index) => (
<MenuItemComponent
key={`search-${index}`}
item={item}
isSelected={selectedIndex === index}
onClick={() => selectItem(index)}
/>
))}
{searchMenuItems.length > 0 && commandOptions.length > 0 && (
<Divider my="sm" mx="sm" />
)}
{commandOptions.map((option, cmdIndex) => {
const index = searchMenuItems.length + cmdIndex;
return (
<CommandMenuItem
key={`cmd-${cmdIndex}`}
ref={(el) => (itemRefs.current[index] = el)}
option={option}
isSelected={selectedIndex === index}
onClick={() => selectItem(index)}
/>
);
})}
</>
) : (
// When not searching, show sections
allCommandSections.map((section, sectionIndex) => {
const filteredItems = section.items.filter((item) =>
commandOptions.includes(item),
);
if (filteredItems.length === 0) {
return null;
}
return (
<Box key={sectionIndex}>
{section.title && sectionIndex > 0 && (
<Box>
<Divider my="sm" mx="sm" />
<Text
size="sm"
c="text-primary"
px="sm"
pt="xs"
pb="xs"
>
{section.title}
</Text>
</Box>
)}
{filteredItems.map((option) => {
const index = commandOptions.indexOf(option);
return (
<CommandMenuItem
key={index}
ref={(el) => (itemRefs.current[index] = el)}
option={option}
isSelected={selectedIndex === index}
onClick={() => selectItem(index)}
/>
);
})}
</Box>
);
})
)}
</>
) : (
<>
<Box p="sm" ta="center">
<Text size="md" c="text-medium">{t`No results found`}</Text>
</Box>
{query && (
<>
<Divider my="sm" mx="sm" />
<SearchResultsFooter
isSelected={selectedIndex === currentItems.length}
onClick={() => {
// Switch to embed mode so selected questions get embedded
setShowEmbedSearch(true);
entityHandlers.openModal();
}}
/>
</>
)}
</>
)}
</>
)}
</SuggestionPaper>
);
});
export const CommandSuggestion = CommandSuggestionComponent;

View File

@@ -0,0 +1,20 @@
import { Extension } from "@tiptap/core";
import { Plugin } from "prosemirror-state";
export const DisableMetabotSidebar = Extension.create({
name: "take-back-mod-b",
addProseMirrorPlugins() {
return [
new Plugin({
props: {
handleKeyDown: (view, event) => {
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
event.stopPropagation();
}
},
},
}),
];
},
});

View File

@@ -0,0 +1,91 @@
import { type VirtualElement, computePosition } from "@floating-ui/dom";
import Emoji, { gitHubEmojis } from "@tiptap/extension-emoji";
import { ReactRenderer } from "@tiptap/react";
import { EmojiList, type EmojiListProps } from "./EmojiList";
export const EmojiExtension = Emoji.configure({
emojis: gitHubEmojis,
enableEmoticons: true,
suggestion: {
items: ({ editor, query }) => {
return editor.storage.emoji.emojis
.filter(({ shortcodes, tags }) => {
return (
shortcodes.find((shortcode) =>
shortcode.startsWith(query.toLowerCase()),
) || tags.find((tag) => tag.startsWith(query.toLowerCase()))
);
})
.slice(0, 5);
},
allowSpaces: false,
render: () => {
let component: ReactRenderer | undefined;
function repositionComponent(clientRect: DOMRect | null | undefined) {
if (!component || !component.element || !clientRect) {
return;
}
const virtualElement: VirtualElement = {
getBoundingClientRect() {
return clientRect;
},
};
computePosition(virtualElement, component.element as HTMLElement, {
placement: "bottom-start",
}).then((pos) => {
Object.assign((component?.element as HTMLElement).style, {
left: `${pos.x}px`,
top: `${pos.y}px`,
position: pos.strategy === "fixed" ? "fixed" : "absolute",
});
});
}
return {
onStart: (props) => {
component = new ReactRenderer<unknown, EmojiListProps>(EmojiList, {
props,
editor: props.editor,
});
document.body.appendChild(component.element);
repositionComponent(props.clientRect?.());
},
onUpdate(props) {
component?.updateProps(props);
repositionComponent(props.clientRect?.());
},
onKeyDown(props) {
if (props.event.key === "Escape") {
if (component) {
document.body.removeChild(component.element);
component.destroy();
return true;
}
}
// @ts-expect-error -- not sure what should be here
return component?.ref?.onKeyDown(props);
},
onExit() {
if (component) {
if (document.body.contains(component.element)) {
document.body.removeChild(component.element);
}
component.destroy();
}
},
};
},
},
});

View File

@@ -0,0 +1,17 @@
.buttonRoot {
&:hover {
background-color: var(--mb-color-brand-lighter) !important;
}
&:hover.isSelected,
&.isSelected {
background-color: var(--mb-color-brand-light) !important;
}
color: var(--mb-color-text-medium) !important;
img {
height: 1rem;
width: 1rem;
}
}

View File

@@ -0,0 +1,96 @@
import type { EmojiItem } from "@tiptap/extension-emoji";
import cx from "classnames";
import {
type KeyboardEvent,
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { Button, Card } from "metabase/ui";
import Styles from "./EmojiList.module.css";
export interface EmojiListProps {
items: EmojiItem[];
command: (params: { name: string }) => void;
}
export const EmojiList = forwardRef(function InnerEmojiList(
{ items, command }: EmojiListProps,
ref,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command({ name: item.name });
}
},
[items, command],
);
const upHandler = useCallback(() => {
setSelectedIndex((index) => (index + items.length - 1) % items.length);
}, [setSelectedIndex, items]);
const downHandler = useCallback(() => {
setSelectedIndex((index) => (index + 1) % items.length);
}, [setSelectedIndex, items]);
const enterHandler = useCallback(() => {
selectItem(selectedIndex);
}, [selectItem, selectedIndex]);
useEffect(() => setSelectedIndex(0), [items]);
useImperativeHandle(ref, () => {
return {
onKeyDown: (x: { event: KeyboardEvent }) => {
if (x.event.key === "ArrowUp") {
upHandler();
return true;
}
if (x.event.key === "ArrowDown") {
downHandler();
return true;
}
if (x.event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
};
}, [upHandler, downHandler, enterHandler]);
return (
<Card withBorder p="sm">
{items.map((item, index) => (
<Button
className={cx({ [Styles.isSelected]: index === selectedIndex })}
classNames={{
root: Styles.buttonRoot,
}}
justify="start"
key={index}
onClick={() => selectItem(index)}
variant="subtle"
leftSection={
item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji
}
>
{`:${item.name}:`}
</Button>
))}
</Card>
);
});

View File

@@ -0,0 +1 @@
export * from "./EmojiExtension";

View File

@@ -0,0 +1,73 @@
import { type Editor, Extension, type Range } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
export interface MentionOptions {
HTMLAttributes: Record<string, unknown>;
renderText: (props: {
options: MentionOptions;
node: ProseMirrorNode;
}) => string;
suggestion: Partial<SuggestionOptions>;
}
interface MentionProps {
type?: string;
id?: number | string;
model?: string;
}
export const MentionPluginKey = new PluginKey("mention");
export const MentionExtension = Extension.create<MentionOptions>({
name: "mention",
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
suggestion: {
char: "@",
pluginKey: MentionPluginKey,
allowSpaces: true,
items: ({ query: _query }: { query: string }) => [],
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: MentionProps;
}) => {
if (props.id && props.model) {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "smartLink",
attrs: {
entityId: props.id,
model: props.model,
},
})
.run();
}
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,87 @@
import type { Editor, Range } from "@tiptap/core";
import { forwardRef, useCallback, useImperativeHandle } from "react";
import { t } from "ttag";
import type { SearchResult } from "metabase-types/api";
import {
LoadingSuggestionPaper,
SuggestionPaper,
} from "../../shared/SuggestionPaper";
import { EntitySearchSection } from "../shared/EntitySearchSection";
import { useEntitySuggestions } from "../shared/useEntitySuggestions";
interface MentionSuggestionProps {
items: SearchResult[];
command: (item: MentionCommandItem) => void;
editor: Editor;
range: Range;
query: string;
}
interface MentionCommandItem {
type?: string;
id?: number | string;
model?: string;
}
interface SuggestionRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
const MentionSuggestionComponent = forwardRef<
SuggestionRef,
MentionSuggestionProps
>(function MentionSuggestionComponent(
{ items: _items, command, editor, range: _range, query },
ref,
) {
const onSelectEntity = useCallback(
(item: { id: number | string; model: string }) => {
command({
id: item.id,
model: item.model,
});
},
[command],
);
const {
menuItems,
isLoading,
searchResults,
selectedIndex,
modal,
handlers,
} = useEntitySuggestions({
query,
editor,
onSelectEntity,
});
useImperativeHandle(ref, () => ({
onKeyDown: handlers.onKeyDown,
}));
if (isLoading) {
return <LoadingSuggestionPaper aria-label={t`Mention Dialog`} />;
}
return (
<SuggestionPaper aria-label={t`Mention Dialog`}>
<EntitySearchSection
menuItems={menuItems}
selectedIndex={selectedIndex}
onItemSelect={handlers.selectItem}
onFooterClick={handlers.openModal}
query={query}
searchResults={searchResults}
modal={modal}
onModalSelect={handlers.handleModalSelect}
onModalClose={handlers.handleModalClose}
/>
</SuggestionPaper>
);
});
export const MentionSuggestion = MentionSuggestionComponent;

View File

@@ -0,0 +1,18 @@
.codeBlockTextArea {
color: var(--mb-color-text-primary);
padding: var(--mantine-spacing-md);
padding-right: var(--mantine-spacing-lg);
height: 110px;
&[contenteditable="false"] {
opacity: 0.5;
cursor: progress;
}
}
.placeholder {
position: absolute;
pointer-events: none;
padding: var(--mantine-spacing-md);
color: var(--mb-color-text-light);
}

View File

@@ -0,0 +1,347 @@
import { type JSONContent, Node, mergeAttributes } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { Selection } from "@tiptap/pm/state";
import {
NodeViewContent,
type NodeViewProps,
NodeViewWrapper,
ReactNodeViewRenderer,
} from "@tiptap/react";
import { memo, useEffect, useRef, useState } from "react";
import { useLatest } from "react-use";
import { t } from "ttag";
import CS from "metabase/css/core/index.css";
import { Box, Button, Flex, Icon, Text, Tooltip } from "metabase/ui";
import { useLazyMetabotDocumentNodeQuery } from "metabase-enterprise/api/metabot";
import {
createDraftCard,
generateDraftCardId,
loadMetadataForDocumentCard,
} from "metabase-enterprise/documents/documents.slice";
import { useDocumentsDispatch } from "metabase-enterprise/documents/redux-utils";
import MetabotThinkingStyles from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking.module.css";
import type { Card, MetabotDocumentNodeRequest } from "metabase-types/api";
import Styles from "./MetabotEmbed.module.css";
const createTextNode = (text: string, marks?: JSONContent["marks"]) => {
return { type: "text", text, marks };
};
// unsets bold/italic/etc when user edits around `content`
const padWithUnstyledText = (content: JSONContent): JSONContent[] => [
createTextNode(" "),
content,
createTextNode(" "),
];
export type PromptSerializer = (
node: ProseMirrorNode,
) => MetabotDocumentNodeRequest;
const serializePromptDefault: PromptSerializer = (node) => ({
instructions: node.textContent,
});
export const MetabotNode = Node.create<{
serializePrompt?: PromptSerializer;
}>({
name: "metabot",
group: "block",
content: "inline*",
marks: "",
draggable: true,
selectable: false,
code: true, // disallows adding markdown (headings, blockquote, etc) to metabot block
addOptions() {
return {
serializePrompt: serializePromptDefault,
};
},
addKeyboardShortcuts() {
return {
// run metabot on mod-Enter
"mod-Enter": ({ editor }) => {
const { state } = editor;
const { selection } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
editor.emit("runMetabot", $from.parent);
return true;
},
// exit node on arrow down
ArrowDown: ({ editor }) => {
const { state } = editor;
const { selection, doc } = state;
const { $from, empty } = selection;
if (!empty || $from.parent.type !== this.type) {
return false;
}
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
if (!isAtEnd) {
return false;
}
const after = $from.after();
if (after === undefined) {
return false;
}
const nodeAfter = doc.nodeAt(after);
if (nodeAfter) {
return editor.commands.command(({ tr }) => {
tr.setSelection(Selection.near(doc.resolve(after)));
return true;
});
}
return editor.commands.exitCode();
},
};
},
parseHTML() {
return [
{
tag: 'span[data-type="metabot"]',
},
];
},
addNodeView() {
return ReactNodeViewRenderer(MetabotComponent);
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-type": "metabot",
}),
0,
];
},
});
export const MetabotComponent = memo(
({ editor, getPos, deleteNode, node, extension }: NodeViewProps) => {
const documentsDispatch = useDocumentsDispatch();
const controllerRef = useRef<AbortController | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [errorText, setErrorText] = useState("");
const [queryMetabot] = useLazyMetabotDocumentNodeQuery();
const handleRunMetabot = async () => {
const serializePrompt =
extension?.options?.serializePrompt || serializePromptDefault;
const payload = serializePrompt(node);
if (!payload.instructions.trim()) {
return;
}
const controller = new AbortController();
controllerRef.current = controller;
setIsLoading(true);
setErrorText("");
editor.commands.focus();
const res = queryMetabot(payload);
controller.signal.addEventListener("abort", () => res.abort());
const { data, error } = await res;
if (controller.signal.aborted) {
return;
}
setIsLoading(false);
if (error || !data?.draft_card) {
setErrorText(
data?.error || t`There was a problem connecting to Metabot`,
);
return;
}
const nodePosition = getPos();
if (nodePosition == null) {
setErrorText(t`Could not find Metabot block`);
return;
}
const newCardId = generateDraftCardId();
const card: Card = {
...data.draft_card,
id: newCardId,
entity_id: "entity_id" as Card["entity_id"],
created_at: "",
updated_at: "",
name: data.draft_card.name || t`Exploration`,
description: null,
type: "question",
public_uuid: null,
enable_embedding: false,
embedding_params: null,
can_write: false,
can_restore: false,
can_delete: false,
can_manage_db: false,
initially_published_at: null,
collection_id: null,
collection_position: null,
dashboard: null,
dashboard_id: null,
dashboard_count: null,
result_metadata: [],
last_query_start: null,
average_query_time: null,
cache_ttl: null,
archived: false,
};
await documentsDispatch(loadMetadataForDocumentCard(card));
documentsDispatch(
createDraftCard({
originalCard: card,
modifiedData: {},
draftId: newCardId,
}),
);
editor.commands.insertContentAt(nodePosition, [
{
type: "cardEmbed",
attrs: {
id: newCardId,
},
},
{
type: "paragraph",
content: [createTextNode(data.description)],
},
{
type: "paragraph",
content: padWithUnstyledText(
createTextNode(t`Created with Metabot`, [
{ type: "bold" },
{ type: "italic" },
]),
),
},
]);
deleteNode();
};
const handleStopMetabot = () => {
controllerRef.current?.abort();
setIsLoading(false);
setErrorText("");
};
const onRunMetabotRef = useLatest((target: ProseMirrorNode) => {
if (target === node) {
handleRunMetabot();
}
});
useEffect(() => {
const onRunMetabot = (target: ProseMirrorNode) => {
onRunMetabotRef.current(target);
};
editor.on("runMetabot", onRunMetabot);
return () => {
editor.off("runMetabot", onRunMetabot);
};
}, [editor, onRunMetabotRef]);
return (
<NodeViewWrapper>
<Flex
bg="bg-light"
bd="1px solid var(--border-color)"
style={{ borderRadius: "4px" }}
pos="relative"
direction="column"
mb="1rem"
>
<Button
variant="subtle"
pos="absolute"
top={0}
right={0}
p="sm"
m="sm"
size="sm"
opacity="0.5"
style={{ zIndex: 1 }}
onClick={() => deleteNode()}
>
<Icon name="close" />
</Button>
<Flex flex={1} direction="column" style={{ overflow: "auto" }}>
<Box
className={Styles.placeholder}
hidden={!!node.content.content.length}
>
{t`Ask Metabot to generate a chart for you, and use @ to select a specific Database to use`}
</Box>
<NodeViewContent
contentEditable={!isLoading}
className={Styles.codeBlockTextArea}
/>
</Flex>
<Flex px="md" pb="md" pt="sm" gap="sm">
<Flex flex={1} my="auto">
{isLoading ? (
<Text
flex={1}
className={MetabotThinkingStyles.toolCallStarted}
>
{t`Working on it...`}
</Text>
) : errorText ? (
<Flex gap="sm" c="text-secondary">
<Icon name="warning" h="1.2rem" style={{ flexShrink: 0 }} />
<Text c="text-secondary" lh={1.4}>
{errorText}
</Text>
</Flex>
) : null}
</Flex>
<Tooltip
label={t`Stop generating`}
disabled={!isLoading}
position="bottom"
>
<Button
size="sm"
onClick={() =>
isLoading ? handleStopMetabot() : handleRunMetabot()
}
classNames={{
label: CS.flex, // ensures icon is vertically centered
}}
>
{isLoading ? <Icon name="close" /> : t`Run`}
</Button>
</Tooltip>
</Flex>
</Flex>
</NodeViewWrapper>
);
},
);
MetabotComponent.displayName = "MetabotComponent";

View File

@@ -0,0 +1 @@
export * from "./MetabotEmbed";

View File

@@ -0,0 +1,71 @@
import { type Editor, Extension, type Range } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import { PluginKey } from "@tiptap/pm/state";
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
export interface MentionOptions {
HTMLAttributes: Record<string, unknown>;
renderText: (props: {
options: MentionOptions;
node: ProseMirrorNode;
}) => string;
suggestion: Partial<SuggestionOptions>;
}
interface MentionProps {
type?: string;
id?: number | string;
model?: string;
}
export const MetabotMentionPluginKey = new PluginKey("metabot-mention");
export const MetabotMentionExtension = Extension.create<MentionOptions>({
name: "metabot-mention",
addOptions() {
return {
HTMLAttributes: {},
renderText({ options, node }) {
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
},
suggestion: {
char: "@",
pluginKey: MetabotMentionPluginKey,
allowSpaces: true,
items: ({ query: _query }: { query: string }) => [],
command: ({
editor,
range,
props,
}: {
editor: Editor;
range: Range;
props: MentionProps;
}) => {
editor
.chain()
.focus()
.deleteRange(range)
.insertContent({
type: "smartLink",
attrs: {
entityId: props.id,
model: props.model,
},
})
.run();
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,174 @@
import type { Editor, Range } from "@tiptap/core";
import {
forwardRef,
useCallback,
useEffect,
useImperativeHandle,
useMemo,
useState,
} from "react";
import { t } from "ttag";
import { useListDatabasesQuery } from "metabase/api";
import type {
Item,
Section,
} from "metabase/common/components/AccordionList/types";
import { searchFilter } from "metabase/common/components/AccordionList/utils";
import { Box, Group, type IconName, Loader, Text } from "metabase/ui";
import type { Database, SearchResult } from "metabase-types/api";
import type { MenuItem } from "../../shared/MenuComponents";
import { MenuItemComponent } from "../../shared/MenuComponents";
import { SuggestionPaper } from "../../shared/SuggestionPaper";
import { buildDbMenuItems } from "../shared/suggestionUtils";
interface MentionSuggestionProps {
items: SearchResult[];
command: (item: MentionCommandItem) => void;
editor: Editor;
range: Range;
query: string;
}
interface MentionCommandItem {
type?: string;
id?: number | string;
model?: string;
}
interface SuggestionRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
interface MentionItem {
icon: IconName;
iconColor?: string;
label: string;
description?: string;
action: () => void;
id: number | string;
type?: string;
}
const MetabotMentionSuggestionComponent = forwardRef<
SuggestionRef,
MentionSuggestionProps
>(function MetabotMentionSuggestionComponent(
{ items: _items, command, range: _range, query },
ref,
) {
const [selectedIndex, setSelectedIndex] = useState(0);
const { data: dbsResponse, isLoading } = useListDatabasesQuery();
const handleDbSelect = useCallback(
(item: Database) => {
command({
id: item.id,
model: "database",
});
},
[command],
);
const menuItems = useMemo(() => {
const items: Array<MentionItem | MenuItem> = [];
if (dbsResponse?.data) {
const sections: Section<Item & { database: Database }>[] = [];
sections.push({
items: dbsResponse.data.map((database, index) => ({
name: database.name,
index,
database,
})),
});
const sorted = searchFilter<
Item & { database: Database },
(typeof sections)[number]
>({
sections,
searchText: query,
})[0];
const dbs = sorted?.items?.map((item) => item.item.database) || [];
items.push(...buildDbMenuItems(dbs, handleDbSelect));
}
return items as MentionItem[];
}, [dbsResponse?.data, handleDbSelect, query]);
const totalItems = menuItems.length;
const selectItem = (index: number) => {
menuItems[index].action();
};
const upHandler = () => {
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
};
const downHandler = () => {
setSelectedIndex((prev) => (prev + 1) % totalItems);
};
const enterHandler = () => {
selectItem(selectedIndex);
};
useEffect(() => {
setSelectedIndex(0);
}, [menuItems.length]);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
}));
return (
<SuggestionPaper aria-label={t`Metabot dialog`}>
{isLoading ? (
<Group justify="center" p="sm">
<Loader size="sm" />
</Group>
) : (
<>
{menuItems.map((item, index) => (
<MenuItemComponent
key={index}
item={item}
isSelected={selectedIndex === index}
onClick={() => selectItem(index)}
/>
))}
{query.length > 0 && totalItems === 0 && !isLoading ? (
<Box p="sm">
<Text size="md" c="text-medium" ta="center">
{t`No results found`}
</Text>
</Box>
) : null}
</>
)}
</SuggestionPaper>
);
});
export const MetabotMentionSuggestion = MetabotMentionSuggestionComponent;

View File

@@ -0,0 +1,20 @@
.smartLink {
color: var(--mb-color-brand);
font-weight: 700;
padding: 0.125rem;
border-radius: var(--mantine-radius-sm);
transition: 200ms background-color;
&:hover {
background-color: var(--mb-color-background-hover);
}
}
.icon {
margin-bottom: -0.125rem;
margin-right: 0.25rem;
}
.smartLinkInner {
border-bottom: 1px solid var(--mb-color-brand-light);
}

View File

@@ -0,0 +1,367 @@
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { memo, useEffect } from "react";
import { t } from "ttag";
import { cardApi } from "metabase/api";
import { collectionApi } from "metabase/api/collection";
import { dashboardApi } from "metabase/api/dashboard";
import { databaseApi } from "metabase/api/database";
import { tableApi } from "metabase/api/table";
import {
type IconModel,
type ObjectWithModel,
getIcon,
} from "metabase/lib/icon";
import { type UrlableModel, modelToUrl } from "metabase/lib/urls/modelToUrl";
import { extractEntityId } from "metabase/lib/urls/utils";
import { Icon } from "metabase/ui";
import { updateMentionsCache } from "metabase-enterprise/documents/documents.slice";
import { useDocumentsDispatch } from "metabase-enterprise/documents/redux-utils";
import type {
Card,
CardDisplayType,
Collection,
Dashboard,
Database,
SearchModel,
Table,
} from "metabase-types/api";
import styles from "./SmartLinkNode.module.css";
type SmartLinkEntity = Card | Dashboard | Collection | Table | Database;
// Utility function to parse entity URLs and extract entityId and model
export function parseEntityUrl(
url: string,
siteUrl?: string,
): { entityId: number; model: SearchModel } | null {
try {
const urlObj = new URL(url);
// Validate URL matches the site-url if provided
if (siteUrl) {
const siteUrlObj = new URL(siteUrl);
// Check if origins match
if (urlObj.origin !== siteUrlObj.origin) {
return null; // URL is not from this Metabase instance
}
// Check if the path starts with the site-url path
const siteUrlPath = siteUrlObj.pathname;
if (!urlObj.pathname.startsWith(siteUrlPath)) {
return null; // URL is not under the correct path
}
} else {
// Fallback: validate against current window origin
if (urlObj.origin !== window.location.origin) {
return null;
}
}
// Remove site-url path prefix if present to get the clean pathname
let cleanPathname = urlObj.pathname;
if (siteUrl) {
const siteUrlPath = new URL(siteUrl).pathname;
// Remove the site-url path prefix, ensuring we don't create double slashes
cleanPathname = urlObj.pathname.replace(siteUrlPath, "");
// Ensure the pathname starts with /
if (!cleanPathname.startsWith("/")) {
cleanPathname = "/" + cleanPathname;
}
}
// Match different entity URL patterns
const patterns = [
{ pattern: /^\/question\/(\d+)/, model: "card" as SearchModel },
{ pattern: /^\/model\/(\d+)/, model: "dataset" as SearchModel },
{ pattern: /^\/dashboard\/(\d+)/, model: "dashboard" as SearchModel },
{ pattern: /^\/collection\/(\d+)/, model: "collection" as SearchModel },
{
pattern: /^\/browse\/(\d+)\/table\/(\d+)/,
model: "table" as SearchModel,
idIndex: 2,
},
{ pattern: /^\/browse\/(\d+)/, model: "database" as SearchModel },
];
for (const { pattern, model, idIndex = 1 } of patterns) {
const match = cleanPathname.match(pattern);
if (match) {
const entityId = extractEntityId(match[idIndex]);
if (entityId) {
return { entityId, model };
}
}
}
return null;
} catch {
return null;
}
}
export const SmartLinkNode = Node.create<{
HTMLAttributes: Record<string, unknown>;
siteUrl?: string;
}>({
name: "smartLink",
group: "inline",
inline: true,
atom: true,
priority: 1000, // Higher priority than Link extension (default 100)
addAttributes() {
return {
entityId: {
default: null,
parseHTML: (element) => {
const id = element.getAttribute("data-entity-id");
return id ? parseInt(id, 10) : null;
},
},
model: {
default: null,
parseHTML: (element) => element.getAttribute("data-model"),
},
};
},
parseHTML() {
return [
{
tag: 'span[data-type="smart-link"]',
},
];
},
renderHTML({ node, HTMLAttributes }) {
const { entityId, model } = node.attrs;
return [
"span",
mergeAttributes(
HTMLAttributes,
{
"data-type": "smart-link",
"data-entity-id": entityId,
"data-model": model,
},
this.options.HTMLAttributes,
),
`{% entity id="${entityId}" model="${model}" %}`,
];
},
renderText({ node }) {
const { entityId, model } = node.attrs;
return `{% entity id="${entityId}" model="${model}" %}`;
},
addPasteRules() {
return [
nodePasteRule({
find: /https?:\/\/[^\s]+/g,
type: this.type,
getAttributes: (match) => {
const url = match[0];
const parsedEntity = parseEntityUrl(url, this.options.siteUrl);
if (parsedEntity) {
return {
entityId: parsedEntity.entityId,
model: parsedEntity.model,
};
}
return null; // Return null to prevent node creation
},
}),
];
},
});
const useEntityData = (entityId: number | null, model: SearchModel | null) => {
const cardQuery = cardApi.useGetCardQuery(
{ id: entityId! },
{ skip: !entityId || (model !== "card" && model !== "dataset") },
);
const dashboardQuery = dashboardApi.useGetDashboardQuery(
{ id: entityId! },
{ skip: !entityId || model !== "dashboard" },
);
const collectionQuery = collectionApi.useGetCollectionQuery(
{ id: entityId! },
{ skip: !entityId || model !== "collection" },
);
const tableQuery = tableApi.useGetTableQuery(
{ id: entityId! },
{ skip: !entityId || model !== "table" },
);
const databaseQuery = databaseApi.useGetDatabaseQuery(
{ id: entityId! },
{ skip: !entityId || model !== "database" },
);
// Determine which query is active and return its state
if (model === "card" || model === "dataset") {
return {
entity: cardQuery.data,
isLoading: cardQuery.isLoading,
error: cardQuery.error,
};
}
if (model === "dashboard") {
return {
entity: dashboardQuery.data,
isLoading: dashboardQuery.isLoading,
error: dashboardQuery.error,
};
}
if (model === "collection") {
return {
entity: collectionQuery.data,
isLoading: collectionQuery.isLoading,
error: collectionQuery.error,
};
}
if (model === "table") {
return {
entity: tableQuery.data,
isLoading: tableQuery.isLoading,
error: tableQuery.error,
};
}
if (model === "database") {
return {
entity: databaseQuery.data,
isLoading: databaseQuery.isLoading,
error: databaseQuery.error,
};
}
return { entity: null, isLoading: false, error: null };
};
export const SmartLinkComponent = memo(
({ node }: NodeViewProps) => {
const { entityId, model } = node.attrs;
const { entity, isLoading, error } = useEntityData(entityId, model);
const documentsDispatch = useDocumentsDispatch();
useEffect(() => {
if (entity?.name) {
documentsDispatch(
updateMentionsCache({ entityId, model, name: entity.name }),
);
}
}, [documentsDispatch, entity?.name, entityId, model]);
if (isLoading) {
return (
<NodeViewWrapper as="span">
<span className={styles.smartLink}>
<span className={styles.smartLinkInner}>
<Icon name="hourglass" className={styles.icon} />
{t`Loading ${model}...`}
</span>
</span>
</NodeViewWrapper>
);
}
if (error || !entity) {
return (
<NodeViewWrapper as="span">
<span className={styles.smartLink}>
<span className={styles.smartLinkInner}>
<Icon name="warning" className={styles.icon} />
{error ? t`Failed to load` : t`Unknown`} {model}
</span>
</span>
</NodeViewWrapper>
);
}
const entityUrlableModel = entityToUrlableModel(entity, model);
const entityUrl = modelToUrl(entityUrlableModel);
const iconData = getIcon(entityToObjectWithModel(entity, model));
return (
<NodeViewWrapper as="span">
<a
href={entityUrl || "#"}
target="_blank"
rel="noreferrer"
onMouseUp={(e) => {
// Stop tiptap from opening this link twice
e.stopPropagation();
}}
className={styles.smartLink}
>
<span className={styles.smartLinkInner}>
<Icon name={iconData.name} className={styles.icon} />
{entity.name}
</span>
</a>
</NodeViewWrapper>
);
},
(prevProps, nextProps) => {
// Custom comparison function to prevent re-renders
// Only re-render if these specific props change
return (
prevProps.node.attrs.entityId === nextProps.node.attrs.entityId &&
prevProps.node.attrs.model === nextProps.node.attrs.model &&
prevProps.selected === nextProps.selected
);
},
);
SmartLinkComponent.displayName = "SmartLinkComponent";
function entityToUrlableModel(
entity: SmartLinkEntity,
model: SearchModel | null,
): UrlableModel {
const result: UrlableModel = {
id: entity.id as number, // it is string | number in reality, but then gets casted to a string in "modelToUrl"
model: (entity as Dashboard).model || model || "",
name: entity.name,
};
if ("db_id" in entity && entity.db_id) {
result.database = {
id: entity.db_id,
};
}
if ("database_id" in entity && entity.database_id) {
result.database = { id: entity.database_id };
}
return result;
}
function entityToObjectWithModel(
entity: SmartLinkEntity,
model: SearchModel | null,
): ObjectWithModel {
return {
model: ((entity as Dashboard).model || model || "") as IconModel,
display: (entity as Card).display as CardDisplayType,
is_personal: (entity as Collection).is_personal,
};
}

View File

@@ -0,0 +1,11 @@
import { ReactNodeViewRenderer } from "@tiptap/react";
import { SmartLinkComponent, SmartLinkNode } from "./SmartLinkNode";
export * from "./SmartLinkNode";
export const SmartLinkEmbed = SmartLinkNode.extend({
addNodeView() {
return ReactNodeViewRenderer(SmartLinkComponent);
},
});

View File

@@ -0,0 +1,34 @@
// Shared utility for generating card embed markdown format
import type { CardEmbedAttributes } from "metabase-enterprise/documents/components/Editor/extensions/CardEmbed";
export function formatCardEmbed(attrs: CardEmbedAttributes): string {
if (attrs.name) {
return `{% card id=${attrs.id} name="${attrs.name}" %}`;
} else {
return `{% card id=${attrs.id} %}`;
}
}
// Shared utility for generating smart link markdown format
export function formatSmartLink(attrs: {
entityId?: number;
model?: string;
}): string {
return `{% entity id="${attrs.entityId}" model="${attrs.model}" %}`;
}
// Shared utility for generating spacer markdown format
export function formatSpacer(lines: number): string {
return `{% spacer lines=${lines} %}`;
}
// Regex pattern for matching card embeds in markdown
export const CARD_EMBED_PATTERN =
/{%\s*card\s+id=(\d+)(?:\s+name="([^"]*)")?\s*%}/g;
// Regex pattern for matching smart links in markdown
export const SMART_LINK_PATTERN =
/{%\s*entity\s+id="(\d+)"\s+model="([^"]+)"\s*%}/g;
// Regex pattern for matching spacers in markdown
export const SPACER_PATTERN = /{%\s*spacer\s+lines=(\d+)\s*%}/g;

View File

@@ -0,0 +1,84 @@
import type MarkdownIt from "markdown-it";
import type { StateBlock } from "markdown-it";
export function cardPlugin(md: MarkdownIt): void {
md.block.ruler.before("paragraph", "card", cardBlock, {
alt: ["paragraph", "reference", "blockquote", "list"],
});
}
function cardBlock(
state: StateBlock,
startLine: number,
_endLine: number,
silent: boolean,
): boolean {
const pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
// Check if line starts with {% card
if (pos + 7 > max) {
return false;
}
if (state.src.slice(pos, pos + 7) !== "{% card") {
return false;
}
// Find the closing %}
const closePos = state.src.indexOf("%}", pos + 7);
if (closePos === -1 || closePos > max) {
return false;
}
// Extract attributes
const content = state.src.slice(pos + 7, closePos).trim();
const attrs = parseCardAttributes(content);
if (!attrs.id) {
return false;
}
if (silent) {
return true;
}
const oldParent = state.parentType;
const oldLineMax = state.lineMax;
state.parentType = "card" as StateBlock.ParentType;
// Create card token
const token = state.push("card", "div", 0);
token.attrSet("id", attrs.id);
if (attrs.name) {
token.attrSet("name", attrs.name);
}
token.map = [startLine, startLine + 1];
state.parentType = oldParent;
state.lineMax = oldLineMax;
state.line = startLine + 1;
return true;
}
function parseCardAttributes(content: string): {
id?: string;
name?: string;
} {
const attrs: { id?: string; name?: string } = {};
// Match id=123
const idMatch = content.match(/id=(\d+)/);
if (idMatch) {
attrs.id = idMatch[1];
}
// Match name="..." (with proper quote handling)
const nameMatch = content.match(/name="([^"]*)"/);
if (nameMatch) {
attrs.name = nameMatch[1];
}
return attrs;
}

View File

@@ -0,0 +1,220 @@
import { Extension } from "@tiptap/core";
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
import MarkdownIt from "markdown-it";
import {
MarkdownParser,
MarkdownSerializer,
type ParseSpec,
} from "prosemirror-markdown";
import { cardPlugin } from "./cardPlugin";
import { linkPlugin } from "./linkPlugin";
import { createParserTokens } from "./markdown-to-prosemirror";
import {
createMarkSerializers,
createNodeSerializers,
} from "./prosemirror-to-markdown";
import { spacerPlugin } from "./spacerPlugin";
declare module "@tiptap/core" {
interface Commands<ReturnType> {
markdown: {
getMarkdown: () => string extends ReturnType ? string : ReturnType;
setMarkdown: (markdown: string) => ReturnType;
};
}
interface Storage {
markdown: MarkdownStorage;
}
interface EditorEvents {
runMetabot: ProseMirrorNode; // the metabot node in the AST
}
}
export interface MarkdownStorage {
getMarkdown: () => string;
setMarkdown: (markdown: string, emit?: boolean) => void;
parser: MarkdownParser | null;
serializer: MarkdownSerializer | null;
}
export const Markdown = Extension.create<
{
extraNodeSerializers?: MarkdownSerializer["nodes"];
extraTokenSpecs?: Record<string, ParseSpec>;
extraMarkSerializers?: MarkdownSerializer["marks"];
},
MarkdownStorage
>({
name: "markdown",
addOptions() {
return {
extraNodeSerializers: {},
extraTokenSpecs: {},
extraMarkSerializers: {},
};
},
addStorage() {
return {
parser: null,
serializer: null,
getMarkdown: () => "",
setMarkdown: () => {},
} as MarkdownStorage;
},
onBeforeCreate() {
const getSerializer = () => {
if (this.storage.serializer) {
return this.storage.serializer;
}
const schema = this.editor.state.schema;
const nodeSerializers = createNodeSerializers(
this.options.extraNodeSerializers,
);
const markSerializers = createMarkSerializers(
this.options.extraMarkSerializers,
);
// Validate that all nodes in schema have serializers
const missingNodeSerializers = Object.keys(schema.nodes).filter(
(nodeName) => !nodeSerializers[nodeName],
);
if (missingNodeSerializers.length > 0) {
console.error(
`Missing serializers for nodes: ${missingNodeSerializers.join(", ")}`,
);
}
// Validate that all marks in schema have serializers
const missingMarkSerializers = Object.keys(schema.marks).filter(
(markName) => !markSerializers[markName],
);
if (missingMarkSerializers.length > 0) {
throw new Error(
`Missing serializers for marks: ${missingMarkSerializers.join(", ")}`,
);
}
this.storage.serializer = new MarkdownSerializer(
nodeSerializers,
markSerializers,
);
return this.storage.serializer;
};
const getParser = () => {
if (this.storage.parser) {
return this.storage.parser;
}
const schema = this.editor.state.schema;
const md = new MarkdownIt("commonmark", {
html: false,
linkify: true,
breaks: false,
});
md.use(spacerPlugin);
md.use(cardPlugin);
md.use(linkPlugin);
// Add a rule to expand spacer tokens to multiple empty paragraphs
md.core.ruler.after("block", "expand_spacers", (state) => {
const tokens = state.tokens;
const newTokens = [];
for (let i = 0; i < tokens.length; i++) {
const token = tokens[i];
if (token.type === "spacer") {
const lines = parseInt(token.attrGet("lines") ?? "1", 10);
// Create multiple empty paragraph tokens
for (let j = 0; j < lines; j++) {
const pOpen = new state.Token("paragraph_open", "p", 1);
pOpen.block = true;
pOpen.map = token.map; // Preserve source mapping
const inline = new state.Token("inline", "", 0);
inline.content = "";
inline.children = [];
inline.nesting = 0;
const pClose = new state.Token("paragraph_close", "p", -1);
pClose.block = true;
newTokens.push(pOpen, inline, pClose);
}
} else {
newTokens.push(token);
}
}
state.tokens = newTokens;
});
const tokens = createParserTokens(this.options.extraTokenSpecs);
this.storage.parser = new MarkdownParser(schema, md, tokens);
return this.storage.parser;
};
this.storage.getMarkdown = () => {
return getSerializer().serialize(this.editor.state.doc, {
tightLists: false, // Allow loose lists to preserve spacing
});
};
this.storage.setMarkdown = (markdown: string) => {
try {
if (!markdown || typeof markdown !== "string") {
console.warn("Invalid markdown content provided:", markdown);
return;
}
const doc = getParser().parse(markdown);
if (!doc) {
console.warn("Failed to parse markdown into document");
return;
}
if (!this.editor || this.editor.isDestroyed) {
console.warn("Editor is not available or destroyed");
return;
}
// Check if the editor is ready and has a valid schema
if (!this.editor.state || !this.editor.state.schema) {
console.warn("Editor not ready for content update");
return;
}
// Validate that the document matches the schema
try {
const docJSON = doc.toJSON();
this.editor.chain().setContent(docJSON).run();
} catch (validationError) {
console.error("Document validation failed:", validationError);
}
} catch (error) {
console.error("Failed to parse and set markdown content:", error);
}
};
},
addCommands() {
return {
setMarkdown: (markdown: string) => () => {
this.storage.setMarkdown(markdown);
return true;
},
};
},
});

View File

@@ -0,0 +1,63 @@
import type MarkdownIt from "markdown-it";
import type { StateInline } from "markdown-it";
export function linkPlugin(md: MarkdownIt): void {
md.inline.ruler.before("text", "smartlink", smartLinkInline);
}
function smartLinkInline(state: StateInline, silent: boolean): boolean {
const pos = state.pos;
const max = state.posMax;
// Check if we have enough characters for {% entity
if (pos + 9 > max) {
return false;
}
if (state.src.slice(pos, pos + 9) !== "{% entity") {
return false;
}
// Find the closing %}
const closePos = state.src.indexOf("%}", pos + 9);
if (closePos === -1) {
return false;
}
// Extract attributes
const content = state.src.slice(pos + 9, closePos).trim();
const attrs = parseEntityAttributes(content);
if (!attrs.id || !attrs.model) {
return false;
}
if (!silent) {
const token = state.push("smartlink", "", 0);
token.attrSet("id", attrs.id);
token.attrSet("model", attrs.model);
}
state.pos = closePos + 2;
return true;
}
function parseEntityAttributes(content: string): {
id?: string;
model?: string;
} {
const attrs: { id?: string; model?: string } = {};
// Match id="..."
const idMatch = content.match(/id="([^"]*)"/);
if (idMatch) {
attrs.id = idMatch[1];
}
// Match model="..."
const modelMatch = content.match(/model="([^"]*)"/);
if (modelMatch) {
attrs.model = modelMatch[1];
}
return attrs;
}

View File

@@ -0,0 +1,95 @@
import Blockquote from "@tiptap/extension-blockquote";
import Bold from "@tiptap/extension-bold";
import BulletList from "@tiptap/extension-bullet-list";
import Code from "@tiptap/extension-code";
import CodeBlock from "@tiptap/extension-code-block";
import HardBreak from "@tiptap/extension-hard-break";
import Heading from "@tiptap/extension-heading";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Image from "@tiptap/extension-image";
import Italic from "@tiptap/extension-italic";
import ListItem from "@tiptap/extension-list-item";
import OrderedList from "@tiptap/extension-ordered-list";
import Paragraph from "@tiptap/extension-paragraph";
import Strike from "@tiptap/extension-strike";
import Underline from "@tiptap/extension-underline";
import type { Token } from "markdown-it";
import type { ParseSpec } from "prosemirror-markdown";
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
// Parser tokens - how markdown tokens are converted to ProseMirror nodes/marks
export const PARSER_TOKENS: Record<string, ParseSpec> = {
// Basic blocks
paragraph: { block: Paragraph.name },
blockquote: { block: Blockquote.name },
heading: {
block: Heading.name,
getAttrs: (tok: Token) => ({ level: +tok.tag.slice(1) }),
},
// Lists
bullet_list: { block: BulletList.name },
ordered_list: {
block: OrderedList.name,
getAttrs: (tok: Token) => ({ order: +(tok.attrGet("start") ?? 1) }),
},
list_item: { block: ListItem.name },
// Code
code_block: { block: CodeBlock.name, noCloseToken: true },
fence: {
block: CodeBlock.name,
getAttrs: (tok: Token) => ({ params: tok.info || "" }),
noCloseToken: true,
},
// Other nodes
hr: { node: HorizontalRule.name },
hardbreak: { node: HardBreak.name },
image: {
node: Image.name,
getAttrs: (tok: Token) => ({
src: tok.attrGet("src"),
alt: tok.content || null,
title: tok.attrGet("title") || null,
}),
},
// Marks
strong: { mark: Bold.name },
em: { mark: Italic.name },
code_inline: { mark: Code.name, noCloseToken: true },
link: {
mark: "link",
getAttrs: (tok: Token) => ({
href: tok.attrGet("href"),
title: tok.attrGet("title") || null,
}),
},
s: { mark: Strike.name },
u: { mark: Underline.name },
// Custom nodes
card: {
node: CardEmbedNode.name,
getAttrs: (tok: Token) => ({
id: parseInt(tok.attrGet("id") || "0", 10),
name: tok.attrGet("name") || null,
}),
},
smartlink: {
node: SmartLinkNode.name,
getAttrs: (tok: Token) => ({
entityId: parseInt(tok.attrGet("id") || "0", 10),
model: tok.attrGet("model"),
}),
},
};
export function createParserTokens(
extraTokens: Record<string, ParseSpec> = {},
): Record<string, ParseSpec> {
return { ...PARSER_TOKENS, ...extraTokens };
}

View File

@@ -0,0 +1,143 @@
import Blockquote from "@tiptap/extension-blockquote";
import Bold from "@tiptap/extension-bold";
import BulletList from "@tiptap/extension-bullet-list";
import Code from "@tiptap/extension-code";
import CodeBlock from "@tiptap/extension-code-block";
import HardBreak from "@tiptap/extension-hard-break";
import Heading from "@tiptap/extension-heading";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
import Image from "@tiptap/extension-image";
import Italic from "@tiptap/extension-italic";
import ListItem from "@tiptap/extension-list-item";
import OrderedList from "@tiptap/extension-ordered-list";
import Paragraph from "@tiptap/extension-paragraph";
import Strike from "@tiptap/extension-strike";
import Text from "@tiptap/extension-text";
import Underline from "@tiptap/extension-underline";
import {
type MarkdownSerializer,
defaultMarkdownSerializer,
} from "prosemirror-markdown";
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
import {
formatCardEmbed,
formatSmartLink,
formatSpacer,
} from "./card-embed-format";
export const NODE_SERIALIZERS: MarkdownSerializer["nodes"] = {
doc: (state, node) => {
state.renderContent(node);
},
// Text nodes
[Text.name]: defaultMarkdownSerializer.nodes.text,
[Paragraph.name]: (state, node, parent, index) => {
// Handle empty paragraphs at document level
if (parent.type.name === "doc" && node.content.size === 0) {
// Look ahead to find consecutive empty paragraphs
let emptyCount = 1; // Current paragraph is empty
let nextIndex = index + 1;
while (nextIndex < parent.childCount) {
const nextChild = parent.child(nextIndex);
if (
nextChild.type.name === "paragraph" &&
nextChild.content.size === 0
) {
emptyCount++;
nextIndex++;
} else {
break;
}
}
// Only output spacer for the FIRST empty paragraph in a sequence
// The rest will be skipped by checking if we're in the middle of a sequence
let isFirstInSequence = true;
if (index > 0) {
const prevChild = parent.child(index - 1);
if (
prevChild.type.name === "paragraph" &&
prevChild.content.size === 0
) {
isFirstInSequence = false;
}
}
if (isFirstInSequence) {
state.write(formatSpacer(emptyCount));
state.closeBlock(node);
}
// Skip subsequent empty paragraphs in the sequence
} else {
// Handle non-empty paragraphs or paragraphs in nested contexts normally
defaultMarkdownSerializer.nodes.paragraph(state, node, parent, index);
}
},
// Headings
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
// Lists
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
[OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
// Blocks
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
[CodeBlock.name]: defaultMarkdownSerializer.nodes.code_block,
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
// Breaks
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
// Image (from TipTap Image extension)
[Image.name]: defaultMarkdownSerializer.nodes.image,
// Card embed nodes
[CardEmbedNode.name]: (state, node) => {
state.write(formatCardEmbed(node.attrs));
state.closeBlock(node);
},
// Smart link nodes
[SmartLinkNode.name]: (state, node) => {
state.write(formatSmartLink(node.attrs));
},
};
// Mark serializers - how ProseMirror marks are converted to markdown
export const MARK_SERIALIZERS: MarkdownSerializer["marks"] = {
[Bold.name]: defaultMarkdownSerializer.marks.strong,
[Italic.name]: defaultMarkdownSerializer.marks.em,
[Code.name]: defaultMarkdownSerializer.marks.code,
link: defaultMarkdownSerializer.marks.link,
[Strike.name]: {
open: "~~",
close: "~~",
mixable: true,
expelEnclosingWhitespace: true,
},
[Underline.name]: {
open: "<u>",
close: "</u>",
mixable: true,
expelEnclosingWhitespace: true,
},
};
export function createNodeSerializers(
extraSerializers: MarkdownSerializer["nodes"] = {},
): MarkdownSerializer["nodes"] {
return { ...NODE_SERIALIZERS, ...extraSerializers };
}
export function createMarkSerializers(
extraSerializers: MarkdownSerializer["marks"] = {},
): MarkdownSerializer["marks"] {
return { ...MARK_SERIALIZERS, ...extraSerializers };
}

View File

@@ -0,0 +1,37 @@
import type MarkdownIt from "markdown-it";
import type { StateBlock } from "markdown-it";
export const spacerPlugin = (md: MarkdownIt) => {
const RE = /^\{%[ \t]*spacer[ \t]+lines=(\d+)[ \t]*%}[ \t]*$/;
function spacerRule(
state: StateBlock,
startLine: number,
_endLine: number,
silent: boolean,
) {
const pos = state.bMarks[startLine] + state.tShift[startLine];
const max = state.eMarks[startLine];
const lineStr = state.src.slice(pos, max);
const m = RE.exec(lineStr);
if (!m) {
return false;
}
if (silent) {
return true;
}
const token = state.push("spacer", "", 0);
token.block = true;
token.map = [startLine, startLine + 1];
token.attrSet("lines", m[1]);
state.line = startLine + 1;
return true;
}
md.block.ruler.before("fence", "spacer", spacerRule, {
alt: ["paragraph", "blockquote", "list"],
});
};

View File

@@ -0,0 +1,63 @@
import { t } from "ttag";
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
import { Box, Divider, Text } from "metabase/ui";
import type { SearchResult } from "metabase-types/api";
import type { MenuItem } from "../../shared/MenuComponents";
import {
MenuItemComponent,
SearchResultsFooter,
} from "../../shared/MenuComponents";
interface EntitySearchSectionProps {
menuItems: MenuItem[];
selectedIndex: number;
onItemSelect: (index: number) => void;
onFooterClick: () => void;
query: string;
searchResults: SearchResult[];
modal: "question-picker" | null;
onModalSelect: (item: QuestionPickerValueItem) => void;
onModalClose: () => void;
}
export function EntitySearchSection({
menuItems,
selectedIndex,
onItemSelect,
onFooterClick,
query,
searchResults,
modal,
onModalSelect,
onModalClose,
}: EntitySearchSectionProps) {
return (
<>
{menuItems.map((item, index) => (
<MenuItemComponent
key={index}
item={item}
isSelected={selectedIndex === index}
onClick={() => onItemSelect(index)}
/>
))}
{query.length > 0 && searchResults.length === 0 ? (
<Box p="sm" ta="center">
<Text size="md" c="text-medium">{t`No results found`}</Text>
</Box>
) : null}
<Divider my="sm" mx="sm" />
<SearchResultsFooter
isSelected={selectedIndex === menuItems.length}
onClick={onFooterClick}
/>
{modal === "question-picker" && (
<QuestionPickerModal onChange={onModalSelect} onClose={onModalClose} />
)}
</>
);
}

View File

@@ -0,0 +1,15 @@
import type { SearchModel } from "metabase-types/api";
export const LINK_SEARCH_MODELS: SearchModel[] = [
"card",
"dataset",
"metric",
"dashboard",
"database",
"table",
"collection",
];
export const EMBED_SEARCH_MODELS: SearchModel[] = ["card", "dataset"];
export const LINK_SEARCH_LIMIT = 5;

View File

@@ -0,0 +1,71 @@
import { getIcon } from "metabase/lib/icon";
import { getName } from "metabase/lib/name";
import type {
Database,
RecentCollectionItem,
RecentItem,
SearchModel,
SearchResult,
} from "metabase-types/api";
import type { MenuItem } from "../../shared/MenuComponents";
export const isRecentQuestion = (
item: RecentItem,
): item is RecentCollectionItem & { model: "card" | "dataset" } =>
item.model === "card" || item.model === "dataset";
export function buildSearchMenuItems(
searchResults: SearchResult[],
onSelect: (result: SearchResult) => void,
): MenuItem[] {
return searchResults.map((result) => {
const iconData = getIcon({
model: result.model,
// display is optional in SearchResult, pass through when present
display: result.display ?? undefined,
});
return {
icon: iconData.name,
label: result.name,
id: result.id,
model: result.model as SearchModel,
action: () => onSelect(result),
};
});
}
export function buildRecentsMenuItems(
recents: Array<RecentCollectionItem & { model: "card" | "dataset" }>,
onSelect: (recent: RecentCollectionItem) => void,
): MenuItem[] {
return recents.map((recent) => {
const iconData = getIcon({
model: recent.model,
display: recent.display ?? undefined,
});
return {
icon: iconData.name,
label: getName(recent),
id: recent.id,
model: recent.model as SearchModel,
action: () => onSelect(recent),
};
});
}
export function buildDbMenuItems(
dbs: Database[],
onSelect: (db: Database) => void,
): MenuItem[] {
return dbs.map((db) => {
const iconData = getIcon({ model: "database" });
return {
icon: iconData.name,
label: db.name,
id: db.id,
model: "database",
action: () => onSelect(db),
};
});
}

View File

@@ -0,0 +1,106 @@
import { useMemo } from "react";
import { useListRecentsQuery, useSearchQuery } from "metabase/api";
import type { RecentItem, SearchModel, SearchResult } from "metabase-types/api";
import type { MenuItem } from "../../shared/MenuComponents";
import { LINK_SEARCH_LIMIT, LINK_SEARCH_MODELS } from "./constants";
import {
buildRecentsMenuItems,
buildSearchMenuItems,
isRecentQuestion,
} from "./suggestionUtils";
interface UseEntitySearchOptions {
query: string;
onSelectRecent: (item: RecentItem) => void;
onSelectSearchResult: (item: SearchResult) => void;
enabled?: boolean;
searchModels?: SearchModel[];
}
interface UseEntitySearchResult {
menuItems: MenuItem[];
isLoading: boolean;
searchResults: SearchResult[];
}
export function useEntitySearch({
query,
onSelectRecent,
onSelectSearchResult,
enabled = true,
searchModels = LINK_SEARCH_MODELS,
}: UseEntitySearchOptions): UseEntitySearchResult {
const shouldFetchRecents = enabled && query.length === 0;
const { data: recents = [], isLoading: isRecentsLoading } =
useListRecentsQuery(undefined, {
refetchOnMountOrArgChange: true,
skip: !shouldFetchRecents,
});
const filteredRecents = useMemo(
() =>
shouldFetchRecents
? recents.filter(isRecentQuestion).slice(0, LINK_SEARCH_LIMIT)
: [],
[recents, shouldFetchRecents],
);
const { data: searchResponse, isLoading: isSearchLoading } = useSearchQuery(
{
q: query,
models: searchModels,
limit: LINK_SEARCH_LIMIT,
},
{
skip: !enabled || !query || query.length === 0,
},
);
const searchResults = useMemo(
() => (searchResponse?.data as SearchResult[]) ?? [],
[searchResponse],
);
const menuItems = useMemo(() => {
const items: MenuItem[] = [];
if (query.length > 0) {
// When there's a query, always show search results (even if empty)
// Don't show recents when searching
if (!isSearchLoading) {
items.push(
...buildSearchMenuItems(searchResults, onSelectSearchResult),
);
}
} else {
// Only show recents when there's no query
if (!isRecentsLoading && filteredRecents.length > 0) {
items.push(...buildRecentsMenuItems(filteredRecents, onSelectRecent));
}
}
return items;
}, [
query,
searchResults,
isSearchLoading,
filteredRecents,
isRecentsLoading,
onSelectRecent,
onSelectSearchResult,
]);
const isLoading =
(shouldFetchRecents && isRecentsLoading) ||
(query.length > 0 && isSearchLoading);
return {
menuItems,
isLoading,
searchResults,
};
}

View File

@@ -0,0 +1,165 @@
import type { Editor } from "@tiptap/core";
import { useCallback, useEffect, useState } from "react";
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
import type { RecentItem, SearchModel, SearchResult } from "metabase-types/api";
import { useEntitySearch } from "./useEntitySearch";
interface UseEntitySuggestionsOptions {
query: string;
editor: Editor;
onSelectEntity: (item: { id: number | string; model: string }) => void;
enabled?: boolean;
searchModels?: SearchModel[];
}
interface UseEntitySuggestionsResult {
menuItems: ReturnType<typeof useEntitySearch>["menuItems"];
isLoading: boolean;
searchResults: SearchResult[];
selectedIndex: number;
modal: "question-picker" | null;
totalItems: number;
handlers: {
selectItem: (index: number) => void;
upHandler: () => void;
downHandler: () => void;
enterHandler: () => void;
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
handleModalSelect: (item: QuestionPickerValueItem) => void;
handleModalClose: () => void;
openModal: () => void;
};
}
export function useEntitySuggestions({
query,
editor,
onSelectEntity,
enabled = true,
searchModels,
}: UseEntitySuggestionsOptions): UseEntitySuggestionsResult {
const [selectedIndex, setSelectedIndex] = useState(0);
const [modal, setModal] = useState<"question-picker" | null>(null);
const handleRecentSelect = useCallback(
(item: RecentItem) => {
onSelectEntity({
id: item.id,
model: item.model,
});
},
[onSelectEntity],
);
const handleSearchResultSelect = useCallback(
(item: SearchResult) => {
onSelectEntity({
id: item.id,
model: item.model,
});
},
[onSelectEntity],
);
const { menuItems, isLoading, searchResults } = useEntitySearch({
query,
onSelectRecent: handleRecentSelect,
onSelectSearchResult: handleSearchResultSelect,
enabled,
searchModels,
});
const totalItems = menuItems.length + 1;
const selectItem = useCallback(
(index: number) => {
if (index < menuItems.length) {
menuItems[index].action();
} else {
setModal("question-picker");
}
},
[menuItems],
);
const upHandler = useCallback(() => {
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
}, [totalItems]);
const downHandler = useCallback(() => {
setSelectedIndex((prev) => (prev + 1) % totalItems);
}, [totalItems]);
const enterHandler = useCallback(() => {
selectItem(selectedIndex);
}, [selectItem, selectedIndex]);
const onKeyDown = useCallback(
({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}
if (event.key === "ArrowDown") {
downHandler();
return true;
}
if (event.key === "Enter") {
enterHandler();
return true;
}
return false;
},
[upHandler, downHandler, enterHandler],
);
const handleModalSelect = useCallback(
(item: QuestionPickerValueItem) => {
onSelectEntity({
id: item.id,
model: item.model,
});
setModal(null);
},
[onSelectEntity],
);
const handleModalClose = useCallback(() => {
setModal(null);
setTimeout(() => {
editor.commands.focus();
}, 0);
}, [editor]);
const openModal = useCallback(() => {
setModal("question-picker");
}, []);
useEffect(() => {
setSelectedIndex(0);
}, [menuItems.length]);
return {
menuItems,
isLoading,
searchResults,
selectedIndex,
modal,
totalItems,
handlers: {
selectItem,
upHandler,
downHandler,
enterHandler,
onKeyDown,
handleModalSelect,
handleModalClose,
openModal,
},
};
}

View File

@@ -0,0 +1,120 @@
import { type VirtualElement, computePosition } from "@floating-ui/dom";
import { ReactRenderer } from "@tiptap/react";
import type {
SuggestionKeyDownProps,
SuggestionProps,
} from "@tiptap/suggestion";
import type React from "react";
export const createSuggestionRenderer = <I = unknown, TSelected = unknown>(
SuggestionComponent: React.ComponentType<SuggestionProps<I, TSelected>>,
) => {
return () => {
let component: ReactRenderer | undefined;
let currentClientRect: (() => DOMRect | null) | null | undefined;
function repositionComponent(clientRect: DOMRect | null) {
if (!component || !component.element) {
return;
}
if (!clientRect) {
return;
}
const element = component.element;
if (!(element instanceof HTMLElement)) {
return;
}
const virtualElement: VirtualElement = {
getBoundingClientRect() {
return clientRect;
},
};
computePosition(virtualElement, element, {
placement: "bottom-start",
}).then((pos) => {
if (component?.element instanceof HTMLElement) {
Object.assign(component.element.style, {
left: `${pos.x}px`,
top: `${pos.y}px`,
position: pos.strategy === "fixed" ? "fixed" : "absolute",
zIndex: "3",
});
}
});
}
const handleScroll = () => {
if (currentClientRect) {
repositionComponent(currentClientRect());
}
};
return {
onStart: (props: SuggestionProps<I, TSelected>) => {
component = new ReactRenderer(SuggestionComponent, {
props,
editor: props.editor,
});
if (component.element instanceof HTMLElement) {
document.body.appendChild(component.element);
}
currentClientRect = props.clientRect;
repositionComponent(props.clientRect?.() ?? null);
// Add scroll listeners to reposition on scroll
window.addEventListener("scroll", handleScroll, true);
window.addEventListener("resize", handleScroll);
},
onUpdate(props: SuggestionProps<I, TSelected>) {
component?.updateProps(props);
currentClientRect = props.clientRect;
repositionComponent(props.clientRect?.() ?? null);
},
onKeyDown(props: SuggestionKeyDownProps) {
if (props.event.key === "Escape") {
if (
component?.element instanceof HTMLElement &&
document.body.contains(component.element)
) {
document.body.removeChild(component.element);
}
component?.destroy();
return true;
}
const ref = component?.ref;
if (
ref &&
typeof ref === "object" &&
"onKeyDown" in ref &&
typeof ref.onKeyDown === "function"
) {
return ref.onKeyDown(props);
}
return false;
},
onExit() {
// Remove scroll listeners
window.removeEventListener("scroll", handleScroll, true);
window.removeEventListener("resize", handleScroll);
if (
component?.element instanceof HTMLElement &&
document.body.contains(component.element)
) {
document.body.removeChild(component.element);
}
component?.destroy();
currentClientRect = undefined;
},
};
};
};

View File

@@ -0,0 +1,2 @@
export { useCardEmbedSelection as useQuestionSelection } from "./useCardEmbedSelection";
export { useCardEmbedsTracking } from "./useCardEmbedsTracking";

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