Compare commits

...

406 Commits

Author SHA1 Message Date
Ngoc Khuat
4672ead0d2 Productionize bulk sync table apis (#65687) 2025-11-11 20:51:14 +03:00
Jakub Chodorowicz
0288191d2b fix: transform url link 2025-11-11 20:49:57 +03:00
Jakub Chodorowicz
92892e2e27 fix: data source input not showing transform source 2025-11-11 11:33:09 +01:00
Jakub Chodorowicz
86ab2a190b feat: add transform link in table metadata edit 2025-11-11 11:28:08 +01:00
Ngoc Khuat
d700232bcb hydrate transform for query metadata as well 2025-11-11 18:52:54 +09:00
Jakub Chodorowicz
18a5852242 feat: remove tooltip on publish model buttons 2025-11-11 10:46:44 +01:00
Ngoc Khuat
3570a81ced don't fail when there are no transforms 2025-11-11 16:29:17 +09:00
Ngoc Khuat
8c024a44bc hydrate transform for table api (#65655) 2025-11-11 11:07:23 +09:00
Aleksandr Lesnenko
da932efd65 fix 2025-11-10 12:53:23 -05:00
Aleksandr Lesnenko
728aad8e02 Merge remote-tracking branch 'origin/workbench-models' into data-structure 2025-11-10 12:50:20 -05:00
Aleksandr Lesnenko
d41816354a fix not being able to create models and metrics in data studio 2025-11-10 12:12:43 -05:00
Alexander Polyankin
f153ba8935 Fix menu 2025-11-10 12:10:34 -05:00
Alexander Polyankin
0deda78c1a archive modal 2025-11-10 12:05:56 -05:00
Alexander Polyankin
323fd59cb0 archive modal 2025-11-10 12:00:28 -05:00
Uladzimir Havenchyk
82f0efd0ce feat(tree): add range selection 2025-11-10 19:35:01 +03:00
Jakub Chodorowicz
6a03fd2515 style: :refactor adjust spacing, eslint fixes 2025-11-10 17:22:53 +01:00
Jakub Chodorowicz
f78dad62d0 fix: naming of the acknowledge user value 2025-11-10 17:20:36 +01:00
Jakub Chodorowicz
6b012dc8ad feat: new publish models UI flow 2025-11-10 17:18:01 +01:00
Jakub Chodorowicz
59778c4f56 refactor: remove unused variable 2025-11-10 17:18:00 +01:00
Alexander Polyankin
2ea287bbf2 archive modal 2025-11-10 10:32:19 -05:00
Dan Stone
8187e6c9aa Kondo 2025-11-10 15:22:21 +00:00
Alexander Polyankin
b89d5f7134 Add move modal 2025-11-10 10:10:20 -05:00
Uladzimir Havenchyk
2e9ec55b68 make checkbox area bigger 2025-11-10 17:34:26 +03:00
Uladzimir Havenchyk
b01f0a0249 do not show a placeholder for not selected table 2025-11-10 16:48:03 +03:00
Jakub Chodorowicz
98a1c3d5a4 style: unify padding of columns 2025-11-10 14:36:51 +01:00
Jakub Chodorowicz
71baf0cac6 feat: add dependencies links to metadata info 2025-11-10 14:26:31 +01:00
Uladzimir Havenchyk
8ba55572bd fix search crash 2025-11-10 14:56:38 +03:00
Jakub Chodorowicz
926195e57f refactor: use lib isNullOrUndefined 2025-11-10 11:38:05 +01:00
Uladzimir Havenchyk
3e06c52b49 fix(tree): sort fields properly 2025-11-10 13:37:46 +03:00
Dan Stone
2fec2796e0 Check published table is mapped to the source table of the model 2025-11-10 10:13:00 +00:00
Dan Stone
04ee950cce Delete substitute as model test (api removed) 2025-11-10 09:59:28 +00:00
Jakub Chodorowicz
003e201ac5 feat: format numbers in metadata section 2025-11-10 10:48:55 +01:00
Aleksandr Lesnenko
a854b3b853 move segments to modeling 2025-11-07 21:35:41 -05:00
Aleksandr Lesnenko
c8df278e98 Merge branch 'workbench2' into workbench-models 2025-11-07 21:07:41 -05:00
Aleksandr Lesnenko
45ca2337ba update transforms sidebar 2025-11-07 21:05:52 -05:00
Aleksandr Lesnenko
e71b66fef2 review 2025-11-07 20:00:55 -05:00
Aleksandr Lesnenko
978c972256 at least split megafile 2025-11-07 20:00:55 -05:00
Aleksandr Lesnenko
fedce7a0d6 wip 2025-11-07 20:00:55 -05:00
Alexander Polyankin
7d5ed6904d Add move modal 2025-11-07 19:07:05 -05:00
Alexander Polyankin
7c2daf91a1 Add move modal 2025-11-07 19:02:28 -05:00
Aleksandr Lesnenko
5198680d05 review 2025-11-07 16:53:37 -05:00
Aleksandr Lesnenko
ac35d94116 Merge remote-tracking branch 'origin/workbench-models' into data-structure 2025-11-07 16:39:15 -05:00
Alexander Polyankin
9af152ffeb Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-07 15:30:56 -05:00
Alexander Polyankin
64f693555c Add move modal 2025-11-07 15:30:16 -05:00
Aleksandr Lesnenko
6db1918afa let scroll away from selected transform in the tree view 2025-11-07 14:09:57 -05:00
Alexander Polyankin
a56f5ed11e test fixes 2025-11-07 14:05:33 -05:00
Alexander Polyankin
d9a9f74c69 test fixes 2025-11-07 13:48:04 -05:00
Alexander Polyankin
ec06c8fe7a use a token feature 2025-11-07 13:08:32 -05:00
Alexander Polyankin
5fa8825281 use a token feature 2025-11-07 13:06:43 -05:00
Alexander Polyankin
f0ad0a1c0e use a token feature 2025-11-07 13:01:40 -05:00
Alexander Polyankin
0978943115 fix command palette test 2025-11-07 12:59:34 -05:00
Alexander Polyankin
218110f276 fix snippets 2025-11-07 12:53:57 -05:00
Alexander Polyankin
c9320ce815 types 2025-11-07 12:46:24 -05:00
Alexander Polyankin
633c504a4f types 2025-11-07 12:44:39 -05:00
Aleksandr Lesnenko
69e042b282 drop library 2025-11-07 12:21:34 -05:00
Alexander Polyankin
11cd8b2fda types 2025-11-07 12:18:51 -05:00
Alexander Polyankin
8a3663bb2c types 2025-11-07 12:15:50 -05:00
Alexander Polyankin
324965f261 types 2025-11-07 12:12:16 -05:00
Alexander Polyankin
dfe0d6aea4 Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-07 11:59:21 -05:00
Alexander Polyankin
deb8aa00fd types 2025-11-07 11:58:54 -05:00
Alexander Polyankin
a5b2744944 types 2025-11-07 11:53:53 -05:00
Alexander Polyankin
d9bbc48164 types 2025-11-07 11:45:32 -05:00
Nathan Voxland
e4f882cb7b Revert "Added /api/ee/library/create API" 2025-11-07 10:45:17 -06:00
Alexander Polyankin
da3392f199 types 2025-11-07 10:39:39 -05:00
Alexander Polyankin
0db5e36d32 types 2025-11-07 10:30:25 -05:00
Alexander Polyankin
73f09312b9 types 2025-11-07 10:25:55 -05:00
Alexander Polyankin
10a5316e66 types 2025-11-07 10:18:59 -05:00
Jakub Chodorowicz
2848e5e4e2 feat: show field preview in 3rd column 2025-11-07 16:10:05 +01:00
Alexander Polyankin
0651bdfc78 types 2025-11-07 10:10:02 -05:00
Dan Stone
d79c85d77d Temporary published_as_model 2025-11-07 15:05:05 +00:00
Jakub Chodorowicz
cc20dc585a fix: hide estimated row count row when null 2025-11-07 15:58:26 +01:00
Alexander Polyankin
4d897184a2 types 2025-11-07 09:47:42 -05:00
Alexander Polyankin
6fa4f686fe types 2025-11-07 09:34:35 -05:00
Jakub Chodorowicz
954f645e8d fix: dependency graph link 2025-11-07 15:14:22 +01:00
Uladzimir Havenchyk
8936c9b9c9 fixup 2025-11-07 16:31:12 +03:00
Uladzimir Havenchyk
2fd17e4869 render more data 2025-11-07 16:29:32 +03:00
Jakub Chodorowicz
44b7dc6ec9 feat: add view count and estimated row count 2025-11-07 13:47:18 +01:00
Jakub Chodorowicz
e441049a93 feat: add dependencies and dependants count 2025-11-07 13:42:05 +01:00
Jakub Chodorowicz
02f1a66452 feat: table metadata fields (name on disk, updated at) 2025-11-07 13:26:16 +01:00
Jakub Chodorowicz
1575fac1ef style: adjust empty view width 2025-11-07 12:59:18 +01:00
Jakub Chodorowicz
f2400ca1b8 feat: two columns layout for data model 2025-11-07 12:55:13 +01:00
Uladzimir Havenchyk
e7c662693f adjust colors 2025-11-07 14:30:04 +03:00
Ngoc Khuat
f82e22f016 bulk edit entity_type support in api 2025-11-07 18:17:52 +07:00
Kamil Mielnik
2a142ab6f3 Merge remote-tracking branch 'origin/data-structure' into data-structure 2025-11-07 18:15:15 +07:00
Kamil Mielnik
98f758c3a2 Add bulk-edit of entity type, reorder inputs in bulk-edit, minor styling (bold labels) 2025-11-07 18:15:07 +07:00
Uladzimir Havenchyk
f84ef88c77 simplify code and update styling 2025-11-07 14:14:19 +03:00
Kamil Mielnik
441383ba61 Merge remote-tracking branch 'origin/data-structure' into data-structure 2025-11-07 17:31:34 +07:00
Kamil Mielnik
7ebcc7821b Styling 2025-11-07 17:31:24 +07:00
Kamil Mielnik
ee02a5f0c5 Remove EditTableMetadataModal 2025-11-07 17:30:40 +07:00
Uladzimir Havenchyk
26a475043b simplify logic 2025-11-07 12:46:21 +03:00
Uladzimir Havenchyk
407d37b595 another edge case 2025-11-07 12:24:34 +03:00
Jakub Chodorowicz
47d6bad49d style: fix: paddings in table metadata 2025-11-07 10:05:17 +01:00
Uladzimir Havenchyk
cf73e9bcf3 another edge case 2025-11-07 12:01:11 +03:00
Uladzimir Havenchyk
1c6be916f7 fix collapse/expand and active item logic 2025-11-07 11:41:33 +03:00
Kamil Mielnik
88219aa5b2 Copy 2025-11-07 14:58:38 +07:00
Kamil Mielnik
06e3630980 Merge branch 'workbench-models' into data-structure 2025-11-07 14:27:02 +07:00
Kamil Mielnik
b65576f35d Merge branch 'workbench2' into workbench-models 2025-11-06 23:24:43 -08:00
Kamil Mielnik
258e4f2c31 Merge branch 'master' into workbench2 2025-11-06 23:23:41 -08:00
Aleksandr Lesnenko
7b86079cee cleanup 2025-11-06 23:09:36 -05:00
Aleksandr Lesnenko
cf05d56fe9 snippets 2025-11-06 22:37:28 -05:00
Alexander Polyankin
35fc4464e8 types 2025-11-06 20:45:11 -05:00
Alexander Polyankin
34194dd880 types 2025-11-06 20:34:05 -05:00
Alexander Polyankin
6606cfb41e types 2025-11-06 20:19:37 -05:00
Alexander Polyankin
7f6f34ba0a types 2025-11-06 20:03:58 -05:00
Alexander Polyankin
98bce01778 types 2025-11-06 20:03:24 -05:00
Alexander Polyankin
6ee88fd206 types 2025-11-06 20:03:00 -05:00
Alexander Polyankin
a7efaa97c8 types 2025-11-06 20:02:04 -05:00
Alexander Polyankin
49e3c56c16 types 2025-11-06 20:01:13 -05:00
Alexander Polyankin
0b1f0774f3 Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-06 19:59:35 -05:00
Alexander Polyankin
6b5d859cd6 types 2025-11-06 19:59:25 -05:00
Alexander Polyankin
a6cffcbb3c types 2025-11-06 19:38:29 -05:00
Nathan Voxland
d99fc6f0ff Don't hard code library feature yet 2025-11-06 16:46:02 -06:00
Nathan Voxland
20ef87952a Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-06 16:07:26 -06:00
Nathan Voxland
57359bbbd9 Added /api/ee/library/create API 2025-11-06 16:07:13 -06:00
Uladzimir Havenchyk
f5d357807d fix checkbox position 2025-11-07 00:34:22 +03:00
Alexander Polyankin
4db90ba49c fields 2025-11-06 14:51:43 -05:00
Alexander Polyankin
29058a5825 fields 2025-11-06 14:29:39 -05:00
Alexander Polyankin
5f6e0cb94f fields 2025-11-06 14:27:44 -05:00
Alexander Polyankin
4787ad5245 fields 2025-11-06 14:05:15 -05:00
Jakub Chodorowicz
f16245b845 style: adjust spacing in table edit 2025-11-06 19:22:04 +01:00
Uladzimir Havenchyk
18d7f97009 refactor tree and fix some bugs 2025-11-06 21:17:27 +03:00
Jakub Chodorowicz
4dde186a07 style: bulk metadata tweaks 2025-11-06 19:16:51 +01:00
Alexander Polyankin
29fc8b10dd fields 2025-11-06 11:37:21 -05:00
Alexander Polyankin
3bd09e569a fields 2025-11-06 11:33:41 -05:00
Alexander Polyankin
5f64cfed72 fields 2025-11-06 10:34:10 -05:00
Alexander Polyankin
d72f390fe0 fields 2025-11-06 10:23:31 -05:00
Alexander Polyankin
549f2e468a fields 2025-11-06 09:59:15 -05:00
Kamil Mielnik
ce0cf9032a Fix settings icon to use currentColor 2025-11-06 21:54:42 +07:00
Kamil Mielnik
356fc58cf2 Even buttons out 2025-11-06 21:53:16 +07:00
Alexander Polyankin
150d1d4cf1 fields 2025-11-06 09:49:24 -05:00
Alexander Polyankin
8dfa72365d fields 2025-11-06 09:45:30 -05:00
Kamil Mielnik
3d6618578f Remove old fields component 2025-11-06 21:44:58 +07:00
Alexander Polyankin
97cbbc9a43 fields 2025-11-06 09:43:12 -05:00
Kamil Mielnik
a5f411b1c7 Bulk sync/scan/discard (GDGT-1194) 2025-11-06 21:40:58 +07:00
Alexander Polyankin
b2edb0ed0f fields 2025-11-06 09:26:05 -05:00
Alexander Polyankin
04cd3e6f4a fields 2025-11-06 09:14:17 -05:00
Alexander Polyankin
8d3fb6d04a fields 2025-11-06 09:11:35 -05:00
Alexander Polyankin
78fde89589 fields 2025-11-06 09:06:31 -05:00
Jakub Chodorowicz
287a329fc2 style: adjust table metadata group padding 2025-11-06 14:38:03 +01:00
Jakub Chodorowicz
8230d5468d fix: table models section showing for no models 2025-11-06 14:36:11 +01:00
Kamil Mielnik
12bb0b0d19 Merge remote-tracking branch 'origin/data-structure' into data-structure 2025-11-06 20:26:16 +07:00
Kamil Mielnik
f94c9b68a5 Field sort order (GDGT-1191) 2025-11-06 20:26:02 +07:00
Jakub Chodorowicz
8f8bec586d fix: spacing when transform link is not there 2025-11-06 14:25:09 +01:00
Jakub Chodorowicz
bffa22a416 style: add shared table metadata group styling 2025-11-06 14:21:17 +01:00
Jakub Chodorowicz
fc7edc8f34 fix: show sync button when sorting 2025-11-06 14:06:30 +01:00
Kamil Mielnik
8a6970d671 Fix tooltip 2025-11-06 19:57:28 +07:00
Jakub Chodorowicz
dd989042b3 feat: link to models from list of dependencies 2025-11-06 13:29:10 +01:00
Jakub Chodorowicz
6ba2bad5c6 feat: link to dependency graph from table metadata 2025-11-06 13:29:10 +01:00
Dan Stone
1aad748cab Fix some kebab left-overs 2025-11-06 11:44:50 +00:00
Ngoc Khuat
dc0d61f41c kebab the get /table params and remove substitute-model api 2025-11-06 18:22:06 +07:00
Dan Stone
9150c53ef5 Fix unused binding warnings 2025-11-06 11:21:35 +00:00
Dan Stone
0264eb74a7 Fix database-metadata-test for real 2025-11-06 11:19:46 +00:00
Dan Stone
462fc577fa Fix database-metadata-test 2025-11-06 11:02:16 +00:00
Dan Stone
ccf3cdccfc Remove transform_id from table-defaults 2025-11-06 10:58:47 +00:00
Dan Stone
7859f8bccf Update serde baseline 2025-11-06 10:57:18 +00:00
Jakub Chodorowicz
f126574790 feat: display table model dependecies 2025-11-06 11:41:47 +01:00
Jakub Chodorowicz
77df08fc8d style: new button style for table metadata 2025-11-06 11:17:51 +01:00
Kamil Mielnik
e082fa5e04 Merge branch 'master' into data-structure 2025-11-06 01:20:49 -08:00
Alexander Polyankin
fa9fd8c0b0 fields 2025-11-06 00:21:46 -05:00
Alexander Polyankin
1951614fb2 fields 2025-11-06 00:15:08 -05:00
Alexander Polyankin
c576043fdc fields 2025-11-06 00:13:03 -05:00
Alexander Polyankin
25c93fb073 fields 2025-11-06 00:08:00 -05:00
Alexander Polyankin
9d7a3ed47f fields 2025-11-05 23:52:47 -05:00
Alexander Polyankin
08ab8b1627 fields 2025-11-05 23:46:44 -05:00
Alexander Polyankin
b75bf3154f Fix source path 2025-11-05 23:28:21 -05:00
Alexander Polyankin
2fd8978f75 Fix source path 2025-11-05 23:05:07 -05:00
Alexander Polyankin
606d14bb30 Fix source path 2025-11-05 23:03:50 -05:00
Alexander Polyankin
0ac9898be7 Fix source path 2025-11-05 23:03:09 -05:00
Alexander Polyankin
12ebd6badf Fix source path 2025-11-05 23:01:29 -05:00
Alexander Polyankin
ecb25cc518 Fix source path 2025-11-05 22:48:19 -05:00
Alexander Polyankin
0ad7491469 Fix source path 2025-11-05 22:42:28 -05:00
Alexander Polyankin
c3ccd02c18 Fix source path 2025-11-05 22:29:28 -05:00
Alexander Polyankin
cdd6bf84a7 Fix source path 2025-11-05 22:23:40 -05:00
Alexander Polyankin
f0bce0966f Fix source path 2025-11-05 22:18:15 -05:00
Alexander Polyankin
efb7081659 Fix source path 2025-11-05 22:15:12 -05:00
Alexander Polyankin
691b64faf1 Add fields 2025-11-05 21:14:03 -05:00
Alexander Polyankin
25351666b1 Add fields 2025-11-05 21:10:37 -05:00
Alexander Polyankin
a3bd5b2f58 Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-05 21:02:44 -05:00
Alexander Polyankin
2274aebd01 perms 2025-11-05 21:02:31 -05:00
Aleksandr Lesnenko
5babd2ef9d separate folders for components 2025-11-05 21:02:07 -05:00
Alexander Polyankin
e98b8c12ca Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-05 20:56:17 -05:00
Alexander Polyankin
d764554ed4 perms 2025-11-05 20:56:07 -05:00
Aleksandr Lesnenko
70ebc8644b markdown descriptions 2025-11-05 20:44:44 -05:00
Alexander Polyankin
d710df1b8d Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-05 20:39:21 -05:00
Alexander Polyankin
553e0a8af5 perms 2025-11-05 20:39:08 -05:00
Aleksandr Lesnenko
f14998e569 chopped snippets in data studio 2025-11-05 20:35:57 -05:00
Alexander Polyankin
40bbde0735 perms 2025-11-05 20:34:02 -05:00
Alexander Polyankin
384b90f3c0 Merge remote-tracking branch 'origin/workbench-models' into workbench-models
# Conflicts:
#	frontend/src/metabase/data-studio/components/PaneHeader/PaneHeader.tsx
#	frontend/src/metabase/metrics/components/MetricHeader/MetricHeader.tsx
#	frontend/src/metabase/models/components/ModelHeader/ModelHeader.tsx
2025-11-05 18:33:57 -05:00
Alexander Polyankin
30dd9373e4 perms 2025-11-05 18:33:07 -05:00
Aleksandr Lesnenko
066a466f9f polish 2025-11-05 17:08:06 -05:00
Aleksandr Lesnenko
b399cf0292 overview more like in figma 2025-11-05 16:10:43 -05:00
Alexander Polyankin
6fda387aee perms 2025-11-05 15:57:48 -05:00
Uladzimir Havenchyk
d87a6a0e93 fix checkbox rendering 2025-11-05 23:52:24 +03:00
Alexander Polyankin
7fd4b7084d perms 2025-11-05 15:52:10 -05:00
Alexander Polyankin
a162e53382 perms 2025-11-05 15:50:32 -05:00
Uladzimir Havenchyk
4e00921884 render fields in the tree 2025-11-05 23:48:21 +03:00
Alexander Polyankin
b11a747fd1 result_metadata 2025-11-05 15:34:06 -05:00
Alexander Polyankin
68d3a7112d result_metadata 2025-11-05 15:32:13 -05:00
Alexander Polyankin
2a4164646c result_metadata 2025-11-05 15:20:47 -05:00
Alexander Polyankin
df9b508984 Refactor models 2025-11-05 15:16:14 -05:00
Alexander Polyankin
2da3f2701b Refactor models 2025-11-05 15:10:20 -05:00
Alexander Polyankin
85511ceab1 Refactor models 2025-11-05 14:20:57 -05:00
Alexander Polyankin
4ac2e59315 Refactor models 2025-11-05 12:29:26 -05:00
Alexander Polyankin
d0e0a02f47 Refactor models 2025-11-05 12:28:29 -05:00
Alexander Polyankin
c9230e6a4a Refactor models 2025-11-05 12:28:14 -05:00
Alexander Polyankin
b4d6c01d82 Refactor models 2025-11-05 12:24:39 -05:00
Alexander Polyankin
6cab61e366 Refactor models 2025-11-05 12:22:51 -05:00
Alexander Polyankin
9d0562afae Refactor models 2025-11-05 11:45:47 -05:00
Jakub Chodorowicz
6d66f751a9 fix: transform link always visible 2025-11-05 17:44:29 +01:00
Jakub Chodorowicz
19ef1a98ec style: single table metadata edit 2025-11-05 17:41:04 +01:00
Dan Stone
ab7d240fed Fix sync tests 2025-11-05 16:12:37 +00:00
Jakub Chodorowicz
d5bdd73a71 fix: workaround issues with indeterminate state for non-stacked checkboxes 2025-11-05 15:35:09 +01:00
Alexander Polyankin
69a4b9e3a1 Refactor models 2025-11-05 09:30:26 -05:00
Ngoc Khuat
2637589169 Merge branch 'master' into data-structure 2025-11-05 21:20:25 +07:00
Alexander Polyankin
3ad26f2653 Refactor models 2025-11-05 09:17:45 -05:00
Alexander Polyankin
7ba2628c69 Refactor metrics 2025-11-05 09:11:49 -05:00
Jakub Chodorowicz
77769dc4b9 feat: toggle selection when clicking whole tables in the selection mode 2025-11-05 14:50:14 +01:00
Jakub Chodorowicz
db3ad08d3c feat: display full view of table when only one table is selected 2025-11-05 14:49:33 +01:00
Jakub Chodorowicz
888701b8c9 style: title of edit table section 2025-11-05 14:23:30 +01:00
Dan Stone
03b4ebc081 Merge remote-tracking branch 'origin/master' into data-structure 2025-11-05 13:04:42 +00:00
Dan Stone
6de4279460 Merge remote-tracking branch 'origin/master' into data-structure
# Conflicts:
#	resources/migrations/057_update_migrations.yaml
2025-11-05 13:04:02 +00:00
Dan Stone
4f361fc759 Fix put table test 2025-11-05 10:33:50 +00:00
Dan Stone
7e75aa10f7 Fix feature req in orphan-only-filter-test 2025-11-05 10:27:01 +00:00
Dan Stone
b7e104cf99 Fix str/kw in bulk-edit-test 2025-11-05 10:24:29 +00:00
Dan Stone
b6451fb1d1 Module use 2025-11-05 10:18:30 +00:00
Alexander Polyankin
ff0c58e2c9 Fix metrics 2025-11-04 22:34:59 -05:00
Alexander Polyankin
fe27a60086 Fix metrics 2025-11-04 21:26:22 -05:00
Alexander Polyankin
5e5af46277 Fix metrics 2025-11-04 21:24:28 -05:00
Alexander Polyankin
77558f265b Fix metrics 2025-11-04 21:18:55 -05:00
Alexander Polyankin
02eeab73f0 Fix metrics 2025-11-04 21:15:19 -05:00
Alexander Polyankin
d29ec97a5d Fix metrics 2025-11-04 21:08:18 -05:00
Alexander Polyankin
953a90b33e Fix metrics 2025-11-04 21:00:26 -05:00
Alexander Polyankin
6cc4135f9b Remove icons 2025-11-04 20:44:47 -05:00
Alexander Polyankin
a52b61315a Remove icons 2025-11-04 20:39:55 -05:00
Aleksandr Lesnenko
7ba19e06f6 empty state 2025-11-04 19:27:14 -05:00
Aleksandr Lesnenko
c6436f19fa fixes 2025-11-04 19:21:34 -05:00
Aleksandr Lesnenko
581b571939 create models and metrics 2025-11-04 18:57:24 -05:00
Aleksandr Lesnenko
3d77de040b metrics 2025-11-04 18:27:35 -05:00
Aleksandr Lesnenko
d8bc169017 nit 2025-11-04 17:17:22 -05:00
Aleksandr Lesnenko
658ed79f82 polish 2025-11-04 17:06:51 -05:00
Aleksandr Lesnenko
9eb72b54c3 modeling: sidebar, list view, glossary 2025-11-04 15:43:48 -05:00
Alexander Polyankin
e3d446fd61 Remove icons 2025-11-04 15:27:25 -05:00
Alexander Polyankin
6e20ebcc6b Models! 2025-11-04 14:46:09 -05:00
Alexander Polyankin
5ceefb4d45 Models! 2025-11-04 14:45:51 -05:00
Alexander Polyankin
da1bd244b6 Models! 2025-11-04 14:05:43 -05:00
Alexander Polyankin
337a897ac9 Models! 2025-11-04 14:01:03 -05:00
Alexander Polyankin
b23af30f53 Models! 2025-11-04 13:48:00 -05:00
Alexander Polyankin
a5c38998fd Models! 2025-11-04 13:41:03 -05:00
Alexander Polyankin
c8d5370577 Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-04 12:19:50 -05:00
Alexander Polyankin
18dc4d9d86 Models! 2025-11-04 12:19:37 -05:00
Aleksandr Lesnenko
7e8ffe48c4 Merge branch 'workbench2' into workbench-models 2025-11-04 12:19:30 -05:00
Aleksandr Lesnenko
2205142c1d Merge branch 'master' into workbench2 2025-11-04 12:16:58 -05:00
Aleksandr Lesnenko
a6e689c827 update a path 2025-11-04 12:15:19 -05:00
Jakub Chodorowicz
763043c73a feat: add transform link 2025-11-04 17:17:57 +01:00
Aleksandr Lesnenko
5282d3a312 fix metabot data-studio integration 2025-11-04 10:56:20 -05:00
Jakub Chodorowicz
c022d44a3c feat: publish models in bulk for items 2025-11-04 16:53:50 +01:00
Alexander Polyankin
9af38153f7 Models! 2025-11-04 10:53:45 -05:00
Alexander Polyankin
0998e11b57 Models! 2025-11-04 10:44:47 -05:00
Alexander Polyankin
ea3f77f59c Models! 2025-11-04 10:41:50 -05:00
Alexander Polyankin
8dcdb1871b Models! 2025-11-04 10:36:28 -05:00
Alexander Polyankin
b0094608a1 Models! 2025-11-04 10:26:11 -05:00
Alexander Polyankin
a38bbee057 Models! 2025-11-04 10:14:36 -05:00
Jakub Chodorowicz
ded691dd68 style: change the table icon to default 2025-11-04 15:35:24 +01:00
Jakub Chodorowicz
dc54e63335 feat: reorganize action buttons for table metadata
remove substitute with model button
2025-11-04 15:33:38 +01:00
Jakub Chodorowicz
f8100f0430 fix: add missing icon type 2025-11-04 15:07:34 +01:00
Jakub Chodorowicz
c3721ddeed feat: add disabled dependency graph button 2025-11-04 14:59:07 +01:00
Kamil Mielnik
2a56f7f3d3 Merge remote-tracking branch 'origin/data-structure' into data-structure 2025-11-04 20:45:39 +07:00
Kamil Mielnik
042b45b95e Display icon in UserInput 2025-11-04 20:43:52 +07:00
Jakub Chodorowicz
0ffdeb3f84 feat: title in the bulk selection pane 2025-11-04 14:42:31 +01:00
Jakub Chodorowicz
6139d6693b feat: bulk table metadata edit in middle column 2025-11-04 14:06:04 +01:00
Dan Stone
cd97b591e7 Add discard_values endpoint 2025-11-04 12:01:58 +00:00
Jakub Chodorowicz
438ce07032 feat: completely hide ActiveInput for now 2025-11-04 10:28:54 +01:00
Jakub Chodorowicz
c28872fd1f fix: missing property error 2025-11-04 10:27:22 +01:00
Jakub Chodorowicz
6cbba3a0d2 feat: make active field readonly 2025-11-04 10:25:53 +01:00
Jakub Chodorowicz
58ef121a49 Add missing param property 2025-11-04 10:23:33 +01:00
Jakub Chodorowicz
b2ff60f8fe Add active input to tablemetadata 2025-11-04 10:21:52 +01:00
Alexander Polyankin
768038b21f . 2025-11-03 23:39:18 -05:00
Alexander Polyankin
64210dc82f . 2025-11-03 23:32:57 -05:00
Alexander Polyankin
23343b6ed3 . 2025-11-03 23:26:09 -05:00
Alexander Polyankin
8f66b4ef34 . 2025-11-03 23:17:58 -05:00
Alexander Polyankin
f63677e971 . 2025-11-03 22:57:07 -05:00
Alexander Polyankin
4163c1641b . 2025-11-03 22:35:48 -05:00
Alexander Polyankin
5c174399c6 Fix paths 2025-11-03 22:33:08 -05:00
Alexander Polyankin
fb10250deb Fix paths 2025-11-03 22:23:06 -05:00
Alexander Polyankin
a58f30b34c Fix paths 2025-11-03 22:14:32 -05:00
Alexander Polyankin
710f25794a Fix paths 2025-11-03 22:09:53 -05:00
Alexander Polyankin
6cba76168a Fix paths 2025-11-03 22:02:57 -05:00
Alexander Polyankin
fc82ab1b9a Fix paths 2025-11-03 21:57:43 -05:00
Alexander Polyankin
3cf5565030 Fix paths 2025-11-03 21:57:25 -05:00
Alexander Polyankin
3c7e12b8c6 Fix paths 2025-11-03 21:51:45 -05:00
Alexander Polyankin
f448ab6de5 Fix paths 2025-11-03 21:42:00 -05:00
Alexander Polyankin
fa59881ddf Fix paths 2025-11-03 21:38:52 -05:00
Aleksandr Lesnenko
986c3289ea lint 2025-11-03 20:40:00 -05:00
Aleksandr Lesnenko
f5e258f051 specs 2025-11-03 20:24:10 -05:00
Aleksandr Lesnenko
99da7a96e5 specs 2025-11-03 20:24:10 -05:00
Alexander Polyankin
c6a32ea431 Fix paths 2025-11-03 20:21:59 -05:00
Alexander Polyankin
2a90485d4a Merge remote-tracking branch 'origin/workbench2' into workbench2 2025-11-03 20:18:15 -05:00
Alexander Polyankin
c9f7e8f66b Fix paths 2025-11-03 20:17:57 -05:00
Aleksandr Lesnenko
21eaffb153 specs 2025-11-03 20:10:12 -05:00
Alexander Polyankin
83e4d260fa Merge remote-tracking branch 'origin/workbench-models' into workbench-models 2025-11-03 20:02:50 -05:00
Alexander Polyankin
588584807b Fix paths 2025-11-03 20:02:40 -05:00
Aleksandr Lesnenko
f760b2d78d polish, fixing bugs caught by e2e specs, update e2e specs 2025-11-03 20:01:29 -05:00
Aleksandr Lesnenko
1f1142860e UXW-2211 transforms inner nav width 2025-11-03 18:02:27 -05:00
Aleksandr Lesnenko
3c3e7776ab move Metabot to data studio 2025-11-03 18:00:22 -05:00
Aleksandr Lesnenko
af982a3ca4 replace transforms urls 2025-11-03 17:59:54 -05:00
Aleksandr Lesnenko
5a12277416 fix component name in DataStudioLayout 2025-11-03 17:50:03 -05:00
Aleksandr Lesnenko
178a9ce965 replace datamodel path to point to data studio 2025-11-03 17:27:50 -05:00
Aleksandr Lesnenko
a7c893f7c0 types 2025-11-03 17:21:47 -05:00
Alexander Polyankin
2c078fc595 Fix paths 2025-11-03 16:08:48 -05:00
Alexander Polyankin
c82eb367fd Merge branch 'workbench2' into workbench-models 2025-11-03 16:05:43 -05:00
Aleksandr Lesnenko
203ae9c55b some ui polish 2025-11-03 15:00:25 -05:00
Alexander Polyankin
cc3105ef0f Fix paths 2025-11-03 14:58:04 -05:00
Alexander Polyankin
547ad1204d Fix icon 2025-11-03 13:51:23 -05:00
Alexander Polyankin
48819b05a7 Fix icon 2025-11-03 13:41:40 -05:00
Alexander Polyankin
5ab21902d1 Fix icon 2025-11-03 13:38:51 -05:00
Dan Stone
ad9422ce9e Add sketch of /table/rescan_values 2025-11-03 17:40:36 +00:00
Alexander Polyankin
88f0851fd4 Fix icon 2025-11-03 12:23:21 -05:00
Dan Stone
29220d5ca1 Add sketch of /table/sync_schema for experimentation 2025-11-03 17:18:38 +00:00
Jakub Chodorowicz
31f4f8e1bd change: make label have normal weight 2025-11-03 17:41:57 +01:00
Jakub Chodorowicz
4a5437c479 Remove old metadata section 2025-11-03 17:41:32 +01:00
Alexander Polyankin
b64803ab91 Merge remote-tracking branch 'origin/workbench2' into workbench2 2025-11-03 11:35:13 -05:00
Alexander Polyankin
c700965477 Flicker 2025-11-03 11:35:01 -05:00
Alexander Polyankin
6ade875f8b Flicker 2025-11-03 11:31:17 -05:00
Jakub Chodorowicz
ce548b2e0f feat: new table metadata styles, add entity type toggle 2025-11-03 17:28:12 +01:00
Aleksandr Lesnenko
1c7e5f448c layout fixes, python shared lib stub link -- need to use update with correct icon 2025-11-03 11:27:12 -05:00
Alexander Polyankin
8fdc854aea Flicker 2025-11-03 10:43:27 -05:00
Alexander Polyankin
f3c35d4184 Merge branch 'master' into workbench2 2025-11-03 10:37:14 -05:00
Alexander Polyankin
47384a7965 Flicker 2025-11-03 10:33:49 -05:00
Alexander Polyankin
35594ed38e Flicker 2025-11-03 10:31:16 -05:00
Alexander Polyankin
5cb92f6592 Flicker 2025-11-03 10:27:07 -05:00
Alexander Polyankin
31d5471b5b Flicker 2025-11-03 10:23:30 -05:00
Alexander Polyankin
0242840402 Flicker 2025-11-03 10:10:11 -05:00
Alexander Polyankin
8866f2d446 Permissions 2025-11-03 10:07:59 -05:00
Alexander Polyankin
a6f4ba70d6 Permissions 2025-11-03 09:43:21 -05:00
Alexander Polyankin
800496c30b Permissions 2025-11-03 09:41:56 -05:00
Alexander Polyankin
49d04555c9 Permissions 2025-11-03 09:39:18 -05:00
Alexander Polyankin
ab719e84f6 Empty states 2025-11-03 09:34:02 -05:00
Alexander Polyankin
126fefa0f1 Empty states 2025-11-03 09:29:15 -05:00
Alexander Polyankin
9bb252a52b Empty states 2025-11-03 09:18:55 -05:00
Alexander Polyankin
47b4c2473a Empty states 2025-11-03 09:17:11 -05:00
Alexander Polyankin
8a34280084 Empty states 2025-11-03 09:14:53 -05:00
Alexander Polyankin
dd301f5587 port runs 2025-11-03 09:10:54 -05:00
Alexander Polyankin
3748a5a0c4 port runs 2025-11-03 09:04:36 -05:00
Alexander Polyankin
4cefbd1011 port runs 2025-11-03 09:03:00 -05:00
Alexander Polyankin
84eeb9d35b port runs 2025-11-03 09:00:00 -05:00
Jakub Chodorowicz
d5d21fc5b3 Bulk metadata frontend (#65342)
* port changes from feature/bulk-metadata-editing branch

* Rename visibility type 2 param to TableDataLyer
2025-11-03 12:13:56 +01:00
Ngoc Khuat
a347b96a9d remove metabase_table.transform_id 2025-11-03 17:10:00 +07:00
Ngoc Khuat
f87aa5d49b Cherry pick bulk data editing BE code to the new branch (#65339) 2025-11-03 16:59:29 +07:00
Alexander Polyankin
22935320ca port runs 2025-10-31 22:53:30 -04:00
Alexander Polyankin
6bb12036ba port runs 2025-10-31 22:37:01 -04:00
Alexander Polyankin
5214db1b34 port runs 2025-10-31 22:36:14 -04:00
Alexander Polyankin
65007662b6 port runs 2025-10-31 22:36:03 -04:00
Alexander Polyankin
fc8e625fd1 port runs 2025-10-31 22:26:17 -04:00
Alexander Polyankin
7f880449e6 port runs 2025-10-31 22:18:30 -04:00
Alexander Polyankin
4126a6fb3c port runs 2025-10-31 22:01:07 -04:00
Alexander Polyankin
97b0caa82b port runs 2025-10-31 21:52:35 -04:00
Alexander Polyankin
a848c57956 Merge remote-tracking branch 'origin/workbench2' into workbench2 2025-10-31 21:42:00 -04:00
Alexander Polyankin
2b06075990 port runs 2025-10-31 21:41:49 -04:00
Alexander Polyankin
8fdf387887 port runs 2025-10-31 21:17:21 -04:00
Alexander Polyankin
1c5c3da666 port runs 2025-10-31 21:12:37 -04:00
Aleksandr Lesnenko
bfb3848543 new transform 2025-10-31 20:04:49 -04:00
Alexander Polyankin
1a14bfa131 port runs 2025-10-31 19:05:08 -04:00
Alexander Polyankin
96acca5e92 Merge remote-tracking branch 'origin/master' into workbench2 2025-10-31 19:02:15 -04:00
Alexander Polyankin
bb5fa428b5 port runs 2025-10-31 19:01:47 -04:00
Alexander Polyankin
7d11c2f799 port runs 2025-10-31 19:00:23 -04:00
Alexander Polyankin
e7e029a58c port runs 2025-10-31 18:56:16 -04:00
Alexander Polyankin
1d10123201 port runs 2025-10-31 18:50:02 -04:00
Alexander Polyankin
a77842e92b port runs 2025-10-31 18:49:22 -04:00
Alexander Polyankin
9b0d149418 port runs 2025-10-31 18:42:06 -04:00
Alexander Polyankin
dc7ae946c6 port runs 2025-10-31 18:39:17 -04:00
Alexander Polyankin
ddf5a9d460 port runs 2025-10-31 18:37:52 -04:00
Alexander Polyankin
cc6ce2834f Merge remote-tracking branch 'origin/workbench2' into workbench2 2025-10-31 18:37:06 -04:00
Alexander Polyankin
82a7b2f5e4 port runs 2025-10-31 18:36:55 -04:00
Aleksandr Lesnenko
7e4daf2721 jobs list 2025-10-31 17:41:21 -04:00
Aleksandr Lesnenko
ac8c6e6e0c tree view, search, sorting 2025-10-31 17:07:23 -04:00
Aleksandr Lesnenko
2e94a0f579 show transforms list in the sidebar 2025-10-31 15:23:15 -04:00
Alexander Polyankin
d746234cb6 port runs 2025-10-31 14:01:48 -04:00
Alexander Polyankin
eecc699fbd port runs 2025-10-31 14:00:22 -04:00
Alexander Polyankin
72de9e73ce port runs 2025-10-31 13:59:10 -04:00
Alexander Polyankin
0abe4483fc port runs 2025-10-31 13:44:37 -04:00
Alexander Polyankin
4292db5bcf Merge remote-tracking branch 'origin/workbench2' into workbench2 2025-10-31 13:29:38 -04:00
Alexander Polyankin
c6027c28c6 port runs 2025-10-31 13:29:18 -04:00
Aleksandr Lesnenko
43cca10568 transforms inner nav 2025-10-31 13:28:55 -04:00
Alexander Polyankin
bac077c21d port jobs 2025-10-31 13:26:27 -04:00
Alexander Polyankin
1895291a04 Bench 2025-10-31 13:19:00 -04:00
Alexander Polyankin
fa7ba42783 Bench 2025-10-31 12:59:30 -04:00
Alexander Polyankin
9f39d20e07 Bench 2025-10-31 12:57:43 -04:00
Alexander Polyankin
0df67d497a Bench 2025-10-31 12:53:07 -04:00
Alexander Polyankin
876e3e3c27 Bench 2025-10-31 12:50:38 -04:00
Alexander Polyankin
9db2c15514 Bench 2025-10-31 12:46:20 -04:00
Alexander Polyankin
7ecdf0c5dd Bench 2025-10-31 12:37:06 -04:00
Alexander Polyankin
4f0a8c14b4 Bench 2025-10-31 12:34:27 -04:00
Alexander Polyankin
92d0358840 Bench 2025-10-31 12:27:32 -04:00
Alexander Polyankin
7ce95abb57 Bench 2025-10-31 12:20:01 -04:00
Alexander Polyankin
db118fbe12 Bench 2025-10-31 12:07:23 -04:00
Alexander Polyankin
06e2499210 Bench 2025-10-31 12:06:36 -04:00
Alexander Polyankin
1fd9f81f3b Bench 2025-10-31 11:53:06 -04:00
Alexander Polyankin
7e2a230c90 Bench 2025-10-31 11:52:03 -04:00
Alexander Polyankin
b508b28048 Bench 2025-10-31 11:43:24 -04:00
Alexander Polyankin
400d8426c0 Bench 2025-10-31 10:58:07 -04:00
Alexander Polyankin
7c437e28e6 Bench 2025-10-31 10:54:55 -04:00
Alexander Polyankin
657ab9590b Bench 2025-10-31 10:51:11 -04:00
Alexander Polyankin
8b68bbcc75 Bench 2025-10-31 10:42:02 -04:00
Alexander Polyankin
fe4ee32e2f Bench 2025-10-31 10:24:31 -04:00
Alexander Polyankin
99b089d83d Bench 2025-10-31 10:23:19 -04:00
Alexander Polyankin
a0da007219 Empty 2025-10-31 09:41:38 -04:00
597 changed files with 15606 additions and 5593 deletions

View File

@@ -1429,6 +1429,7 @@
api
app-db
audit-app
collections
database-routing
driver
events
@@ -1438,6 +1439,7 @@
parameters
permissions
premium-features
queries
query-processor
request
search

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(`/data-studio/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(`/data-studio/transforms/jobs/${body.id}`);
}
});
}

View File

@@ -0,0 +1,23 @@
export const DataStudio = {
header: () => cy.findByTestId("data-studio-header"),
exitButton: () => DataStudio.header().findByText("Exit data studio"),
Transforms: {
header: () => cy.findByTestId("transforms-header"),
content: () => cy.findByTestId("transforms-content"),
sidebar: () => cy.findByTestId("transforms-sidebar"),
saveChangesButton: () => DataStudio.Transforms.queryEditor().button("Save"),
queryEditor: () => cy.findByTestId("transform-query-editor"),
runTab: () => DataStudio.Transforms.header().findByText("Run"),
targetTab: () => DataStudio.Transforms.header().findByText("Target"),
dependenciesTab: () =>
DataStudio.Transforms.header().findByText("Dependencies"),
},
Jobs: {
header: () => cy.findByTestId("jobs-header"),
content: () => cy.findByTestId("transforms-content"),
sidebar: () => cy.findByTestId("jobs-sidebar"),
},
PythonLibrary: {
header: () => cy.findByTestId("python-library-header"),
},
};

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(`/data-studio/transforms/${transformId}`);
}
const WAIT_TIMEOUT = 10000;

View File

@@ -15,6 +15,7 @@ export * from "./e2e-dashboard-helpers";
export * from "./e2e-dashboard-visualizer-helpers";
export * from "./e2e-database-metadata-helpers";
export * from "./e2e-datamodel-helpers";
export * from "./e2e-data-studio-helpers";
export * from "./e2e-detail-view-helpers";
export * from "./e2e-dimension-list-helpers";
export * from "./e2e-document-helpers";

View File

@@ -2317,6 +2317,10 @@ describe("scenarios > dashboard > parameters", () => {
H.findDashCardAction(H.getDashboardCard(0), "Duplicate").click();
H.getDashboardCard(1).within(() => {
cy.findByTestId("chart-container").should("exist");
});
H.getDashboardCard(1).within(() => {
H.filterWidget({ isEditing: true })
.contains("Category 1")

View File

@@ -190,11 +190,10 @@ H.describeWithSnowplowEE("scenarios > dependencies > dependency checks", () => {
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.NativeEditor.clear().type('SELECT name FROM "Schema A"."Animals"');
cy.log("cancel breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
@@ -204,7 +203,7 @@ H.describeWithSnowplowEE("scenarios > dependencies > dependency checks", () => {
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {
cy.findByText("Question with fields").should("be.visible");
cy.findByText("Question without fields").should("not.exist");
@@ -217,11 +216,10 @@ H.describeWithSnowplowEE("scenarios > dependencies > dependency checks", () => {
it("should not show a confirmation if there are no breaking changes when updating a SQL transform after it was run", () => {
createSqlTransformWithDependentMbqlQuestions();
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.NativeEditor.clear().type(
'SELECT score, name FROM "Schema A"."Animals"',
);
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.DataStudio.Transforms.saveChangesButton().click();
cy.wait("@updateTransform");
});
@@ -230,12 +228,11 @@ H.describeWithSnowplowEE("scenarios > dependencies > dependency checks", () => {
cy.log("make breaking changes");
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.getNotebookStep("data").findByLabelText("Pick columns").click();
H.popover().findByLabelText("Score").click();
cy.log("cancel breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
@@ -245,7 +242,7 @@ H.describeWithSnowplowEE("scenarios > dependencies > dependency checks", () => {
cy.get("@updateTransform.all").should("have.length", 0);
cy.log("confirm breaking changes");
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.DataStudio.Transforms.saveChangesButton().click();
H.modal().within(() => {
cy.findByText("Score transform").should("be.visible");
cy.findByText("Name transform").should("not.exist");
@@ -257,10 +254,9 @@ H.describeWithSnowplowEE("scenarios > dependencies > dependency checks", () => {
it("should not show a confirmation if there are no breaking changes when updating a MBQL transform before it was run", () => {
createMbqlTransformWithDependentSqlTransforms();
cy.get<number>("@transformId").then(H.visitTransform);
cy.findByTestId("transform-page").findByText("Edit query").click();
H.getNotebookStep("data").button("Sort").click();
H.popover().findByText("Score").click();
cy.findByTestId("transform-query-editor").button("Save changes").click();
H.DataStudio.Transforms.saveChangesButton().click();
cy.wait("@updateTransform");
});
});

View File

@@ -19,7 +19,7 @@ import type {
TransformId,
} from "metabase-types/api";
const BASE_URL = "/admin/tools/dependencies";
const BASE_URL = "/data-studio/dependencies";
const TABLE_NAME = "scoreboard_actions";
const TABLE_DISPLAY_NAME = "Scoreboard Actions";
const TABLE_ID_ALIAS = "tableId";
@@ -153,7 +153,7 @@ describe("scenarios > dependencies > dependency graph", () => {
});
testEntitySearch({
itemName: TABLE_BASED_TRANSFORM_NAME,
itemIcon: "refresh_downstream",
itemIcon: "transform",
isRecentItem: false,
});
});
@@ -193,7 +193,7 @@ describe("scenarios > dependencies > dependency graph", () => {
tabName: "Transforms",
itemName: TABLE_BASED_TRANSFORM_NAME,
itemLevel: 0,
itemIcon: "refresh_downstream",
itemIcon: "transform",
});
});
});

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("/data-studio/transforms");
const suggestions = () => cy.findAllByTestId("metabot-chat-suggestion");
// eslint-disable-next-line no-unsafe-element-filtering
@@ -49,8 +49,12 @@ const assertSuggestionInSidebar = (values: {
const assertEditorDiffState = (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);
H.DataStudio.Transforms.content()
.findByRole("button", { name: /apply|create/i })
.should(should);
H.DataStudio.Transforms.content()
.findByRole("button", { name: /reject/i })
.should(should);
};
const assertAcceptRejectUI = (opts: { visible: boolean }) => {
@@ -59,338 +63,342 @@ const assertAcceptRejectUI = (opts: { visible: boolean }) => {
rejectSuggestionBtn().should(should);
};
H.describeWithSnowplowEE("scenarios > metabot > transforms codegen", () => {
beforeEach(() => {
H.restore("postgres-writable");
H.resetTestTable({ type: "postgres", table: "many_schemas" });
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: SOURCE_TABLE });
H.describeWithSnowplowEE(
"scenarios > metabot > transforms codegen",
{ viewportWidth: 1600, viewportHeight: 1200 },
() => {
beforeEach(() => {
H.restore("postgres-writable");
H.resetTestTable({ type: "postgres", table: "many_schemas" });
H.resetSnowplow();
cy.signInAsAdmin();
H.activateToken("bleeding-edge");
H.resyncDatabase({ dbId: WRITABLE_DB_ID, tableName: SOURCE_TABLE });
cy.intercept("POST", "/api/ee/metabot-v3/agent-streaming").as("agentReq");
cy.intercept("POST", "/api/ee/transform").as("createTransform");
cy.intercept("PUT", "/api/ee/transform/*").as("updateTransform");
});
cy.intercept("POST", "/api/ee/metabot-v3/agent-streaming").as("agentReq");
cy.intercept("POST", "/api/ee/transform").as("createTransform");
cy.intercept("PUT", "/api/ee/transform/*").as("updateTransform");
});
describe("Native SQL transform tests", () => {
describe("create new transform", () => {
it("should create SQL transform via metabot", () => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
describe("Native SQL transform tests", () => {
describe("create new transform", () => {
it("should create SQL transform via metabot", () => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
cy.log("Ask metabot for a new transform");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"I'll create a new transform that gets the number 1 for you.",
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 1"),
),
});
H.sendMetabotMessage(
"Create a new native SQL transform that gives me the number 1",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT 1" });
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
assertEditorContent("native", "SELECT 1");
// User should ask metabot for a change
cy.log("Ask metabot for an edit");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 2"),
),
});
H.sendMetabotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 1",
newSourcePartial: "SELECT 2",
});
assertAcceptRejectUI({ visible: true });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("native", "SELECT 2");
assertAcceptRejectUI({ visible: false });
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit("native", "SELECT 3");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 4"),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
});
assertAcceptRejectUI({ visible: true });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("native", "SELECT 3");
assertAcceptRejectUI({ visible: false });
});
it("should create Python transform via metabot", () => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
cy.log("Ask metabot for a new transform");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"I'll create a new transform that gets the number 1 for you.",
createMockPythonTransformJSON(
null,
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(metabase_table_df):\\n return pd.DataFrame({'value': [1]})",
cy.log("Ask metabot for a new transform");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"I'll create a new transform that gets the number 1 for you.",
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 1"),
),
),
});
H.sendMetabotMessage(
"Create a new native python transform that gives me the number 1",
);
assertSuggestionInSidebar({
newSourcePartial: "pd.DataFrame({'value': [1]})",
});
});
H.sendMetabotMessage(
"Create a new native SQL transform that gives me the number 1",
);
assertSuggestionInSidebar({ newSourcePartial: "SELECT 1" });
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
assertEditorContent("python", "pd.DataFrame({'value': [1]})");
cy.log("Should be able to visit and the see new transform");
viewLastSuggestion();
cy.url().should("include", "/data-studio/transforms/new/native");
assertEditorDiffState({ 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
cy.log("Ask metabot for an edit");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockPythonTransformJSON(
null,
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(metabase_table_df):\\n return pd.DataFrame({'value': [2]})",
// User should ask metabot for a change
cy.log("Ask metabot for an edit");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 2"),
),
),
});
H.sendMetabotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [1]})",
newSourcePartial: "pd.DataFrame({'value': [2]})",
});
assertAcceptRejectUI({ visible: true });
});
H.sendMetabotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 1",
newSourcePartial: "SELECT 2",
});
assertAcceptRejectUI({ visible: true });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [2]})");
assertAcceptRejectUI({ visible: false });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("native", "SELECT 2");
assertAcceptRejectUI({ visible: false });
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit(
"python",
dedent`
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit("native", "SELECT 3");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockNativeTransformJSON(null, WRITABLE_DB_ID, "SELECT 4"),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
});
assertAcceptRejectUI({ visible: true });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("native", "SELECT 3");
assertAcceptRejectUI({ visible: false });
});
it("should create Python transform via metabot", () => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
cy.log("Ask metabot for a new transform");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"I'll create a new transform that gets the number 1 for you.",
createMockPythonTransformJSON(
null,
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(metabase_table_df):\\n return pd.DataFrame({'value': [1]})",
),
),
});
H.sendMetabotMessage(
"Create a new native python transform that gives me the number 1",
);
assertSuggestionInSidebar({
newSourcePartial: "pd.DataFrame({'value': [1]})",
});
cy.log("Should be able to visit and the see new transform");
viewLastSuggestion();
cy.url().should("include", "/data-studio/transforms/new/python");
assertEditorDiffState({ 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
cy.log("Ask metabot for an edit");
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockPythonTransformJSON(
null,
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(metabase_table_df):\\n return pd.DataFrame({'value': [2]})",
),
),
});
H.sendMetabotMessage("Make this give me the number 2 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [1]})",
newSourcePartial: "pd.DataFrame({'value': [2]})",
});
assertAcceptRejectUI({ visible: true });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [2]})");
assertAcceptRejectUI({ visible: false });
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit(
"python",
dedent`
import pandas as pd
def transform(metabase_table_df):
return pd.DataFrame({'value': [3]})`,
);
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockPythonTransformJSON(
null,
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(metabase_table_df):\\n return pd.DataFrame({'value': [4]})",
),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",
});
assertAcceptRejectUI({ visible: true });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [3]})");
assertAcceptRejectUI({ visible: false });
});
});
describe("update existing transform", () => {
it("should update existing SQL transform via metabot", () => {
H.createSqlTransform({
sourceQuery: "SELECT 1",
targetTable: "table_a",
targetSchema: "Schema A",
}).as("transformId");
visitTransformListPage();
H.openMetabotViaSearchButton(true);
// Ask metabot for a change to existing transform
cy.get("@transformId").then((transformId) => {
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockNativeTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
"SELECT 2",
),
),
});
});
H.sendMetabotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 1",
newSourcePartial: "SELECT 2",
});
cy.log("Should be able to visit and the see the updated transform");
viewLastSuggestion();
assertAcceptRejectUI({ visible: true });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("native", "SELECT 2");
assertAcceptRejectUI({ visible: false });
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit("native", "SELECT 3");
cy.get("@transformId").then((transformId) => {
);
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockNativeTransformJSON(
Number(transformId),
createMockPythonTransformJSON(
null,
WRITABLE_DB_ID,
"SELECT 4",
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(metabase_table_df):\\n return pd.DataFrame({'value': [4]})",
),
),
});
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
});
assertAcceptRejectUI({ visible: true });
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",
});
assertAcceptRejectUI({ visible: true });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("native", "SELECT 3");
assertAcceptRejectUI({ visible: false });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [3]})");
assertAcceptRejectUI({ visible: false });
});
});
it("should update existing Python transform via metabot", () => {
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).then(
(tableId) => {
H.createPythonTransform({
targetTable: "transform_table",
targetSchema: "Schema A",
body: dedent`
describe("update existing transform", () => {
it("should update existing SQL transform via metabot", () => {
H.createSqlTransform({
sourceQuery: "SELECT 1",
targetTable: "table_a",
targetSchema: "Schema A",
}).as("transformId");
visitTransformListPage();
H.openMetabotViaSearchButton(true);
// Ask metabot for a change to existing transform
cy.get("@transformId").then((transformId) => {
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockNativeTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
"SELECT 2",
),
),
});
});
H.sendMetabotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 1",
newSourcePartial: "SELECT 2",
});
cy.log("Should be able to visit and the see the updated transform");
viewLastSuggestion();
assertAcceptRejectUI({ visible: true });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("native", "SELECT 2");
assertAcceptRejectUI({ visible: false });
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit("native", "SELECT 3");
cy.get("@transformId").then((transformId) => {
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockNativeTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
"SELECT 4",
),
),
});
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "SELECT 3",
newSourcePartial: "SELECT 4",
});
assertAcceptRejectUI({ visible: true });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("native", "SELECT 3");
assertAcceptRejectUI({ visible: false });
});
it("should update existing Python transform via metabot", () => {
H.getTableId({ name: SOURCE_TABLE, databaseId: WRITABLE_DB_ID }).then(
(tableId) => {
H.createPythonTransform({
targetTable: "transform_table",
targetSchema: "Schema A",
body: dedent`
import pandas as pd
def transform(foo):
return pd.DataFrame({'value': [1]})
`,
sourceTables: { foo: tableId },
}).then((transformId) => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
sourceTables: { foo: tableId },
}).then((transformId) => {
visitTransformListPage();
H.openMetabotViaSearchButton(true);
// Ask metabot for a change to existing transform
cy.get("@transformId").then((transformId) => {
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockPythonTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
{ foo: tableId },
"import pandas as pd\\n\\ndef transform(foo):\\n return pd.DataFrame({'value': [2]})",
// Ask metabot for a change to existing transform
cy.get("@transformId").then((transformId) => {
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that update for you.",
createMockPythonTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
{ foo: tableId },
"import pandas as pd\\n\\ndef transform(foo):\\n return pd.DataFrame({'value': [2]})",
),
),
),
});
});
H.sendMetabotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [1]})",
newSourcePartial: "pd.DataFrame({'value': [2]})",
});
});
H.sendMetabotMessage(
"Update my SQL transform to select 2 instead of 1.",
);
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [1]})",
newSourcePartial: "pd.DataFrame({'value': [2]})",
});
cy.log(
"Should be able to visit and the see the updated transform",
);
viewLastSuggestion();
assertAcceptRejectUI({ visible: true });
cy.log(
"Should be able to visit and the see the updated transform",
);
viewLastSuggestion();
assertAcceptRejectUI({ visible: true });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [2]})");
assertAcceptRejectUI({ visible: false });
// User can accept a change (editor should have new value)
cy.log("Should be able to select an edit");
acceptSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [2]})");
assertAcceptRejectUI({ visible: false });
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit(
"python",
dedent`
// User should make changes to the source
cy.log("Should diff with user edits");
makeManualEdit(
"python",
dedent`
import pandas as pd
def transform(foo):
return pd.DataFrame({'value': [3]})`,
);
);
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockPythonTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(foo):\\n return pd.DataFrame({'value': [4]})",
H.mockMetabotResponse({
body: createMockTransformSuggestionResponse(
"Let me make that change for you.",
createMockPythonTransformJSON(
Number(transformId),
WRITABLE_DB_ID,
{ metabase_table_df: 152 },
"import pandas as pd\\n\\ndef transform(foo):\\n return pd.DataFrame({'value': [4]})",
),
),
),
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",
});
assertAcceptRejectUI({ visible: true });
});
H.sendMetabotMessage("Make this give me the number 4 instead");
assertSuggestionInSidebar({
oldSourcePartial: "pd.DataFrame({'value': [3]})",
newSourcePartial: "pd.DataFrame({'value': [4]})",
});
assertAcceptRejectUI({ visible: true });
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [3]})");
assertAcceptRejectUI({ visible: false });
});
},
);
cy.log("Should be able to reject an edit");
rejectSuggestion();
assertEditorContent("python", "pd.DataFrame({'value': [3]})");
assertAcceptRejectUI({ visible: false });
});
},
);
});
});
});
});
});
},
);
const createMockNativeTransformJSON = (
id: number | null,

View File

@@ -56,6 +56,7 @@
:ai-entity-analysis (deferred-tru "AI Entity Analysis")
:collection-cleanup (deferred-tru "Collection Cleanup")
:content-translation (deferred-tru "Content translation")
:data-studio (deferred-tru "Data studio")
:dependencies (deferred-tru "Dependency Tracking")
:documents (deferred-tru "Documents")
:embedding (deferred-tru "Embedding")

View File

@@ -115,15 +115,10 @@
(defn sync-target!
"Sync target of a transform"
([transform-id run-id]
(let [{:keys [source target]} (t2/select-one :model/Transform transform-id)
db (get-in source [:query :database])
database (t2/select-one :model/Database db)]
(sync-target! target database run-id)))
([target database _run-id]
;; sync the new table (note that even a failed sync status means that the execution succeeded)
(log/info "Syncing target" (pr-str target) "for transform")
(activate-table-and-mark-computed! database target)))
[target database _run-id]
;; sync the new table (note that even a failed sync status means that the execution succeeded)
(log/info "Syncing target" (pr-str target) "for transform")
(activate-table-and-mark-computed! database target))
(defn target-table-exists?
"Test if the target table of a transform already exists."
@@ -155,8 +150,14 @@
"Activate table for `target` in `database` in the app db."
[database target]
(when-let [table (sync-table! database target {:create? true})]
(when (or (not (:active table)) (not (= (:data_authority table) :computed)))
(t2/update! :model/Table (:id table) {:active true, :data_authority :computed}))))
(when-not (= {:active true
:data_authority :computed
;; todo orphaned source if no longer target (detect target change?)
:data_source "metabase-transform"}
(select-keys table [:active :data_authority :data_source]))
(t2/update! :model/Table (:id table) {:active true
:data_authority :computed
:data_source "metabase-transform"}))))
(defn deactivate-table!
"Deactivate table for `target` in `database` in the app db."

View File

@@ -15,6 +15,7 @@
:config-text-file
:content-translation
:content-verification
:data-studio
:dashboard-subscription-filters
:disable-password-login
:database-auth-providers
@@ -62,6 +63,7 @@
:config_text_file true
:content_translation true
:content_verification true
:data_studio true
:dashboard_subscription_filters true
:disable_password_login true
:database_auth_providers true

View File

@@ -37,8 +37,8 @@ export type MetabaseCollectionItem = {
| null;
"last-edit-info"?: {
email: string;
first_name: string;
last_name: string;
first_name: string | null;
last_name: string | null;
id: SdkUserId;
timestamp: string;
};

View File

@@ -25,6 +25,25 @@ export const pythonLibraryApi = EnterpriseApi.injectEndpoints({
}),
invalidatesTags: (_, error, { path }) =>
invalidateTags(error, [idTag("python-transform-library", path)]),
onQueryStarted: async (
{ path, ...patch },
{ dispatch, queryFulfilled },
) => {
const patchResult = dispatch(
pythonLibraryApi.util.updateQueryData(
"getPythonLibrary",
{ path },
(draft) => {
Object.assign(draft, patch);
},
),
);
try {
await queryFulfilled;
} catch {
patchResult.undo();
}
},
}),
}),
});

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",
"/data-studio/transforms/1",
);
});
});

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

