Compare commits
406 Commits
python-sec
...
data-struc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4672ead0d2 | ||
|
|
0288191d2b | ||
|
|
92892e2e27 | ||
|
|
86ab2a190b | ||
|
|
d700232bcb | ||
|
|
18a5852242 | ||
|
|
3570a81ced | ||
|
|
8c024a44bc | ||
|
|
da932efd65 | ||
|
|
728aad8e02 | ||
|
|
d41816354a | ||
|
|
f153ba8935 | ||
|
|
0deda78c1a | ||
|
|
323fd59cb0 | ||
|
|
82f0efd0ce | ||
|
|
6a03fd2515 | ||
|
|
f78dad62d0 | ||
|
|
6b012dc8ad | ||
|
|
59778c4f56 | ||
|
|
2ea287bbf2 | ||
|
|
8187e6c9aa | ||
|
|
b89d5f7134 | ||
|
|
2e9ec55b68 | ||
|
|
b01f0a0249 | ||
|
|
98a1c3d5a4 | ||
|
|
71baf0cac6 | ||
|
|
8ba55572bd | ||
|
|
926195e57f | ||
|
|
3e06c52b49 | ||
|
|
2fec2796e0 | ||
|
|
04ee950cce | ||
|
|
003e201ac5 | ||
|
|
a854b3b853 | ||
|
|
c8df278e98 | ||
|
|
45ca2337ba | ||
|
|
e71b66fef2 | ||
|
|
978c972256 | ||
|
|
fedce7a0d6 | ||
|
|
7d5ed6904d | ||
|
|
7c2daf91a1 | ||
|
|
5198680d05 | ||
|
|
ac35d94116 | ||
|
|
9af152ffeb | ||
|
|
64f693555c | ||
|
|
6db1918afa | ||
|
|
a56f5ed11e | ||
|
|
d9a9f74c69 | ||
|
|
ec06c8fe7a | ||
|
|
5fa8825281 | ||
|
|
f0ad0a1c0e | ||
|
|
0978943115 | ||
|
|
218110f276 | ||
|
|
c9320ce815 | ||
|
|
633c504a4f | ||
|
|
69e042b282 | ||
|
|
11cd8b2fda | ||
|
|
8a3663bb2c | ||
|
|
324965f261 | ||
|
|
dfe0d6aea4 | ||
|
|
deb8aa00fd | ||
|
|
a5b2744944 | ||
|
|
d9bbc48164 | ||
|
|
e4f882cb7b | ||
|
|
da3392f199 | ||
|
|
0db5e36d32 | ||
|
|
73f09312b9 | ||
|
|
10a5316e66 | ||
|
|
2848e5e4e2 | ||
|
|
0651bdfc78 | ||
|
|
d79c85d77d | ||
|
|
cc20dc585a | ||
|
|
4d897184a2 | ||
|
|
6fa4f686fe | ||
|
|
954f645e8d | ||
|
|
8936c9b9c9 | ||
|
|
2fd17e4869 | ||
|
|
44b7dc6ec9 | ||
|
|
e441049a93 | ||
|
|
02f1a66452 | ||
|
|
1575fac1ef | ||
|
|
f2400ca1b8 | ||
|
|
e7c662693f | ||
|
|
f82e22f016 | ||
|
|
2a142ab6f3 | ||
|
|
98f758c3a2 | ||
|
|
f84ef88c77 | ||
|
|
441383ba61 | ||
|
|
7ebcc7821b | ||
|
|
ee02a5f0c5 | ||
|
|
26a475043b | ||
|
|
407d37b595 | ||
|
|
47d6bad49d | ||
|
|
cf73e9bcf3 | ||
|
|
1c6be916f7 | ||
|
|
88219aa5b2 | ||
|
|
06e3630980 | ||
|
|
b65576f35d | ||
|
|
258e4f2c31 | ||
|
|
7b86079cee | ||
|
|
cf05d56fe9 | ||
|
|
35fc4464e8 | ||
|
|
34194dd880 | ||
|
|
6606cfb41e | ||
|
|
7f6f34ba0a | ||
|
|
98bce01778 | ||
|
|
6ee88fd206 | ||
|
|
a7efaa97c8 | ||
|
|
49e3c56c16 | ||
|
|
0b1f0774f3 | ||
|
|
6b5d859cd6 | ||
|
|
a6cffcbb3c | ||
|
|
d99fc6f0ff | ||
|
|
20ef87952a | ||
|
|
57359bbbd9 | ||
|
|
f5d357807d | ||
|
|
4db90ba49c | ||
|
|
29058a5825 | ||
|
|
5f6e0cb94f | ||
|
|
4787ad5245 | ||
|
|
f16245b845 | ||
|
|
18d7f97009 | ||
|
|
4dde186a07 | ||
|
|
29fc8b10dd | ||
|
|
3bd09e569a | ||
|
|
5f64cfed72 | ||
|
|
d72f390fe0 | ||
|
|
549f2e468a | ||
|
|
ce0cf9032a | ||
|
|
356fc58cf2 | ||
|
|
150d1d4cf1 | ||
|
|
8dfa72365d | ||
|
|
3d6618578f | ||
|
|
97cbbc9a43 | ||
|
|
a5f411b1c7 | ||
|
|
b2edb0ed0f | ||
|
|
04cd3e6f4a | ||
|
|
8d3fb6d04a | ||
|
|
78fde89589 | ||
|
|
287a329fc2 | ||
|
|
8230d5468d | ||
|
|
12bb0b0d19 | ||
|
|
f94c9b68a5 | ||
|
|
8f8bec586d | ||
|
|
bffa22a416 | ||
|
|
fc7edc8f34 | ||
|
|
8a6970d671 | ||
|
|
dd989042b3 | ||
|
|
6ba2bad5c6 | ||
|
|
1aad748cab | ||
|
|
dc0d61f41c | ||
|
|
9150c53ef5 | ||
|
|
0264eb74a7 | ||
|
|
462fc577fa | ||
|
|
ccf3cdccfc | ||
|
|
7859f8bccf | ||
|
|
f126574790 | ||
|
|
77df08fc8d | ||
|
|
e082fa5e04 | ||
|
|
fa9fd8c0b0 | ||
|
|
1951614fb2 | ||
|
|
c576043fdc | ||
|
|
25c93fb073 | ||
|
|
9d7a3ed47f | ||
|
|
08ab8b1627 | ||
|
|
b75bf3154f | ||
|
|
2fd8978f75 | ||
|
|
606d14bb30 | ||
|
|
0ac9898be7 | ||
|
|
12ebd6badf | ||
|
|
ecb25cc518 | ||
|
|
0ad7491469 | ||
|
|
c3ccd02c18 | ||
|
|
cdd6bf84a7 | ||
|
|
f0bce0966f | ||
|
|
efb7081659 | ||
|
|
691b64faf1 | ||
|
|
25351666b1 | ||
|
|
a3bd5b2f58 | ||
|
|
2274aebd01 | ||
|
|
5babd2ef9d | ||
|
|
e98b8c12ca | ||
|
|
d764554ed4 | ||
|
|
70ebc8644b | ||
|
|
d710df1b8d | ||
|
|
553e0a8af5 | ||
|
|
f14998e569 | ||
|
|
40bbde0735 | ||
|
|
384b90f3c0 | ||
|
|
30dd9373e4 | ||
|
|
066a466f9f | ||
|
|
b399cf0292 | ||
|
|
6fda387aee | ||
|
|
d87a6a0e93 | ||
|
|
7fd4b7084d | ||
|
|
a162e53382 | ||
|
|
4e00921884 | ||
|
|
b11a747fd1 | ||
|
|
68d3a7112d | ||
|
|
2a4164646c | ||
|
|
df9b508984 | ||
|
|
2da3f2701b | ||
|
|
85511ceab1 | ||
|
|
4ac2e59315 | ||
|
|
d0e0a02f47 | ||
|
|
c9230e6a4a | ||
|
|
b4d6c01d82 | ||
|
|
6cab61e366 | ||
|
|
9d0562afae | ||
|
|
6d66f751a9 | ||
|
|
19ef1a98ec | ||
|
|
ab7d240fed | ||
|
|
d5bdd73a71 | ||
|
|
69a4b9e3a1 | ||
|
|
2637589169 | ||
|
|
3ad26f2653 | ||
|
|
7ba2628c69 | ||
|
|
77769dc4b9 | ||
|
|
db3ad08d3c | ||
|
|
888701b8c9 | ||
|
|
03b4ebc081 | ||
|
|
6de4279460 | ||
|
|
4f361fc759 | ||
|
|
7e75aa10f7 | ||
|
|
b7e104cf99 | ||
|
|
b6451fb1d1 | ||
|
|
ff0c58e2c9 | ||
|
|
fe27a60086 | ||
|
|
5e5af46277 | ||
|
|
77558f265b | ||
|
|
02eeab73f0 | ||
|
|
d29ec97a5d | ||
|
|
953a90b33e | ||
|
|
6cc4135f9b | ||
|
|
a52b61315a | ||
|
|
7ba19e06f6 | ||
|
|
c6436f19fa | ||
|
|
581b571939 | ||
|
|
3d77de040b | ||
|
|
d8bc169017 | ||
|
|
658ed79f82 | ||
|
|
9eb72b54c3 | ||
|
|
e3d446fd61 | ||
|
|
6e20ebcc6b | ||
|
|
5ceefb4d45 | ||
|
|
da1bd244b6 | ||
|
|
337a897ac9 | ||
|
|
b23af30f53 | ||
|
|
a5c38998fd | ||
|
|
c8d5370577 | ||
|
|
18dc4d9d86 | ||
|
|
7e8ffe48c4 | ||
|
|
2205142c1d | ||
|
|
a6e689c827 | ||
|
|
763043c73a | ||
|
|
5282d3a312 | ||
|
|
c022d44a3c | ||
|
|
9af38153f7 | ||
|
|
0998e11b57 | ||
|
|
ea3f77f59c | ||
|
|
8dcdb1871b | ||
|
|
b0094608a1 | ||
|
|
a38bbee057 | ||
|
|
ded691dd68 | ||
|
|
dc54e63335 | ||
|
|
f8100f0430 | ||
|
|
c3721ddeed | ||
|
|
2a56f7f3d3 | ||
|
|
042b45b95e | ||
|
|
0ffdeb3f84 | ||
|
|
6139d6693b | ||
|
|
cd97b591e7 | ||
|
|
438ce07032 | ||
|
|
c28872fd1f | ||
|
|
6cbba3a0d2 | ||
|
|
58ef121a49 | ||
|
|
b2ff60f8fe | ||
|
|
768038b21f | ||
|
|
64210dc82f | ||
|
|
23343b6ed3 | ||
|
|
8f66b4ef34 | ||
|
|
f63677e971 | ||
|
|
4163c1641b | ||
|
|
5c174399c6 | ||
|
|
fb10250deb | ||
|
|
a58f30b34c | ||
|
|
710f25794a | ||
|
|
6cba76168a | ||
|
|
fc82ab1b9a | ||
|
|
3cf5565030 | ||
|
|
3c7e12b8c6 | ||
|
|
f448ab6de5 | ||
|
|
fa59881ddf | ||
|
|
986c3289ea | ||
|
|
f5e258f051 | ||
|
|
99da7a96e5 | ||
|
|
c6a32ea431 | ||
|
|
2a90485d4a | ||
|
|
c9f7e8f66b | ||
|
|
21eaffb153 | ||
|
|
83e4d260fa | ||
|
|
588584807b | ||
|
|
f760b2d78d | ||
|
|
1f1142860e | ||
|
|
3c3e7776ab | ||
|
|
af982a3ca4 | ||
|
|
5a12277416 | ||
|
|
178a9ce965 | ||
|
|
a7c893f7c0 | ||
|
|
2c078fc595 | ||
|
|
c82eb367fd | ||
|
|
203ae9c55b | ||
|
|
cc3105ef0f | ||
|
|
547ad1204d | ||
|
|
48819b05a7 | ||
|
|
5ab21902d1 | ||
|
|
ad9422ce9e | ||
|
|
88f0851fd4 | ||
|
|
29220d5ca1 | ||
|
|
31f4f8e1bd | ||
|
|
4a5437c479 | ||
|
|
b64803ab91 | ||
|
|
c700965477 | ||
|
|
6ade875f8b | ||
|
|
ce548b2e0f | ||
|
|
1c7e5f448c | ||
|
|
8fdc854aea | ||
|
|
f3c35d4184 | ||
|
|
47384a7965 | ||
|
|
35594ed38e | ||
|
|
5cb92f6592 | ||
|
|
31d5471b5b | ||
|
|
0242840402 | ||
|
|
8866f2d446 | ||
|
|
a6f4ba70d6 | ||
|
|
800496c30b | ||
|
|
49d04555c9 | ||
|
|
ab719e84f6 | ||
|
|
126fefa0f1 | ||
|
|
9bb252a52b | ||
|
|
47b4c2473a | ||
|
|
8a34280084 | ||
|
|
dd301f5587 | ||
|
|
3748a5a0c4 | ||
|
|
4cefbd1011 | ||
|
|
84eeb9d35b | ||
|
|
d5d21fc5b3 | ||
|
|
a347b96a9d | ||
|
|
f87aa5d49b | ||
|
|
22935320ca | ||
|
|
6bb12036ba | ||
|
|
5214db1b34 | ||
|
|
65007662b6 | ||
|
|
fc8e625fd1 | ||
|
|
7f880449e6 | ||
|
|
4126a6fb3c | ||
|
|
97b0caa82b | ||
|
|
a848c57956 | ||
|
|
2b06075990 | ||
|
|
8fdf387887 | ||
|
|
1c5c3da666 | ||
|
|
bfb3848543 | ||
|
|
1a14bfa131 | ||
|
|
96acca5e92 | ||
|
|
bb5fa428b5 | ||
|
|
7d11c2f799 | ||
|
|
e7e029a58c | ||
|
|
1d10123201 | ||
|
|
a77842e92b | ||
|
|
9b0d149418 | ||
|
|
dc7ae946c6 | ||
|
|
ddf5a9d460 | ||
|
|
cc6ce2834f | ||
|
|
82a7b2f5e4 | ||
|
|
7e4daf2721 | ||
|
|
ac8c6e6e0c | ||
|
|
2e94a0f579 | ||
|
|
d746234cb6 | ||
|
|
eecc699fbd | ||
|
|
72de9e73ce | ||
|
|
0abe4483fc | ||
|
|
4292db5bcf | ||
|
|
c6027c28c6 | ||
|
|
43cca10568 | ||
|
|
bac077c21d | ||
|
|
1895291a04 | ||
|
|
fa7ba42783 | ||
|
|
9f39d20e07 | ||
|
|
0df67d497a | ||
|
|
876e3e3c27 | ||
|
|
9db2c15514 | ||
|
|
7ecdf0c5dd | ||
|
|
4f0a8c14b4 | ||
|
|
92d0358840 | ||
|
|
7ce95abb57 | ||
|
|
db118fbe12 | ||
|
|
06e2499210 | ||
|
|
1fd9f81f3b | ||
|
|
7e2a230c90 | ||
|
|
b508b28048 | ||
|
|
400d8426c0 | ||
|
|
7c437e28e6 | ||
|
|
657ab9590b | ||
|
|
8b68bbcc75 | ||
|
|
fe4ee32e2f | ||
|
|
99b089d83d | ||
|
|
a0da007219 |
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
23
e2e/support/helpers/e2e-data-studio-helpers.ts
Normal file
23
e2e/support/helpers/e2e-data-studio-helpers.ts
Normal 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"),
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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")
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -134,7 +134,7 @@ function getItemIcon(item: DependencyItem): IconName {
|
||||
}
|
||||
|
||||
if (item.type === "transform") {
|
||||
return "refresh_downstream";
|
||||
return "transform";
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
|
||||
@@ -188,7 +188,7 @@ describe("CheckDependenciesForm", () => {
|
||||
});
|
||||
expect(screen.getByRole("link", { name: /SQL transform/ })).toHaveAttribute(
|
||||
"href",
|
||||
"/admin/transforms/1",
|
||||
"/data-studio/transforms/1",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -137,7 +137,7 @@ export function getNodeIconWithType(
|
||||
case "table":
|
||||
return "table";
|
||||
case "transform":
|
||||
return "refresh_downstream";
|
||||
return "transform";
|
||||
case "snippet":
|
||||
return "sql";
|
||||
case "dashboard":
|
||||
|
||||
@@ -53,7 +53,7 @@ describe("getNodeIcon", () => {
|
||||
},
|
||||
{
|
||||
node: createMockTransformDependencyNode(),
|
||||
expectedIcon: "refresh_downstream",
|
||||
expectedIcon: "transform",
|
||||
},
|
||||
{
|
||||
node: createMockSnippetDependencyNode(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IndexRoute } from "react-router";
|
||||
|
||||
import { DependencyGraphPage } from "./pages/DependencyGraphPage";
|
||||
|
||||
export function getDataStudioDependencyRoutes() {
|
||||
return <IndexRoute component={DependencyGraphPage} />;
|
||||
}
|
||||
@@ -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"),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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")) {
|
||||
|
||||
@@ -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} />;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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`;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
.root {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.root:focus-within {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
li:hover .root {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./SnippetCollectionMenu";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SnippetCollectionPermissionsModal } from "./SnippetCollectionPermissionsModal";
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./SourceSection";
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.body {
|
||||
overflow: auto;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./ColumnLayout";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./JobEditor";
|
||||
export * from "./types";
|
||||
@@ -1,3 +0,0 @@
|
||||
.row {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) : "",
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./HeaderSection";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./ManageSection";
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./NameSection";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./SaveSection";
|
||||
@@ -1,2 +0,0 @@
|
||||
export * from "./JobView";
|
||||
export * from "./types";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./NameDescriptionInput";
|
||||
@@ -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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./QueryView";
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
export * from "./SplitSection";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user