Compare commits

...

358 Commits

Author SHA1 Message Date
John Swanson
55c5ded341 Fix tests 2025-10-28 14:38:09 -07:00
Brad Anderson
3110fbbad4 UXW-2155 Hides "Add" button for models and metrics if user does not have data access 2025-10-28 12:58:23 -04:00
Brad Anderson
8d80ea4745 UXW-2152 Adds cancel buttons for models and metrics 2025-10-28 11:36:00 -04:00
Brad Anderson
5ddda06d19 UXW-2152 Adds tabs slot to BenchPaneHeader for better alignment, adds withBorder to models/metrics instances 2025-10-28 10:11:11 -04:00
Alexander Polyankin
af1f08d495 Fix 2025-10-27 22:22:07 -04:00
Alexander Polyankin
9edccfcda7 Fix 2025-10-27 22:18:16 -04:00
Alexander Polyankin
ae1413f78a Fix 2025-10-27 21:18:02 -04:00
Alexander Polyankin
17711d1948 Fix 2025-10-27 21:12:49 -04:00
Alexander Polyankin
d0091b7a35 Merge remote-tracking branch 'origin/feature/workbench' into feature/workbench 2025-10-27 21:11:31 -04:00
Alexander Polyankin
39007c80e3 Fix 2025-10-27 21:11:13 -04:00
Alexander Polyankin
6172456495 Fix 2025-10-27 20:46:46 -04:00
Aleksandr Lesnenko
f02c1f3032 tiny ui polish 2025-10-27 18:11:15 -04:00
Brad Anderson
9cfbb06022 UXW-2120 Adds info button for metrics, makes it so they can be renamed 2025-10-27 17:47:04 -04:00
Denis Berezin
851a46ef08 Merge branch 'master' into feature/workbench
# Conflicts:
#	frontend/src/metabase/querying/metrics/components/MetricEditor/MetricEditor.tsx
2025-10-28 00:42:56 +03:00
Denis Berezin
d15a88abb1 Fix typecheck 2025-10-28 00:41:13 +03:00
Denis Berezin
bf3126f5fd Uxw 2130 polish lists UI (#65075)
* UXW-2130 Polish lists UI

* Remove automatic menu open on first bench launch
2025-10-28 00:33:48 +03:00
Sloan Sparger
7fab07f8a7 Fix transforms codegen feature in workbench (#65070)
* fix transform codegen feature + related e2e tests

* rip out admin metabot button

* impl automated pr feedback
2025-10-27 16:05:29 -05:00
Brad Anderson
4d43b1fd76 UXW-2120 Makes model info button work more like chill mode model editor (only present when not dirty to prevent data from being overwritten) 2025-10-27 16:55:08 -04:00
Brad Anderson
180ab8289e Removes now-unnecessary invalidateTags calls 2025-10-27 16:26:09 -04:00
Brad Anderson
c77a7d2c6e UXW-2120 Handles changing name input in new models 2025-10-27 15:58:31 -04:00
Brad Anderson
0c2492ca2d UXW-2120 Updates name on blur for models in workbench 2025-10-27 15:50:07 -04:00
Brad Anderson
f32e2ca250 Adds info button and sidebar to model editor 2025-10-27 13:28:58 -04:00
Alexander Polyankin
c29943cc6e Fix 2025-10-27 13:13:35 -04:00
Alexander Polyankin
9d50ac2b2c paddings 2025-10-27 13:05:59 -04:00
Alexander Polyankin
6b03084603 paddings 2025-10-27 12:58:59 -04:00
Alexander Polyankin
3a2fd8dec5 paddings 2025-10-27 12:57:27 -04:00
Alexander Polyankin
aa318f3c3f loaders 2025-10-27 12:48:53 -04:00
Alexander Polyankin
46451c391b merge 2025-10-27 12:43:30 -04:00
Alexander Polyankin
fc356df795 Fix node layout 2025-10-27 12:38:50 -04:00
Alexander Polyankin
7ef6084b82 Merge remote-tracking branch 'origin/feature/workbench' into feature/workbench 2025-10-27 11:57:46 -04:00
Alexander Polyankin
c2311a937c Fix run button state 2025-10-27 11:57:35 -04:00
Brad Anderson
eac016b6e0 Starts adding editable title for models/metrics 2025-10-27 11:55:01 -04:00
Alexander Polyankin
63c6cb2e8a wip 2025-10-27 11:40:06 -04:00
Alexander Polyankin
c91abe2289 wip 2025-10-27 11:30:53 -04:00
Alexander Polyankin
ccbcc9d827 wip 2025-10-27 11:22:32 -04:00
Alexander Polyankin
6f23b3628c Merge remote-tracking branch 'origin/feature/workbench' into feature/workbench
# Conflicts:
2025-10-27 11:17:56 -04:00
Alexander Polyankin
754c46ccff wip 2025-10-27 11:17:44 -04:00
Denis Berezin
2435844a0a UXW-2131 main section nav hover state color 2025-10-27 18:04:51 +03:00
Alexander Polyankin
a8233878b1 wip 2025-10-27 11:01:20 -04:00
Alexander Polyankin
51eea03918 wip 2025-10-27 10:58:10 -04:00
Alexander Polyankin
3d257d9cb8 wip 2025-10-27 10:57:51 -04:00
Alexander Polyankin
2ab7a09e74 wip 2025-10-27 10:56:26 -04:00
Alexander Polyankin
44e7b1fec4 wip 2025-10-27 10:32:28 -04:00
Alexander Polyankin
137dc1f6c0 wip 2025-10-27 10:23:42 -04:00
Alexander Polyankin
b1d86a1f64 wip 2025-10-27 10:12:12 -04:00
Alexander Polyankin
fdaa2725e2 wip 2025-10-27 10:10:43 -04:00
Alexander Polyankin
918ee7d967 wip 2025-10-27 10:07:28 -04:00
Alexander Polyankin
8616e0201b wip 2025-10-27 01:04:47 -04:00
Alexander Polyankin
093ff50138 wip 2025-10-27 01:01:43 -04:00
Alexander Polyankin
04639e0f89 wip 2025-10-27 00:58:16 -04:00
Alexander Polyankin
8903ee8503 wip 2025-10-27 00:56:57 -04:00
Alexander Polyankin
a2da9a591d wip 2025-10-27 00:48:28 -04:00
Alexander Polyankin
ce64753799 wip 2025-10-27 00:43:14 -04:00
Alexander Polyankin
0787aba9df wip 2025-10-27 00:33:14 -04:00
Alexander Polyankin
19f0abe663 wip 2025-10-27 00:31:28 -04:00
Alexander Polyankin
7cb77b683d wip 2025-10-27 00:29:38 -04:00
Alexander Polyankin
c1eaec238d wip 2025-10-27 00:14:20 -04:00
Alexander Polyankin
bd19df717b wip 2025-10-27 00:10:36 -04:00
Alexander Polyankin
819df161e8 wip 2025-10-27 00:10:22 -04:00
Alexander Polyankin
f0e235a9a1 wip 2025-10-27 00:09:59 -04:00
Alexander Polyankin
a5acb175bf wip 2025-10-26 23:58:06 -04:00
Alexander Polyankin
50bc335216 wip 2025-10-26 23:38:49 -04:00
Alexander Polyankin
1eedf421ec wip 2025-10-26 23:37:29 -04:00
Alexander Polyankin
35ea73bafa wip 2025-10-26 23:36:59 -04:00
Alexander Polyankin
e50f7eebac wip 2025-10-26 23:34:04 -04:00
Alexander Polyankin
4e48c521cc wip 2025-10-26 23:28:51 -04:00
Alexander Polyankin
d369ae87a2 wip 2025-10-26 23:26:19 -04:00
Alexander Polyankin
2fd96a5131 wip 2025-10-26 23:21:27 -04:00
Alexander Polyankin
69e1f4b44f wip 2025-10-26 22:48:38 -04:00
Alexander Polyankin
4b6f6720b8 wip 2025-10-26 22:36:57 -04:00
Alexander Polyankin
004b5e19aa wip 2025-10-26 22:34:17 -04:00
Alexander Polyankin
d6a7827fdb wip 2025-10-26 22:30:14 -04:00
Alexander Polyankin
8dd754914e wip 2025-10-26 22:19:01 -04:00
Alexander Polyankin
cb8107c889 wip 2025-10-25 23:19:57 -04:00
Alexander Polyankin
c46f10cca9 wip 2025-10-25 23:14:11 -04:00
Alexander Polyankin
600a5adc60 wip 2025-10-25 23:12:11 -04:00
Alexander Polyankin
d8d4aa62c6 wip 2025-10-25 23:11:15 -04:00
Alexander Polyankin
e8d0d7ccf0 wip 2025-10-25 23:06:06 -04:00
Alexander Polyankin
8a94afe24e wip 2025-10-25 23:01:13 -04:00
Alexander Polyankin
8b7b9f23ee wip 2025-10-25 22:59:04 -04:00
Alexander Polyankin
846a06d468 wip 2025-10-25 22:46:07 -04:00
Alexander Polyankin
334f6764a4 wip 2025-10-25 22:30:10 -04:00
Alexander Polyankin
2d238a59b8 wip 2025-10-25 21:48:24 -04:00
Alexander Polyankin
aebc2f9cec wip 2025-10-25 21:46:09 -04:00
Alexander Polyankin
ab3ad35169 wip 2025-10-25 15:40:47 -04:00
Alexander Polyankin
741c5141a1 wip 2025-10-25 15:20:31 -04:00
Alexander Polyankin
66d0ca33a1 wip 2025-10-25 15:18:17 -04:00
Alexander Polyankin
2a77406380 Add dependencies 2025-10-25 14:16:36 -04:00
Alexander Polyankin
d82859096b Add dependencies 2025-10-25 13:59:03 -04:00
Alexander Polyankin
a4d54730a7 Add dependencies 2025-10-25 13:56:54 -04:00
Alexander Polyankin
cde71d4e7b Add dependencies 2025-10-25 13:48:47 -04:00
Alexander Polyankin
7ba374ed2c Add dependencies 2025-10-25 13:42:36 -04:00
Alexander Polyankin
9ff08e435e Add dependencies 2025-10-25 12:46:47 -04:00
Alexander Polyankin
66bbc46041 Add dependencies 2025-10-25 09:07:19 -04:00
Alexander Polyankin
fa39c3f3a2 Add dependencies 2025-10-25 08:47:57 -04:00
Alexander Polyankin
1f47cabe12 Add dependencies 2025-10-25 08:42:58 -04:00
Alexander Polyankin
2a909a7331 Add dependencies 2025-10-25 08:38:53 -04:00
Alexander Polyankin
38f2183fd8 Add dependencies 2025-10-25 08:29:45 -04:00
Alexander Polyankin
153b7d2e6b Add dependencies 2025-10-24 23:21:38 -04:00
Alexander Polyankin
8fae4a1b53 Add dependencies 2025-10-24 22:23:46 -04:00
Aleksandr Lesnenko
eb78a56002 add padding 2025-10-24 19:40:01 -04:00
Sloan Sparger
fb10eca5ec Merge branch 'master' into feature/workbench 2025-10-24 17:55:14 -05:00
Brad Anderson
2f73e2a313 Updates Overview page so it uses the same permissions-based show/hide logic as the hover nav 2025-10-24 17:40:12 -04:00
Brad Anderson
7e916af416 UXW-2086 Disallow creating SQL entities if user doesn't have create queries permissions 2025-10-24 17:11:05 -04:00
Denis Berezin
143c15fe3a Fix Transforms list item size 2025-10-25 01:05:34 +04:00
Denis Berezin
41effadd03 UXW-2084 fixes for types and Python transforms 2025-10-25 00:25:21 +04:00
John Swanson
60959aab28 fix more backend tests 2025-10-24 13:21:19 -07:00
Denis Berezin
39bf0a2acd UXW-2084 Restrict access to Transforms, Metadata and Python Library (#65007)
# Conflicts:
#	enterprise/frontend/src/metabase-enterprise/transforms/routes.tsx
#	frontend/src/metabase/bench/components/BenchNavMenu.tsx
#	frontend/src/metabase/bench/constants/navigation.ts
2025-10-25 00:20:02 +04:00
Brad Anderson
bfcbe96c25 Adds icons to BenchTabs 2025-10-24 16:00:40 -04:00
Alexander Polyankin
6a809f57df Merge remote-tracking branch 'origin/feature/workbench' into feature/workbench 2025-10-24 15:50:27 -04:00
Alexander Polyankin
5cc67c9837 Runs 2025-10-24 15:50:15 -04:00
Alexander Polyankin
f50caba00a Runs 2025-10-24 15:31:49 -04:00
Aleksandr Lesnenko
17e7061d65 UXW-2083: update bench navbar 2025-10-24 15:31:19 -04:00
Brad Anderson
afcd00937c Remove duplicated snippets link 2025-10-24 14:47:46 -04:00
Aleksandr Lesnenko
25110a9c64 UXW-2116: metadata search should include models 2025-10-24 14:46:31 -04:00
Alexander Polyankin
7daae384a4 Fix metadata link 2025-10-24 14:29:02 -04:00
Alexander Polyankin
72545f91f8 Fix jumping 2025-10-24 14:26:29 -04:00
Alexander Polyankin
84b4ed11e5 Fix initial state 2025-10-24 14:12:23 -04:00
Alexander Polyankin
a460095faa Fix types 2025-10-24 14:09:24 -04:00
Alexander Polyankin
529e8c0918 Fix layout 2025-10-24 14:06:33 -04:00
Alexander Polyankin
4a684a69eb Fix layout 2025-10-24 14:01:32 -04:00
Alexander Polyankin
10c2ddf94d python tabs 2025-10-24 13:31:08 -04:00
Alexander Polyankin
3558477a00 Merge remote-tracking branch 'origin/feature/workbench' into feature/workbench
# Conflicts:
2025-10-24 13:22:19 -04:00
Alexander Polyankin
f22ba1f492 python tabs 2025-10-24 13:22:04 -04:00
Brad Anderson
7ebccaee5a UXW-2118 Adds Open in Workbench link in models and metrics menu 2025-10-24 13:13:40 -04:00
Alexander Polyankin
4c28250828 python tabs 2025-10-24 13:11:43 -04:00
Alexander Polyankin
00f5258ae9 python tabs 2025-10-24 12:51:41 -04:00
Alexander Polyankin
240f4e7c05 Workbench transforms tabs (#64979) 2025-10-24 12:30:05 -04:00
Brad Anderson
64a60cf3fd UXW-1856 Adds workbench overview page 2025-10-24 12:24:30 -04:00
Maz Ameli
ab7955a71c rearrange nav and add network icon for dep graph (#64974)
Co-authored-by: eliotmetabase <eliot@metabase.com>
2025-10-24 08:55:48 -07:00
Denis Berezin
3bc02091d3 Fix transforms tree view subtitle design 2025-10-24 18:31:05 +04:00
Aleksandr Lesnenko
c4c1197ad9 UXW-2097, UXW-2096: add search and virtualization to metadata, transforms, models, metrics 2025-10-23 21:54:03 -04:00
Aleksandr Lesnenko
30c6870162 UXW-2082: bench nav ui polish 2025-10-23 21:53:42 -04:00
Maz Ameli
2c64f9762f move keyboard shortcuts nav item down in the menu 2025-10-23 15:59:43 -07:00
Brad Anderson
de9fab0a69 Adds workaround to make sure metric and model data is up-to-date when refreshing because of invalidateTags 2025-10-23 17:01:52 -04:00
Denis Berezin
188e1ed86b UXW-2100 Add a link to Metadata from a Model 2025-10-24 00:55:24 +04:00
Denis Berezin
26fe968cc4 Uxw 2081 update metadata side nav (#64967)
* UXW-2081 Update metadata tree nav

* Fix CI
2025-10-24 00:06:23 +04:00
Brad Anderson
a8709f8e67 UXW-2079 Uses fixed width for bench side nav, defaults to 320px 2025-10-23 15:07:51 -04:00
Denis Berezin
ab5fd38545 UXW 2057 update list views for all entities (#64937)
* UXW-2057 Update models and metrics

# Conflicts:
#	frontend/src/metabase/bench/components/metrics/MetricsList.tsx
#	frontend/src/metabase/bench/components/models/ModelsList.tsx

* Add transforms, jobs, segments, snippets

* Update tree views

* Update icons

* Transforms tags

* Update metadata icon colors
2025-10-23 20:25:25 +04:00
Brad Anderson
52839f51f5 UXW-2078 When opening the workbench, user is taken to the last list they viewed. If user has never been to the workbench, floating nav menu is automatically opened. 2025-10-22 18:03:13 -04:00
Brad Anderson
ba366d0fc3 UXW-2054 Removes Metabot and Collapse editor buttons in Model SQL editor 2025-10-22 16:07:20 -04:00
Brad Anderson
d4e48d9364 Removes showAdminSettingsItem check for workbench link in Settings menu 2025-10-22 15:55:08 -04:00
Brad Anderson
f8a044be14 UXW-2080 Table picker is not shown when opening existing sql question, model, etc. 2025-10-22 15:39:12 -04:00
Brad Anderson
28967cc943 Updates "new native model" button to work with updates from master 2025-10-22 15:11:17 -04:00
Aleksandr Lesnenko
069efdb40a uxw-2082: floating menu should take full height and have 16px bottom margin 2025-10-22 20:52:26 +02:00
Aleksandr Lesnenko
3db8123705 uxw-2082: update nav layout 2025-10-22 20:38:46 +02:00
Brad Anderson
42ba3f9c14 UXW-2055 Adds shouldShowQuestionSettingsSidebar check for model's and metric's Settings tab 2025-10-22 14:09:35 -04:00
Brad Anderson
4ab4f51396 UXW-2055 Adds model and metric cache settings to workbench 2025-10-22 14:09:35 -04:00
John Swanson
495c3688ea fix another test 2025-10-22 10:57:47 -07:00
John Swanson
921dfcf21a fix tests 2025-10-22 10:05:42 -07:00
Aleksandr Lesnenko
a15d84f2f6 fix deps 2025-10-22 18:32:49 +02:00
Aleksandr Lesnenko
791d521c1d update workbench nav 2025-10-22 17:55:33 +02:00
Brad Anderson
562447cb8b UXW-1861 Fix typo in copy api call 2025-10-22 10:12:42 -04:00
Brad Anderson
4648e5ce4e UXW-1862 Adds "more" menu to metrics list 2025-10-22 09:44:21 -04:00
Brad Anderson
4ec14ba0cb UXW-1861 Stubs out a "more" menu for models 2025-10-21 17:49:49 -04:00
Denis Berezin
7b660ecf97 UXW-1977 unified metadata tree (#64751) 2025-10-22 01:44:44 +04:00
Brad Anderson
8b280b2e0f Fix TS error after rebase 2025-10-21 16:51:35 -04:00
Brad Anderson
b36f48841f Fix e2e failures in group-10 2025-10-21 16:43:22 -04:00
Brad Anderson
9381eeeef7 Fix e2e failures in group-8 2025-10-21 16:43:22 -04:00
Brad Anderson
cc207ba264 Fix e2e failures in group-6 2025-10-21 16:43:22 -04:00
Brad Anderson
4924a10623 Fix e2e failures in group-3 2025-10-21 16:43:22 -04:00
Brad Anderson
5f62970982 Fixes a bunch of e2e tests 2025-10-21 16:43:22 -04:00
Brad Anderson
a965b27c76 UXW-1927 Adds "Last modified" option for transforms 2025-10-21 16:43:22 -04:00
Brad Anderson
e791b25304 Makes tree/alphabetical display use localStorage instead of the location query (for simplicity and because it's potentially annoying to have to switch every time you go to the bench if you prefer the non-default) 2025-10-21 16:43:22 -04:00
Brad Anderson
bb3bb67bab lint-css 2025-10-21 16:43:22 -04:00
Brad Anderson
1c2c2bec0d Fix issue where metabot causes the workbench to crash for non-EE users 2025-10-21 16:43:22 -04:00
Brad Anderson
7d2de7b026 Removes unused "isEmbeddingSetup" plumbing 2025-10-21 16:43:22 -04:00
Brad Anderson
7e08851537 prettier 2025-10-21 16:43:22 -04:00
Brad Anderson
4ba305e11c Fix more unit tests 2025-10-21 16:43:22 -04:00
Brad Anderson
24cdd2b5b9 Fix query builder unsaved changes unit tests 2025-10-21 16:43:21 -04:00
Brad Anderson
fff42a67f9 Brings back scheduleCallback from original SegmentApp.jsx implementation to fix failing unit test 2025-10-21 16:43:21 -04:00
Brad Anderson
b39ab269b5 Starts fixing unit tests 2025-10-21 16:43:21 -04:00
Brad Anderson
86e00e29ac Allows non-admins to see /bench routes 2025-10-21 16:43:21 -04:00
Brad Anderson
acd131aad5 Fix jest compilation error 2025-10-21 16:43:21 -04:00
Brad Anderson
c9f7077f67 Fix low-hanging lint errors 2025-10-21 16:43:21 -04:00
Brad Anderson
2f04965b1c Fix verify-i18n-files error 2025-10-21 16:43:21 -04:00
Brad Anderson
acceeb354d Fix TS error. Reverts changes from 3631acdd17 that appear to be a result of an incorrectly resolved merge conflict. 2025-10-21 16:43:21 -04:00
Brad Anderson
a9a5d21b76 Fixes a few TS errors 2025-10-21 16:43:21 -04:00
Brad Anderson
d81b2f8a7f UXW-1985 Ensures lists are sorted correctly 2025-10-21 16:43:21 -04:00
Brad Anderson
53fd65a225 Fix tree view rendering issues when transform's data lacks database or schema 2025-10-21 16:43:21 -04:00
Brad Anderson
4f540fd2a5 UXW-1996 Fix issue where no + button is present when there are no transforms 2025-10-21 16:43:21 -04:00
Brad Anderson
fe60ba8212 UXW-1972 UXW-1973 Organizes personal collections in models and metrics tree views 2025-10-21 16:43:21 -04:00
Brad Anderson
2c2054cab9 UXW-1971 Implements tree view for transforms 2025-10-21 16:43:21 -04:00
Brad Anderson
17c4c33396 Fix incorrectly ported transform/job params 2025-10-21 16:43:21 -04:00
Denis Berezin
714e95869b Remove empty collections in models tree 2025-10-21 16:43:21 -04:00
Brad Anderson
5135164b66 Wraps name in Ellipsified in ItemsListTreeNode 2025-10-21 16:43:21 -04:00
Brad Anderson
967c4d7cd1 Segment polish: fix issue where going from one segment's revisions to another doesn't update the revision history that's displayed. Also prevents segment tree from displaying if it's still loading table data. 2025-10-21 16:43:21 -04:00
Brad Anderson
b7408de5f4 Ports initiallyExpanded and rightSection Tree additions from source-of-truth branch and uses them in segments tree view 2025-10-21 16:43:21 -04:00
Brad Anderson
9bf024ae3c Tweaks styles for models, metrics, and segments trees 2025-10-21 16:43:21 -04:00
Brad Anderson
458e7a698b UXW-1984 Organizes segments by db > schema > table 2025-10-21 16:43:21 -04:00
Denis Berezin
a60b61696e UXW-1970 Fix issue with horizontal scroll 2025-10-21 16:43:21 -04:00
Denis Berezin
f16ca27e27 UXW-1970 Fix issue with FK field options 2025-10-21 16:43:21 -04:00
Denis Berezin
a73733429e UXW-1970 Fix issue with content scrolling 2025-10-21 16:43:21 -04:00
Brad Anderson
06f9f8a915 Fix issue where display=alphabetical is not preserved when clicking metrics and models 2025-10-21 16:43:21 -04:00
Brad Anderson
2d311a0041 Consolidates onAddNewItem with AddButton props in ItemsListSection for simplicity and eliminate TS errors 2025-10-21 16:43:21 -04:00
Denis Berezin
1f7eeb5f73 UXW-1970 Add state preserve in localStorage 2025-10-21 16:43:21 -04:00
Brad Anderson
a2d8aa365b Fix incorrectly ported params in jobs list 2025-10-21 16:43:21 -04:00
Brad Anderson
88d153e3dd Updates styles for jobs list 2025-10-21 16:43:21 -04:00
Brad Anderson
143c741be0 Fix issue where not having any jobs prevents you from being able to create more jobs 2025-10-21 16:43:21 -04:00
Brad Anderson
46cd2d6776 Adds ability to create a new job in the workbench 2025-10-21 16:43:21 -04:00
Denis Berezin
fede88fb6c UXW-1970 Refactor navigation menu 2025-10-21 16:43:21 -04:00
Brad Anderson
492f70de28 UXW-1850 Making segments nicer 2025-10-21 16:43:21 -04:00
Denis Berezin
4345336b8b UXW-1976 Add sorting to the metadata of a model 2025-10-21 16:43:21 -04:00
Denis Berezin
ac1b7821f8 Add missing models editing logic 2025-10-21 16:43:21 -04:00
Brad Anderson
467a9f6c3a Removes reference to variable added in transforms-codegen that is required on that branch, but removed on feature/workbench in (9dab2ae348) 2025-10-21 16:43:21 -04:00
Brad Anderson
595c0c470c Organizes metrics by collection 2025-10-21 16:43:21 -04:00
Brad Anderson
7c2c016c49 Fixing Runs issues:
* Adds padding (UXW-1930)
* Fixes issue where filter labels are cut off
* Fixes filters
2025-10-21 16:43:21 -04:00
Brad Anderson
51f2ba9afb Adds view setting to segments list 2025-10-21 16:43:21 -04:00
Brad Anderson
e92daf3345 Adds "Target table"/"Alphabetical" setting for transforms list 2025-10-21 16:43:21 -04:00
Brad Anderson
1b38a2ede9 Fix open/close state issues in ItemsListSettings that came over from ItemsListSection 2025-10-21 16:43:21 -04:00
Denis Berezin
714dd5a28a Fix models list height 2025-10-21 16:43:19 -04:00
Brad Anderson
7925f15150 Refactors ItemsListSection so the different entity types can have more control over their own view settings. Also makes it easier to sync those settings to url params. 2025-10-21 16:39:04 -04:00
Denis Berezin
e156483792 Models metadata editing with new table UI (#64434)
* bench scaffold

* WIP: models section for workbench (#64210)

* WIP: models section for workbench

* Add more reusable component for list section

* rejigger transforms

* little fixes

* very rough cut of functioning workbench

* make routing less bad

* python tables reference fix

* rejigger transforms

* make routing less bad

* query builder in the bench

* fix conflict

* fix code gen

* nav updates

* tabs for transforms

* Updates workbench metric editor to use individual metric editor components rather than entire query builder

* Stubs out using one component for both model and metric editing in workbench

* fix new transform creation - sort of

* fix rebase

* Start with model metadata editing

* Add model field title / descr real editing

* Add model rest field props editing, moved to metadata section

* Temp

* Add models tree view in metadata section

* Add table tree selection reset on model select

* Self review fixes

---------

Co-authored-by: Ryan Laurie <iethree@gmail.com>
Co-authored-by: Sloan Sparger <sloansparger@gmail.com>
Co-authored-by: Brad Anderson <brad@metabase.com>
2025-10-21 16:39:04 -04:00
Brad Anderson
eed8e62a40 Clean up AI slop 2025-10-21 16:39:04 -04:00
Brad Anderson
de1da8ea36 Stubs out collection tree view in workbench (models only for now) 2025-10-21 16:39:04 -04:00
Ryan Laurie
66edb69b49 better debouncery 2025-10-21 16:39:04 -04:00
Ryan Laurie
bf51f83ef9 dependencies 2025-10-21 16:39:04 -04:00
Ryan Laurie
f3375d4c73 pull in glossary 2025-10-21 16:39:04 -04:00
Brad Anderson
167acd21c1 Adds snippets to workbench 2025-10-21 16:39:04 -04:00
John Swanson
99378d6545 Check new application perms in transforms API
The `GET /run` endpoint I haven't updated yet, I need to fix the read
checks for that model too I think.

In some ways it feels a bit silly to have an application level
permission but still check permissions per-object, so maybe I'll revert this.
2025-10-21 16:39:01 -04:00
John Swanson
7d2a4f80b4 Add a transforms application permission
This can be set just like other application permissions, e.g.:

```
curl http://localhost:3000/api/ee/advanced-permissions/application/graph

{
  "groups": {
    "2": {
      "setting":"yes",
      "monitoring":"yes",
      "subscription":"yes",
      "transforms":"yes"
    }
  },
  "revision": 1
}
```

You can read/write transforms iff you have this application level permission.
2025-10-21 16:27:09 -04:00
Ryan Laurie
899e60782a jobs and runs improvments 2025-10-21 16:27:09 -04:00
Ryan Laurie
0d26c3a39b collapsible nav 2025-10-21 16:27:09 -04:00
Sloan Sparger
eb4ed5164e small tweaks 2025-10-21 16:27:06 -04:00
Brad Anderson
5319abd9e0 Implements creating new models in workbench (native and query builder) 2025-10-21 16:18:38 -04:00
Brad Anderson
7f7fb5ca23 Minor workbench model editor style tweaks 2025-10-21 16:18:38 -04:00
Brad Anderson
7a18fcdd72 Fix dark mode filter/sort controls in workbench 2025-10-21 16:18:38 -04:00
Ryan Laurie
b453743dfd improve nav panel behavior 2025-10-21 16:18:38 -04:00
Brad Anderson
4156768ba0 Fix metric editor background color 2025-10-21 16:18:38 -04:00
Brad Anderson
830f90eb49 Stubs out a way to allow editing native models in the workbench 2025-10-21 16:18:38 -04:00
Sloan Sparger
4c147e883e rename/clean up transform editor state 2025-10-21 16:18:36 -04:00
Sloan Sparger
8ac8ec7393 get suggested transforms working again 2025-10-21 16:10:38 -04:00
Sloan Sparger
f918750673 config updates 2025-10-21 16:02:03 -04:00
Ryan Laurie
12f84b20ec even nicer segments 2025-10-21 16:02:03 -04:00
Ryan Laurie
2e8477e76a even nicer segments 2025-10-21 16:02:03 -04:00
Ryan Laurie
9d6c8964fd make segments nice again 2025-10-21 16:02:03 -04:00
Brad Anderson
4b30b4a9db Updates metric editor in workbench so it can create new metrics (breaks workbench model editing in the process (but that needs updating anyway)) 2025-10-21 16:02:03 -04:00
Brad Anderson
16387fc2c0 Changes selected metric's text to brand color to match designs/models 2025-10-21 16:02:03 -04:00
Brad Anderson
cc643ed0bc Stubs out (+) buttons for models and metrics 2025-10-21 16:02:03 -04:00
Brad Anderson
8d9d49eca7 Updates styles for filter/sort UI 2025-10-21 16:02:03 -04:00
Brad Anderson
0fc4e30ce9 Adds selected state to metrics and models lists in workbench 2025-10-21 16:02:03 -04:00
Ryan Laurie
164b80195a improve python layout etc 2025-10-21 16:02:03 -04:00
Ryan Laurie
f3ba3a0c18 fix rebase 2025-10-21 16:02:01 -04:00
Ryan Laurie
4328582106 fix new transform creation - sort of 2025-10-21 15:34:33 -04:00
Brad Anderson
ac5f1a7b33 Stubs out using one component for both model and metric editing in workbench 2025-10-21 15:34:33 -04:00
Brad Anderson
9ccc3e6c74 Updates workbench metric editor to use individual metric editor components rather than entire query builder 2025-10-21 15:34:33 -04:00
Ryan Laurie
e11188ba1c tabs for transforms 2025-10-21 15:34:33 -04:00
Ryan Laurie
ad02ea5fb0 nav updates 2025-10-21 15:34:32 -04:00
Ryan Laurie
7e6b37511d fix code gen 2025-10-21 15:34:32 -04:00
Ryan Laurie
6b58ccb118 fix conflict 2025-10-21 15:34:32 -04:00
Ryan Laurie
b15835b64c query builder in the bench 2025-10-21 15:34:32 -04:00
Ryan Laurie
eed61ada77 make routing less bad 2025-10-21 15:34:32 -04:00
Ryan Laurie
3c45858fc0 rejigger transforms 2025-10-21 15:34:32 -04:00
Sloan Sparger
b3982f6788 python tables reference fix 2025-10-21 15:34:32 -04:00
Ryan Laurie
db69938a46 make routing less bad 2025-10-21 15:34:32 -04:00
Ryan Laurie
d950320a6e very rough cut of functioning workbench 2025-10-21 15:34:32 -04:00
Ryan Laurie
0275bbcfa1 little fixes 2025-10-21 15:34:32 -04:00
Ryan Laurie
bd11f3fd3d rejigger transforms 2025-10-21 15:34:32 -04:00
Denis Berezin
2b9f879c54 WIP: models section for workbench (#64210)
* WIP: models section for workbench

* Add more reusable component for list section
2025-10-21 15:34:32 -04:00
Ryan Laurie
85424dbb5e bench scaffold 2025-10-21 15:34:32 -04:00
Sloan Sparger
75b89bec06 prevent leave confirmation when saving 2025-10-21 13:18:56 -05:00
Sloan Sparger
1eb5126ede couple of e2e tests fixes 2025-10-21 13:18:56 -05:00
Sloan Sparger
cbf835a844 @mentions tests and improvements 2025-10-21 13:18:56 -05:00
Noah Moss
403c81cc7f Surface broken cards from Metabot dependencies check API (#64781)
* fix missing sidebar diff after mbql5 change

* surface broken cards

* add test

* fix kondo

* remove unused code

* Fix up dep check question terminology & schema

* fix up test

* fix up another test

* address feedback

* Update the transform source schema to use ::queries.schema/query

* remove unused require

* address FE feedback

* fix modules config test
2025-10-21 12:52:11 -04:00
Sloan Sparger
e071bdc36c Merge branch 'master' into transforms-codegen 2025-10-21 09:59:24 -05:00
appleby
b4c36549d8 Fix MetabotAgentSuggestionMessage only show "create" for new transforms (#64598)
https://metaboat.slack.com/archives/C09CR3MT1KR/p1760034396345789
2025-10-20 13:37:22 -07:00
Noah Moss
10e0b2058f Subtract a pixel when computing metabot chat filler height to avoid scrollbar flicker (#64690) 2025-10-20 15:25:26 -04:00
Noah Moss
22bce8c93e Add transforms dependency check API for metabot (#64542)
* WIP - add dependency check API for metabot

* omit bad_cards for now

* update return format

* add tests

* Fix fn call

* revert unneeded refactor

* Fix search transform-ingestion-test to use mt/native-query

* switch test to mt/native-query

* Fix module usage test

* fix up tests to use lib/native-query instead

* try to fix last test

* switch native-query-transform? to use lib.query/native?

* more mbql 5/metabase lib updates

* revert the 1-pixel fix
2025-10-14 11:21:08 -04:00
Noah Moss
e1f1918874 Fix failing test & kondo warning 2025-10-13 14:46:16 -04:00
Noah Moss
3ede797ec7 Merge branch 'master' into transforms-codegen 2025-10-13 12:30:33 -04:00
Mike Appleby
d40982ee2e Stop pretending to snake_case transform source and target keys
The malli schemas were wrong, so these weren't actually getting snake_cased anyway.

See also:

https://github.com/metabase/ai-service/pull/387#discussion_r2414569482
2025-10-09 19:21:36 -07:00
Sloan Sparger
f652a2dfbf Merge branch 'master' into transforms-codegen 2025-10-07 09:48:37 -05:00
Noah Moss
2bf2599881 Ensure transforms get deleted synchronously from the search index (#64382)
* Delete transforms from search index in t2/define-before-delete

* kondo

* fix up tests
2025-10-06 15:52:12 -04:00
Sloan Sparger
a1ef9454a5 add on leave modal, clear transforms on route leave, calc dirty state, fix onChange but for native query editor, better logic for showing cta text for transform suggestions 2025-10-03 15:48:03 -05:00
Sloan Sparger
5f11c86f04 Merge branch 'master' into transforms-codegen 2025-10-03 10:57:50 -05:00
Sloan Sparger
9e4d935802 fix types 2025-10-03 10:56:28 -05:00
Sloan Sparger
1112b082c3 better draft support 2025-10-03 10:32:22 -05:00
Sloan Sparger
8c42f74af8 bug fixes and better state organization 2025-10-02 18:12:34 -05:00
Sloan Sparger
c0e4ec81fa Merge branch 'master' into transforms-codegen 2025-10-02 12:02:34 -05:00
Sloan Sparger
4f433602bc python tables reference fix 2025-10-01 17:33:45 -05:00
appleby
9a2190d44d Update the list of source tables in Python editor when metabot changes it (#64258)
If metabot proposes changes to the list of input tables for a Python transform, update the PythonDataPicker component
to keep it in sync with the text of the transform source.
2025-10-01 17:27:05 -05:00
Sloan Sparger
d3cf46ea88 better state management for suggested transofrms, move css around, clean up suggestion message code 2025-10-01 17:26:59 -05:00
Noah Moss
dd004902d7 Handful of fixes for transforms search (#64287)
* Add both search and search_v2 endpoints, and fix bug with search-native-query option

* add a docstring

* add a basic unit test for search-native-query option

* address comments
2025-10-01 17:26:32 -04:00
Sloan Sparger
0cd22badfa for rye guy 2025-10-01 14:59:18 -05:00
Mike Appleby
f08d7c996a Merge remote-tracking branch 'origin/master' into transforms-codegen
Conflicts:
	enterprise/backend/src/metabase_enterprise/transforms/core.clj
2025-10-01 09:24:10 -07:00
Sloan Sparger
d85ea68a4f quick fixes 2025-10-01 10:38:56 -05:00
Sloan Sparger
8545180dc5 fix @mention recents in documents, update suggestion sidebar component to not accept / reject items + calculate isViewing better 2025-10-01 09:11:39 -05:00
Sloan Sparger
2071b499ab make suggestion models configurable for a Metabot instance 2025-10-01 09:11:39 -05:00
Noah Moss
42f7f91247 Add transforms to search for Metabot (#64134)
* index transforms content

* Add support for transforms & query search in metabot search tool API

* fixup whitespace

* update to use new :fields syntax

* Fix up serach-native-query malli types

* Fix the 15-day interval for transforms to be db-agnostic

* move engine loading to metabase.search.init to break circular dep

* fix kondo

* Revert "Fix the 15-day interval for transforms to be db-agnostic"

This reverts commit 11b34e4cd4a8a5a0cf8041a1d82d487ed8c81025.

* add transform to set of appdb scorer models; unrevert db type fix

* fix kondo

* implement requisite multimethods for transforms not to error in in-place search engine

* ensure we enforce metabot/unknown contexts for transforms in in-place search backend as well

* fix filter test

* transform->postprocess to reduce naming ambiguity; add a basic transform ingestion test

* add a metabot result postprocessing test

* add transforms scoring test

* expand ingestion tests

* fix tests + kondo

* address feedback
2025-09-30 16:58:19 -04:00
Sloan Sparger
c7ab945216 fix type issues and few small feedback things 2025-09-30 13:12:54 -05:00
Sloan Sparger
11e7f8f792 fix search bug 2025-09-30 09:53:43 -05:00
Sloan Sparger
06187e258c @mention solution that plays nicely with documents 2025-09-29 16:37:30 -05:00
Sloan Sparger
1521f87c35 prevent rewinding conversation for admin pages 2025-09-29 13:12:56 -05:00
Sloan Sparger
9746d26262 lots of fe clean up 2025-09-29 12:56:21 -05:00
Noah Moss
bde75ec8cb Merge branch 'master' into transforms-codegen 2025-09-29 12:33:12 -04:00
Mike Appleby
c386f5dc06 Move native-query-transform? into transforms.util and re-use existing utils
metabot-v3.tools.transforms defined a couple of helper functions that were nearly identical to ones that have since
been added to transforms.util ns. Move some things around and make use of the existing transforms.util predicates,
which are now exported from the module's core namespace.
2025-09-26 16:34:42 -07:00
Mike Appleby
db9915dc19 Add transforms-python to module deps for metabot 2025-09-26 16:32:49 -07:00
Noah Moss
96c5218a0e Conditionally add transforms to model->db-model 2025-09-26 15:42:07 -04:00
Mike Appleby
5e512b1870 Merge remote-tracking branch 'origin/master' into transforms-codegen 2025-09-26 12:23:29 -07:00
Sloan Sparger
d093af8894 first pass at nested @mentions 2025-09-26 13:38:42 -05:00
Noah Moss
7e54498224 improve ranking of transforms in search by hardcoding a last_viewed_at time in user-recency-expr 2025-09-26 10:08:59 -04:00
Sloan Sparger
83ecd08c47 cleaner code for todos 2025-09-25 23:18:42 -05:00
Sloan Sparger
bb30961bab smaller todos 2025-09-25 22:54:17 -05:00
Sloan Sparger
0176fb79c3 better diff colors 2025-09-25 20:53:26 -05:00
Sloan Sparger
fc63da673f fix accept in sidebar not saving 2025-09-25 20:04:40 -05:00
Sloan Sparger
83212f5a87 clear proposed on cancel 2025-09-25 19:37:03 -05:00
Sloan Sparger
d47c329ed7 python fe improvements + edit experience now works 2025-09-25 19:29:46 -05:00
Sloan Sparger
4c3aaac096 python FE progress 2025-09-25 16:58:26 -05:00
Sloan Sparger
78e3516d32 some type fixes 2025-09-25 16:58:26 -05:00
Noah Moss
4b483e8ac4 surface transforms in @-mention search 2025-09-25 17:07:37 -04:00
Mike Appleby
025f903439 Enhance context with :used_tables for python transforms 2025-09-25 12:55:17 -07:00
Mike Appleby
47f82c8a77 Add get-transform-python-library-details metabot tool api endpoint 2025-09-25 12:55:17 -07:00
Mike Appleby
9599fc0116 Ensure transform :source and :target keys get snake cased when returned from metabot tool apis 2025-09-25 12:44:06 -07:00
Mike Appleby
697426d297 Update metabot tools test with basic python transform tests 2025-09-25 12:25:32 -07:00
Mike Appleby
942643a1a8 Fix metabot get-transforms api test to :post not :get 2025-09-25 12:25:16 -07:00
Sloan Sparger
21870b3bdf Merge branch 'master' into transforms-codegen 2025-09-25 14:02:46 -05:00
Sloan Sparger
7cf553aee0 cleaner, more copy/pastable, better flowing @mentions in metabot 2025-09-25 08:51:18 -05:00
Noah Moss
29b6ee74a8 Ensure we parse template tags for transform edits in Metabot sidebar (#64001) 2025-09-24 17:12:44 -04:00
Tyler Plude
b4b7907cc6 further logging 2025-09-24 11:35:37 -05:00
Tyler Plude
18e00b770c debug logging for search table bug 2025-09-24 11:35:37 -05:00
Sloan Sparger
5fb62d64b7 good'nuff @mentions 2025-09-24 11:35:36 -05:00
Sloan Sparger
cd507e20a3 better suggestion chat ui 2025-09-24 11:33:15 -05:00
Sloan Sparger
8c62c354ad crude sidebar ui for query transforms diffs 2025-09-24 11:33:15 -05:00
Sloan Sparger
0ae57508ec even better todo list ui 2025-09-24 11:33:15 -05:00
Sloan Sparger
8fc39832c8 better todos 2025-09-24 11:29:56 -05:00
Noah Moss
20f5fa2761 ensure proposed queries go through nativeQuery.setQueryText so that template tags are parsed 2025-09-24 11:29:56 -05:00
Noah Moss
5dce588375 Add basic snippets tools 2025-09-24 11:29:55 -05:00
Sloan Sparger
e92e9017b9 hide suggested prompts 2025-09-24 11:10:32 -05:00
Sloan Sparger
4515a136ff hack - better emtpy convo text 2025-09-24 11:10:32 -05:00
Sloan Sparger
874cd510b5 better hidden message ui 2025-09-24 11:10:32 -05:00
Sloan Sparger
7dec13db6c copy change for create transform buttons in chat 2025-09-24 11:10:32 -05:00
Mihael Konjevic
a840799f61 Change button label to "Proposed changes to <transform>" 2025-09-24 11:10:32 -05:00
Sloan Sparger
96b217181c temp - make metabot always visible on transforms admin pages 2025-09-24 11:10:32 -05:00
Sloan Sparger
c7831044e2 persist diff of transform to the conversation history 2025-09-24 11:10:31 -05:00
Noah Moss
c84d7977e8 Ensure we have a fallback for context items that don't have SQL to parse 2025-09-24 11:10:31 -05:00
Sloan Sparger
8fb2f8af83 handle new transforms better 2025-09-24 11:10:31 -05:00
Mihael Konjevic
45206f30b5 Show 'Apply Changes' button when a transform change is generated by the agent 2025-09-24 11:10:31 -05:00
Mike Appleby
b2db725412 Switch /ee/metabot-v3/get-transforms from :get to :post
This endpoint doesn't expect args of it's own in the body, but does accept the standard metabot conversation_id that
it echoes back.
2025-09-24 11:10:31 -05:00
Sloan Sparger
f089029b69 half hacky way to use current editor state for context in transform editing 2025-09-24 11:10:31 -05:00
Sloan Sparger
5215a1f04b fix transform context on subsequent edits for the same transform 2025-09-24 11:10:31 -05:00
Sloan Sparger
ea7cc08248 misc. fe improvements 2025-09-24 11:10:29 -05:00
Sloan Sparger
fc9ed44ef4 quick fix preview error not displaying correctly 2025-09-24 11:03:23 -05:00
Sloan Sparger
6b50f2284a hack in a message to metabot on save 2025-09-24 11:03:23 -05:00
Sloan Sparger
30b0e76808 wip on creation flow 2025-09-24 11:03:23 -05:00
Noah Moss
625d9c357c Parse out tables from transforms in metabot user context 2025-09-24 11:03:23 -05:00
Sloan Sparger
5aed907492 use transforms instead of data queries 2025-09-24 11:03:23 -05:00
Sloan Sparger
b991bc7052 add transform context on edit query page 2025-09-24 11:03:23 -05:00
Mike Appleby
add9cea204 Cleanup malli schemas for get-transforms and get-transform-details 2025-09-24 11:03:23 -05:00
Sloan Sparger
d27be47233 accept tranform suggestions from metabot 2025-09-24 11:03:23 -05:00
Sloan Sparger
abf38d93bd show metabot on edit + create transforms page 2025-09-24 11:03:23 -05:00
Sloan Sparger
78b7f95c52 send transform in context 2025-09-24 11:03:23 -05:00
Sloan Sparger
90a338f8a7 use transforms_codegen profile for metabot on transforms page 2025-09-24 11:03:23 -05:00
Sloan Sparger
4b8bd6e61f allow opening metabot on transforms admin pages 2025-09-24 11:03:23 -05:00
Mike Appleby
0934b50409 Add /ee/metabot-tools/get-transform-details endpoint 2025-09-24 11:03:23 -05:00
Mike Appleby
5e095f519b Add test that get-transforms does not include non-native MBQL transforms 2025-09-24 11:03:23 -05:00
Mike Appleby
ba34ffd9ca Add /ee/metabot-tools/get-transforms endpoint
Returns a sequence of all python or native query transforms.

BOT-391 [Backend] Create transform index tool API
2025-09-24 11:03:21 -05:00
Sloan Sparger
5cf33d5aef FE - add codemirror diffing, add diff support to native query editor, fake a proposed query 2025-09-24 10:57:29 -05:00
Sloan Sparger
b7ee9cb338 init branch 2025-09-24 10:57:28 -05:00
483 changed files with 10403 additions and 5985 deletions

View File

@@ -2052,6 +2052,7 @@
lib
lib-be
models
permissions
premium-features
queries
query-processor

View File

@@ -41,7 +41,7 @@ export function createTransform(
cy.wrap(body.id).as(idAlias);
}
if (visitTransform) {
cy.visit(`/admin/transforms/${body.id}`);
cy.visit(`/bench/transforms/${body.id}`);
}
});
}

View File

@@ -40,7 +40,7 @@ export function createTransformJob(
cy.wrap(body.id).as(idAlias);
}
if (visitTransformJob) {
cy.visit(`/admin/transforms/jobs/${body.id}`);
cy.visit(`/bench/transforms/jobs/${body.id}`);
}
});
}

View File

@@ -94,7 +94,7 @@ function visit({
fieldId != null
) {
cy.visit(
`/admin/datamodel/database/${databaseId}/schema/${schemaId}/table/${tableId}/field/${fieldId}`,
`/bench/metadata/database/${databaseId}/schema/${schemaId}/table/${tableId}/field/${fieldId}`,
);
if (!skipWaiting) {
@@ -112,7 +112,7 @@ function visit({
if (databaseId != null && schemaId != null && tableId != null) {
cy.visit(
`/admin/datamodel/database/${databaseId}/schema/${schemaId}/table/${tableId}`,
`/bench/metadata/database/${databaseId}/schema/${schemaId}/table/${tableId}`,
);
if (!skipWaiting) {
@@ -128,7 +128,7 @@ function visit({
}
if (databaseId != null && schemaId != null) {
cy.visit(`/admin/datamodel/database/${databaseId}/schema/${schemaId}`);
cy.visit(`/bench/metadata/database/${databaseId}/schema/${schemaId}`);
if (!skipWaiting) {
cy.wait([
@@ -141,7 +141,7 @@ function visit({
}
if (databaseId != null) {
cy.visit(`/admin/datamodel/database/${databaseId}`);
cy.visit(`/bench/metadata/database/${databaseId}`);
if (!skipWaiting) {
cy.wait([
@@ -154,7 +154,7 @@ function visit({
return;
}
cy.visit("/admin/datamodel");
cy.visit("/bench/metadata");
cy.wait(["@datamodel/visit/databases"]);
}

View File

@@ -16,7 +16,7 @@ export function saveMetadataChanges() {
});
cy.intercept("PUT", "/api/card/*").as("updateModelMetadata");
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.wait("@updateModelMetadata");
cy.findByTestId("dataset-edit-bar").should("not.exist");

View File

@@ -11,7 +11,7 @@ import { createTransform } from "./api";
import { getTableId } from "./e2e-qa-databases-helpers";
export function visitTransform(transformId: TransformId) {
cy.visit(`/admin/transforms/${transformId}`);
cy.visit(`/bench/transforms/${transformId}`);
}
const WAIT_TIMEOUT = 10000;

View File

@@ -130,6 +130,14 @@ export function appBar() {
return cy.findByLabelText("Navigation bar");
}
export function benchNav() {
return cy.findByTestId("bench-nav");
}
export function benchNavTitleMenu() {
return cy.findByTestId("bench-nav-title-menu");
}
export function openNavigationSidebar() {
appBar().findByTestId("sidebar-toggle").click();
navigationSidebar().should("be.visible");

View File

@@ -57,8 +57,9 @@ describe("scenarios > admin > datamodel", () => {
});
cy.findByRole("link", { name: /Segments/ }).click();
cy.location("pathname").should("eq", "/admin/datamodel/segments");
cy.location("pathname").should("eq", "/bench/segment");
cy.wait("@schema");
cy.findByRole("link", { name: /Metadata/ }).click();
H.DataModel.TablePicker.getTable("Reviews").click();
H.DataModel.TableSection.getNameInput()
@@ -66,7 +67,7 @@ describe("scenarios > admin > datamodel", () => {
.and("have.value", "Reviews");
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${REVIEWS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${REVIEWS_ID}`,
);
});
@@ -79,7 +80,7 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getDatabases().should("have.length", 1);
TablePicker.getTables().should("have.length", 0);
H.DataModel.get().findByText("Not found.").should("be.visible");
cy.location("pathname").should("eq", "/admin/datamodel/database/54321");
cy.location("pathname").should("eq", "/bench/metadata/database/54321");
});
it("should show 404 if table does not exist", () => {
@@ -97,7 +98,7 @@ describe("scenarios > admin > datamodel", () => {
H.DataModel.get().findByText("Not found.").should("be.visible");
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/12345`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/12345`,
);
verifyTableSectionEmptyState();
});
@@ -118,7 +119,7 @@ describe("scenarios > admin > datamodel", () => {
H.DataModel.get().findByText("Not found.").should("be.visible");
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/field/12345`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}/field/12345`,
);
});
@@ -192,7 +193,7 @@ describe("scenarios > admin > datamodel", () => {
verifyTableSectionEmptyState();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}`,
`/bench/metadata/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}`,
);
TablePicker.getTable("Products").click();
@@ -200,7 +201,7 @@ describe("scenarios > admin > datamodel", () => {
verifyFieldSectionEmptyState();
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}/table/`,
`/bench/metadata/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}/table/`,
);
});
});
@@ -220,7 +221,7 @@ describe("scenarios > admin > datamodel", () => {
verifyFieldSectionEmptyState();
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}/table/`,
`/bench/metadata/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}/table/`,
);
});
});
@@ -235,13 +236,13 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getDatabase("QA MySQL8").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}`,
`/bench/metadata/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}`,
);
TablePicker.getDatabase("QA MySQL8").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}`,
`/bench/metadata/database/${MYSQL_DB_ID}/schema/${MYSQL_DB_SCHEMA_ID}`,
);
cy.log("ensure navigation to another db works");
@@ -257,7 +258,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("should auto-open the only schema in the only database");
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}`,
);
verifyTableSectionEmptyState();
TablePicker.getDatabases().should("have.length", 1);
@@ -269,7 +270,7 @@ describe("scenarios > admin > datamodel", () => {
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
);
TableSection.get().should("be.visible");
verifyFieldSectionEmptyState();
@@ -277,7 +278,7 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getTable("Products").should("be.visible").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${PRODUCTS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${PRODUCTS_ID}`,
);
TableSection.get().should("be.visible");
verifyFieldSectionEmptyState();
@@ -293,7 +294,7 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getTable("Orders").should("be.visible").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
);
TableSection.getNameInput().should("have.value", "Orders");
@@ -318,13 +319,13 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getDatabase("Sample Database").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
);
TablePicker.getDatabase("Sample Database").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
);
});
});
@@ -342,7 +343,7 @@ describe("scenarios > admin > datamodel", () => {
it("should allow to navigate databases, schemas, and tables", () => {
H.DataModel.visit();
cy.location("pathname").should("eq", "/admin/datamodel/database");
cy.location("pathname").should("eq", "/bench/metadata/database");
TablePicker.getDatabases().should("have.length", 2);
TablePicker.getSchemas().should("have.length", 0);
TablePicker.getTables().should("have.length", 0);
@@ -354,7 +355,7 @@ describe("scenarios > admin > datamodel", () => {
.click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${WRITABLE_DB_ID}`,
`/bench/metadata/database/${WRITABLE_DB_ID}`,
);
TablePicker.getDatabases().should("have.length", 2);
TablePicker.getSchemas().should("have.length", 2);
@@ -366,7 +367,7 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getSchema("Domestic").click();
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic`,
);
TablePicker.getDatabases().should("have.length", 2);
TablePicker.getSchemas().should("have.length", 2);
@@ -377,7 +378,7 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getTable("Animals").click();
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
);
});
TableSection.getNameInput().should("have.value", "Animals");
@@ -389,12 +390,12 @@ describe("scenarios > admin > datamodel", () => {
);
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
);
});
cy.location("pathname").should(
"not.eq",
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Wild`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Wild`,
);
TablePicker.getDatabases().should("have.length", 2);
TablePicker.getSchemas().should("have.length", 2);
@@ -405,7 +406,7 @@ describe("scenarios > admin > datamodel", () => {
TablePicker.getTable("Birds").click();
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
);
});
TableSection.getNameInput().should("have.value", "Birds");
@@ -426,7 +427,7 @@ describe("scenarios > admin > datamodel", () => {
cy.log("we still have a table opened");
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic/table/`,
);
});
@@ -438,21 +439,21 @@ describe("scenarios > admin > datamodel", () => {
.and(
"have.attr",
"href",
`/admin/datamodel/database/${WRITABLE_DB_ID}`,
`/bench/metadata/database/${WRITABLE_DB_ID}`,
);
TablePicker.getSchema("Domestic")
.should("have.prop", "tagName", "A")
.and(
"have.attr",
"href",
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Domestic`,
);
TablePicker.getTable("Orders")
.should("have.prop", "tagName", "A")
.and(
"have.attr",
"href",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}/table/${ORDERS_ID}`,
);
});
@@ -474,7 +475,7 @@ describe("scenarios > admin > datamodel", () => {
cy.location("pathname").should((pathname) => {
return pathname.startsWith(
`/admin/datamodel/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Wild/table/`,
`/bench/metadata/database/${WRITABLE_DB_ID}/schema/${WRITABLE_DB_ID}:Wild/table/`,
);
});
TableSection.getNameInput().should("have.value", "Birds");

View File

@@ -12,12 +12,7 @@ describe("scenarios > admin > datamodel > segments", () => {
describe("with no segments", () => {
it("should have 'Custom expression' in a filter list (metabase#13069)", () => {
cy.visit("/admin/datamodel/segments");
cy.log("should initially show no segments in UI");
cy.get("main").findByText(
"Create segments to add them to the Filter dropdown in the query builder",
);
cy.visit("/bench/segment");
cy.button("New segment").click();
@@ -104,21 +99,18 @@ describe("scenarios > admin > datamodel > segments", () => {
});
it("should show up in UI list and should show the segment details of a specific id", () => {
cy.visit("/admin/datamodel/segments");
cy.visit("/bench/segment");
cy.findByRole("table").within(() => {
cy.findByText("Filtered by Total is less than 100").should(
"be.visible",
);
cy.findByRole("menu").within(() => {
cy.findByText("Sample Database").should("be.visible");
cy.findByText("Orders").should("be.visible");
});
cy.findByRole("link", { name: /Orders < 100/ })
cy.findByRole("menuitem", { name: /Orders < 100/ })
.should("be.visible")
.click();
cy.get("form").within(() => {
cy.findByText("Edit Your Segment").should("be.visible");
cy.findByText("Edit your segment").should("be.visible");
cy.findByText("Sample Database").should("be.visible");
cy.findByText("Orders").should("be.visible");
});
@@ -163,8 +155,7 @@ describe("scenarios > admin > datamodel > segments", () => {
});
it("should update that segment", () => {
cy.visit("/admin");
cy.findByTestId("admin-navbar-items").contains("Table Metadata").click();
cy.visit("/bench");
cy.findByRole("link", { name: /Segments/ }).click();
cy.findByTestId("segment-list-app")
@@ -178,7 +169,7 @@ describe("scenarios > admin > datamodel > segments", () => {
// update the filter from "< 100" to "> 10"
cy.url().should("match", /segment\/1$/);
cy.get("label").contains("Edit Your Segment");
cy.get("div").contains("Edit your segment");
cy.findByTestId("filter-pill")
.contains(/Total\s+is less than/)
.click();
@@ -196,10 +187,10 @@ describe("scenarios > admin > datamodel > segments", () => {
.clear()
.type("All orders with a total over $10.");
cy.get('[name="revision_message"]').type("time for a change");
cy.findByTestId("field-set-content").findByText("Save changes").click();
cy.findByRole("button", { name: "Save changes" }).click();
// get redirected to previous page and see the new segment name
cy.url().should("match", /datamodel\/segments$/);
cy.url().should("match", /bench\/segment$/);
cy.findByTestId("segment-list-app").findByText("Orders > 10");
@@ -235,34 +226,27 @@ describe("scenarios > admin > datamodel > segments", () => {
cy.log(
"Make sure revisions are displayed properly in admin table metadata",
);
cy.visit("/admin/datamodel/segments");
cy.get("tr")
cy.visit("/bench/segment");
cy.get("li")
.filter(`:contains(${SEGMENT_NAME})`)
.icon("ellipsis")
.click();
H.popover().findByTextEnsureVisible("Revision History").click();
cy.location("pathname").should(
"eq",
"/admin/datamodel/segment/1/revisions",
);
cy.location("pathname").should("eq", "/bench/segment/1/revisions");
cy.findByTestId("segment-revisions").within(() => {
// metabase#45594
cy.findByRole("heading", {
name: `Revision History for "${SEGMENT_NAME}"`,
name: `Revision history for "${SEGMENT_NAME}"`,
}).should("be.visible");
assertRevisionHistory();
});
cy.findByTestId("breadcrumbs").within(() => {
cy.findByText("Segment History");
cy.findByRole("link", { name: "Segments" }).click();
});
cy.findByRole("link", { name: /Segments/ }).click();
cy.location("pathname").should("eq", "/admin/datamodel/segments");
cy.location("search").should("eq", `?table=${ORDERS_ID}`);
cy.location("pathname").should("eq", "/bench/segment");
function assertRevisionHistory() {
cy.findAllByRole("listitem").as("revisions").should("have.length", 2);

View File

@@ -1794,7 +1794,7 @@ LIMIT
);
function visitCommonLibrary(path = "common.py") {
cy.visit(`/admin/transforms/library/${path}`);
cy.visit(`/bench/transforms/library/${path}`);
}
function getLibraryEditorHeader() {
@@ -2808,15 +2808,15 @@ function getEndAtFilterWidget() {
}
function visitTransformListPage() {
return cy.visit("/admin/transforms");
return cy.visit("/bench/transforms");
}
function visitJobListPage() {
return cy.visit("/admin/transforms/jobs");
return cy.visit("/bench/jobs");
}
function visitRunListPage() {
return cy.visit("/admin/transforms/runs");
return cy.visit("/bench/runs");
}
function runTransformAndWaitForSuccess() {

View File

@@ -155,7 +155,7 @@ describe("scenarios > dependencies > dependency graph", () => {
});
testEntitySearch({
itemName: TABLE_BASED_TRANSFORM_NAME,
itemIcon: "refresh_downstream",
itemIcon: "transform",
isRecentItem: false,
});
});
@@ -195,7 +195,7 @@ describe("scenarios > dependencies > dependency graph", () => {
tabName: "Transforms",
itemName: TABLE_BASED_TRANSFORM_NAME,
itemLevel: 0,
itemIcon: "refresh_downstream",
itemIcon: "transform",
});
});
});

View File

@@ -1159,18 +1159,19 @@ describe("issue 21528", () => {
H.popover().contains("Aerodynamic Bronze Hat - 144").should("be.visible");
cy.log("The following scenario breaks on 46");
// Navigating to another page via JavaScript is faster than using `cy.visit("/admin/datamodel")` to load the whole page again.
// Navigating to another page via JavaScript is faster than using `cy.visit("/bench/metadata")` to load the whole page again.
H.appBar().findByRole("button", { name: "Settings" }).click();
H.popover().findByText("Admin settings").click();
H.appBar().findByText("Table Metadata").click();
H.popover().findByText("Workbench").click();
H.benchNav().findByText("Metadata").click();
cy.findByRole("main")
.findByText("Start by selecting data to model")
.should("be.visible");
cy.location("pathname").should(
"eq",
`/admin/datamodel/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}`,
`/bench/metadata/database/${SAMPLE_DB_ID}/schema/${SAMPLE_DB_SCHEMA_ID}`,
);
cy.findByTestId("admin-navbar").findByText("Exit admin").click();
H.benchNavTitleMenu().click();
H.popover().findByText("Explore").click();
H.openNavigationSidebar();
H.navigationSidebar().findByText("Our analytics").click();

View File

@@ -7,7 +7,7 @@ const { H } = cy;
const SOURCE_TABLE = "Animals";
// Helper functions for metabot transform interactions
const visitTransformListPage = () => cy.visit("/admin/transforms");
const visitTransformListPage = () => cy.visit("/bench/transforms");
const suggestions = () => cy.findAllByTestId("metabot-chat-suggestion");
// eslint-disable-next-line no-unsafe-element-filtering
@@ -47,10 +47,17 @@ const assertSuggestionInSidebar = (values: {
}
};
const assertEditorDiffState = (opts: { exists: boolean }) => {
const assertEditorDiffState = (
editorType: EditorType,
opts: { exists: boolean },
) => {
const should = opts.exists ? "exist" : "not.exist";
cy.findByRole("button", { name: /apply|create/i }).should(should);
cy.findByRole("button", { name: /reject/i }).should(should);
editor(editorType)
.get()
.within(() => {
cy.findByRole("button", { name: /apply|create/i }).should(should);
cy.findByRole("button", { name: /reject/i }).should(should);
});
};
const assertAcceptRejectUI = (opts: { visible: boolean }) => {
@@ -93,8 +100,8 @@ H.describeWithSnowplowEE("scenarios > metabot > transforms codegen", () => {
cy.log("Should be able to visit and the see new transform");
viewLastSuggestion();
cy.url().should("include", "/admin/transforms/new/native");
assertEditorDiffState({ exists: false }); // nothing to diff, so we shouldn't show the UI for it
cy.url().should("include", "/bench/transforms/new/native");
assertEditorDiffState("native", { exists: false }); // nothing to diff, so we shouldn't show the UI for it
assertEditorContent("native", "SELECT 1");
// User should ask metabot for a change
@@ -165,8 +172,8 @@ H.describeWithSnowplowEE("scenarios > metabot > transforms codegen", () => {
cy.log("Should be able to visit and the see new transform");
viewLastSuggestion();
cy.url().should("include", "/admin/transforms/new/python");
assertEditorDiffState({ exists: false }); // nothing to diff, so we shouldn't show the UI for it
cy.url().should("include", "/bench/transforms/new/python");
assertEditorDiffState("python", { exists: false }); // nothing to diff, so we shouldn't show the UI for it
assertEditorContent("python", "pd.DataFrame({'value': [1]})");
// User should ask metabot for a change

View File

@@ -37,7 +37,7 @@ describe("scenarios > models list view", () => {
cy.log("Ensure List view is enabled");
cy.findByTestId("list-view").should("be.visible");
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.wait("@dataset");
cy.log("Display data as list after saving");
@@ -147,7 +147,7 @@ describe("scenarios > models list view", () => {
cy.findByRole("img").should("have.attr", "aria-label", "company icon");
});
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.wait("@dataset");
cy.log("Verify that custom column set is correct");
@@ -252,7 +252,7 @@ describe("scenarios > models list view", () => {
cy.findByText("List").click();
});
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.wait("@dataset");
cy.findByTestId("list-view").should("be.visible");
@@ -298,7 +298,7 @@ describe("scenarios > models list view", () => {
cy.findByText("List").click();
});
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.wait("@dataset");
cy.findByTestId("list-view").should("be.visible");

View File

@@ -36,7 +36,7 @@ describe("scenarios > models query editor", () => {
});
cy.findByTestId("data-step-cell").contains("Orders");
cy.button("Save changes").should("be.disabled");
cy.button("Save").should("be.disabled");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Row limit").click();
@@ -49,7 +49,7 @@ describe("scenarios > models query editor", () => {
.should("contain", "37.65")
.and("not.contain", "109.22");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateCard");
cy.url()
@@ -141,7 +141,7 @@ describe("scenarios > models query editor", () => {
});
cy.url().should("include", "/query");
cy.button("Save changes").should("be.disabled");
cy.button("Save").should("be.disabled");
H.NativeEditor.focus().type("{backspace}2");
@@ -151,7 +151,7 @@ describe("scenarios > models query editor", () => {
.should("contain", "37.65")
.and("not.contain", "109.22");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateCard");
cy.get("[data-testid=cell-data]")
@@ -182,7 +182,7 @@ describe("scenarios > models query editor", () => {
});
cy.url().should("include", "/query");
cy.button("Save changes").should("be.disabled");
cy.button("Save").should("be.disabled");
H.NativeEditor.focus().type("{backspace}2");
@@ -236,7 +236,7 @@ describe("scenarios > models query editor", () => {
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText(/Syntax error in SQL/).should("not.exist");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateCard");
cy.get("[data-testid=cell-data]").contains(1);

View File

@@ -286,13 +286,11 @@ describe("issue 20517", () => {
});
it("should be able to save metadata changes with empty description (metabase#20517)", () => {
cy.findByTestId("dataset-edit-bar")
.button("Save changes")
.should("be.disabled");
cy.findByTestId("dataset-edit-bar").button("Save").should("be.disabled");
cy.findByDisplayValue(/^This is a unique ID/).clear();
cy.findByDisplayValue(/^This is a unique ID/).should("not.exist");
cy.findByTestId("dataset-edit-bar")
.button("Save changes")
.button("Save")
.should("not.be.disabled")
.click();
cy.wait("@updateModel").then(({ response: { body, statusCode } }) => {
@@ -336,7 +334,7 @@ describe("issue 20624", { tags: "@skip" }, () => {
// Let's set a new name for it
cy.findByDisplayValue(renamedColumn).clear().type("Foo").blur();
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateCard");
cy.get("[data-testid=cell-data]").should("contain", "Foo");
@@ -419,7 +417,7 @@ describe("issue 22517", () => {
renameColumn("ID", "Foo");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateMetadata");
});
@@ -449,7 +447,7 @@ describe("issue 22517", () => {
cy.findByText("Foo");
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Save changes").click();
cy.findByText("Save").click();
// eslint-disable-next-line no-unscoped-text-selectors -- deprecated usage
cy.findByText("Foo");
@@ -482,7 +480,7 @@ describe("issue 22518", () => {
cy.findByTestId("native-query-editor-container").icon("play").click();
cy.wait("@dataset");
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.findAllByTestId("header-cell")
.should("have.length", 3)
@@ -575,7 +573,7 @@ describe(
// Make sure the column name updated before saving
cy.findByDisplayValue("Created At");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
H.visitModel(id);
@@ -730,7 +728,7 @@ describe("issue 23421", () => {
H.NativeEditor.get().should("be.visible").and("contain", query);
cy.findByRole("columnheader", { name: "id" }).should("be.visible");
cy.findByRole("columnheader", { name: "created_at" }).should("be.visible");
cy.button("Save changes").should("be.visible");
cy.button("Save").should("be.visible");
});
it("`visualization_settings` with hidden columns should not break UI (metabase#23421)", () => {
@@ -745,7 +743,7 @@ describe("issue 23421", () => {
cy.findByText("id").should("be.visible");
cy.findByText("created_at").should("be.visible");
});
cy.button("Save changes").should("be.disabled");
cy.button("Save").should("be.disabled");
});
});
@@ -903,7 +901,7 @@ describe("issue 28193", () => {
cy.findByTestId("run-button").click();
cy.wait("@dataset");
cy.button("Save changes").click();
cy.button("Save").click();
cy.location("pathname").should("not.include", "/query");
assertOnColumns();
@@ -1048,7 +1046,7 @@ describe("issue 29517 - nested question based on native model with remapped valu
mapModelColumnToDatabase({ table: "Orders", field: "Created At" });
cy.intercept("PUT", "/api/card/*").as("updateModel");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
const nestedQuestionDetails = {
@@ -1160,7 +1158,7 @@ describe("issue 53556 - nested question based on native model with remapped valu
mapModelColumnToDatabase({ table: "Orders", field: "Total" });
cy.intercept("PUT", "/api/card/*").as("updateModel");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
const nestedQuestionDetails = {
@@ -1356,7 +1354,7 @@ FROM
mapModelColumnToDatabase({ table: "People", field: "Source" });
cy.intercept("PUT", "/api/card/*").as("updateModel");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
const nestedQuestionDetails = {
@@ -1950,7 +1948,7 @@ describe("issues 35039 and 37009", () => {
cy.wait("@dataset");
cy.findByTestId("dataset-edit-bar").within(() => {
cy.button("Save changes").click();
cy.button("Save").click();
cy.button("Saving…").should("not.exist");
});
@@ -2019,7 +2017,7 @@ describe("issue 37009", () => {
H.popover().findByText("Edit query definition").click();
H.NativeEditor.focus().type(" WHERE CATEGORY = 'Gadget'");
cy.findByTestId("dataset-edit-bar")
.button("Save changes")
.button("Save")
.should("be.disabled")
.trigger("mousemove", { force: true });
cy.findByRole("tooltip").should(
@@ -2030,7 +2028,7 @@ describe("issue 37009", () => {
cy.wait("@dataset");
cy.findByRole("tooltip").should("not.exist");
cy.findByTestId("dataset-edit-bar")
.button("Save changes")
.button("Save")
.should("be.enabled")
.click();
cy.wait("@updateCard")
@@ -2114,7 +2112,7 @@ describe("issue 40252", () => {
cy.intercept("POST", "/api/dataset").as("dataset");
cy.findByTestId("dataset-edit-bar")
.findByRole("button", { name: "Save changes" })
.findByRole("button", { name: "Save" })
.should("be.enabled")
.click();
@@ -2167,7 +2165,7 @@ describe("issue 42355", () => {
cy.findByText("Orders").click();
cy.findByText("ID").click();
});
cy.button("Save changes").click();
cy.button("Save").click();
cy.log("check metadata changes are visible");
H.openQuestionActions();
@@ -2253,14 +2251,14 @@ describe("issue 45926", () => {
cy.findByDisplayValue("ID").type(" updated");
});
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@dataset");
cy.findByRole("columnheader", { name: "ID updated" }).should("be.visible");
H.openQuestionActions("Edit query definition");
cy.button("Sort").click();
H.popover().findByText("ID").click();
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@dataset");
H.questionInfoButton().click();

View File

@@ -84,7 +84,7 @@ describe("issue 29943", () => {
H.waitForLoaderToBeRemoved();
reorderTotalAndCustomColumns();
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@dataset");
H.openQuestionActions();
@@ -245,7 +245,7 @@ describe("issue 23103", () => {
cy.findByText("Category").click();
});
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
cy.button("Saving…").should("not.exist");
@@ -734,7 +734,7 @@ describe("issue 39749", () => {
cy.findByLabelText("Description").should("have.text", "A");
H.tableHeaderClick("Sum of Total");
cy.findByLabelText("Description").should("have.text", "B");
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
cy.log("verify that the description was updated successfully");
@@ -853,7 +853,7 @@ describe("issue 33844", () => {
cy.findAllByTestId("detail-shortcut").should("not.exist");
H.tableHeaderClick("ID");
cy.findByLabelText("Detail views only").click();
cy.button(isNew ? "Save" : "Save changes").click();
cy.button("Save").click();
if (isNew) {
H.modal().button("Save").click();
cy.wait("@createModel");
@@ -877,7 +877,7 @@ describe("issue 33844", () => {
H.tableHeaderClick("ID");
cy.findByLabelText("Detail views only").should("be.checked");
cy.findByLabelText("Table and details views").click();
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
cy.wait("@dataset");
H.tableInteractive().findByText("ID").should("be.visible");
@@ -933,7 +933,7 @@ describe("issue 45924", () => {
cy.findByTestId("dataset-edit-bar").findByText("Columns").click();
H.tableHeaderClick("ID1");
cy.findByLabelText("Display name").should("have.value", "ID1");
cy.findByTestId("dataset-edit-bar").button("Save changes").click();
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.wait("@updateCard");
cy.wait("@dataset");
H.tableInteractive().findByText("ID1").should("be.visible");
@@ -995,7 +995,7 @@ describe("issue 39993", () => {
H.moveDnDKitElement(H.tableHeaderColumn(columnName), {
horizontal: -100,
});
cy.button("Save changes").click();
cy.button("Save").click();
cy.wait("@updateModel");
cy.findAllByTestId("header-cell").eq(0).should("have.text", "Exp");
cy.findAllByTestId("header-cell").eq(1).should("have.text", "ID");
@@ -1440,7 +1440,7 @@ describe("issue 20624", () => {
H.waitForLoaderToBeRemoved();
H.tableHeaderClick("Vendor");
cy.findByLabelText("Display name").clear().type("Retailer");
cy.button("Save changes").should("be.enabled").click();
cy.button("Save").should("be.enabled").click();
cy.wait("@updateCard");
H.tableInteractive().within(() => {
cy.findByText("Retailer").should("be.visible");
@@ -1493,14 +1493,14 @@ describe("issue 32037", () => {
it("should show unsaved changes modal and allow to discard changes when editing model's query (metabase#32037)", () => {
H.openQuestionActions("Edit query definition");
cy.button("Save changes").should("be.disabled");
cy.button("Save").should("be.disabled");
H.filter({ mode: "notebook" });
H.popover().within(() => {
cy.findByText("ID").click();
cy.findByPlaceholderText("Enter an ID").type("1").blur();
cy.button("Add filter").click();
});
cy.button("Save changes").should("be.enabled");
cy.button("Save").should("be.enabled");
cy.go("back");
verifyDiscardingChanges();
@@ -1509,9 +1509,9 @@ describe("issue 32037", () => {
it("should show unsaved changes modal and allow to discard changes when editing model's metadata (metabase#32037)", () => {
H.openQuestionActions("Edit metadata");
H.waitForLoaderToBeRemoved();
cy.button("Save changes").should("be.disabled");
cy.button("Save").should("be.disabled");
cy.findByLabelText("Description").type("123").blur();
cy.button("Save changes").should("be.enabled");
cy.button("Save").should("be.enabled");
cy.go("back");
verifyDiscardingChanges();
@@ -1524,7 +1524,7 @@ describe("issue 32037", () => {
});
H.tableInteractive().should("be.visible");
cy.button("Save changes").should("not.exist");
cy.button("Save").should("not.exist");
cy.get("@modelPathname").then((modelPathname) => {
cy.location("pathname").should("eq", modelPathname);
});

View File

@@ -2664,10 +2664,9 @@ describe("issue 23449", () => {
cy.button("Save").click();
cy.log("make a model on Reviews");
cy.findByRole("link", { name: "Exit admin" }).click();
H.navigationSidebar().findByLabelText("Browse models").click();
H.benchNav().findByText("Models").click();
cy.findByLabelText("Create a new model").click();
cy.findByRole("link", { name: /Use the notebook editor/ }).click();
H.popover().findByText("Query builder").click();
H.entityPickerModal().findByText("Reviews").click();
cy.log("Remove Body column");
@@ -2675,17 +2674,13 @@ describe("issue 23449", () => {
H.popover().findByText("Body").click();
cy.log("save model");
cy.findByTestId("dataset-edit-bar").button("Save").click();
cy.findByTestId("bench-pane-header").button("Save").click();
cy.log("confirm save in a modal");
H.modal().button("Save").click();
cy.log("check that the data renders");
checkTable();
cy.log("reload to flush cached results and check again");
cy.findByTestId("qb-header").button("Refresh").click();
checkTable();
});
});

View File

@@ -13,7 +13,7 @@
(def ^:private GroupPermissionsGraph
[:map-of
[:enum :setting :monitoring :subscription]
[:enum :setting :monitoring :subscription :transforms]
[:enum :yes :no]])
(def ^:private ApplicationPermissionsGraph
@@ -45,7 +45,8 @@
[permission-set]
{:setting (permission-for-type permission-set :setting)
:monitoring (permission-for-type permission-set :monitoring)
:subscription (permission-for-type permission-set :subscription)})
:subscription (permission-for-type permission-set :subscription)
:transforms (permission-for-type permission-set :transforms)})
(mu/defn graph :- ApplicationPermissionsGraph
"Fetch a graph representing the application permissions status for groups that has at least one application permission

View File

@@ -15,6 +15,8 @@
[metabase.api.util.handlers :as handlers]
[metabase.driver.util :as driver.u]
[metabase.events.core :as events]
[metabase.models.interface :as mi]
[metabase.permissions.core :as perms]
[metabase.queries.schema :as queries.schema]
[metabase.request.core :as request]
[metabase.util :as u]
@@ -88,7 +90,7 @@
(defn get-transforms
"Get a list of transforms."
[& {:keys [last_run_start_time last_run_statuses tag_ids]}]
(api/check-superuser)
(api/check-403 (perms/current-user-has-application-permissions? :transforms))
(let [transforms (t2/select :model/Transform)]
(into []
(comp (transforms.util/->date-field-filter-xf [:last_run :start_time] last_run_start_time)
@@ -118,7 +120,7 @@
[:target ::transform-target]
[:run_trigger {:optional true} ::run-trigger]
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(api/check-superuser)
(api/check-403 (perms/current-user-has-application-permissions? :transforms))
(check-database-feature body)
(check-feature-enabled! body)
(api/check (not (transforms.util/target-table-exists? body))
@@ -139,8 +141,7 @@
(defn get-transform
"Get a specific transform."
[id]
(api/check-superuser)
(let [{:keys [target] :as transform} (api/check-404 (t2/select-one :model/Transform id))
(let [{:keys [target] :as transform} (api/check-404 (api/read-check (t2/select-one :model/Transform id)))
target-table (transforms.util/target-table (transforms.i/target-db-id transform) target :active true)]
(-> transform
(t2/hydrate :last_run :transform_tag_ids)
@@ -157,12 +158,11 @@
"Get the dependencies of a specific transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(let [id->transform (t2/select-pk->fn identity :model/Transform)
_ (api/check-404 (get id->transform id))
_ (api/check-404 (api/read-check (get id->transform id)))
global-ordering (transforms.ordering/transform-ordering (vals id->transform))
dep-ids (get global-ordering id)]
(map id->transform dep-ids)))
(filter mi/can-read? (map id->transform dep-ids))))
(api.macros/defendpoint :get "/run"
"Get transform runs based on a set of filter params."
@@ -195,10 +195,9 @@
[:target {:optional true} ::transform-target]
[:run_trigger {:optional true} ::run-trigger]
[:tag_ids {:optional true} [:sequential ms/PositiveInt]]]]
(api/check-superuser)
(let [transform (t2/with-transaction [_]
;; Cycle detection should occur within the transaction to avoid race
(let [old (t2/select-one :model/Transform id)
(let [old (api/write-check (t2/select-one :model/Transform id))
new (merge old body)
target-fields #(-> % :target (select-keys [:schema :name]))]
;; we must validate on a full transform object
@@ -224,7 +223,7 @@
"Delete a transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(api/write-check :model/Transform id)
(t2/delete! :model/Transform id)
nil)
@@ -232,7 +231,7 @@
"Delete a transform's output table."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(api/write-check :model/Transform id)
(transforms.util/delete-target-table-by-id! id)
nil)
@@ -240,7 +239,7 @@
"Cancel the current run for a given transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(api/write-check :model/Transform id)
(let [transform (api/check-404 (t2/select-one :model/Transform id))
run (api/check-404 (transform-run/running-run-for-transform-id id))]
(transform-run-cancelation/mark-cancel-started-run! (:id run))
@@ -252,7 +251,7 @@
"Run a transform."
[{:keys [id]} :- [:map
[:id ms/PositiveInt]]]
(api/check-superuser)
(api/write-check :model/Transform id)
(let [transform (api/check-404 (t2/select-one :model/Transform id))
_ (check-feature-enabled! transform)
start-promise (promise)]

View File

@@ -4,11 +4,13 @@
[medley.core :as m]
[metabase-enterprise.transforms.interface :as transforms.i]
[metabase-enterprise.transforms.models.transform-run :as transform-run]
[metabase.api.common :as api]
[metabase.events.core :as events]
[metabase.lib-be.core :as lib-be]
[metabase.lib.core :as lib]
[metabase.models.interface :as mi]
[metabase.models.serialization :as serdes]
[metabase.permissions.core :as perms]
[metabase.search.core :as search.core]
[metabase.search.ingestion :as search]
[metabase.search.spec :as search.spec]
@@ -23,10 +25,17 @@
(doseq [trait [:metabase/model :hook/entity-id :hook/timestamped?]]
(derive :model/Transform trait))
;; Only superusers can access transforms
(doto :model/Transform
(derive ::mi/read-policy.superuser)
(derive ::mi/write-policy.superuser))
(defmethod mi/can-read? :model/Transform
([_instance]
(perms/set-has-application-permission-of-type? @api/*current-user-permissions-set* :transforms))
([_model _pk]
(perms/set-has-application-permission-of-type? @api/*current-user-permissions-set* :transforms)))
(defmethod mi/can-write? :model/Transform
([_instance]
(perms/set-has-application-permission-of-type? @api/*current-user-permissions-set* :transforms))
([_model _pk]
(perms/set-has-application-permission-of-type? @api/*current-user-permissions-set* :transforms)))
(defn- transform-source-out [m]
(-> m

View File

@@ -46,10 +46,12 @@
(with-new-group-and-current-graph group-id current-graph
(let [new-graph (assoc-in current-graph [:groups group-id] {:setting :yes
:monitoring :no
:subscription :no})
:subscription :no
:transforms :no})
_ (g-perms/update-graph! new-graph)
updated-graph (g-perms/graph)]
(is (partial= (:groups new-graph) (:groups updated-graph)))
(is (= (:groups new-graph) (:groups updated-graph)))
(is (=? (:groups new-graph) (:groups updated-graph)))
(is (= (inc (:revision current-graph)) (:revision updated-graph)))
(is (< initial-revision (a-perm-revision/latest-id)))))))
@@ -94,7 +96,8 @@
(let [current-graph (g-perms/graph)
new-graph (assoc-in current-graph [:groups group-id] {:setting :yes
:subscription :yes
:monitoring :no})
:monitoring :no
:transforms :no})
_ (g-perms/update-graph! new-graph)
updated-graph (g-perms/graph)]
(is (= (:groups new-graph) (:groups updated-graph)))

View File

@@ -1221,7 +1221,7 @@
;; note: t2 not included because it's a (non-native) MBQL query
(mt/obj->json->obj (select-keys t3 [:id :entity_id :name :description :source]))]
:conversation_id conversation-id}
(-> (mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transforms"
(-> (mt/user-http-request :crowberto :post 200 "ee/metabot-tools/get-transforms"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:conversation_id conversation-id})
(update :structured_output (fn [output]
@@ -1255,7 +1255,7 @@
:conversation_id conversation-id}))))
(testing "With non-existent transform"
(is (= "Not found."
(mt/user-http-request :rasta :post 404 "ee/metabot-tools/get-transform-details"
(mt/user-http-request :crowberto :post 404 "ee/metabot-tools/get-transform-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:transform_id (+ 10000 (:id t2))}
:conversation_id conversation-id}))))
@@ -1264,7 +1264,7 @@
(testing (:name transform)
(is (=? {:structured_output (mt/obj->json->obj (select-keys transform [:id :entity_id :name :description :source :target]))
:conversation_id conversation-id}
(mt/user-http-request :rasta :post 200 "ee/metabot-tools/get-transform-details"
(mt/user-http-request :crowberto :post 200 "ee/metabot-tools/get-transform-details"
{:request-options {:headers {"x-metabase-session" crowberto-ai-token}}}
{:arguments {:transform_id (:id transform)}
:conversation_id conversation-id}))))))))))

View File

@@ -132,8 +132,11 @@ export const transformJobApi = EnterpriseApi.injectEndpoints({
method: "DELETE",
url: `/api/ee/transform-job/${id}`,
}),
invalidatesTags: (_, error) =>
invalidateTags(error, [listTag("transform-job")]),
invalidatesTags: (_, error, id) =>
invalidateTags(error, [
listTag("transform-job"),
idTag("transform-job", id),
]),
}),
}),
});

View File

@@ -134,7 +134,7 @@ function getItemIcon(item: DependencyItem): IconName {
}
if (item.type === "transform") {
return "refresh_downstream";
return "transform";
}
return "unknown";

View File

@@ -188,7 +188,7 @@ describe("CheckDependenciesForm", () => {
});
expect(screen.getByRole("link", { name: /SQL transform/ })).toHaveAttribute(
"href",
"/admin/transforms/1",
"/bench/transforms/1",
);
});
});

View File

@@ -19,6 +19,7 @@ import type { DependencyEntry } from "metabase-types/api";
import S from "./DependencyGraph.module.css";
import { GraphContext } from "./GraphContext";
import { GraphDependencyPanel } from "./GraphDependencyPanel";
import { GraphEdge } from "./GraphEdge";
import { GraphEntryInput } from "./GraphEntryInput";
import { GraphInfoPanel } from "./GraphInfoPanel";
import { GraphNode } from "./GraphNode";
@@ -32,11 +33,21 @@ const NODE_TYPES = {
node: GraphNode,
};
type DependencyGraphProps = {
entry?: DependencyEntry;
const EDGE_TYPES = {
edge: GraphEdge,
};
export function DependencyGraph({ entry }: DependencyGraphProps) {
type DependencyGraphProps = {
entry?: DependencyEntry;
getGraphUrl: (entry?: DependencyEntry) => string;
withEntryPicker?: boolean;
};
export function DependencyGraph({
entry,
getGraphUrl,
withEntryPicker,
}: DependencyGraphProps) {
const {
data: graph,
isFetching,
@@ -48,13 +59,11 @@ export function DependencyGraph({ entry }: DependencyGraphProps) {
const { sendErrorToast } = useMetadataToasts();
const entryNode = useMemo(() => {
return entry != null ? findNode(nodes, entry.id, entry.type) : null;
return entry != null ? findNode(nodes, entry) : null;
}, [nodes, entry]);
const selectedNode = useMemo(() => {
return selection != null
? findNode(nodes, selection.id, selection.type)
: null;
return selection != null ? findNode(nodes, selection) : null;
}, [nodes, selection]);
useEffect(() => {
@@ -87,6 +96,7 @@ export function DependencyGraph({ entry }: DependencyGraphProps) {
nodes={nodes}
edges={edges}
nodeTypes={NODE_TYPES}
edgeTypes={EDGE_TYPES}
fitView
minZoom={MIN_ZOOM}
maxZoom={MAX_ZOOM}
@@ -99,24 +109,29 @@ export function DependencyGraph({ entry }: DependencyGraphProps) {
<GraphNodeLayout />
<Panel position="top-left">
<Group>
<GraphEntryInput
node={entryNode?.data ?? null}
isGraphFetching={isFetching}
/>
{withEntryPicker && (
<GraphEntryInput
node={entryNode?.data ?? null}
isGraphFetching={isFetching}
getGraphUrl={getGraphUrl}
/>
)}
{nodes.length > 1 && <GraphSelectInput nodes={nodes} />}
</Group>
</Panel>
{selection != null && selection.withInfo && selectedNode != null && (
{selection != null && selectedNode != null && (
<Panel className={S.panel} position="top-right">
{selection.groupType != null ? (
<GraphDependencyPanel
node={selectedNode.data}
groupType={selection.groupType}
getGraphUrl={getGraphUrl}
onClose={handlePanelClose}
/>
) : (
<GraphInfoPanel
node={selectedNode.data}
getGraphUrl={getGraphUrl}
onClose={handlePanelClose}
/>
)}

View File

@@ -6,7 +6,11 @@ import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErr
import { SEARCH_DEBOUNCE_DURATION } from "metabase/lib/constants";
import { Box, Card } from "metabase/ui";
import { useListNodeDependentsQuery } from "metabase-enterprise/api";
import type { DependencyGroupType, DependencyNode } from "metabase-types/api";
import type {
DependencyEntry,
DependencyGroupType,
DependencyNode,
} from "metabase-types/api";
import { DEPENDENTS_SEARCH_THRESHOLD } from "../constants";
@@ -25,12 +29,14 @@ import {
type GraphDependencyPanelProps = {
node: DependencyNode;
groupType: DependencyGroupType;
getGraphUrl: (entry: DependencyEntry) => string;
onClose: () => void;
};
export function GraphDependencyPanel({
node,
groupType,
getGraphUrl,
onClose,
}: GraphDependencyPanelProps) {
const {
@@ -83,7 +89,7 @@ export function GraphDependencyPanel({
{t`Didn't find any results`}
</Box>
) : (
<PanelBody nodes={visibleNodes} />
<PanelBody nodes={visibleNodes} getGraphUrl={getGraphUrl} />
)}
</Card>
);

View File

@@ -1,8 +1,7 @@
import { memo } from "react";
import * as Urls from "metabase/lib/urls";
import { Box, Group, Stack } from "metabase/ui";
import type { DependencyNode } from "metabase-types/api";
import type { DependencyEntry, DependencyNode } from "metabase-types/api";
import { GraphBreadcrumbs } from "../../GraphBreadcrumbs";
import { GraphExternalLink } from "../../GraphExternalLink";
@@ -21,13 +20,21 @@ import { getNodeViewCountLabel } from "./utils";
type PanelBodyProps = {
nodes: DependencyNode[];
getGraphUrl: (entry: DependencyEntry) => string;
};
export const PanelBody = memo(function ListBody({ nodes }: PanelBodyProps) {
export const PanelBody = memo(function ListBody({
nodes,
getGraphUrl,
}: PanelBodyProps) {
return (
<Box className={S.body}>
{nodes.map((node) => (
<ListItem key={getNodeId(node.id, node.type)} node={node} />
<ListItem
key={getNodeId(node.id, node.type)}
node={node}
getGraphUrl={getGraphUrl}
/>
))}
</Box>
);
@@ -35,9 +42,10 @@ export const PanelBody = memo(function ListBody({ nodes }: PanelBodyProps) {
type ListItemProps = {
node: DependencyNode;
getGraphUrl: (entry: DependencyEntry) => string;
};
function ListItem({ node }: ListItemProps) {
function ListItem({ node, getGraphUrl }: ListItemProps) {
const label = getNodeLabel(node);
const link = getNodeLink(node);
const location = getNodeLocationInfo(node);
@@ -49,7 +57,7 @@ function ListItem({ node }: ListItemProps) {
<GraphLink
label={label}
icon={getNodeIcon(node)}
url={Urls.dependencyGraph({ entry: node })}
url={getGraphUrl(node)}
/>
{viewCount != null ? (
<Box c="text-secondary" fz="sm" lh="1rem">

View File

@@ -0,0 +1,15 @@
import { BezierEdge, type EdgeProps, useNodesInitialized } from "@xyflow/react";
export function GraphEdge({ style, ...props }: EdgeProps) {
const isInitialized = useNodesInitialized();
return (
<BezierEdge
{...props}
style={{
...style,
visibility: !isInitialized ? "hidden" : undefined,
}}
/>
);
}

View File

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

View File

@@ -8,7 +8,7 @@ import { getNodeIcon, getNodeLabel } from "../../utils";
type EntryButtonProps = {
node: DependencyNode | null;
onEntryChange: (entry: DependencyEntry | null) => void;
onEntryChange: (entry: DependencyEntry | undefined) => void;
onPickerOpen: () => void;
};
@@ -19,7 +19,7 @@ export function EntryButton({
}: EntryButtonProps) {
const handleIconClick = (event: MouseEvent) => {
event.stopPropagation();
onEntryChange(null);
onEntryChange(undefined);
};
return (

View File

@@ -106,7 +106,7 @@ export function EntryPickerModal({
displayName: t`Transforms`,
models: ["transform"],
folderModels: [],
icon: "refresh_downstream",
icon: "transform",
render: ({ onItemSelect }) => (
<PLUGIN_TRANSFORMS.TransformPicker
value={value ? getTransformPickerItem(value) : undefined}

View File

@@ -3,7 +3,6 @@ import { useState } from "react";
import { push } from "react-router-redux";
import { useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { Card } from "metabase/ui";
import type {
DependencyEntry,
@@ -19,11 +18,13 @@ import { SEARCH_MODELS } from "./constants";
type GraphEntryInputProps = {
node: DependencyNode | null;
isGraphFetching: boolean;
getGraphUrl: (entry: DependencyEntry | undefined) => string;
};
export function GraphEntryInput({
node,
isGraphFetching,
getGraphUrl,
}: GraphEntryInputProps) {
const [searchModels, setSearchModels] =
useState<SearchModel[]>(SEARCH_MODELS);
@@ -31,8 +32,8 @@ export function GraphEntryInput({
const [isPickerOpened, { open: openPicker, close: closePicker }] =
useDisclosure();
const handleEntryChange = (newEntry: DependencyEntry | null) => {
dispatch(push(Urls.dependencyGraph(newEntry ? { entry: newEntry } : {})));
const handleEntryChange = (newEntry: DependencyEntry | undefined) => {
dispatch(push(getGraphUrl(newEntry)));
};
const handlePickerChange = (newEntry: DependencyEntry) => {

View File

@@ -1,5 +1,5 @@
import { Card } from "metabase/ui";
import type { DependencyNode } from "metabase-types/api";
import type { DependencyEntry, DependencyNode } from "metabase-types/api";
import S from "./GraphInfoPanel.module.css";
import { PanelBody } from "./PanelBody";
@@ -7,14 +7,19 @@ import { PanelHeader } from "./PanelHeader";
type GraphInfoPanelProps = {
node: DependencyNode;
getGraphUrl: (entry: DependencyEntry) => string;
onClose: () => void;
};
export function GraphInfoPanel({ node, onClose }: GraphInfoPanelProps) {
export function GraphInfoPanel({
node,
getGraphUrl,
onClose,
}: GraphInfoPanelProps) {
return (
<Card className={S.root} withBorder data-testid="graph-info-panel">
<PanelHeader node={node} onClose={onClose} />
<PanelBody node={node} />
<PanelBody node={node} getGraphUrl={getGraphUrl} />
</Card>
);
}

View File

@@ -5,7 +5,7 @@ import { getColumnIcon } from "metabase/common/utils/columns";
import { getUserName } from "metabase/lib/user";
import { Box, FixedSizeIcon, Group, Stack, Title } from "metabase/ui";
import * as Lib from "metabase-lib";
import type { DependencyNode } from "metabase-types/api";
import type { DependencyEntry, DependencyNode } from "metabase-types/api";
import { GraphBreadcrumbs } from "../../GraphBreadcrumbs";
import { GraphExternalLink } from "../../GraphExternalLink";
@@ -25,14 +25,15 @@ import {
type PanelBodyProps = {
node: DependencyNode;
getGraphUrl: (entry: DependencyEntry) => string;
};
export function PanelBody({ node }: PanelBodyProps) {
export function PanelBody({ node, getGraphUrl }: PanelBodyProps) {
return (
<Stack className={S.body} p="lg" gap="lg">
<DescriptionSection node={node} />
<CreatorAndLastEditorSection node={node} />
<TableSection node={node} />
<TableSection node={node} getGraphUrl={getGraphUrl} />
<FieldsSection node={node} />
</Stack>
);
@@ -98,8 +99,13 @@ function CreatorAndLastEditorSection({ node }: SectionProps) {
);
}
function TableSection({ node }: SectionProps) {
const info = getNodeTableInfo(node);
type TableSectionProps = {
node: DependencyNode;
getGraphUrl: (entry: DependencyEntry) => string;
};
function TableSection({ node, getGraphUrl }: TableSectionProps) {
const info = getNodeTableInfo(node, getGraphUrl);
if (info == null) {
return null;
}

View File

@@ -3,6 +3,7 @@ import { msgid, ngettext, t } from "ttag";
import * as Urls from "metabase/lib/urls";
import type {
CardCreatorInfo,
DependencyEntry,
DependencyNode,
Field,
LastEditInfo,
@@ -66,7 +67,10 @@ export function getNodeLastEditedBy(node: DependencyNode): LastEditInfo | null {
}
}
export function getNodeTableInfo(node: DependencyNode): NodeTableInfo | null {
export function getNodeTableInfo(
node: DependencyNode,
getGraphUrl: (entry: DependencyEntry) => string,
): NodeTableInfo | null {
if (node.type !== "transform" && node.type !== "sandbox") {
return null;
}
@@ -80,7 +84,7 @@ export function getNodeTableInfo(node: DependencyNode): NodeTableInfo | null {
label: node.type === "transform" ? t`Generated table` : t`Restricted table`,
title: {
label: table.display_name,
url: Urls.dependencyGraph({ entry: { id: table.id, type: "table" } }),
url: getGraphUrl({ id: table.id, type: "table" }),
},
metadata: {
label: t`View metadata`,

View File

@@ -7,6 +7,15 @@
}
}
.card,
.handle {
visibility: hidden;
&.initialized {
visibility: visible;
}
}
.pill {
color: var(--mb-color-text-primary);
background-color: var(--mb-color-bg-medium);

View File

@@ -3,6 +3,7 @@ import {
type NodeProps,
Position,
useNodeConnections,
useNodesInitialized,
} from "@xyflow/react";
import cx from "classnames";
import { type MouseEvent, memo, useContext } from "react";
@@ -46,17 +47,20 @@ export const GraphNode = memo(function ItemNode({
const groups = getDependentGroups(node);
const sources = useNodeConnections({ handleType: "source" });
const targets = useNodeConnections({ handleType: "target" });
const isSelected =
selection != null && isSameNode(node, selection.id, selection.type);
const isSelected = selection != null && isSameNode(node, selection);
const isInitialized = useNodesInitialized();
const handleClick = () => {
setSelection({ id: node.id, type: node.type, withInfo: true });
setSelection({ id: node.id, type: node.type });
};
return (
<>
<Card
className={cx(S.card, { [S.selected]: isSelected })}
className={cx(S.card, {
[S.initialized]: isInitialized,
[S.selected]: isSelected,
})}
p="lg"
withBorder
aria-label={label}
@@ -91,10 +95,20 @@ export const GraphNode = memo(function ItemNode({
</Stack>
</Card>
{sources.length > 0 && (
<Handle type="source" position={Position.Left} isConnectable={false} />
<Handle
className={cx(S.handle, { [S.initialized]: isInitialized })}
type="source"
position={Position.Left}
isConnectable={false}
/>
)}
{targets.length > 0 && (
<Handle type="target" position={Position.Right} isConnectable={false} />
<Handle
className={cx(S.handle, { [S.initialized]: isInitialized })}
type="target"
position={Position.Right}
isConnectable={false}
/>
)}
</>
);
@@ -115,7 +129,7 @@ function DependencyGroupButton({
}: DependencyGroupButtonProps) {
const isSelected =
selection != null &&
isSameNode(node, selection.id, selection.type) &&
isSameNode(node, selection) &&
selection.groupType === group.type;
const handleClick = (event: MouseEvent) => {
@@ -124,7 +138,6 @@ function DependencyGroupButton({
id: node.id,
type: node.type,
groupType: group.type,
withInfo: true,
});
};

View File

@@ -36,15 +36,15 @@ export function getDependencyGroupTitle(
node: DependencyNode,
groups: DependentGroup[],
) {
if (node.type === "transform") {
return t`Generates`;
}
if (node.type === "sandbox") {
return t`Restricts table data`;
}
if (groups.length === 0) {
return t`Nothing uses this`;
}
if (node.type === "transform") {
return t`Generates`;
}
return t`Used by`;
}

View File

@@ -30,7 +30,6 @@ export type GraphSelection = {
id: DependencyId;
type: DependencyType;
groupType?: DependencyGroupType;
withInfo?: boolean;
};
export type GraphContextType = {

View File

@@ -7,6 +7,7 @@ import visualizations from "metabase/visualizations";
import type {
CardType,
DependencyEdge,
DependencyEntry,
DependencyGraph,
DependencyGroupType,
DependencyId,
@@ -54,6 +55,7 @@ function getEdges(edges: DependencyEdge[]): Edge[] {
return {
id: getEdgeId(sourceId, targetId),
type: "edge",
data: edge,
source: sourceId,
target: targetId,
@@ -71,19 +73,17 @@ export function getInitialGraph({ nodes, edges }: DependencyGraph): GraphData {
}
export function isSameNode(
node: DependencyNode,
id: DependencyId,
type: DependencyType,
entry1: DependencyEntry,
entry2: DependencyEntry,
): boolean {
return node.id === id && node.type === type;
return entry1.id === entry2.id && entry1.type === entry2.type;
}
export function findNode(
nodes: NodeType[],
id: DependencyId,
type: DependencyType,
): NodeType | null {
return nodes.find((node) => isSameNode(node.data, id, type)) ?? null;
entry: DependencyEntry,
): NodeType | undefined {
return nodes.find((node) => isSameNode(node.data, entry));
}
export function getNodeLabel(node: DependencyNode): string {
@@ -137,7 +137,7 @@ export function getNodeIconWithType(
case "table":
return "table";
case "transform":
return "refresh_downstream";
return "transform";
case "snippet":
return "sql";
case "dashboard":

View File

@@ -53,7 +53,7 @@ describe("getNodeIcon", () => {
},
{
node: createMockTransformDependencyNode(),
expectedIcon: "refresh_downstream",
expectedIcon: "transform",
},
{
node: createMockSnippetDependencyNode(),

View File

@@ -6,7 +6,7 @@ import type { CheckDependenciesResponse } from "metabase-types/api";
type UseCheckDependenciesQueryData = {
data?: CheckDependenciesResponse;
error?: unknown;
isLoading: boolean;
isFetching?: boolean;
};
type UseCheckDependenciesArgsData<TRequest> = {
@@ -35,7 +35,7 @@ export function useCheckDependencies<TChange, TRequest>({
>): UseCheckDependenciesResult<TChange> {
const [change, setChange] = useState<TChange | null>(null);
const [isConfirmationShown, setIsConfirmationShown] = useState(false);
const [checkDependencies, { data, isLoading }] =
const [checkDependencies, { data, isFetching = false }] =
useLazyCheckDependenciesQuery();
const handleInitialSave = useCallback(
@@ -70,7 +70,7 @@ export function useCheckDependencies<TChange, TRequest>({
return {
checkData: data,
isCheckingDependencies: isLoading,
isCheckingDependencies: isFetching,
isConfirmationShown,
handleInitialSave,
handleSaveAfterConfirmation,

View File

@@ -8,9 +8,12 @@ import { useCheckCardDependencies } from "./hooks/use-check-card-dependencies";
import { useCheckSnippetDependencies } from "./hooks/use-check-snippet-dependencies";
import { useCheckTransformDependencies } from "./hooks/use-check-transform-dependencies";
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
import { getBenchNavItems, getBenchRoutes } from "./routes";
if (hasPremiumFeature("dependencies")) {
PLUGIN_DEPENDENCIES.isEnabled = true;
PLUGIN_DEPENDENCIES.getBenchRoutes = getBenchRoutes;
PLUGIN_DEPENDENCIES.getBenchNavItems = getBenchNavItems;
PLUGIN_DEPENDENCIES.DependencyGraphPage = DependencyGraphPage;
PLUGIN_DEPENDENCIES.CheckDependenciesForm = CheckDependenciesForm;
PLUGIN_DEPENDENCIES.CheckDependenciesModal = CheckDependenciesModal;

View File

@@ -1,13 +1,36 @@
import type { Location } from "history";
import { useContext } from "react";
import * as Urls from "metabase/lib/urls";
import { PLUGIN_DEPENDENCIES } from "metabase/plugins";
import { DependencyGraph } from "../../components/DependencyGraph";
import type { DependencyGraphRawParams } from "../../types";
import { isSameNode } from "../../components/DependencyGraph/utils";
import { parseParams } from "./utils";
import { parseDependencyEntry } from "./utils";
type DependencyGraphPageProps = {
params?: DependencyGraphRawParams;
export type DependencyGraphPageQuery = {
id?: string;
type?: string;
};
export function DependencyGraphPage({ params }: DependencyGraphPageProps) {
const { entry } = parseParams(params);
return <DependencyGraph entry={entry} />;
type DependencyGraphPageProps = {
location?: Location<DependencyGraphPageQuery>;
};
export function DependencyGraphPage({ location }: DependencyGraphPageProps) {
const entry = parseDependencyEntry(location?.query?.id, location?.query.type);
const { defaultEntry, baseUrl } = useContext(
PLUGIN_DEPENDENCIES.DependencyGraphPageContext,
);
const withEntryPicker =
defaultEntry == null || (entry != null && !isSameNode(entry, defaultEntry));
return (
<DependencyGraph
entry={entry ?? defaultEntry}
getGraphUrl={(entry) => Urls.dependencyGraph({ entry, baseUrl })}
withEntryPicker={withEntryPicker}
/>
);
}

View File

@@ -1,19 +1,16 @@
import * as Urls from "metabase/lib/urls";
import type { DependencyType } from "metabase-types/api";
import type { DependencyEntry, DependencyType } from "metabase-types/api";
import type { DependencyGraphRawParams } from "../../types";
export function parseParams(
params: DependencyGraphRawParams = {},
): Urls.DependencyGraphParams {
const id = Urls.extractEntityId(params.id);
const type = parseDependencyType(params.type);
return {
entry: id != null && type != null ? { id, type } : undefined,
};
export function parseDependencyEntry(
rawId?: string,
rawType?: string,
): DependencyEntry | undefined {
const id = Urls.extractEntityId(rawId);
const type = parseDependencyType(rawType);
return id != null && type != null ? { id, type } : undefined;
}
function parseDependencyType(type: unknown): DependencyType | null {
function parseDependencyType(type: unknown): DependencyType | undefined {
switch (type) {
case "card":
case "table":
@@ -24,6 +21,6 @@ function parseDependencyType(type: unknown): DependencyType | null {
case "sandbox":
return type;
default:
return null;
return undefined;
}
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import { t } from "ttag";
import type { BenchNavItem } from "metabase/bench/constants/navigation";
import { Route } from "metabase/hoc/Title";
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
export function getBenchRoutes(): ReactNode {
return (
<Route
title={t`Dependency graph`}
path="dependencies"
component={DependencyGraphPage}
/>
);
}
export const getBenchNavItems = (): BenchNavItem[] => {
return [
{
id: "dependencies",
url: "/bench/dependencies",
icon: "network",
getLabel: () => t`Dependency graph`,
getDescription: () =>
t`Use the Dependency Graph to see what's upstream and downstream of anything.`,
},
];
};

View File

@@ -1,4 +0,0 @@
export type DependencyGraphRawParams = {
id?: string;
type?: string;
};

View File

@@ -17,7 +17,7 @@ export const Transforms = createEntity({
objectSelectors: {
getName: (transform: Transform) => transform && transform.name,
getUrl: (transform: Transform) => transform && Urls.transform(transform.id),
getIcon: () => ({ name: "refresh_downstream" }),
getIcon: () => ({ name: "transform" }),
getColor: () => color("brand"),
},
});

View File

@@ -17,15 +17,16 @@ export interface MetabotConfig {
hideSuggestedPrompts?: boolean;
preventClose?: boolean;
preventRetryMessage?: boolean;
suggestionModels: SuggestionModel[];
suggestionModels?: SuggestionModel[];
}
export interface MetabotProps {
hide?: boolean;
config?: MetabotConfig;
w?: string;
}
export const MetabotAuthenticated = ({ hide, config }: MetabotProps) => {
export const MetabotAuthenticated = ({ hide, config, w }: MetabotProps) => {
const { visible, setVisible } = useMetabotAgent();
useEffect(() => {
@@ -55,7 +56,7 @@ export const MetabotAuthenticated = ({ hide, config }: MetabotProps) => {
return (
<ErrorBoundary errorComponent={() => null}>
<MetabotChat config={config} />
<MetabotChat config={config} w={w} />
</ErrorBoundary>
);
};

View File

@@ -1,39 +0,0 @@
import { useLocation } from "react-use";
import { t } from "ttag";
import { METAKEY } from "metabase/lib/browser";
import * as Urls from "metabase/lib/urls";
import { AdminActionButton } from "metabase/nav/components/AdminNavbar/AdminNavbar.styled";
import { Icon, Tooltip } from "metabase/ui";
import { trackMetabotChatOpened } from "../analytics";
import { useMetabotAgent } from "../hooks";
export const MetabotAdminAppBarButton = () => {
const metabot = useMetabotAgent();
const location = useLocation();
const disabled = !location.pathname?.startsWith(Urls.transformList());
const handleClick = () => {
if (!metabot.visible) {
trackMetabotChatOpened("header");
}
metabot.setVisible(!metabot.visible);
};
return (
<Tooltip
label={
disabled
? `Metabot can't be viewed on this page`
: t`Chat with Metabot (${METAKEY}+E)`
}
>
<AdminActionButton disabled={disabled} onClick={handleClick}>
<Icon name="metabot" />
</AdminActionButton>
</Tooltip>
);
};

View File

@@ -140,7 +140,7 @@ export const AgentSuggestionMessage = ({
<Paper
shadow="none"
radius="md"
bg="white"
bg="bg-white"
className={S.container}
data-testid="metabot-chat-suggestion"
>
@@ -152,7 +152,7 @@ export const AgentSuggestionMessage = ({
className={cx(opened && S.headerOpened)}
>
<Flex align="center" gap="sm">
<Icon name="refresh_downstream" size="1rem" c="text-secondary" />
<Icon name="transform" size="1rem" c="text-secondary" />
<Text size="sm">{suggestedTransform.name}</Text>
</Flex>
<Flex align="center" gap="sm">
@@ -251,12 +251,8 @@ function getSourceCode(
function getTransformUrl(transform: SuggestedTransform): string {
return match(transform)
.with({ id: P.number }, ({ id }) => Urls.transformQuery(id))
.with({ source: { type: "python" } }, () =>
Urls.newTransformFromType("python"),
)
.with({ source: { type: "query" } }, () =>
Urls.newTransformFromType("native"),
)
.with({ id: P.number }, ({ id }) => Urls.transform(id))
.with({ source: { type: "python" } }, () => Urls.newPythonTransform())
.with({ source: { type: "query" } }, () => Urls.newNativeTransform())
.exhaustive();
}

View File

@@ -1,6 +1,6 @@
.todoContainer {
border: 1px solid var(--mb-color-brand);
background-color: var(--mb-color-brand-lighter);
background-color: var(--mb-color-brand-light);
}
.todoHeader {

View File

@@ -17,6 +17,7 @@ import {
} from "metabase/ui";
import { useGetSuggestedMetabotPromptsQuery } from "metabase-enterprise/api";
import { MetabotResetLongChatButton } from "metabase-enterprise/metabot/components/MetabotChat/MetabotResetLongChatButton";
import type { SuggestionModel } from "metabase-enterprise/rich_text_editing/tiptap/extensions/shared/types";
import { useMetabotAgent, useMetabotChatHandlers } from "../../hooks";
import type { MetabotConfig } from "../Metabot";
@@ -27,21 +28,21 @@ import { Messages } from "./MetabotChatMessage";
import { MetabotThinking } from "./MetabotThinking";
import { useScrollManager } from "./hooks";
const defaultConfig: MetabotConfig = {
suggestionModels: [
"dataset",
"metric",
"card",
"table",
"database",
"dashboard",
],
};
const defaultSuggesstionModels: SuggestionModel[] = [
"dataset",
"metric",
"card",
"table",
"database",
"dashboard",
];
export const MetabotChat = ({
config = defaultConfig,
config,
w = "30rem",
}: {
config?: MetabotConfig;
w?: string;
}) => {
const metabot = useMetabotAgent();
const { handleSubmitInput, handleRetryMessage, handleResetInput } =
@@ -80,7 +81,7 @@ export const MetabotChat = ({
<Sidebar
isOpen={metabot.visible}
side="right"
width="30rem"
width={w}
aria-hidden={!metabot.visible}
>
<Box className={Styles.container} data-testid="metabot-chat">
@@ -103,7 +104,7 @@ export const MetabotChat = ({
<Icon c="text-primary" name="revert" />
</ActionIcon>
</Tooltip>
{!config.preventClose && (
{!config?.preventClose && (
<ActionIcon
onClick={handleClose}
data-testid="metabot-close-chat"
@@ -133,11 +134,11 @@ export const MetabotChat = ({
>
<Box component={EmptyDashboardBot} w="6rem" />
<Text c="text-light" maw="12rem" ta="center">
{config.emptyText ??
{config?.emptyText ??
t`I can help you explore your metrics and models.`}
</Text>
</Flex>
{!config.hideSuggestedPrompts && (
{!config?.hideSuggestedPrompts && (
<Stack
gap="sm"
className={Styles.promptSuggestionsContainer}
@@ -202,7 +203,9 @@ export const MetabotChat = ({
placeholder={t`Tell me to do something, or ask a question`}
onChange={handleEditorChange}
onSubmit={handleEditorSubmit}
suggestionModels={config.suggestionModels}
suggestionModels={
config?.suggestionModels ?? defaultSuggesstionModels
}
/>
</Paper>
</Box>

View File

@@ -10,11 +10,11 @@ import { hasPremiumFeature } from "metabase-enterprise/settings";
import { Metabot } from "./components/Metabot";
import { MetabotAdminPage } from "./components/MetabotAdmin/MetabotAdminPage";
import { MetabotAdminAppBarButton } from "./components/MetabotAdminAppBarButton";
import { MetabotAppBarButton } from "./components/MetabotAppBarButton";
import { getMetabotQuickLinks } from "./components/MetabotQuickLinks";
import { MetabotToggleButton } from "./components/MetabotToggleButton";
import { MetabotContext, MetabotProvider, defaultContext } from "./context";
import { useMetabotAgent } from "./hooks";
import { getMetabotVisible, metabotReducer } from "./state";
if (hasPremiumFeature("metabot_v3")) {
@@ -50,7 +50,7 @@ if (hasPremiumFeature("metabot_v3")) {
PLUGIN_METABOT.MetabotToggleButton = MetabotToggleButton;
PLUGIN_METABOT.MetabotAppBarButton = MetabotAppBarButton;
PLUGIN_METABOT.MetabotAdminAppBarButton = MetabotAdminAppBarButton;
PLUGIN_METABOT.useMetabotAgent = useMetabotAgent;
PLUGIN_REDUCERS.metabotPlugin = metabotReducer;
} else if (hasPremiumFeature("offer_metabase_ai")) {

View File

@@ -130,7 +130,8 @@ export const getProfile = createSelector(
return profileOverride;
}
return location.pathname.startsWith(Urls.transformList())
const transformsUrl = Urls.transformList();
return transformsUrl && location.pathname.startsWith(transformsUrl)
? "transforms_codegen"
: undefined;
},

View File

@@ -31,12 +31,7 @@ class CollectionRow extends Component {
)}
{...(collection.archived ? undefined : { onClick: onSelectCollection })}
>
<Icon
name="folder"
size={ICON_SIZE}
style={{ opacity: 0.25 }}
className={CS.flexNoShrink}
/>
<Icon name="folder" size={ICON_SIZE} className={CS.flexNoShrink} />
<Ellipsified className={cx(CS.flexFull, CS.ml1, CS.textBold)} flex={1}>
{collection.name}
</Ellipsified>

View File

@@ -74,6 +74,7 @@ if (hasPremiumFeature("snippet_collections")) {
const collection = snippetSidebar.props.snippetCollection;
return (
<CollectionOptionsButton
key="options"
{...snippetSidebar.props}
{...props}
setSidebarState={snippetSidebar.setState.bind(snippetSidebar)}

View File

@@ -1,8 +1,3 @@
.dataPicker {
border-right: 1px solid var(--mb-color-border);
overflow: auto;
}
.databaseSelector {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
border: 1px solid var(--mb-color-border);

View File

@@ -1,3 +1,4 @@
import { useDisclosure } from "@mantine/hooks";
import { useEffect, useState } from "react";
import { t } from "ttag";
@@ -9,7 +10,7 @@ import {
} from "metabase/api";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { DatabaseDataSelector } from "metabase/query_builder/components/DataSelector";
import { Box, Button, Icon, Stack, Text } from "metabase/ui";
import { ActionIcon, Box, Button, Icon, Stack, Text } from "metabase/ui";
import type {
Database,
DatabaseId,
@@ -47,6 +48,8 @@ export function PythonDataPicker({
getInitialTableSelections(tables),
);
const [isCollapsed, { toggle: toggleCollapse }] = useDisclosure(false);
useEffect(() => {
setTableSelections(getInitialTableSelections(tables));
}, [tables]);
@@ -141,59 +144,61 @@ export function PythonDataPicker({
};
return (
<Stack
p="md"
gap="md"
h="100%"
w="30%"
miw="300px"
className={S.dataPicker}
data-testid="python-data-picker"
>
<Box>
<Text fw="bold">{t`Source database`}</Text>
<Text size="sm" c="text-light" mb="sm">
{t`Select the database that contains your source data.`}
</Text>
<DatabaseDataSelector
className={S.databaseSelector}
selectedDatabaseId={database}
setDatabaseFn={handleDatabaseChange}
databases={databases?.data ?? []}
databaseIsDisabled={(database: Database) =>
!hasFeature(database, "transforms/python")
}
<Stack p="md" gap="sm">
<ActionIcon onClick={toggleCollapse} py="md">
<Icon
name={isCollapsed ? "chevronright" : "chevronleft"}
c={isCollapsed ? "brand" : undefined}
/>
</Box>
{database && (
<Box>
<Text fw="bold">{t`Pick tables and alias them`}</Text>
<Text size="sm" c="text-light" mb="sm">
{t`Select tables to use as data sources and provide aliases that can be referenced in your Python script.`}
</Text>
<Stack gap="md">
{tableSelections.map((selection, index) => (
<SelectionInput
key={index}
selection={selection}
database={database}
tables={tables}
usedAliases={usedAliases}
availableTables={availableTables}
onChange={(selection) =>
handleSelectionChange(index, selection)
}
onRemove={() => handleRemoveTable(index)}
disabled={isLoadingTables}
/>
))}
<AddTableButton
onClick={handleAddTable}
disabled={availableTables.length === 0}
</ActionIcon>
{!isCollapsed && (
<Stack gap="md" w="100%" data-testid="python-data-picker">
<Box>
<Text fw="bold">{t`Source database`}</Text>
<Text size="sm" c="text-light" mb="sm">
{t`Select the database that contains your source data.`}
</Text>
<DatabaseDataSelector
className={S.databaseSelector}
selectedDatabaseId={database}
setDatabaseFn={handleDatabaseChange}
databases={databases?.data ?? []}
databaseIsDisabled={(database: Database) =>
!hasFeature(database, "transforms/python")
}
/>
</Stack>
</Box>
</Box>
{database && (
<Box>
<Text fw="bold">{t`Pick tables and alias them`}</Text>
<Text size="sm" c="text-light" mb="sm">
{t`Select tables to use as data sources and provide aliases that can be referenced in your Python script.`}
</Text>
<Stack gap="md">
{tableSelections.map((selection, index) => (
<SelectionInput
key={index}
selection={selection}
database={database}
tables={tables}
usedAliases={usedAliases}
availableTables={availableTables}
onChange={(selection) =>
handleSelectionChange(index, selection)
}
onRemove={() => handleRemoveTable(index)}
disabled={isLoadingTables}
/>
))}
<AddTableButton
onClick={handleAddTable}
disabled={availableTables.length === 0}
/>
</Stack>
</Box>
)}
</Stack>
)}
</Stack>
);

View File

@@ -0,0 +1,37 @@
import { t } from "ttag";
import { Button, Group, Tooltip } from "metabase/ui";
import type { PythonTransformSourceDraft } from "metabase-types/api";
import { getValidationResult } from "./utils";
type PythonEditorActionsProps = {
source: PythonTransformSourceDraft;
isSaving: boolean;
onSave: () => void;
onCancel: () => void;
};
export function PythonEditorActions({
source,
isSaving,
onSave,
onCancel,
}: PythonEditorActionsProps) {
const { isValid, errorMessage } = getValidationResult(source);
return (
<Group>
<Button onClick={onCancel}>{t`Cancel`}</Button>
<Tooltip label={errorMessage} disabled={errorMessage == null}>
<Button
variant="filled"
disabled={!isValid || isSaving}
onClick={onSave}
>
{t`Save`}
</Button>
</Tooltip>
</Group>
);
}

View File

@@ -1,4 +1,4 @@
export type QueryValidationResult = {
export type ValidationResult = {
isValid: boolean;
errorMessage?: string;
};

View File

@@ -0,0 +1,29 @@
import { t } from "ttag";
import type { PythonTransformSourceDraft } from "metabase-types/api";
import type { ValidationResult } from "./types";
export function getValidationResult(
source: PythonTransformSourceDraft,
): ValidationResult {
if (!source["source-database"]) {
return { isValid: false, errorMessage: t`Select a source a database` };
}
if (source.body.trim() === "") {
return {
isValid: false,
errorMessage: t`The Python script cannot be empty`,
};
}
if (Object.keys(source["source-tables"]).length === 0) {
return {
isValid: false,
errorMessage: t`Select at least one table to alias`,
};
}
return { isValid: true };
}

View File

@@ -2,10 +2,19 @@ import type { ReactNode } from "react";
import { ResizableBox } from "react-resizable";
import { t } from "ttag";
import Link from "metabase/common/components/Link";
import { ForwardRefLink } from "metabase/common/components/Link";
import * as Urls from "metabase/lib/urls";
import RunButtonWithTooltip from "metabase/query_builder/components/RunButtonWithTooltip";
import { Box, Button, Checkbox, Flex, Icon, Stack, Tooltip } from "metabase/ui";
import {
ActionIcon,
Box,
Button,
Flex,
Group,
Icon,
Stack,
Tooltip,
} from "metabase/ui";
import { SHARED_LIB_IMPORT_PATH } from "metabase-enterprise/transforms-python/constants";
import { PythonEditor } from "../../PythonEditor";
@@ -111,7 +120,11 @@ function MaybeResizableBox({
children?: ReactNode;
}) {
if (!resizable) {
return <Box h="100%">{children}</Box>;
return (
<Box w="100%" h="100%">
{children}
</Box>
);
}
return (
@@ -134,22 +147,20 @@ function SharedLibraryActions({
onChange: (source: string) => void;
}) {
return (
<Stack className={S.libraryActions} p="md" gap="sm">
<SharedLibraryImportToggle source={source} onChange={onChange} />
<Group className={S.libraryActions} p="md" gap="sm">
<SharedLibraryImportButton source={source} onChange={onChange} />
<SharedLibraryEditLink />
</Stack>
</Group>
);
}
function SharedLibraryImportToggle({
function SharedLibraryImportButton({
source,
onChange,
}: {
source: string;
onChange: (source: string) => void;
}) {
const hasSharedLib = hasImport(source, SHARED_LIB_IMPORT_PATH);
function handleToggleSharedLib() {
if (hasImport(source, SHARED_LIB_IMPORT_PATH)) {
onChange(removeImport(source, SHARED_LIB_IMPORT_PATH));
@@ -159,25 +170,24 @@ function SharedLibraryImportToggle({
}
return (
<Checkbox
label={t`Import common library`}
checked={hasSharedLib}
onChange={handleToggleSharedLib}
size="sm"
/>
<Tooltip label={t`Import common library`}>
<ActionIcon onChange={handleToggleSharedLib}>
<Icon name="reference" c="text-dark" />
</ActionIcon>
</Tooltip>
);
}
function SharedLibraryEditLink() {
return (
<Flex
component={Link}
target="_blank"
to={Urls.transformPythonLibrary({ path: SHARED_LIB_IMPORT_PATH })}
gap="sm"
>
<Icon name="pencil" />
{t`Edit common library`}
</Flex>
<Tooltip label={t`Edit common library`}>
<ActionIcon
component={ForwardRefLink}
target="_blank"
to={Urls.transformPythonLibrary({ path: SHARED_LIB_IMPORT_PATH })}
>
<Icon name="pencil" c="text-dark" />
</ActionIcon>
</Tooltip>
);
}

View File

@@ -1,61 +1,36 @@
import { useHotkeys } from "@mantine/hooks";
import { useState } from "react";
import type { PythonTransformEditorProps } from "metabase/plugins";
import { Flex, Stack } from "metabase/ui";
import { EditorHeader } from "metabase-enterprise/transforms/components/QueryEditor/EditorHeader";
import { useRegisterMetabotTransformContext } from "metabase-enterprise/transforms/hooks/use-register-transform-metabot-context";
import type {
PythonTransformSource,
PythonTransformSourceDraft,
PythonTransformTableAliases,
Table,
Transform,
} from "metabase-types/api";
import { TransformHeader } from "metabase-enterprise/transforms/components/TransformHeader";
import { TransformMoreMenuWithModal } from "metabase-enterprise/transforms/components/TransformMoreMenu";
import type { PythonTransformTableAliases, Table } from "metabase-types/api";
import { PythonDataPicker } from "./PythonDataPicker";
import { PythonEditorActions } from "./PythonEditorActions";
import { PythonEditorBody } from "./PythonEditorBody";
import { PythonEditorResults } from "./PythonEditorResults";
import S from "./PythonTransformEditor.module.css";
import {
getValidationResult,
isPythonTransformSource,
updateTransformSignature,
useShouldShowPythonDebugger,
useTestPythonTransform,
} from "./utils";
export type PythonTransformEditorProps = {
transform?: Transform | undefined;
initialSource: PythonTransformSourceDraft;
proposedSource?: PythonTransformSource;
isNew?: boolean;
isSaving?: boolean;
isRunnable?: boolean;
onChange?: (newSource: PythonTransformSourceDraft) => void;
onSave: (newSource: PythonTransformSource) => void;
onCancel: () => void;
onRejectProposed?: () => void;
onAcceptProposed?: (query: PythonTransformSource) => void;
};
export function PythonTransformEditor({
transform,
initialSource,
id,
name,
source,
proposedSource,
isNew = true,
isSaving = false,
isRunnable = true,
onChange,
isSaving,
isSourceDirty,
onNameChange,
onSourceChange,
onSave,
onCancel,
onRejectProposed,
onAcceptProposed,
onRejectProposed,
}: PythonTransformEditorProps) {
const [source, setSource] = useState(initialSource);
const saveSource = proposedSource ?? source;
const [isSourceDirty, setIsSourceDirty] = useState(false);
useRegisterMetabotTransformContext(transform, proposedSource ?? source);
const { isRunning, isDirty, cancel, run, executionResult } =
useTestPythonTransform(source);
@@ -64,9 +39,7 @@ export function PythonTransformEditor({
...source,
body,
};
setSource(newSource);
setIsSourceDirty(true);
onChange?.(newSource);
onSourceChange(newSource);
};
const handleDataChange = (
@@ -86,25 +59,9 @@ export function PythonTransformEditor({
"source-database": database,
"source-tables": sourceTables,
};
setSource(newSource);
setIsSourceDirty(true);
onSourceChange(newSource);
};
const handleSave = () => {
if (isPythonTransformSource(saveSource)) {
onSave(saveSource);
}
};
const handleAcceptProposed =
proposedSource && onAcceptProposed
? () => {
setSource(proposedSource);
setIsSourceDirty(true);
onAcceptProposed(proposedSource);
}
: undefined;
const showDebugger = useShouldShowPythonDebugger();
const handleCmdEnter = () => {
@@ -113,15 +70,13 @@ export function PythonTransformEditor({
}
if (isRunning) {
cancel();
} else if (isRunnable && isPythonTransformSource(saveSource)) {
} else {
run();
}
};
useHotkeys([["mod+Enter", handleCmdEnter]], []);
const validationResult = getValidationResult(saveSource);
return (
<Stack
w="100%"
@@ -130,43 +85,49 @@ export function PythonTransformEditor({
data-testid="transform-query-editor"
gap={0}
>
<EditorHeader
isNew={isNew}
isSaving={isSaving}
hasProposedQuery={!!proposedSource}
onSave={handleSave}
onCancel={onCancel}
validationResult={validationResult}
isQueryDirty={isSourceDirty}
<TransformHeader
id={id}
name={name}
actions={
isSaving || isSourceDirty ? (
<PythonEditorActions
source={source}
isSaving={isSaving}
onSave={onSave}
onCancel={onCancel}
/>
) : id != null ? (
<TransformMoreMenuWithModal transformId={id} />
) : null
}
onNameChange={onNameChange}
/>
<Flex h="100%" w="100%">
<Flex className={S.main}>
<PythonDataPicker
database={saveSource["source-database"]}
tables={saveSource["source-tables"]}
database={source["source-database"]}
tables={source["source-tables"]}
onChange={handleDataChange}
/>
<Stack w="100%" h="100%" gap={0}>
<PythonEditorBody
isRunnable={isRunnable && isPythonTransformSource(source)}
isRunning={isRunning}
isDirty={isDirty}
onRun={run}
onCancel={cancel}
source={source.body}
proposedSource={proposedSource?.body}
onChange={handleScriptChange}
withDebugger={showDebugger}
onAcceptProposed={handleAcceptProposed}
onRejectProposed={onRejectProposed}
/>
{showDebugger && (
<PythonEditorResults
isRunning={isRunning}
executionResult={executionResult}
/>
)}
</Stack>
<PythonEditorBody
isRunnable
isRunning={isRunning}
isDirty={isDirty}
onRun={run}
onCancel={cancel}
source={source.body}
proposedSource={proposedSource?.body}
onChange={handleScriptChange}
withDebugger={showDebugger}
onAcceptProposed={onAcceptProposed}
onRejectProposed={onRejectProposed}
/>
</Flex>
{showDebugger && (
<PythonEditorResults
isRunning={isRunning}
executionResult={executionResult}
/>
)}
</Stack>
);
}

View File

@@ -6,7 +6,6 @@ import { getErrorMessage } from "metabase/api/utils";
import { useExecutePythonMutation } from "metabase-enterprise/api/transform-python";
import type {
ExecutePythonTransformResponse,
PythonTransformSource,
PythonTransformSourceDraft,
PythonTransformTableAliases,
Table,
@@ -213,36 +212,8 @@ export function useTestPythonTransform(
};
}
export function isPythonTransformSource(
source: PythonTransformSourceDraft,
): source is PythonTransformSource {
return source.type === "python" && source["source-database"] !== undefined;
}
export function useShouldShowPythonDebugger() {
const location = useLocation();
const params = new URLSearchParams(location.search);
return params.get("debugger") === "1";
}
export function getValidationResult(source: PythonTransformSourceDraft) {
if (!source["source-database"]) {
return { isValid: false, errorMessage: t`Select a source a database` };
}
if (source.body.trim() === "") {
return {
isValid: false,
errorMessage: t`The Python script cannot be empty`,
};
}
if (Object.keys(source["source-tables"]).length === 0) {
return {
isValid: false,
errorMessage: t`Select at least one table to alias`,
};
}
return { isValid: true };
}

View File

@@ -1,10 +0,0 @@
.grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--mantine-spacing-sm) var(--mantine-spacing-md);
}
.alias {
font-family: var(--mb-default-monospace-font-family);
font-size: 0.75rem;
}

View File

@@ -1,114 +0,0 @@
import { t } from "ttag";
import { skipToken, useGetTableQuery } from "metabase/api";
import Link from "metabase/common/components/Link";
import CS from "metabase/css/core/index.css";
import * as Urls from "metabase/lib/urls";
import { Box, Group, Icon, type IconName, Loader, Stack } from "metabase/ui";
import { SplitSection } from "metabase-enterprise/transforms/components/SplitSection";
import type { Transform } from "metabase-types/api";
import S from "./SourceSection.module.css";
type SourceSectionProps = {
transform: Transform;
};
export function SourceSection({ transform }: SourceSectionProps) {
const { source } = transform;
if (source.type !== "python") {
return null;
}
const { "source-database": sourceDatabaseId, "source-tables": sourceTables } =
source;
return (
<SplitSection
label={t`Transform source`}
description={t`The data sources for this Python transform, by alias.`}
>
<Stack p="lg" gap="sm" className={S.grid}>
{Object.entries(sourceTables).map(([alias, tableId]) => (
<TableWithAlias
key={alias}
alias={alias}
tableId={tableId as number}
databaseId={sourceDatabaseId as number}
/>
))}
</Stack>
</SplitSection>
);
}
type TableWithAliasProps = {
alias: string;
tableId: number;
databaseId: number;
};
function TableWithAlias({ alias, tableId, databaseId }: TableWithAliasProps) {
const { data: table, isLoading } = useGetTableQuery(
tableId ? { id: tableId } : skipToken,
);
if (isLoading) {
return <Loader size="xs" data-testid="loader" />;
}
return (
<>
<Group className={S.alias}>{alias}</Group>
<Group gap="sm">
{table?.db && (
<>
<SourceItemLink
label={table.db.name}
icon="database"
to={Urls.dataModelDatabase(table.db.id)}
data-testid="source-database-link"
/>
<SourceItemDivider />
</>
)}
{table?.schema && (
<>
<SourceItemLink
label={table.schema}
icon="folder"
to={Urls.dataModelSchema(databaseId, table.schema)}
/>
<SourceItemDivider />
</>
)}
<SourceItemLink
label={table?.display_name || table?.name || `Table ${tableId}`}
icon="table2"
to={table ? Urls.queryBuilderTable(table.id, table.db_id) : undefined}
/>
</Group>
</>
);
}
type SourceItemLinkProps = {
label: string;
icon: IconName;
to?: string;
};
function SourceItemLink({ label, icon, to }: SourceItemLinkProps) {
return (
<Link className={CS.link} to={to ?? ""} disabled={to == null}>
<Group gap="xs" align="center" justify="center">
<Icon name={icon} />
<Box>{label}</Box>
</Group>
</Link>
);
}
function SourceItemDivider() {
return <Icon name="chevronright" size={8} />;
}

View File

@@ -1,83 +0,0 @@
import { setupTableEndpoints } from "__support__/server-mocks";
import { renderWithProviders, screen, waitFor } from "__support__/ui";
import type { ConcreteTableId, Table, Transform } from "metabase-types/api";
import { createMockTable } from "metabase-types/api/mocks";
import {
createMockPythonTransformSource,
createMockTransform,
} from "metabase-types/api/mocks/transform";
import { SourceSection } from "./SourceSection";
const TABLE_A = createMockTable({
id: 1,
name: "table_a",
display_name: "Table A",
schema: "schema_a",
});
const TABLE_B = createMockTable({
id: 2,
name: "table_b",
display_name: "Table B",
schema: "schema_b",
});
type SetupOpts = {
tables?: Table[];
transform?: Transform;
};
function setup({
tables = [TABLE_A, TABLE_B],
transform = createMockTransform(),
}: SetupOpts = {}) {
for (const table of tables) {
setupTableEndpoints(table);
}
renderWithProviders(<SourceSection transform={transform} />);
}
describe("SourceSection", () => {
it("should render nothing for query transform", () => {
setup({
transform: createMockTransform({
source: {
type: "query",
query: { type: "query", database: 1, query: { "source-table": 1 } },
},
}),
});
expect(screen.queryByText("Transform source")).not.toBeInTheDocument();
});
it("should render the section with label and description", async () => {
setup({
transform: createMockTransform({
source: createMockPythonTransformSource({
sourceTables: {
orders: TABLE_A.id as ConcreteTableId,
customers: TABLE_B.id as ConcreteTableId,
},
}),
}),
});
expect(screen.getByText("Transform source")).toBeInTheDocument();
expect(
screen.getByText("The data sources for this Python transform, by alias."),
).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryAllByTestId("loader")).toHaveLength(0);
});
expect(screen.getByText("orders")).toBeInTheDocument();
expect(screen.getByText("schema_a")).toBeInTheDocument();
expect(screen.getByText("Table A")).toBeInTheDocument();
expect(screen.getByText("customers")).toBeInTheDocument();
expect(screen.getByText("schema_b")).toBeInTheDocument();
expect(screen.getByText("Table B")).toBeInTheDocument();
});
});

View File

@@ -1,53 +1,14 @@
import { t } from "ttag";
import { AdminNavItem } from "metabase/admin/components/AdminNav";
import { ForwardRefLink } from "metabase/common/components/Link";
import { Route } from "metabase/hoc/Title";
import * as Urls from "metabase/lib/urls";
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
import { Icon, Menu } from "metabase/ui";
import { hasPremiumFeature } from "metabase-enterprise/settings";
import { trackTransformCreate } from "metabase-enterprise/transforms/analytics";
import { FullWidthTransformPageLayout } from "metabase-enterprise/transforms/pages/TransformPageLayout";
import { PythonTransformEditor } from "./components/PythonTransformEditor";
import { SourceSection } from "./components/SourceSection";
import { SHARED_LIB_IMPORT_PATH } from "./constants";
import { PythonLibraryEditorPage } from "./pages/PythonLibraryEditorPage";
import { PythonRunnerSettingsPage } from "./pages/PythonRunnerSettingsPage";
import { getBenchNavItems, getBenchRoutes } from "./routes";
if (hasPremiumFeature("transforms-python")) {
PLUGIN_TRANSFORMS_PYTHON.PythonRunnerSettingsPage = PythonRunnerSettingsPage;
PLUGIN_TRANSFORMS_PYTHON.isEnabled = true;
PLUGIN_TRANSFORMS_PYTHON.getBenchRoutes = getBenchRoutes;
PLUGIN_TRANSFORMS_PYTHON.getBenchNavItems = getBenchNavItems;
PLUGIN_TRANSFORMS_PYTHON.TransformEditor = PythonTransformEditor;
PLUGIN_TRANSFORMS_PYTHON.SourceSection = SourceSection;
PLUGIN_TRANSFORMS_PYTHON.getAdminRoutes = () => (
<Route component={FullWidthTransformPageLayout}>
<Route path="library/:path" component={PythonLibraryEditorPage} />
</Route>
);
PLUGIN_TRANSFORMS_PYTHON.getTransformsNavLinks = () => (
<AdminNavItem
label={t`Python library`}
path={Urls.transformPythonLibrary({ path: SHARED_LIB_IMPORT_PATH })}
icon="code_block"
/>
);
PLUGIN_TRANSFORMS_PYTHON.getCreateTransformsMenuItems = () => (
<Menu.Item
component={ForwardRefLink}
to={Urls.newTransformFromType("python")}
leftSection={<Icon name="code_block" />}
onClick={() => {
trackTransformCreate({
triggeredFrom: "transform-page-create-menu",
creationType: "python",
});
}}
>
{t`Python script`}
</Menu.Item>
);
PLUGIN_TRANSFORMS_PYTHON.PythonRunnerSettingsPage = PythonRunnerSettingsPage;
}

View File

@@ -0,0 +1,38 @@
import { t } from "ttag";
import { createBenchAdminRouteGuard } from "metabase/bench/components/utils";
import type { BenchNavItem } from "metabase/bench/constants/navigation";
import { Route } from "metabase/hoc/Title";
import * as Urls from "metabase/lib/urls";
import { SHARED_LIB_IMPORT_PATH } from "./constants";
import { PythonLibraryEditorPage } from "./pages/PythonLibraryEditorPage";
export function getBenchRoutes() {
return (
<Route
path="library/:path"
component={createBenchAdminRouteGuard(
"transforms-python",
PythonLibraryEditorPage,
)}
/>
);
}
export function getBenchNavItems(isAdmin: boolean): BenchNavItem[] {
if (!isAdmin) {
return [];
}
return [
{
id: "library",
url: Urls.transformPythonLibrary({ path: SHARED_LIB_IMPORT_PATH }),
icon: "code_block",
getLabel: () => t`Python Library`,
getDescription: () =>
t`A customizable function library for use with your Python transforms.`,
},
];
}

View File

@@ -0,0 +1,39 @@
import type { ReactNode } from "react";
import { Flex, Stack } from "metabase/ui";
import S from "./ColumnLayout.module.css";
type ColumnLayoutProps = {
children?: ReactNode;
};
export function ColumnLayout({ children }: ColumnLayoutProps) {
return (
<Flex direction="column" h="100%">
{children}
</Flex>
);
}
type ColumnLayoutBodyProps = {
children?: ReactNode;
};
export function ColumnLayoutBody({ children }: ColumnLayoutBodyProps) {
return (
<Flex
className={S.body}
flex={1}
justify="center"
pt="3.5rem"
pb="xl"
px="xl"
bg="bg-light"
>
<Stack flex={1} gap="3.5rem" maw="50rem">
{children}
</Stack>
</Flex>
);
}

View File

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

View File

@@ -0,0 +1,68 @@
import { push } from "react-router-redux";
import { t } from "ttag";
import { AdminContentTable } from "metabase/common/components/AdminContentTable";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { useListTransformJobTransformsQuery } from "metabase-enterprise/api";
import type { Transform, TransformJobId } from "metabase-types/api";
import { ListEmptyState } from "../../ListEmptyState";
import { TitleSection } from "../../TitleSection";
import S from "./DependenciesSection.module.css";
type DependenciesSectionProps = {
jobId: TransformJobId;
};
export function DependenciesSection({ jobId }: DependenciesSectionProps) {
const {
data: transforms = [],
error,
isLoading,
} = useListTransformJobTransformsQuery(jobId);
return (
<TitleSection
label={t`Transforms`}
description={t`Transforms will be run in this order.`}
>
{isLoading || error != null ? (
<LoadingAndErrorWrapper loading={isLoading} error={error} />
) : transforms.length === 0 ? (
<ListEmptyState label={t`There are no transforms for this job.`} />
) : (
<TransformTable transforms={transforms} />
)}
</TitleSection>
);
}
type TransformTableProps = {
transforms: Transform[];
};
export function TransformTable({ transforms }: TransformTableProps) {
const dispatch = useDispatch();
const handleRowClick = (transform: Transform) => {
dispatch(push(Urls.transform(transform.id)));
};
return (
<AdminContentTable columnTitles={[t`Transform`, t`Target`]}>
{transforms.map((transform) => (
<tr
key={transform.id}
className={S.row}
onClick={() => handleRowClick(transform)}
>
<td className={S.wrap}>{transform.name}</td>
<td className={S.wrap}>{transform.target.name}</td>
</tr>
))}
</AdminContentTable>
);
}

View File

@@ -0,0 +1,53 @@
import type { ReactNode } from "react";
import { BenchPaneHeader } from "metabase/bench/components/BenchPaneHeader";
import { BenchNameInput } from "metabase/bench/components/shared/BenchNameInput";
import type { ScheduleDisplayType, TransformTagId } from "metabase-types/api";
import { NAME_MAX_LENGTH } from "../../constants";
import { ColumnLayout, ColumnLayoutBody } from "../ColumnLayout";
import { DependenciesSection } from "./DependenciesSection";
import { ScheduleSection } from "./ScheduleSection";
import { TagSection } from "./TagSection";
import type { TransformJobInfo } from "./types";
type JobEditorProps = {
job: TransformJobInfo;
actions?: ReactNode;
onNameChange: (name: string) => void;
onScheduleChange: (
schedule: string,
uiDisplayType: ScheduleDisplayType,
) => void;
onTagListChange: (tagIds: TransformTagId[]) => void;
};
export function JobEditor({
job,
actions,
onNameChange,
onScheduleChange,
onTagListChange,
}: JobEditorProps) {
return (
<ColumnLayout>
<BenchPaneHeader
title={
<BenchNameInput
initialValue={job.name}
maxLength={NAME_MAX_LENGTH}
onChange={onNameChange}
/>
}
actions={actions}
withBorder
/>
<ColumnLayoutBody>
<ScheduleSection job={job} onScheduleChange={onScheduleChange} />
<TagSection job={job} onTagsChange={onTagListChange} />
{job.id != null && <DependenciesSection jobId={job.id} />}
</ColumnLayoutBody>
</ColumnLayout>
);
}

View File

@@ -5,7 +5,7 @@ import {
import { renderWithProviders, screen } from "__support__/ui";
import { createMockTransformJob } from "metabase-types/api/mocks";
import { JobView } from "./JobView";
import { JobEditor } from "./JobEditor";
import type { TransformJobInfo } from "./types";
type SetupOpts = {
@@ -14,7 +14,6 @@ type SetupOpts = {
function setup({ job = createMockTransformJob() }: SetupOpts) {
const onNameChange = jest.fn();
const onDescriptionChange = jest.fn();
const onScheduleChange = jest.fn();
const onTagListChange = jest.fn();
@@ -24,10 +23,9 @@ function setup({ job = createMockTransformJob() }: SetupOpts) {
}
renderWithProviders(
<JobView
<JobEditor
job={job}
onNameChange={onNameChange}
onDescriptionChange={onDescriptionChange}
onScheduleChange={onScheduleChange}
onTagListChange={onTagListChange}
/>,
@@ -35,7 +33,6 @@ function setup({ job = createMockTransformJob() }: SetupOpts) {
return {
onNameChange,
onDescriptionChange,
onScheduleChange,
onTagListChange,
};

View File

@@ -6,16 +6,16 @@ import { getScheduleExplanation } from "metabase/lib/cron";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Box, Divider, Group, Tooltip } from "metabase/ui";
import { useRunTransformJobMutation } from "metabase-enterprise/api";
import { trackTransformJobTriggerManualRun } from "metabase-enterprise/transforms/analytics";
import type {
ScheduleDisplayType,
ScheduleSettings,
ScheduleType,
} from "metabase-types/api";
import { RunButton } from "../../../components/RunButton";
import { RunStatus } from "../../../components/RunStatus";
import { SplitSection } from "../../../components/SplitSection";
import { trackTransformJobTriggerManualRun } from "../../../analytics";
import { RunButton } from "../../RunButton";
import { RunStatus } from "../../RunStatus";
import { TitleSection } from "../../TitleSection";
import type { TransformJobInfo } from "../types";
type ScheduleSectionProps = {
@@ -31,7 +31,7 @@ export function ScheduleSection({
onScheduleChange,
}: ScheduleSectionProps) {
return (
<SplitSection
<TitleSection
label={t`Schedule`}
description={t`Configure when this job should run.`}
>
@@ -46,7 +46,7 @@ export function ScheduleSection({
/>
<RunButtonSection job={job} />
</Group>
</SplitSection>
</TitleSection>
);
}
@@ -137,6 +137,7 @@ function RunButtonSection({ job }: RunButtonSectionProps) {
return (
<Tooltip label={t`This job doesn't have tags to run.`} disabled={hasTags}>
<RunButton
id={job.id}
run={job.last_run}
isDisabled={!isSaved || !hasTags}
onRun={handleRun}

View File

@@ -3,8 +3,8 @@ import { t } from "ttag";
import { Box } from "metabase/ui";
import type { TransformTagId } from "metabase-types/api";
import { SplitSection } from "../../../components/SplitSection";
import { TagMultiSelect } from "../../../components/TagMultiSelect";
import { TagMultiSelect } from "../../TagMultiSelect";
import { TitleSection } from "../../TitleSection";
import type { TransformJobInfo } from "../types";
type TagSectionProps = {
@@ -14,13 +14,13 @@ type TagSectionProps = {
export function TagSection({ job, onTagsChange }: TagSectionProps) {
return (
<SplitSection
<TitleSection
label={t`Tags to run`}
description={t`This job will run all transforms tagged with any of the tags you pick here.`}
>
<Box p="xl">
<TagMultiSelect tagIds={job.tag_ids ?? []} onChange={onTagsChange} />
</Box>
</SplitSection>
</TitleSection>
);
}

View File

@@ -0,0 +1,2 @@
export * from "./JobEditor";
export * from "./types";

View File

@@ -8,42 +8,39 @@ import {
} from "metabase/forms";
import { Box, Button, FocusTrap, Group, Modal, Stack, Text } from "metabase/ui";
import { useDeleteTransformJobMutation } from "metabase-enterprise/api";
import type { TransformJobInfo } from "../../types";
import type { TransformJobId } from "metabase-types/api";
type DeleteJobModalProps = {
job: TransformJobInfo;
jobId: TransformJobId;
onDelete: () => void;
onClose: () => void;
};
export function DeleteJobModal({
job,
jobId,
onDelete,
onClose,
}: DeleteJobModalProps) {
return (
<Modal title={t`Delete this job?`} opened padding="xl" onClose={onClose}>
<FocusTrap.InitialFocus />
<DeleteJobForm job={job} onDelete={onDelete} onClose={onClose} />
<DeleteJobForm jobId={jobId} onDelete={onDelete} onClose={onClose} />
</Modal>
);
}
type DeleteJobFormProps = {
job: TransformJobInfo;
jobId: TransformJobId;
onDelete: () => void;
onClose: () => void;
};
function DeleteJobForm({ job, onDelete, onClose }: DeleteJobFormProps) {
function DeleteJobForm({ jobId, onDelete, onClose }: DeleteJobFormProps) {
const [deleteJob] = useDeleteTransformJobMutation();
const handleSubmit = async () => {
if (job.id != null) {
await deleteJob(job.id).unwrap();
onDelete();
}
await deleteJob(jobId).unwrap();
onDelete();
};
return (

View File

@@ -0,0 +1,89 @@
import { type MouseEvent, useState } from "react";
import { push } from "react-router-redux";
import { t } from "ttag";
import { useDispatch } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { ActionIcon, Icon, Menu } from "metabase/ui";
import type { TransformJobId } from "metabase-types/api";
import type { JobMoreMenuModalState } from "../../types";
import { DeleteJobModal } from "./DeleteJobModal";
type JobMoreMenuProps = {
jobId: TransformJobId;
onOpenModal: (modal: JobMoreMenuModalState) => void;
};
export function JobMoreMenu({ jobId, onOpenModal }: JobMoreMenuProps) {
const handleIconClick = (event: MouseEvent) => {
event.preventDefault();
event.stopPropagation();
};
return (
<Menu>
<Menu.Target>
<ActionIcon onClick={handleIconClick}>
<Icon name="ellipsis" />
</ActionIcon>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<Icon name="trash" />}
onClick={() => onOpenModal({ jobId, modalType: "delete" })}
>
{t`Delete`}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
}
type JobMoreMenuModalProps = {
modal: JobMoreMenuModalState;
onClose: () => void;
};
export function JobMoreMenuModal({ modal, onClose }: JobMoreMenuModalProps) {
const { sendSuccessToast } = useMetadataToasts();
const dispatch = useDispatch();
const handleDelete = () => {
sendSuccessToast(t`Job deleted`);
dispatch(push(Urls.transformJobList()));
onClose();
};
switch (modal.modalType) {
case "delete":
return (
<DeleteJobModal
jobId={modal.jobId}
onDelete={handleDelete}
onClose={onClose}
/>
);
default:
return null;
}
}
type JobMoreMenuWithModalProps = {
jobId: TransformJobId;
};
export function JobMoreMenuWithModal({ jobId }: JobMoreMenuWithModalProps) {
const [modal, setModal] = useState<JobMoreMenuModalState>();
return (
<>
<JobMoreMenu jobId={jobId} onOpenModal={setModal} />
{modal != null && (
<JobMoreMenuModal modal={modal} onClose={() => setModal(undefined)} />
)}
</>
);
}

View File

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

View File

@@ -1,36 +0,0 @@
import { t } from "ttag";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import { useListTransformJobTransformsQuery } from "metabase-enterprise/api";
import type { TransformJobId } from "metabase-types/api";
import { TitleSection } from "../../../components/TitleSection";
import { TransformTable } from "../../../components/TransformTable";
import { ListEmptyState } from "../../ListEmptyState";
type DependenciesSectionProps = {
jobId: TransformJobId;
};
export function DependenciesSection({ jobId }: DependenciesSectionProps) {
const {
data: transforms = [],
error,
isLoading,
} = useListTransformJobTransformsQuery(jobId);
return (
<TitleSection
label={t`Transforms`}
description={t`Transforms will be run in this order.`}
>
{isLoading || error != null ? (
<LoadingAndErrorWrapper loading={isLoading} error={error} />
) : transforms.length === 0 ? (
<ListEmptyState label={t`There are no transforms for this job.`} />
) : (
<TransformTable transforms={transforms} />
)}
</TitleSection>
);
}

View File

@@ -1,27 +0,0 @@
import { t } from "ttag";
import { BrowserCrumbs } from "metabase/common/components/BrowserCrumbs";
import * as Urls from "metabase/lib/urls";
import type { TransformJobInfo } from "../types";
type HeaderSectionProps = {
job: TransformJobInfo;
};
export function HeaderSection({ job }: HeaderSectionProps) {
return (
<BrowserCrumbs
crumbs={[
{
title: t`Jobs`,
to: Urls.transformJobList(),
},
{
title: job.name,
to: job.id != null ? Urls.transformJob(job.id) : "",
},
]}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { Route } from "react-router";
import { renderWithProviders, screen } from "__support__/ui";
import { createMockTransformJob } from "metabase-types/api/mocks";
import type { TransformJobInfo } from "../types";
import { HeaderSection } from "./HeaderSection";
type SetupOpts = {
job?: TransformJobInfo;
};
function setup({ job = createMockTransformJob() }: SetupOpts = {}) {
renderWithProviders(
<Route path="/" component={() => <HeaderSection job={job} />} />,
{ withRouter: true },
);
}
describe("HeaderSection", () => {
it("should render links", () => {
setup({ job: createMockTransformJob({ name: "Daily job" }) });
expect(screen.getByRole("link", { name: "Jobs" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Daily job" })).toBeInTheDocument();
});
});

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