@@ -2,11 +2,11 @@ import { msgid, ngettext, t } from "ttag";
import * as Urls from "metabase/lib/urls";
import type {
CardCreatorInfo,
DependencyEntry,
DependencyNode,
Field,
LastEditInfo,
UserInfo,
} from "metabase-types/api";
import type { NodeTableInfo } from "./types";
@@ -25,7 +25,7 @@ export function getNodeCreatedAt(node: DependencyNode): string | null {
}
}
export function getNodeCreatedBy(node: DependencyNode): CardCreatorInfo | null {
export function getNodeCreatedBy(node: DependencyNode): UserInfo | null {
switch (node.type) {
case "card":
case "dashboard":

View File

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

@@ -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 { getDataStudioDependencyRoutes } from "./routes";
if (hasPremiumFeature("dependencies")) {
PLUGIN_DEPENDENCIES.isEnabled = true;
PLUGIN_DEPENDENCIES.getDataStudioDependencyRoutes =
getDataStudioDependencyRoutes;
PLUGIN_DEPENDENCIES.DependencyGraphPage = DependencyGraphPage;
PLUGIN_DEPENDENCIES.CheckDependenciesForm = CheckDependenciesForm;
PLUGIN_DEPENDENCIES.CheckDependenciesModal = CheckDependenciesModal;

View File

@@ -0,0 +1,7 @@
import { IndexRoute } from "react-router";
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
export function getDataStudioDependencyRoutes() {
return <IndexRoute component={DependencyGraphPage} />;
}

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

@@ -10,12 +10,14 @@ import {
getDownloadWidgetMessageOverride,
} from "./query-downloads";
import {
canAccessDataModel,
dataModelPermissionAllowedPathGetter,
databaseManagementPermissionAllowedPathGetter,
getDataColumns,
} from "./utils";
if (hasPremiumFeature("advanced_permissions")) {
PLUGIN_FEATURE_LEVEL_PERMISSIONS.canAccessDataModel = canAccessDataModel;
PLUGIN_ADMIN_ALLOWED_PATH_GETTERS.push(dataModelPermissionAllowedPathGetter);
PLUGIN_ADMIN_ALLOWED_PATH_GETTERS.push(
databaseManagementPermissionAllowedPathGetter,

View File

@@ -1,26 +1,32 @@
import { t } from "ttag";
import type { PermissionSubject } from "metabase/admin/permissions/types";
import type { AdminPathKey } from "metabase-types/store";
import { getUser } from "metabase/selectors/user";
import type { AdminPathKey, State } from "metabase-types/store";
import type { UserWithFeaturePermissions } from "./types/user";
const canAccessDataModel = (user?: UserWithFeaturePermissions) =>
const canUserAccessDataModel = (user?: UserWithFeaturePermissions) =>
user?.permissions?.can_access_data_model ?? false;
const canAccessDatabaseManagement = (user?: UserWithFeaturePermissions) =>
const canUserAccessDatabaseManagement = (user?: UserWithFeaturePermissions) =>
user?.permissions?.can_access_db_details ?? false;
export const canAccessDataModel = (state: State): boolean => {
const user = getUser(state);
return user != null && canUserAccessDataModel(user);
};
export const dataModelPermissionAllowedPathGetter = (
user?: UserWithFeaturePermissions,
): AdminPathKey[] => {
return canAccessDataModel(user) ? ["data-model"] : [];
return canUserAccessDataModel(user) ? ["data-model"] : [];
};
export const databaseManagementPermissionAllowedPathGetter = (
user?: UserWithFeaturePermissions,
): AdminPathKey[] => {
return canAccessDatabaseManagement(user) ? ["databases"] : [];
return canUserAccessDatabaseManagement(user) ? ["databases"] : [];
};
export const getDataColumns = (subject: PermissionSubject) => {

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

@@ -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,7 +251,7 @@ function getSourceCode(
function getTransformUrl(transform: SuggestedTransform): string {
return match(transform)
.with({ id: P.number }, ({ id }) => Urls.transformQuery(id))
.with({ id: P.number }, ({ id }) => Urls.transform(id))
.with({ source: { type: "python" } }, () => Urls.newPythonTransform())
.with({ source: { type: "query" } }, () => Urls.newNativeTransform())
.exhaustive();

View File

@@ -0,0 +1,45 @@
import { t } from "ttag";
import { METAKEY } from "metabase/lib/browser";
import { useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getLocation } from "metabase/selectors/routing";
import { ActionIcon, Icon, Tooltip } from "metabase/ui";
import { trackMetabotChatOpened } from "../analytics";
import { useMetabotAgent } from "../hooks";
export const MetabotDataStudioButton = () => {
const metabot = useMetabotAgent();
const location = useSelector(getLocation);
const disabled = !location.pathname?.startsWith(Urls.transformList());
const handleClick = () => {
if (!metabot.visible) {
trackMetabotChatOpened("header");
}
metabot.setVisible(!metabot.visible);
};
const label = disabled
? `Metabot can't be viewed on this page`
: t`Chat with Metabot (${METAKEY}+E)`;
return (
<Tooltip label={label}>
<ActionIcon
variant="subtle"
bd="1px solid var(--mb-color-border)"
p="sm"
h="2.25rem"
w="2.25rem"
disabled={disabled}
aria-label={label}
onClick={handleClick}
>
<Icon c={disabled ? undefined : "text-primary"} name="metabot" />
</ActionIcon>
</Tooltip>
);
};

View File

@@ -0,0 +1,25 @@
import { t } from "ttag";
import { useSelector } from "metabase/lib/redux";
import * as Urls from "metabase/lib/urls";
import { getLocation } from "metabase/selectors/routing";
import { Metabot } from "./Metabot";
export function MetabotDataStudioSidebar() {
const location = useSelector(getLocation);
const disabled = !location.pathname?.startsWith(Urls.transformList());
return (
<Metabot
hide={disabled}
config={{
preventRetryMessage: true,
preventClose: true,
hideSuggestedPrompts: true,
emptyText: t`Let's transform your data together!`,
suggestionModels: ["dataset", "transform", "table", "database"],
}}
/>
);
}

View File

@@ -6,12 +6,13 @@ import { AdminSettingsLayout } from "metabase/common/components/AdminLayout/Admi
import { Route } from "metabase/hoc/Title";
import { PLUGIN_METABOT, PLUGIN_REDUCERS } from "metabase/plugins";
import { MetabotPurchasePage } from "metabase-enterprise/metabot/components/MetabotAdmin/MetabotPurchasePage";
import { MetabotDataStudioSidebar } from "metabase-enterprise/metabot/components/MetabotDataStudioSidebar";
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 { MetabotDataStudioButton } from "./components/MetabotDataStudioButton";
import { getMetabotQuickLinks } from "./components/MetabotQuickLinks";
import { MetabotToggleButton } from "./components/MetabotToggleButton";
import { MetabotContext, MetabotProvider, defaultContext } from "./context";
@@ -50,7 +51,8 @@ if (hasPremiumFeature("metabot_v3")) {
PLUGIN_METABOT.MetabotToggleButton = MetabotToggleButton;
PLUGIN_METABOT.MetabotAppBarButton = MetabotAppBarButton;
PLUGIN_METABOT.MetabotAdminAppBarButton = MetabotAdminAppBarButton;
PLUGIN_METABOT.MetabotDataStudioButton = MetabotDataStudioButton;
PLUGIN_METABOT.MetabotDataStudioSidebar = MetabotDataStudioSidebar;
PLUGIN_REDUCERS.metabotPlugin = metabotReducer;
} else if (hasPremiumFeature("offer_metabase_ai")) {

View File

@@ -1,23 +1,16 @@
import type Question from "metabase-lib/v1/Question";
import type { Dashboard } from "metabase-types/api";
import type { ModerationReview } from "metabase-types/api";
import ModerationReviewIcon from "../../containers/ModerationReviewIcon";
import { getLatestModerationReview } from "../../service";
export interface EntityModerationIconProps {
question?: Question;
dashboard?: Dashboard;
moderationReviews?: ModerationReview[];
}
export const EntityModerationIcon = ({
question,
dashboard,
moderationReviews = [],
}: EntityModerationIconProps): JSX.Element | null => {
const entityReviews = question
? question.getModerationReviews()
: dashboard?.moderation_reviews;
const review = getLatestModerationReview(entityReviews);
const review = getLatestModerationReview(moderationReviews);
if (review) {
return <ModerationReviewIcon review={review} />;

View File

@@ -1,10 +1,7 @@
import { setupEnterprisePlugins } from "__support__/enterprise";
import { mockSettings } from "__support__/settings";
import { renderWithProviders, screen } from "__support__/ui";
import Question from "metabase-lib/v1/Question";
import {
createMockCard,
createMockDashboard,
createMockModerationReview,
createMockTokenFeatures,
} from "metabase-types/api/mocks";
@@ -35,11 +32,7 @@ describe("EntityModerationIcon", () => {
describe("questions", () => {
it("verified question", () => {
setup({
question: new Question(
createMockCard({
moderation_reviews: [createMockModerationReview()],
}),
),
moderationReviews: [createMockModerationReview()],
});
expect(screen.getByRole("img", { name: /verified/ })).toBeInTheDocument();
@@ -47,15 +40,11 @@ describe("EntityModerationIcon", () => {
it("not verified question", () => {
setup({
question: new Question(
createMockCard({
moderation_reviews: [
createMockModerationReview({
status: null,
}),
],
moderationReviews: [
createMockModerationReview({
status: null,
}),
),
],
});
expect(
@@ -67,9 +56,7 @@ describe("EntityModerationIcon", () => {
describe("dashboards", () => {
it("verified dashboard", () => {
setup({
dashboard: createMockDashboard({
moderation_reviews: [createMockModerationReview()],
}),
moderationReviews: [createMockModerationReview()],
});
expect(screen.getByRole("img", { name: /verified/ })).toBeInTheDocument();
@@ -77,13 +64,11 @@ describe("EntityModerationIcon", () => {
it("not verified dashboard", () => {
setup({
dashboard: createMockDashboard({
moderation_reviews: [
createMockModerationReview({
status: null,
}),
],
}),
moderationReviews: [
createMockModerationReview({
status: null,
}),
],
});
expect(

View File

@@ -4,8 +4,7 @@ import { useEditItemVerificationMutation } from "metabase/api";
import { useSelector } from "metabase/lib/redux";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Icon, Menu } from "metabase/ui";
import type Question from "metabase-lib/v1/Question";
import type { Dashboard, ModerationReview } from "metabase-types/api";
import type { Card, Dashboard, ModerationReview } from "metabase-types/api";
import {
MODERATION_STATUS,
@@ -13,44 +12,37 @@ import {
getStatusIcon,
isItemVerified,
} from "../service";
import { getVerifyQuestionTitle } from "../utils";
import { getVerifyCardTitle } from "../utils";
export const useQuestionMenuItems = (
question: Question,
reload: () => void,
) => {
export const useCardMenuItems = (card: Card, reload?: () => void) => {
const latestModerationReview = getLatestModerationReview(
question.getModerationReviews(),
card.moderation_reviews ?? [],
);
const items = useMenuItems({
return useMenuItems({
reload,
moderated_item_id: question.id(),
moderated_item_id: card.id,
moderated_item_type: "card",
latestModerationReview,
title: getVerifyQuestionTitle(question),
title: getVerifyCardTitle(card),
});
return items;
};
export const useDashboardMenuItems = (
dashboard: Dashboard,
reload: () => void,
reload?: () => void,
) => {
const latestModerationReview = getLatestModerationReview(
dashboard.moderation_reviews || [],
dashboard.moderation_reviews ?? [],
);
const items = useMenuItems({
return useMenuItems({
reload,
moderated_item_id: dashboard.id as number,
moderated_item_type: "dashboard",
latestModerationReview,
title: t`Verify this dashboard`,
});
return items;
};
const useMenuItems = ({
@@ -60,7 +52,7 @@ const useMenuItems = ({
title,
latestModerationReview,
}: {
reload: () => void;
reload?: () => void;
moderated_item_id: number;
moderated_item_type: "card" | "dashboard";
title: string;
@@ -97,7 +89,7 @@ const useMenuItems = ({
});
}
reload();
reload?.();
}}
data-testid={testId}
>

View File

@@ -9,10 +9,7 @@ import {
} from "./components/ModerationReviewBanner";
import { ModerationStatusIcon } from "./components/ModerationStatusIcon";
import QuestionModerationSection from "./components/QuestionModerationSection";
import {
useDashboardMenuItems,
useQuestionMenuItems,
} from "./hooks/useMenuItems";
import { useCardMenuItems, useDashboardMenuItems } from "./hooks/useMenuItems";
import {
getModerationTimelineEvents,
getQuestionIcon,
@@ -31,7 +28,7 @@ if (hasPremiumFeature("content_verification")) {
getStatusIcon,
getQuestionIcon,
getModerationTimelineEvents,
useQuestionMenuItems,
useCardMenuItems,
useDashboardMenuItems,
});
}

View File

@@ -1,9 +1,9 @@
import { t } from "ttag";
import type Question from "metabase-lib/v1/Question";
import type { Card } from "metabase-types/api";
export const getVerifyQuestionTitle = (question: Question): string => {
const type = question.type();
export const getVerifyCardTitle = (card: Card): string => {
const type = card.type;
if (type === "question") {
return t`Verify this question`;

View File

@@ -137,7 +137,6 @@ function SnippetCollectionForm({
name="name"
title={t`Give your folder a name`}
placeholder={t`Something short but sweet`}
autoFocus
/>
<FormTextArea
name="description"

View File

@@ -1,47 +1,33 @@
import { useCallback } from "react";
import { t } from "ttag";
import ModalContent from "metabase/common/components/ModalContent";
import type { Collection } from "metabase-types/api";
import type { SnippetFormModalProps } from "metabase/plugins";
import { Modal } from "metabase/ui";
import type { SnippetCollectionFormOwnProps } from "./SnippetCollectionForm";
import SnippetCollectionForm from "./SnippetCollectionForm";
interface SnippetCollectionFormModalOwnProps
extends Omit<SnippetCollectionFormOwnProps, "onCancel"> {
onClose?: () => void;
}
type SnippetCollectionFormModalProps = SnippetCollectionFormModalOwnProps;
function SnippetFormModal({
collection,
onSave,
onSaved,
onClose,
...props
}: SnippetCollectionFormModalProps) {
}: SnippetFormModalProps) {
const isEditing = collection.id != null;
const title = isEditing
? t`Editing ${collection.name}`
: t`Create your new folder`;
const handleSave = useCallback(
(snippetCollection: Collection) => {
onSave?.(snippetCollection);
onClose?.();
},
[onSave, onClose],
);
const handleSave = () => {
onSaved?.();
onClose();
};
return (
<ModalContent title={title} onClose={onClose}>
<Modal opened onClose={onClose} title={title}>
<SnippetCollectionForm
{...props}
collection={collection}
onSave={handleSave}
onCancel={onClose}
/>
</ModalContent>
</Modal>
);
}

View File

@@ -20,7 +20,7 @@ const TOP_SNIPPETS_FOLDER = {
type SetupOpts = {
folder?: Partial<Collection>;
onClose?: null | (() => void);
onClose?: () => void;
};
async function setup({ folder = {}, onClose = jest.fn() }: SetupOpts = {}) {
@@ -53,10 +53,7 @@ async function setup({ folder = {}, onClose = jest.fn() }: SetupOpts = {}) {
});
renderWithProviders(
<SnippetCollectionFormModal
collection={folder}
onClose={onClose || undefined}
/>,
<SnippetCollectionFormModal collection={folder} onClose={onClose} />,
);
if (folder.id) {
@@ -121,13 +118,6 @@ describe("SnippetCollectionFormModal", () => {
});
});
it("doesn't show cancel button if onClose props is not set", async () => {
await setup({ onClose: null });
expect(
screen.queryByRole("button", { name: "Cancel" }),
).not.toBeInTheDocument();
});
it("calls onClose when cancel button is clicked", async () => {
const { onClose } = await setup();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
@@ -188,13 +178,6 @@ describe("SnippetCollectionFormModal", () => {
});
});
it("doesn't show cancel button if onClose props is not set", async () => {
await setupEditing({ onClose: null });
expect(
screen.queryByRole("button", { name: "Cancel" }),
).not.toBeInTheDocument();
});
it("calls onClose when cancel button is clicked", async () => {
const { onClose } = await setupEditing();
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));

View File

@@ -0,0 +1,12 @@
.root {
opacity: 0;
transition: opacity 0.2s ease;
}
.root:focus-within {
opacity: 1;
}
li:hover .root {
opacity: 1;
}

View File

@@ -0,0 +1,112 @@
import { t } from "ttag";
import { useUpdateCollectionMutation } from "metabase/api";
import { isRootCollection } from "metabase/collections/utils";
import { useDispatch, useSelector } from "metabase/lib/redux";
import type { SnippetCollectionMenuProps } from "metabase/plugins";
import { addUndo } from "metabase/redux/undo";
import { getUserIsAdmin } from "metabase/selectors/user";
import { Box, Button, Icon, Menu } from "metabase/ui";
import S from "./SnippetCollectionMenu.module.css";
export function SnippetCollectionMenu({
collection,
onEditDetails,
onChangePermissions,
}: SnippetCollectionMenuProps) {
const dispatch = useDispatch();
const isAdmin = useSelector(getUserIsAdmin);
const [updateCollection] = useUpdateCollectionMutation();
const isRoot = isRootCollection(collection);
const isArchived = collection.archived;
if (!collection.can_write) {
return null;
}
const handleArchiveToggle = async () => {
const wasArchived = collection.archived;
try {
await updateCollection({
id: collection.id,
archived: !wasArchived,
}).unwrap();
dispatch(
addUndo({
message: wasArchived
? t`"${collection.name}" has been unarchived`
: t`"${collection.name}" has been archived`,
undo: async () => {
await updateCollection({
id: collection.id,
archived: wasArchived,
});
},
}),
);
} catch (error) {
console.error("Failed to update collection:", error);
}
};
return (
<Box onClick={(e) => e.stopPropagation()} className={S.root}>
<Menu position="bottom-end">
<Menu.Target>
<Button
variant="subtle"
p={0}
h={20}
w={20}
c="text-medium"
aria-label={t`Collection options`}
>
<Icon name="ellipsis" size={16} />
</Button>
</Menu.Target>
<Menu.Dropdown>
{isArchived ? (
<Menu.Item
leftSection={<Icon name="unarchive" />}
onClick={handleArchiveToggle}
>
{t`Unarchive`}
</Menu.Item>
) : (
<>
{!isRoot && (
<Menu.Item
leftSection={<Icon name="pencil" />}
onClick={() => onEditDetails(collection)}
>
{t`Edit folder details`}
</Menu.Item>
)}
{isAdmin && (
<Menu.Item
leftSection={<Icon name="lock" />}
onClick={() => onChangePermissions(collection.id)}
>
{t`Change permissions`}
</Menu.Item>
)}
{!isRoot && (
<Menu.Item
leftSection={<Icon name="archive" />}
onClick={handleArchiveToggle}
c="error"
>
{t`Archive`}
</Menu.Item>
)}
</>
)}
</Menu.Dropdown>
</Menu>
</Box>
);
}

View File

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

View File

@@ -0,0 +1,28 @@
import { useEffect } from "react";
import CollectionPermissionsModal from "metabase/admin/permissions/components/CollectionPermissionsModal/CollectionPermissionsModal";
import Modal from "metabase/common/components/Modal";
import SnippetCollections from "metabase/entities/snippet-collections";
import { useDispatch } from "metabase/lib/redux";
import type { SnippetCollectionPermissionsModalProps } from "metabase/plugins";
export function SnippetCollectionPermissionsModal({
collectionId,
onClose,
}: SnippetCollectionPermissionsModalProps) {
const dispatch = useDispatch();
useEffect(() => {
dispatch(SnippetCollections.actions.fetchList());
}, [dispatch]);
return (
<Modal onClose={onClose}>
<CollectionPermissionsModal
params={{ slug: collectionId }}
onClose={onClose}
namespace="snippets"
/>
</Modal>
);
}

View File

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

View File

@@ -0,0 +1,42 @@
import { useCallback } from "react";
import { t } from "ttag";
import type { CollectionPickerValueItem } from "metabase/common/components/Pickers/CollectionPicker";
import { CollectionPickerModal } from "metabase/common/components/Pickers/CollectionPicker";
import type { SnippetCollectionPickerModalProps } from "metabase/plugins";
import type { CollectionId } from "metabase-types/api";
export function SnippetCollectionPickerModal({
isOpen,
onSelect,
onClose,
}: SnippetCollectionPickerModalProps) {
const handleChange = useCallback(
(item: CollectionPickerValueItem) => {
const collectionId: CollectionId | null = item.id === "root" ? null : item.id;
onSelect(collectionId);
},
[onSelect],
);
if (!isOpen) {
return null;
}
return (
<CollectionPickerModal
value={undefined}
onChange={handleChange}
onClose={onClose}
title={t`Select a folder for your snippet`}
options={{
namespace: "snippets",
showPersonalCollections: false,
showRootCollection: true,
showSearch: false,
hasConfirmButtons: true,
hasRecents: false,
}}
/>
);
}

View File

@@ -4,6 +4,7 @@ import CollectionPermissionsModal from "metabase/admin/permissions/components/Co
import { canonicalCollectionId } from "metabase/collections/utils";
import Modal from "metabase/common/components/Modal";
import {
PLUGIN_SNIPPET_FOLDERS,
PLUGIN_SNIPPET_SIDEBAR_HEADER_BUTTONS,
PLUGIN_SNIPPET_SIDEBAR_MODALS,
PLUGIN_SNIPPET_SIDEBAR_PLUS_MENU_OPTIONS,
@@ -14,8 +15,17 @@ import { hasPremiumFeature } from "metabase-enterprise/settings";
import CollectionOptionsButton from "./components/CollectionOptionsButton";
import CollectionRow from "./components/CollectionRow";
import SnippetCollectionFormModal from "./components/SnippetCollectionFormModal";
import { SnippetCollectionMenu } from "./components/SnippetCollectionMenu";
import { SnippetCollectionPermissionsModal } from "./components/SnippetCollectionPermissionsModal";
import { SnippetCollectionPickerModal } from "./components/SnippetCollectionPickerModal";
if (hasPremiumFeature("snippet_collections")) {
PLUGIN_SNIPPET_FOLDERS.isEnabled = true;
PLUGIN_SNIPPET_FOLDERS.CollectionPickerModal = SnippetCollectionPickerModal;
PLUGIN_SNIPPET_FOLDERS.CollectionFormModal = SnippetCollectionFormModal;
PLUGIN_SNIPPET_FOLDERS.CollectionMenu = SnippetCollectionMenu;
PLUGIN_SNIPPET_FOLDERS.CollectionPermissionsModal =
SnippetCollectionPermissionsModal;
PLUGIN_SNIPPET_SIDEBAR_PLUS_MENU_OPTIONS.push((snippetSidebar) => ({
icon: "folder",
name: t`New folder`,
@@ -32,21 +42,15 @@ if (hasPremiumFeature("snippet_collections")) {
PLUGIN_SNIPPET_SIDEBAR_MODALS.push(
(snippetSidebar) =>
snippetSidebar.state.modalSnippetCollection && (
<Modal
<SnippetCollectionFormModal
collection={snippetSidebar.state.modalSnippetCollection}
onClose={() =>
snippetSidebar.setState({ modalSnippetCollection: null })
}
>
<SnippetCollectionFormModal
collection={snippetSidebar.state.modalSnippetCollection}
onClose={() =>
snippetSidebar.setState({ modalSnippetCollection: null })
}
onSaved={() => {
snippetSidebar.setState({ modalSnippetCollection: null });
}}
/>
</Modal>
onSaved={() => {
snippetSidebar.setState({ modalSnippetCollection: null });
}}
/>
),
(snippetSidebar) =>
snippetSidebar.state.permissionsModalCollectionId != null && (

View File

@@ -38,7 +38,7 @@ export const ErrorUpdateToast = ({ error }: ErrorUpdateToastProps) => {
<Text c="text-white">{jt`Add a primary key in your database or set an ${(
<Anchor
component={Link}
to={`/admin/datamodel/database/${table?.db_id}/schema/${table?.schema}/table/${table?.id}`}
to={`/data-studio/data/database/${table?.db_id}/schema/${table?.schema}/table/${table?.id}`}
disabled={!table}
key="entity-key-link"
target="_blank"

View File

@@ -2,14 +2,12 @@ import { useHotkeys } from "@mantine/hooks";
import type { PythonTransformEditorProps } from "metabase/plugins";
import { Flex, Stack } from "metabase/ui";
import { EditorHeader } from "metabase-enterprise/transforms/components/TransformEditor/EditorHeader";
import type { PythonTransformTableAliases, Table } from "metabase-types/api";
import { PythonDataPicker } from "./PythonDataPicker";
import { PythonEditorBody } from "./PythonEditorBody";
import { PythonEditorResults } from "./PythonEditorResults";
import {
getValidationResult,
isPythonTransformSource,
updateTransformSignature,
useShouldShowPythonDebugger,
@@ -17,15 +15,10 @@ import {
} from "./utils";
export function PythonTransformEditor({
name,
source,
proposedSource,
isNew = false,
isDirty = false,
isSaving = false,
isDirty,
onChangeSource,
onSave,
onCancel,
onAcceptProposed,
onRejectProposed,
}: PythonTransformEditorProps) {
@@ -75,53 +68,34 @@ export function PythonTransformEditor({
useHotkeys([["mod+Enter", handleCmdEnter]], []);
const validationResult = getValidationResult(source);
return (
<Stack
w="100%"
h="100%"
bg="bg-white"
data-testid="transform-query-editor"
gap={0}
>
<EditorHeader
name={name}
validationResult={validationResult}
isNew={isNew}
isDirty={isDirty}
isSaving={isSaving}
onSave={onSave}
onCancel={onCancel}
<Flex h="100%" w="100%">
<PythonDataPicker
database={source["source-database"]}
tables={source["source-tables"]}
onChange={handleDataChange}
/>
<Flex h="100%" w="100%">
<PythonDataPicker
database={source["source-database"]}
tables={source["source-tables"]}
onChange={handleDataChange}
<Stack w="100%" h="100%" gap={0}>
<PythonEditorBody
isRunnable={isPythonTransformSource(source)}
isRunning={isRunning}
isDirty={isDirty}
onRun={run}
onCancel={cancel}
source={source.body}
proposedSource={proposedSource?.body}
onChange={handleScriptChange}
withDebugger={showDebugger}
onAcceptProposed={onAcceptProposed}
onRejectProposed={onRejectProposed}
/>
<Stack w="100%" h="100%" gap={0}>
<PythonEditorBody
isRunnable={isPythonTransformSource(source)}
{showDebugger && (
<PythonEditorResults
isRunning={isRunning}
isDirty={isDirty}
onRun={run}
onCancel={cancel}
source={source.body}
proposedSource={proposedSource?.body}
onChange={handleScriptChange}
withDebugger={showDebugger}
onAcceptProposed={onAcceptProposed}
onRejectProposed={onRejectProposed}
executionResult={executionResult}
/>
{showDebugger && (
<PythonEditorResults
isRunning={isRunning}
executionResult={executionResult}
/>
)}
</Stack>
</Flex>
</Stack>
)}
</Stack>
</Flex>
);
}

View File

@@ -219,25 +219,3 @@ export function useShouldShowPythonDebugger() {
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,35 +1,16 @@
import { t } from "ttag";
import { AdminNavItem } from "metabase/admin/components/AdminNav";
import { Route } from "metabase/hoc/Title";
import * as Urls from "metabase/lib/urls";
import { PLUGIN_TRANSFORMS_PYTHON } from "metabase/plugins";
import { hasPremiumFeature } from "metabase-enterprise/settings";
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 { getPythonLibraryRoutes } from "./routes";
import { getPythonSourceValidationResult } from "./utils";
if (hasPremiumFeature("transforms-python")) {
PLUGIN_TRANSFORMS_PYTHON.isEnabled = true;
PLUGIN_TRANSFORMS_PYTHON.PythonRunnerSettingsPage = PythonRunnerSettingsPage;
PLUGIN_TRANSFORMS_PYTHON.getPythonLibraryRoutes = getPythonLibraryRoutes;
PLUGIN_TRANSFORMS_PYTHON.getPythonSourceValidationResult =
getPythonSourceValidationResult;
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.PythonRunnerSettingsPage = PythonRunnerSettingsPage;
}

View File

@@ -2,4 +2,5 @@
/* HACK: 133px is the height of the headers */
height: calc(100vh - 133px);
flex-grow: 1;
background-color: var(--mb-color-bg-light);
}

View File

@@ -1,13 +1,17 @@
import { useEffect, useState } from "react";
import { useLayoutEffect, useState } from "react";
import type { Route } from "react-router";
import { t } from "ttag";
import Button from "metabase/common/components/Button";
import EditBar from "metabase/common/components/EditBar";
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
import { LoadingAndErrorWrapper } from "metabase/common/components/LoadingAndErrorWrapper";
import {
PaneHeader,
PanelHeaderTitle,
} from "metabase/data-studio/components/PaneHeader";
import { isResourceNotFoundError } from "metabase/lib/errors";
import type * as Urls from "metabase/lib/urls";
import { useMetadataToasts } from "metabase/metadata/hooks";
import { Box, Flex } from "metabase/ui";
import { Box, Button, Flex, Group } from "metabase/ui";
import {
useGetPythonLibraryQuery,
useUpdatePythonLibraryMutation,
@@ -19,6 +23,7 @@ import S from "./PythonLibraryEditorPage.module.css";
type PythonLibraryEditorPageProps = {
params: Urls.TransformPythonLibraryParams;
route: Route;
};
const EMPTY_LIBRARY_SOURCE = `
@@ -30,6 +35,7 @@ const EMPTY_LIBRARY_SOURCE = `
export function PythonLibraryEditorPage({
params,
route,
}: PythonLibraryEditorPageProps) {
const { path } = params;
const [source, setSource] = useState("");
@@ -68,7 +74,11 @@ export function PythonLibraryEditorPage({
}
// When the library loads, set the source to the current library source
useEffect(handleRevert, [isLoading, error, library]);
useLayoutEffect(() => {
if (library != null) {
setSource(library.source);
}
}, [library]);
const isDirty = source !== (library?.source ?? EMPTY_LIBRARY_SOURCE);
@@ -81,21 +91,24 @@ export function PythonLibraryEditorPage({
}
return (
<Flex h="100%" w="100%" bg="bg-light" gap={0} direction="column">
<LibraryEditorHeader
onSave={handleSave}
onRevert={handleRevert}
isDirty={isDirty}
isSaving={isSaving}
/>
<PythonEditor
value={source}
onChange={setSource}
withPandasCompletions
className={S.editor}
data-testid="python-editor"
/>
</Flex>
<>
<Flex h="100%" w="100%" gap={0} direction="column">
<LibraryEditorHeader
onSave={handleSave}
onRevert={handleRevert}
isDirty={isDirty}
isSaving={isSaving}
/>
<PythonEditor
value={source}
onChange={setSource}
withPandasCompletions
className={S.editor}
data-testid="python-editor"
/>
</Flex>
<LeaveRouteConfirmModal route={route} isEnabled={isDirty} />
</>
);
}
@@ -111,30 +124,21 @@ export function LibraryEditorHeader({
onRevert: () => void;
}) {
return (
<EditBar
title={t`You are editing the shared Python library`}
admin
data-testid="library-editor-header"
buttons={[
<Button
key="save"
onClick={onRevert}
primary
small
disabled={!isDirty || isSaving}
>
{t`Revert`}
</Button>,
<Button
key="save"
onClick={onSave}
primary
small
disabled={!isDirty || isSaving}
>
{t`Save`}
</Button>,
]}
<PaneHeader
title={<PanelHeaderTitle>{t`Python library`}</PanelHeaderTitle>}
actions={
(isDirty || isSaving) && (
<Group>
<Button disabled={isSaving} onClick={onRevert}>
{t`Revert`}
</Button>
<Button variant="filled" disabled={isSaving} onClick={onSave}>
{t`Save`}
</Button>
</Group>
)
}
data-testid="python-library-header"
/>
);
}

View File

@@ -0,0 +1,7 @@
import { Route } from "metabase/hoc/Title";
import { PythonLibraryEditorPage } from "./pages/PythonLibraryEditorPage";
export function getPythonLibraryRoutes() {
return <Route path="library/:path" component={PythonLibraryEditorPage} />;
}

View File

@@ -0,0 +1,28 @@
import { t } from "ttag";
import type { PythonTransformSourceValidationResult } from "metabase/plugins";
import type { PythonTransformSourceDraft } from "metabase-types/api";
export function getPythonSourceValidationResult(
source: PythonTransformSourceDraft,
): PythonTransformSourceValidationResult {
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

@@ -3,42 +3,33 @@ import type { TransformId, TransformJobId } from "metabase-types/api";
export function trackTransformTriggerManualRun({
transformId,
triggeredFrom,
}: {
transformId: TransformId;
triggeredFrom: "transform-page";
}) {
trackSimpleEvent({
event: "transform_trigger_manual_run",
target_id: transformId,
triggered_from: triggeredFrom,
});
}
export function trackTransformJobTriggerManualRun({
jobId,
triggeredFrom,
}: {
jobId: TransformJobId;
triggeredFrom: "job-page";
}) {
trackSimpleEvent({
event: "transform_job_trigger_manual_run",
target_id: jobId,
triggered_from: triggeredFrom,
});
}
export function trackTransformCreate({
triggeredFrom,
creationType,
}: {
triggeredFrom: "transform-page-create-menu";
creationType: "query" | "native" | "python" | "saved-question";
}) {
trackSimpleEvent({
event: "transform_create",
triggered_from: triggeredFrom,
event_detail: creationType,
});
}

View File

@@ -0,0 +1,3 @@
.body {
overflow: auto;
}

View File

@@ -0,0 +1,43 @@
import type { ReactNode } from "react";
import { Flex, Stack } from "metabase/ui";
import S from "./ColumnLayout.module.css";
type ColumnLayoutProps = {
children?: ReactNode;
"data-testid"?: string;
};
export function ColumnLayout({
children,
"data-testid": dataTestId,
}: ColumnLayoutProps) {
return (
<Flex direction="column" h="100%" data-testid={dataTestId}>
{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,58 @@
import type { ReactNode } from "react";
import {
PaneHeader,
PaneHeaderInput,
} from "metabase/data-studio/components/PaneHeader";
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;
menu?: ReactNode;
actions?: ReactNode;
onNameChange: (name: string) => void;
onScheduleChange: (
schedule: string,
uiDisplayType: ScheduleDisplayType,
) => void;
onTagListChange: (tagIds: TransformTagId[]) => void;
};
export function JobEditor({
job,
menu,
actions,
onNameChange,
onScheduleChange,
onTagListChange,
}: JobEditorProps) {
return (
<ColumnLayout data-testid="jobs-editor">
<PaneHeader
title={
<PaneHeaderInput
initialValue={job.name}
maxLength={NAME_MAX_LENGTH}
onChange={onNameChange}
/>
}
menu={menu}
actions={actions}
data-testid="jobs-header"
/>
<ColumnLayoutBody>
<ScheduleSection job={job} onScheduleChange={onScheduleChange} />
<TagSection job={job} onTagsChange={onTagListChange} />
{job.id != null && <DependenciesSection jobId={job.id} />}
</ColumnLayoutBody>
</ColumnLayout>
);
}

View File

@@ -13,9 +13,9 @@ import type {
} from "metabase-types/api";
import { trackTransformJobTriggerManualRun } from "../../../analytics";
import { RunButton } from "../../../components/RunButton";
import { RunStatus } from "../../../components/RunStatus";
import { SplitSection } from "../../../components/SplitSection";
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>
);
}
@@ -122,10 +122,7 @@ function RunButtonSection({ job }: RunButtonSectionProps) {
return;
}
trackTransformJobTriggerManualRun({
jobId: job.id,
triggeredFrom: "job-page",
});
trackTransformJobTriggerManualRun({ jobId: job.id });
const { error } = await runJob(job.id);
@@ -137,6 +134,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

@@ -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();
});
});

View File

@@ -1,48 +0,0 @@
import { Stack } from "metabase/ui";
import type { ScheduleDisplayType, TransformTagId } from "metabase-types/api";
import { DependenciesSection } from "./DependenciesSection";
import { HeaderSection } from "./HeaderSection";
import { ManageSection } from "./ManageSection";
import { NameSection } from "./NameSection";
import { SaveSection } from "./SaveSection";
import { ScheduleSection } from "./ScheduleSection";
import { TagSection } from "./TagSection";
import type { TransformJobInfo } from "./types";
type JobPageProps = {
job: TransformJobInfo;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string | null) => void;
onScheduleChange: (
schedule: string,
uiDisplayType: ScheduleDisplayType,
) => void;
onTagListChange: (tagIds: TransformTagId[]) => void;
};
export function JobView({
job,
onNameChange,
onDescriptionChange,
onScheduleChange,
onTagListChange,
}: JobPageProps) {
return (
<Stack gap="3.5rem" data-testid="job-view">
<Stack gap="lg">
<HeaderSection job={job} />
<NameSection
job={job}
onNameChange={onNameChange}
onDescriptionChange={onDescriptionChange}
/>
</Stack>
<ScheduleSection job={job} onScheduleChange={onScheduleChange} />
<TagSection job={job} onTagsChange={onTagListChange} />
{job.id != null && <ManageSection job={job} />}
{job.id == null && <SaveSection job={job} />}
{job.id != null && <DependenciesSection jobId={job.id} />}
</Stack>
);
}

View File

@@ -1,63 +0,0 @@
import {
setupListTransformJobTransformsEndpoint,
setupListTransformTagsEndpoint,
} from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import { createMockTransformJob } from "metabase-types/api/mocks";
import { JobView } from "./JobView";
import type { TransformJobInfo } from "./types";
type SetupOpts = {
job?: TransformJobInfo;
};
function setup({ job = createMockTransformJob() }: SetupOpts) {
const onNameChange = jest.fn();
const onDescriptionChange = jest.fn();
const onScheduleChange = jest.fn();
const onTagListChange = jest.fn();
setupListTransformTagsEndpoint([]);
if (job.id) {
setupListTransformJobTransformsEndpoint(job.id, []);
}
renderWithProviders(
<JobView
job={job}
onNameChange={onNameChange}
onDescriptionChange={onDescriptionChange}
onScheduleChange={onScheduleChange}
onTagListChange={onTagListChange}
/>,
);
return {
onNameChange,
onDescriptionChange,
onScheduleChange,
onTagListChange,
};
}
describe("JobView", () => {
it("should render the manage section for a saved job", () => {
setup({ job: createMockTransformJob() });
expect(
screen.getByRole("button", { name: "Delete this job" }),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Save" }),
).not.toBeInTheDocument();
});
it("should render the save section for a unsaved job", () => {
const { id, ...unsavedJob } = createMockTransformJob();
setup({ job: unsavedJob });
expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Delete this job" }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,64 +0,0 @@
import { useDisclosure } from "@mantine/hooks";
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 { Box, Button, Icon } from "metabase/ui";
import { SplitSection } from "../../../components/SplitSection";
import type { TransformJobInfo } from "../types";
import { DeleteJobModal } from "./DeleteJobModal";
export type ManageSectionProps = {
job: TransformJobInfo;
};
export function ManageSection({ job }: ManageSectionProps) {
return (
<SplitSection
label={t`Manage this job`}
description={t`Deleting this job won’t delete any transforms.`}
>
<Box p="xl">
<DeleteJobButton job={job} />
</Box>
</SplitSection>
);
}
type DeleteJobButtonProps = {
job: TransformJobInfo;
};
function DeleteJobButton({ job }: DeleteJobButtonProps) {
const dispatch = useDispatch();
const [isModalOpened, { open: openModal, close: closeModal }] =
useDisclosure();
const { sendSuccessToast } = useMetadataToasts();
const handleDelete = () => {
sendSuccessToast(t`Job deleted`);
dispatch(push(Urls.transformJobList()));
};
return (
<>
<Button
leftSection={<Icon name="trash" aria-hidden />}
onClick={openModal}
>
{t`Delete this job`}
</Button>
{isModalOpened && (
<DeleteJobModal
job={job}
onDelete={handleDelete}
onClose={closeModal}
/>
)}
</>
);
}

View File

@@ -1,43 +0,0 @@
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import { setupDeleteTransformJobEndpoint } from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import * as Urls from "metabase/lib/urls";
import type { TransformJob } from "metabase-types/api";
import { createMockTransformJob } from "metabase-types/api/mocks";
import { ManageSection } from "./ManageSection";
type SetupOpts = {
job?: TransformJob;
};
function setup({ job = createMockTransformJob() }: SetupOpts = {}) {
setupDeleteTransformJobEndpoint(job.id);
renderWithProviders(
<>
<Route
path={Urls.transformJobList()}
component={() => <div>Job list</div>}
/>
<Route
path={Urls.transformJob(job.id)}
component={() => <ManageSection job={job} />}
/>
</>,
{ withRouter: true, initialRoute: Urls.transformJob(job.id) },
);
}
describe("ManageSection", () => {
it("should delete the job and navigate to the job list page", async () => {
setup();
await userEvent.click(
screen.getByRole("button", { name: "Delete this job" }),
);
await userEvent.click(screen.getByRole("button", { name: "Delete job" }));
expect(await screen.findByText("Job list")).toBeInTheDocument();
});
});

View File

@@ -1,23 +0,0 @@
import { NameDescriptionInput } from "../../../components/NameDescriptionInput";
import type { TransformJobInfo } from "../types";
type NameSectionProps = {
job: TransformJobInfo;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string | null) => void;
};
export function NameSection({
job,
onNameChange,
onDescriptionChange,
}: NameSectionProps) {
return (
<NameDescriptionInput
name={job.name}
description={job.description}
onNameChange={onNameChange}
onDescriptionChange={onDescriptionChange}
/>
);
}

View File

@@ -1,70 +0,0 @@
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "__support__/ui";
import { createMockTransformJob } from "metabase-types/api/mocks";
import type { TransformJobInfo } from "../types";
import { NameSection } from "./NameSection";
type SetupOpts = {
job?: TransformJobInfo;
};
function setup({ job = createMockTransformJob() }: SetupOpts = {}) {
const onNameChange = jest.fn();
const onDescriptionChange = jest.fn();
renderWithProviders(
<NameSection
job={job}
onNameChange={onNameChange}
onDescriptionChange={onDescriptionChange}
/>,
);
return {
onNameChange,
onDescriptionChange,
};
}
describe("NameSection", () => {
it("should be able to update the name", async () => {
const { onNameChange } = setup({
job: createMockTransformJob({ name: "Original Name" }),
});
const nameInput = screen.getByDisplayValue("Original Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "New Name");
await userEvent.tab();
expect(onNameChange).toHaveBeenCalledWith("New Name");
});
it("should be able to update the description", async () => {
const { onDescriptionChange } = setup({
job: createMockTransformJob({ description: "Original description" }),
});
const descriptionInput = screen.getByDisplayValue("Original description");
await userEvent.clear(descriptionInput);
await userEvent.type(descriptionInput, "New description");
await userEvent.tab();
expect(onDescriptionChange).toHaveBeenCalledWith("New description");
});
it("should be able to clear the description", async () => {
const { onDescriptionChange } = setup({
job: createMockTransformJob({ description: "Some description" }),
});
const descriptionInput = screen.getByDisplayValue("Some description");
await userEvent.clear(descriptionInput);
await userEvent.tab();
expect(onDescriptionChange).toHaveBeenCalledWith(null);
});
});

View File

@@ -1,54 +0,0 @@
import { Link } from "react-router";
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 { Button, Group } from "metabase/ui";
import {
useCreateTransformJobMutation,
useLazyGetTransformJobQuery,
} from "metabase-enterprise/api";
import type { TransformJobInfo } from "../types";
import S from "./SaveSection.module.css";
type SaveSectionProps = {
job: TransformJobInfo;
};
export function SaveSection({ job: jobInfo }: SaveSectionProps) {
const [createJob, { isLoading: isCreating }] =
useCreateTransformJobMutation();
const [fetchJob, { isFetching }] = useLazyGetTransformJobQuery();
const { sendSuccessToast, sendErrorToast } = useMetadataToasts();
const dispatch = useDispatch();
const handleCreate = async () => {
const { data: job, error } = await createJob(jobInfo);
if (error) {
sendErrorToast(t`Failed to create a job`);
} else if (job != null) {
// prefetch the job to avoid the loader on the job details page
await fetchJob(job.id);
sendSuccessToast(t`New job created`);
dispatch(push(Urls.transformJob(job.id)));
}
};
return (
<Group className={S.section} pt="md">
<Button
variant="filled"
disabled={isCreating || isFetching}
onClick={handleCreate}
>
{t`Save`}
</Button>
<Button component={Link} to={Urls.transformJobList()}>{t`Cancel`}</Button>
</Group>
);
}

View File

@@ -1,54 +0,0 @@
import userEvent from "@testing-library/user-event";
import { Route } from "react-router";
import {
setupCreateTransformJobEndpoint,
setupGetTransformJobEndpoint,
} from "__support__/server-mocks";
import { renderWithProviders, screen } from "__support__/ui";
import * as Urls from "metabase/lib/urls";
import type { TransformJob } from "metabase-types/api";
import { createMockTransformJob } from "metabase-types/api/mocks";
import { SaveSection } from "./SaveSection";
type SetupOpts = {
job?: TransformJob;
};
function setup({ job = createMockTransformJob() }: SetupOpts = {}) {
setupCreateTransformJobEndpoint(job);
setupGetTransformJobEndpoint(job);
renderWithProviders(
<>
<Route
path={Urls.newTransformJob()}
component={() => <SaveSection job={job} />}
/>
<Route
path={Urls.transformJob(job.id)}
component={() => <div>Job page</div>}
/>
<Route
path={Urls.transformJobList()}
component={() => <div>Job list page</div>}
/>
</>,
{ withRouter: true, initialRoute: Urls.newTransformJob() },
);
}
describe("SaveSection", () => {
it("should allow to save a job", async () => {
setup();
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByText("Job page")).toBeInTheDocument();
});
it("should allow to cancel creation", async () => {
setup();
await userEvent.click(screen.getByRole("link", { name: "Cancel" }));
expect(await screen.findByText("Job list page")).toBeInTheDocument();
});
});

View File

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

View File

@@ -1,50 +0,0 @@
import { t } from "ttag";
import EditableText from "metabase/common/components/EditableText";
import { Stack } from "metabase/ui";
import { NAME_MAX_LENGTH } from "../../constants";
import S from "./NameDescriptionInput.module.css";
type NameDescriptionInputProps = {
name: string;
description: string | null;
onNameChange: (name: string) => void;
onDescriptionChange: (description: string | null) => void;
};
export function NameDescriptionInput({
name,
description,
onNameChange,
onDescriptionChange,
}: NameDescriptionInputProps) {
const handleDescriptionChange = async (description: string) => {
onDescriptionChange(description.length === 0 ? null : description);
};
return (
<Stack className={S.section} gap="sm" pb="md">
<EditableText
initialValue={name}
maxLength={NAME_MAX_LENGTH}
placeholder={t`Name`}
p={0}
fw="bold"
fz="h2"
lh="h2"
onChange={onNameChange}
/>
<EditableText
initialValue={description ?? ""}
placeholder={t`No description yet`}
p={0}
fz="md"
lh="1.25rem"
isOptional
onChange={handleDescriptionChange}
/>
</Stack>
);
}

View File

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

View File

@@ -1,53 +0,0 @@
import { useMemo } from "react";
import { useSetting } from "metabase/common/hooks";
import { useSelector } from "metabase/lib/redux";
import { CodeMirrorEditor as Editor } from "metabase/query_builder/components/NativeQueryEditor/CodeMirrorEditor";
import { useQueryMetadata } from "metabase/querying/editor/hooks/use-query-metadata";
import { Notebook } from "metabase/querying/notebook/components/Notebook";
import { getMetadata } from "metabase/selectors/metadata";
import { Center, Loader } from "metabase/ui";
import * as Lib from "metabase-lib";
import Question from "metabase-lib/v1/Question";
import type { DatasetQuery } from "metabase-types/api";
type QueryViewProps = {
query: DatasetQuery;
};
export function QueryView({ query }: QueryViewProps) {
const metadata = useSelector(getMetadata);
const question = useMemo(
() => Question.create({ dataset_query: query, metadata }),
[query, metadata],
);
const { isNative } = Lib.queryDisplayInfo(question.query());
const { isLoading } = useQueryMetadata(question);
const reportTimezone = useSetting("report-timezone-long");
if (isLoading) {
return (
<Center>
<Loader />
</Center>
);
}
if (isNative) {
return <Editor query={question.query()} readOnly />;
}
return (
<Notebook
question={question}
reportTimezone={reportTimezone}
readOnly
isDirty={false}
isRunnable={false}
isResultDirty={false}
hasVisualizeButton={false}
updateQuestion={() => Promise.resolve()}
runQuestionQuery={() => Promise.resolve()}
/>
);
}

View File

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

View File

@@ -1,13 +1,23 @@
import { type ReactNode, type Ref, forwardRef, useState } from "react";
import { useUpdateEffect } from "react-use";
import {
type ReactNode,
type Ref,
forwardRef,
useLayoutEffect,
useState,
} from "react";
import { t } from "ttag";
import { Button, Icon, Loader, Tooltip } from "metabase/ui";
import type { TransformRun } from "metabase-types/api";
import type {
TransformId,
TransformJobId,
TransformRun,
} from "metabase-types/api";
const RECENT_TIMEOUT = 5000;
type RunButtonProps = {
id: TransformId | TransformJobId | undefined;
run: TransformRun | null | undefined;
isDisabled?: boolean;
allowCancellation?: boolean;
@@ -17,11 +27,12 @@ type RunButtonProps = {
export const RunButton = forwardRef(function RunButton(
{
id,
run,
isDisabled: isExternallyDisabled = false,
allowCancellation = false,
onRun,
onCancel,
allowCancellation = false,
}: RunButtonProps,
ref: Ref<HTMLButtonElement>,
) {
@@ -32,12 +43,16 @@ export const RunButton = forwardRef(function RunButton(
isDisabled: isExternallyDisabled,
});
useUpdateEffect(() => {
useLayoutEffect(() => {
setIsRecent(true);
const timeoutId = setTimeout(() => setIsRecent(false), RECENT_TIMEOUT);
return () => clearTimeout(timeoutId);
}, [run]);
useLayoutEffect(() => {
setIsRecent(false);
}, [id]);
return (
<Button.Group>
<Button

View File

@@ -1,28 +0,0 @@
import type { HTMLAttributes, ReactNode } from "react";
import { Card, Group, Stack, Text, Title } from "metabase/ui";
type SplitSectionProps = {
label: string;
description: string;
children?: ReactNode;
} & HTMLAttributes<HTMLDivElement>;
export function SplitSection({
label,
description,
children,
...rest
}: SplitSectionProps) {
return (
<Group align="start" gap="5rem" {...rest}>
<Stack flex={3} py="md" gap="md" maw="15rem">
<Title order={4}>{label}</Title>
<Text c="text-secondary">{description}</Text>
</Stack>
<Card flex={8} p={0} shadow="none" withBorder>
{children}
</Card>
</Group>
);
}

View File

@@ -1,37 +0,0 @@
import type { ReactNode } from "react";
import { renderWithProviders, screen } from "__support__/ui";
import { SplitSection } from "./SplitSection";
type SetupOpts = {
label?: string;
description?: string;
children?: ReactNode;
};
function setup({
label = "Default Label",
description = "Default description",
children,
}: SetupOpts = {}) {
renderWithProviders(
<SplitSection label={label} description={description}>
{children}
</SplitSection>,
);
}
describe("SplitSection", () => {
it("should render label, description, and children", () => {
const label = "Custom Section Label";
const description = "Custom section description";
const children = <span>Custom child content</span>;
setup({ label, description, children });
expect(screen.getByText(label)).toBeInTheDocument();
expect(screen.getByText(description)).toBeInTheDocument();
expect(screen.getByText("Custom child content")).toBeInTheDocument();
});
});

View File

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

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