Compare commits
240 Commits
python-run
...
document-s
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2935aefc64 | ||
|
|
0ecddc9870 | ||
|
|
7ebb0f0000 | ||
|
|
25ce9c5dcf | ||
|
|
14a343de12 | ||
|
|
ec0c2dba8b | ||
|
|
39afe7fff5 | ||
|
|
ab958737a9 | ||
|
|
45a0c85158 | ||
|
|
d2479a5b95 | ||
|
|
6d13f02bf3 | ||
|
|
3b8161ca75 | ||
|
|
93047a3e2f | ||
|
|
2b902a4ce1 | ||
|
|
e03e528f09 | ||
|
|
6f74d26021 | ||
|
|
cb9d5e33e2 | ||
|
|
1ddff04bf6 | ||
|
|
640b530563 | ||
|
|
952a52974c | ||
|
|
915abf29eb | ||
|
|
67eceb653a | ||
|
|
11260e4d72 | ||
|
|
11d3fb22d2 | ||
|
|
2627574bce | ||
|
|
53fcff39ef | ||
|
|
8783c54480 | ||
|
|
9f8331cb63 | ||
|
|
65bbe6429e | ||
|
|
7b367b6c0f | ||
|
|
c328d7f7e0 | ||
|
|
08cec1d1f5 | ||
|
|
5c489b9c95 | ||
|
|
4a079f203a | ||
|
|
1fc9390906 | ||
|
|
3164d5d866 | ||
|
|
71d915b7d0 | ||
|
|
bbe95ee859 | ||
|
|
6542c95758 | ||
|
|
88ef82938d | ||
|
|
9477b72420 | ||
|
|
38bd513835 | ||
|
|
e4c92ff6e2 | ||
|
|
4cbb2297a5 | ||
|
|
54ebe17696 | ||
|
|
41674dd1de | ||
|
|
9595d9493d | ||
|
|
3c3a8dd127 | ||
|
|
4373216910 | ||
|
|
efbfc502f1 | ||
|
|
9bd2243573 | ||
|
|
9c2838341e | ||
|
|
a60e02dd1e | ||
|
|
92e9d6360d | ||
|
|
c6f35e0393 | ||
|
|
89b4bbe920 | ||
|
|
0ab1d4ccb6 | ||
|
|
ea64672746 | ||
|
|
b9a446a933 | ||
|
|
7bd57d1d66 | ||
|
|
dfeafc9489 | ||
|
|
9f0c6d1e2e | ||
|
|
e716d1d253 | ||
|
|
f7c8221468 | ||
|
|
4135955236 | ||
|
|
b2a0c144ba | ||
|
|
9a56a9e96b | ||
|
|
436840eac9 | ||
|
|
ab0bd1b8ff | ||
|
|
c800fb73d0 | ||
|
|
0463d74f76 | ||
|
|
f8924beba5 | ||
|
|
92eb7e62a1 | ||
|
|
6351f1c4ef | ||
|
|
eb8f2d91d0 | ||
|
|
8c5adc5f85 | ||
|
|
a792b1706e | ||
|
|
268352d02b | ||
|
|
acafad1b6a | ||
|
|
4f95b0ebb7 | ||
|
|
92017cfe7c | ||
|
|
9ee59951ae | ||
|
|
403bf4eafd | ||
|
|
010af9dc10 | ||
|
|
8c1e179da7 | ||
|
|
40368f85fb | ||
|
|
978e12c2d6 | ||
|
|
ea4c347006 | ||
|
|
5556654506 | ||
|
|
4ea6c16f19 | ||
|
|
6620040235 | ||
|
|
f809d7747c | ||
|
|
181e8f31d2 | ||
|
|
83bd4ba534 | ||
|
|
f770d21cdf | ||
|
|
f6f6b2d3a9 | ||
|
|
745ab32bde | ||
|
|
6760658aec | ||
|
|
bad6bccac2 | ||
|
|
7448009633 | ||
|
|
7418a137c8 | ||
|
|
40269ca9c0 | ||
|
|
42bee66fba | ||
|
|
76ee0b6f8c | ||
|
|
912aca8811 | ||
|
|
2e9f07ede2 | ||
|
|
9568b9956d | ||
|
|
80867e1335 | ||
|
|
7894f1e45e | ||
|
|
fd4a621603 | ||
|
|
0486d547d3 | ||
|
|
ec7163091a | ||
|
|
287e496a1b | ||
|
|
2ac44861b5 | ||
|
|
47d994182c | ||
|
|
17435642ef | ||
|
|
a917ade608 | ||
|
|
1799892d0b | ||
|
|
4f7c22ed53 | ||
|
|
ba806688b6 | ||
|
|
18898a5fdf | ||
|
|
1ebe336a00 | ||
|
|
36790affcf | ||
|
|
a15154cb84 | ||
|
|
095cae88af | ||
|
|
5314f9f976 | ||
|
|
7189344bfd | ||
|
|
11b74e0e4b | ||
|
|
c5862c278d | ||
|
|
0af4b1c0de | ||
|
|
4cd96fa6a2 | ||
|
|
b47ccb6123 | ||
|
|
5271f4092d | ||
|
|
c06ea7d391 | ||
|
|
e2e2e5fdf3 | ||
|
|
329b903a8b | ||
|
|
82acc79f82 | ||
|
|
240862340a | ||
|
|
7849e0394c | ||
|
|
8fa264cb17 | ||
|
|
ca4744dc08 | ||
|
|
78e9dda8dd | ||
|
|
b36f00cf80 | ||
|
|
3f24ba5687 | ||
|
|
d7dc44feb2 | ||
|
|
0450ba6a67 | ||
|
|
b30ecea5ca | ||
|
|
fc8ca9b602 | ||
|
|
68366d7d37 | ||
|
|
63a94c8788 | ||
|
|
10bd9d68ce | ||
|
|
7221915503 | ||
|
|
4b57f82089 | ||
|
|
b8d2ac9ebc | ||
|
|
00e3499af8 | ||
|
|
d81a470a57 | ||
|
|
c329272406 | ||
|
|
75ec895750 | ||
|
|
60ca78c873 | ||
|
|
45ee83044e | ||
|
|
c43a1c5ca5 | ||
|
|
a7930e7395 | ||
|
|
ad58a25fc6 | ||
|
|
7c5f3f11db | ||
|
|
cff5e706f9 | ||
|
|
f889695914 | ||
|
|
db61438bd8 | ||
|
|
6191275795 | ||
|
|
319cf701dc | ||
|
|
0995bf3526 | ||
|
|
00d8e50082 | ||
|
|
606416ba23 | ||
|
|
86a55c97d9 | ||
|
|
850afa8366 | ||
|
|
0f1eb823d3 | ||
|
|
64448e927d | ||
|
|
f2a3859e32 | ||
|
|
2c85824963 | ||
|
|
27420b5cb9 | ||
|
|
c3e67fd73e | ||
|
|
18ab9af893 | ||
|
|
d3469efb09 | ||
|
|
e00d09cc37 | ||
|
|
ef602ae2d0 | ||
|
|
65c5af5155 | ||
|
|
eb0e823d1e | ||
|
|
c6eeb8e6ba | ||
|
|
af496e167d | ||
|
|
48199ff2f7 | ||
|
|
83ca395ccf | ||
|
|
bd83d0691b | ||
|
|
5b2fa90fdb | ||
|
|
436341ab76 | ||
|
|
aabc1d165f | ||
|
|
a4747ed263 | ||
|
|
cb5f72cad4 | ||
|
|
6c0b2d81ad | ||
|
|
e0f1ecee2d | ||
|
|
6b8124421a | ||
|
|
df462d07d5 | ||
|
|
4f908179ec | ||
|
|
9cbdac4de4 | ||
|
|
0b4b4dfced | ||
|
|
19f4980a7e | ||
|
|
38e9f21dfb | ||
|
|
2775a7e069 | ||
|
|
d92773df06 | ||
|
|
023864e72f | ||
|
|
9c479154a5 | ||
|
|
747781ee29 | ||
|
|
31503487b3 | ||
|
|
9c7f08c2ae | ||
|
|
8693547be1 | ||
|
|
29786d318c | ||
|
|
9307c56d7b | ||
|
|
2583528cb3 | ||
|
|
33edd890a3 | ||
|
|
1e8dbe446b | ||
|
|
ce0e349f21 | ||
|
|
fc5a6469ba | ||
|
|
8523bf0412 | ||
|
|
13d11d33cd | ||
|
|
bb15f10b44 | ||
|
|
f9241dd461 | ||
|
|
a4a135903b | ||
|
|
67d31cea38 | ||
|
|
b7e77ac43c | ||
|
|
89fd0b5c2b | ||
|
|
5339aaa1ca | ||
|
|
809896ee0a | ||
|
|
aae3788116 | ||
|
|
d743daaf20 | ||
|
|
853d9b0cf0 | ||
|
|
4e88f438e9 | ||
|
|
5b6d472c96 | ||
|
|
60373ead57 | ||
|
|
a4a927799d | ||
|
|
8e75cd74f4 | ||
|
|
8252df95f2 | ||
|
|
1cd6a43417 |
File diff suppressed because it is too large
Load Diff
BIN
.loki/reference/chrome_laptop_Components_Documents_Default.png
Normal file
BIN
.loki/reference/chrome_laptop_Components_Documents_Default.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
@@ -8,6 +8,7 @@ const { CSS_CONFIG } = require("../frontend/build/shared/rspack/css-config");
|
||||
const mainAppStories = [
|
||||
"../frontend/**/*.mdx",
|
||||
"../frontend/**/*.stories.@(js|jsx|ts|tsx)",
|
||||
"../enterprise/frontend/**/*.stories.@(js|jsx|ts|tsx)",
|
||||
];
|
||||
|
||||
const config: StorybookConfig = {
|
||||
|
||||
3
deps.edn
3
deps.edn
@@ -630,7 +630,8 @@
|
||||
"src/metabase/util/currency\\.cljc$"
|
||||
"test/metabase/channel/render/png_test.clj"
|
||||
"#.+#$"
|
||||
"\\.transit\\.json$"]}}
|
||||
"\\.transit\\.json$"
|
||||
"enterprise/frontend/src/metabase-enterprise/documents/components/Editor/data/data\\.json$"]}}
|
||||
|
||||
;; clojure -X:dev:ee:ee-dev:test:cloverage
|
||||
:cloverage
|
||||
|
||||
31
e2e/support/helpers/api/createDocument.ts
Normal file
31
e2e/support/helpers/api/createDocument.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type {
|
||||
Document,
|
||||
DocumentContent,
|
||||
RegularCollectionId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
export const createDocument = ({
|
||||
name,
|
||||
collection_id,
|
||||
document,
|
||||
alias,
|
||||
}: {
|
||||
name: string;
|
||||
collection_id?: RegularCollectionId | null;
|
||||
document: DocumentContent;
|
||||
alias?: string;
|
||||
}): Cypress.Chainable<Cypress.Response<Document>> => {
|
||||
cy.log(`Create a collection: ${name}`);
|
||||
|
||||
return cy
|
||||
.request("POST", "/api/ee/document", {
|
||||
name,
|
||||
collection_id,
|
||||
document,
|
||||
})
|
||||
.then((response) => {
|
||||
if (alias) {
|
||||
cy.wrap(response.body.id).as(alias);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -10,6 +10,7 @@ export { createDashboard } from "./createDashboard";
|
||||
export type { DashboardDetails } from "./createDashboard";
|
||||
export { createDashboardWithQuestions } from "./createDashboardWithQuestions";
|
||||
export { createDashboardWithTabs } from "./createDashboardWithTabs";
|
||||
export { createDocument } from "./createDocument";
|
||||
export { createModerationReview } from "./createModerationReview";
|
||||
export { createNativeQuestion } from "./createNativeQuestion";
|
||||
export type { NativeQuestionDetails } from "./createNativeQuestion";
|
||||
|
||||
@@ -45,7 +45,12 @@ export function getPersonalCollectionName(
|
||||
|
||||
export function openCollectionItemMenu(item: string, index = 0) {
|
||||
// eslint-disable-next-line no-unsafe-element-filtering
|
||||
cy.findAllByText(item).eq(index).closest("tr").icon("ellipsis").click();
|
||||
cy.findByTestId("collection-table")
|
||||
.findAllByText(item)
|
||||
.eq(index)
|
||||
.closest("tr")
|
||||
.icon("ellipsis")
|
||||
.click();
|
||||
}
|
||||
|
||||
export const getPinnedSection = () => {
|
||||
|
||||
40
e2e/support/helpers/e2e-document-helpers.ts
Normal file
40
e2e/support/helpers/e2e-document-helpers.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const documentContent = () => cy.findByTestId("document-content");
|
||||
|
||||
export const documentFormattingMenu = () =>
|
||||
cy.findByTestId("document-formatting-menu");
|
||||
|
||||
export const addToDocument = (text: string, newLine: boolean = true) => {
|
||||
cy.realType(text);
|
||||
if (newLine) {
|
||||
cy.realType("{enter}");
|
||||
}
|
||||
};
|
||||
|
||||
export const documentSuggestionDialog = () =>
|
||||
cy.findByRole("dialog", { name: "Mention Dialog" });
|
||||
|
||||
export const documentSuggestionItem = (name: RegExp | string) =>
|
||||
documentSuggestionDialog().findByRole("option", { name });
|
||||
|
||||
export const commandSuggestionDialog = () =>
|
||||
cy.findByRole("dialog", { name: "Command Dialog" });
|
||||
|
||||
export const commandSuggestionItem = (name: RegExp | string) =>
|
||||
commandSuggestionDialog().findByRole("option", { name });
|
||||
|
||||
export const getDocumentCard = (name: string) =>
|
||||
documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.contains('[data-testid="document-card-embed"]', name);
|
||||
|
||||
export const assertDocumentCardVizType = (name: string, type: string) =>
|
||||
getDocumentCard(name).find(`[data-viz-ui-name=${type}]`);
|
||||
|
||||
export const getDocumentSidebar = () =>
|
||||
cy.findByTestId("document-card-sidebar");
|
||||
|
||||
export const openDocumentCardMenu = (name: string) => {
|
||||
getDocumentCard(name)
|
||||
.findByRole("button", { name: /ellipsis/ })
|
||||
.click();
|
||||
};
|
||||
@@ -155,8 +155,8 @@ export function removeSummaryGroupingField({
|
||||
index = 0,
|
||||
}: {
|
||||
field: string;
|
||||
stage: number;
|
||||
index: number;
|
||||
stage?: number;
|
||||
index?: number;
|
||||
}) {
|
||||
getNotebookStep("summarize", { stage, index })
|
||||
.findByTestId("breakout-step")
|
||||
|
||||
@@ -14,6 +14,7 @@ export * from "./e2e-dashboard-visualizer-helpers";
|
||||
export * from "./e2e-database-metadata-helpers";
|
||||
export * from "./e2e-datamodel-helpers";
|
||||
export * from "./e2e-dimension-list-helpers";
|
||||
export * from "./e2e-document-helpers";
|
||||
export * from "./e2e-downloads-helpers";
|
||||
export * from "./e2e-dragndrop-helpers";
|
||||
export * from "./e2e-element-visibility-helpers";
|
||||
|
||||
@@ -903,9 +903,9 @@ describe("scenarios > collection defaults", () => {
|
||||
|
||||
cy.log("single move");
|
||||
|
||||
cy.findByTestId("collection-table").within(() => {
|
||||
H.openCollectionItemMenu("Outer collection 1");
|
||||
});
|
||||
cy.wait("@getCollectionItems");
|
||||
|
||||
H.openCollectionItemMenu("Outer collection 1");
|
||||
|
||||
H.popover().findByText("Move").click();
|
||||
|
||||
|
||||
494
e2e/test/scenarios/documents/documents.cy.spec.ts
Normal file
494
e2e/test/scenarios/documents/documents.cy.spec.ts
Normal file
@@ -0,0 +1,494 @@
|
||||
import {
|
||||
ORDERS_BY_YEAR_QUESTION_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
import {
|
||||
ACCOUNTS_COUNT_BY_CREATED_AT,
|
||||
ORDERS_COUNT_BY_PRODUCT_CATEGORY,
|
||||
PRODUCTS_AVERAGE_BY_CATEGORY,
|
||||
PRODUCTS_COUNT_BY_CATEGORY_PIE,
|
||||
} from "e2e/support/test-visualizer-data";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("documents", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
});
|
||||
|
||||
it("should allow you to create a new document from the new button and save", () => {
|
||||
cy.visit("/");
|
||||
|
||||
H.newButton("Document").click();
|
||||
|
||||
cy.findByRole("textbox", { name: "Document Title" })
|
||||
.should("be.focused")
|
||||
.type("Test Document");
|
||||
|
||||
H.documentContent().type("This is a paragraph\nAnd this is another");
|
||||
|
||||
cy.findByRole("button", { name: "Save" }).click();
|
||||
|
||||
H.entityPickerModalTab("Collections").click();
|
||||
H.entityPickerModalItem(0, "Our analytics").should(
|
||||
"have.attr",
|
||||
"data-active",
|
||||
"true",
|
||||
);
|
||||
H.entityPickerModalItem(1, "First collection").click();
|
||||
H.entityPickerModal().findByRole("button", { name: "Select" }).click();
|
||||
|
||||
H.appBar()
|
||||
.findByRole("link", { name: /First collection/ })
|
||||
.click();
|
||||
|
||||
H.collectionTable()
|
||||
.findByRole("link", { name: "Test Document" })
|
||||
.should("exist");
|
||||
|
||||
cy.log("Document Management");
|
||||
|
||||
H.openCollectionItemMenu("Test Document");
|
||||
|
||||
H.popover().findByText("Move").click();
|
||||
|
||||
H.entityPickerModalTab("Collections").click();
|
||||
H.entityPickerModalItem(0, "Our analytics")
|
||||
.should("have.attr", "data-active", "true")
|
||||
.click();
|
||||
H.entityPickerModal().findByRole("button", { name: "Move" }).click();
|
||||
|
||||
H.openNavigationSidebar();
|
||||
|
||||
H.navigationSidebar().findByText("Our analytics").click();
|
||||
H.openCollectionItemMenu("Test Document");
|
||||
|
||||
H.popover().findByText("Bookmark").click();
|
||||
|
||||
H.navigationSidebar()
|
||||
.findByRole("tab", { name: "Bookmarks" })
|
||||
.findByText("Test Document")
|
||||
.click();
|
||||
|
||||
cy.location("pathname").should("equal", "/document/1-test-document");
|
||||
|
||||
H.appBar()
|
||||
.findByRole("link", { name: /Our analytics/ })
|
||||
.click();
|
||||
|
||||
H.openCollectionItemMenu("Test Document");
|
||||
|
||||
H.popover().findByText("Move to trash").click();
|
||||
|
||||
H.openNavigationSidebar();
|
||||
|
||||
H.navigationSidebar().findByText("Trash").click();
|
||||
H.getUnpinnedSection().findByText("Test Document").should("exist");
|
||||
});
|
||||
|
||||
describe("document editing", () => {
|
||||
describe("Document with content", () => {
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "Bar Document",
|
||||
document: {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Lorem Ipsum and some more words",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
},
|
||||
collection_id: null,
|
||||
alias: "documentId",
|
||||
});
|
||||
});
|
||||
|
||||
it("read only access", () => {
|
||||
cy.signIn("readonly");
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
|
||||
H.documentContent()
|
||||
.findByRole("textbox")
|
||||
.should("have.attr", "contenteditable", "false");
|
||||
|
||||
H.openDocumentCardMenu("Orders");
|
||||
H.popover().findAllByRole("menuitem").should("be.disabled");
|
||||
});
|
||||
|
||||
it("no access", () => {
|
||||
cy.signIn("nocollection");
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
cy.findByRole("status").should(
|
||||
"contain.text",
|
||||
"Sorry, you don’t have permission to see that.",
|
||||
);
|
||||
});
|
||||
|
||||
it("not found", () => {
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id + 1}`));
|
||||
H.main().within(() => {
|
||||
cy.findByText("We're a little lost...").should("exist");
|
||||
cy.findByText("The page you asked for couldn't be found.");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Empty Document", () => {
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "Foo Document",
|
||||
document: {
|
||||
content: [],
|
||||
type: "doc",
|
||||
},
|
||||
collection_id: null,
|
||||
alias: "documentId",
|
||||
}).then((data) => {
|
||||
cy.visit(`/document/${data.body.id}`);
|
||||
});
|
||||
});
|
||||
|
||||
it("should support typing with a markdown syntax", () => {
|
||||
H.documentContent().click();
|
||||
|
||||
H.addToDocument("# This is a heading level 1");
|
||||
H.addToDocument("## This is a heading level 2");
|
||||
H.addToDocument("### This is a heading level 3");
|
||||
H.addToDocument("#### This is a heading level 4");
|
||||
|
||||
H.addToDocument("**Some Bold Text**");
|
||||
H.addToDocument("*Some Italic Text*");
|
||||
|
||||
H.addToDocument("Lets start an unordered list");
|
||||
H.addToDocument("- First Item");
|
||||
H.addToDocument("Second Item");
|
||||
// New Line to break out of the list;
|
||||
H.addToDocument("");
|
||||
|
||||
H.addToDocument("Lets start an ordered list");
|
||||
H.addToDocument("1. First Ordered Item");
|
||||
H.addToDocument("Second Ordered Item");
|
||||
// New Line to break out of the list;
|
||||
H.addToDocument("");
|
||||
|
||||
H.addToDocument("http://metabase.com");
|
||||
|
||||
H.addToDocument("We can also add `inline code blocks` to paragraphs");
|
||||
H.addToDocument("```");
|
||||
H.addToDocument("Or add whole code blocks");
|
||||
//Break out of the code block
|
||||
H.addToDocument("\n");
|
||||
|
||||
H.documentContent().within(() => {
|
||||
cy.findByRole("heading", {
|
||||
name: "This is a heading level 1",
|
||||
}).should("exist");
|
||||
cy.findByRole("heading", {
|
||||
name: "This is a heading level 2",
|
||||
}).should("exist");
|
||||
cy.findByRole("heading", {
|
||||
name: "This is a heading level 3",
|
||||
}).should("exist");
|
||||
cy.findByRole("heading", {
|
||||
name: "This is a heading level 4",
|
||||
}).should("exist");
|
||||
cy.findByRole("strong").should("contain.text", "Some Bold Text");
|
||||
cy.findByRole("emphasis").should("contain.text", "Some Italic Text");
|
||||
|
||||
cy.findAllByRole("list").should("have.length", 2);
|
||||
cy.findAllByRole("listitem")
|
||||
.should("contain.text", "First Item")
|
||||
.should("contain.text", "Second Item")
|
||||
.should("contain.text", "First Ordered Item")
|
||||
.should("contain.text", "Second Ordered Item");
|
||||
|
||||
cy.findByRole("link", { name: "http://metabase.com" }).should("exist");
|
||||
|
||||
cy.findAllByRole("code").contains("inline code blocks").should("exist");
|
||||
cy.findAllByRole("code")
|
||||
.contains("Or add whole code blocks")
|
||||
.should("exist");
|
||||
});
|
||||
|
||||
it("should support formatting via floating menu", () => {
|
||||
const content = "Some text to play with";
|
||||
|
||||
const formatTests = [
|
||||
{
|
||||
button: /text_bold/,
|
||||
role: "strong",
|
||||
},
|
||||
{
|
||||
button: /text_italic/,
|
||||
role: "emphasis",
|
||||
},
|
||||
{
|
||||
button: /text_strike/,
|
||||
role: "paragraph", // figure out what to do here
|
||||
},
|
||||
{
|
||||
button: /format_code/,
|
||||
role: "code",
|
||||
},
|
||||
{
|
||||
button: /H1/,
|
||||
role: "heading",
|
||||
},
|
||||
{
|
||||
button: /H2/,
|
||||
role: "heading",
|
||||
},
|
||||
{
|
||||
button: /^list/,
|
||||
role: "list",
|
||||
},
|
||||
{
|
||||
button: /ordered_list/,
|
||||
role: "list",
|
||||
},
|
||||
{
|
||||
button: /quote/,
|
||||
role: "blockquote",
|
||||
},
|
||||
{
|
||||
button: /code_block/,
|
||||
role: "code",
|
||||
revert: false,
|
||||
},
|
||||
];
|
||||
|
||||
const assertUnformatted = () =>
|
||||
H.documentContent()
|
||||
// Converting to a heading currently adds a newline, which generates a new paragraph
|
||||
.findAllByRole("paragraph")
|
||||
.eq(0)
|
||||
.should("contain.text", content);
|
||||
|
||||
H.documentContent().click();
|
||||
|
||||
H.addToDocument(content, false);
|
||||
cy.realPress(["Shift", "{home}"]);
|
||||
|
||||
H.documentFormattingMenu().should("exist");
|
||||
|
||||
formatTests.forEach(({ button, role, revert = true }) => {
|
||||
H.documentFormattingMenu()
|
||||
.findByRole("button", { name: button })
|
||||
.click();
|
||||
H.documentContent().findByRole(role).should("contain.text", content);
|
||||
if (revert) {
|
||||
H.documentFormattingMenu()
|
||||
.findByRole("button", { name: button })
|
||||
.click();
|
||||
assertUnformatted();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Card Embeds", () => {
|
||||
beforeEach(() => {
|
||||
H.createQuestion(PRODUCTS_AVERAGE_BY_CATEGORY);
|
||||
H.createQuestion(ACCOUNTS_COUNT_BY_CREATED_AT);
|
||||
// Need to get this one to simulate recent activity
|
||||
H.createQuestion(PRODUCTS_COUNT_BY_CATEGORY_PIE).then(
|
||||
({ body: { id } }) => cy.request(`/api/card/${id}`),
|
||||
);
|
||||
H.createDashboard({
|
||||
name: "Fancy Dashboard",
|
||||
}).then(({ body: { id } }) => {
|
||||
H.createQuestion({
|
||||
...ORDERS_COUNT_BY_PRODUCT_CATEGORY,
|
||||
dashboard_id: id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should support adding cards and updating viz settings", () => {
|
||||
H.documentContent().click();
|
||||
H.addToDocument("/", false);
|
||||
|
||||
cy.log("search via type");
|
||||
H.addToDocument("Accounts", false);
|
||||
H.commandSuggestionDialog().should(
|
||||
"contain.text",
|
||||
ACCOUNTS_COUNT_BY_CREATED_AT.name,
|
||||
);
|
||||
|
||||
cy.realPress("{downarrow}");
|
||||
H.addToDocument("\n", false);
|
||||
|
||||
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).should("exist");
|
||||
|
||||
cy.realPress("{downarrow}");
|
||||
|
||||
cy.log("via recents");
|
||||
H.addToDocument("/", false);
|
||||
H.commandSuggestionItem("Question").click();
|
||||
H.commandSuggestionItem(
|
||||
new RegExp(RegExp.escape(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)),
|
||||
).click();
|
||||
H.getDocumentCard(PRODUCTS_COUNT_BY_CATEGORY_PIE.name).should("exist");
|
||||
|
||||
cy.realPress("{downarrow}");
|
||||
|
||||
cy.log("via entity picker");
|
||||
H.addToDocument("/", false);
|
||||
|
||||
H.commandSuggestionItem("Question").click();
|
||||
H.commandSuggestionItem(/Browse all/).click();
|
||||
|
||||
H.entityPickerModalTab("Questions").click();
|
||||
H.entityPickerModalItem(1, PRODUCTS_AVERAGE_BY_CATEGORY.name).click();
|
||||
|
||||
H.getDocumentCard(PRODUCTS_AVERAGE_BY_CATEGORY.name).should("exist");
|
||||
cy.realPress("{downarrow}");
|
||||
|
||||
cy.log("dashboard question via entity picker");
|
||||
H.addToDocument("/", false);
|
||||
|
||||
H.commandSuggestionItem("Question").click();
|
||||
H.commandSuggestionItem(/Browse all/).click();
|
||||
|
||||
H.entityPickerModalTab("Questions").click();
|
||||
H.entityPickerModalItem(1, "Fancy Dashboard").click();
|
||||
H.entityPickerModalItem(
|
||||
2,
|
||||
ORDERS_COUNT_BY_PRODUCT_CATEGORY.name,
|
||||
).click();
|
||||
|
||||
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
|
||||
"exist",
|
||||
);
|
||||
|
||||
cy.log("change a cards display type");
|
||||
H.openDocumentCardMenu(ACCOUNTS_COUNT_BY_CREATED_AT.name);
|
||||
H.popover().findByText("Edit Visualization").click();
|
||||
|
||||
H.getDocumentSidebar().findByRole("button", { name: /Bar/i }).click();
|
||||
cy.findByRole("menu", { name: /Bar/i }).findByText("Line").click();
|
||||
H.getDocumentSidebar().within(() => {
|
||||
cy.findByText("Axes").click();
|
||||
cy.findByDisplayValue("Created At: Month").clear().type("Foo Axes");
|
||||
});
|
||||
|
||||
H.assertDocumentCardVizType(ACCOUNTS_COUNT_BY_CREATED_AT.name, "Line");
|
||||
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).findByText(
|
||||
"Foo Axes",
|
||||
);
|
||||
H.getDocumentSidebar().findByRole("button", { name: /close/ }).click();
|
||||
|
||||
// Edit the Query. Assert on the number of breakouts
|
||||
H.openDocumentCardMenu(PRODUCTS_COUNT_BY_CATEGORY_PIE.name);
|
||||
H.getDocumentCard(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)
|
||||
.findByRole("list")
|
||||
.findAllByRole("listitem")
|
||||
.should("have.length", 4);
|
||||
H.popover().findByText("Edit Query").click();
|
||||
|
||||
H.removeSummaryGroupingField({ field: "Category" });
|
||||
H.addSummaryGroupingField({ field: "Price" });
|
||||
H.modal().findByRole("button", { name: "Save and use" }).click();
|
||||
|
||||
H.getDocumentCard(PRODUCTS_COUNT_BY_CATEGORY_PIE.name)
|
||||
.findByRole("list")
|
||||
.findAllByRole("listitem")
|
||||
.should("have.length", 7);
|
||||
|
||||
//Replace Card
|
||||
H.openDocumentCardMenu(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name);
|
||||
H.popover().findByText("Replace").click();
|
||||
|
||||
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
|
||||
"not.exist",
|
||||
);
|
||||
H.commandSuggestionDialog().should("exist");
|
||||
H.addToDocument("Orders", false);
|
||||
cy.realPress("{enter}");
|
||||
|
||||
H.getDocumentCard("Orders").should("exist");
|
||||
});
|
||||
|
||||
it("should copy an added card on save", () => {
|
||||
cy.intercept({
|
||||
method: "PUT",
|
||||
path: "/api/ee/document/*",
|
||||
}).as("documentUpdate");
|
||||
cy.intercept({
|
||||
method: "GET",
|
||||
path: "/api/ee/document/*",
|
||||
}).as("documentGet");
|
||||
|
||||
//initial load
|
||||
cy.wait("@documentGet");
|
||||
H.documentContent().click();
|
||||
|
||||
H.addToDocument("/ord", false);
|
||||
|
||||
H.commandSuggestionDialog()
|
||||
.should("contain.text", "Orders, Count, Grouped by Created At (year)")
|
||||
.should("contain.text", "Orders, Count")
|
||||
.should("contain.text", "Orders Model");
|
||||
|
||||
H.commandSuggestionItem(
|
||||
/Orders, Count, Grouped by Created At \(year\)/,
|
||||
).click();
|
||||
|
||||
//Adding a new line
|
||||
H.addToDocument("");
|
||||
H.addToDocument("Adding a static link: /", false);
|
||||
H.commandSuggestionItem(/Link to\.\.\./).click();
|
||||
H.addToDocument("Ord", false);
|
||||
H.commandSuggestionItem(/Orders, Count$/).click();
|
||||
H.addToDocument(" And continue typing", false);
|
||||
|
||||
H.documentContent().within(() => {
|
||||
cy.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 1)
|
||||
.should(
|
||||
"contain.text",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
);
|
||||
|
||||
cy.findByRole("link", { name: /Orders, Count$/ }).should("exist");
|
||||
});
|
||||
|
||||
cy.findByRole("button", { name: "Save" }).click();
|
||||
|
||||
cy.wait("@documentUpdate");
|
||||
cy.wait("@documentGet");
|
||||
|
||||
cy.findByTestId("document-card-embed")
|
||||
.findByText("Orders, Count, Grouped by Created At (year)")
|
||||
.click();
|
||||
|
||||
cy.location("pathname").should(
|
||||
"not.include",
|
||||
ORDERS_BY_YEAR_QUESTION_ID.toString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
[metabase-enterprise.content-verification.api.routes]
|
||||
[metabase-enterprise.database-replication.api :as database-replication.api]
|
||||
[metabase-enterprise.database-routing.api]
|
||||
[metabase-enterprise.documents.api]
|
||||
[metabase-enterprise.email.api]
|
||||
[metabase-enterprise.gsheets.api :as gsheets.api]
|
||||
[metabase-enterprise.llm.api]
|
||||
@@ -49,6 +50,7 @@
|
||||
:etl-connections-pg (deferred-tru "ETL Connections PG replication")
|
||||
:llm-autodescription (deferred-tru "LLM Auto-description")
|
||||
:metabot-v3 (deferred-tru "MetaBot")
|
||||
:documents (deferred-tru "Documents")
|
||||
:scim (deferred-tru "SCIM configuration")
|
||||
:serialization (deferred-tru "Serialization")
|
||||
:table-data-editing (deferred-tru "Table Data Editing")
|
||||
@@ -98,6 +100,7 @@
|
||||
"/metabot-tools" metabase-enterprise.metabot-v3.tools.api/routes
|
||||
"/metabot-v3" (premium-handler metabase-enterprise.metabot-v3.api/routes :metabot-v3)
|
||||
"/permission_debug" (premium-handler metabase-enterprise.permission-debug.api/routes :advanced-permissions)
|
||||
"/document" (premium-handler metabase-enterprise.documents.api/routes :documents)
|
||||
"/scim" (premium-handler metabase-enterprise.scim.routes/routes :scim)
|
||||
"/serialization" (premium-handler metabase-enterprise.serialization.api/routes :serialization)
|
||||
"/stale" (premium-handler metabase-enterprise.stale.api/routes :collection-cleanup)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
(ns metabase-enterprise.bookmarks.models.bookmark
|
||||
"Enterprise DocumentBookmark model. Provides bookmarking functionality for documents."
|
||||
(:require
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/DocumentBookmark [_model] :document_bookmark)
|
||||
|
||||
(doto :model/DocumentBookmark
|
||||
(derive :metabase/model))
|
||||
@@ -11,6 +11,7 @@
|
||||
[metabase-enterprise.audit-app.init]
|
||||
[metabase-enterprise.cache.init]
|
||||
[metabase-enterprise.database-replication.init]
|
||||
[metabase-enterprise.documents.init]
|
||||
[metabase-enterprise.gsheets.init]
|
||||
[metabase-enterprise.llm.init]
|
||||
[metabase-enterprise.metabot-v3.init]
|
||||
|
||||
10
enterprise/backend/src/metabase_enterprise/documents/api.clj
Normal file
10
enterprise/backend/src/metabase_enterprise/documents/api.clj
Normal file
@@ -0,0 +1,10 @@
|
||||
(ns metabase-enterprise.documents.api
|
||||
"`/api/ee/document/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.documents.api.document]
|
||||
[metabase.api.util.handlers :as handlers]))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/document/` routes."
|
||||
(handlers/routes
|
||||
metabase-enterprise.documents.api.document/routes))
|
||||
@@ -0,0 +1,244 @@
|
||||
(ns metabase-enterprise.documents.api.document
|
||||
"`/api/ee/document/` routes"
|
||||
(:require
|
||||
[metabase-enterprise.documents.models.document :as m.document]
|
||||
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.queries.core :as card]
|
||||
[metabase.query-permissions.core :as query-perms]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [tru]]
|
||||
[metabase.util.malli :as mu]
|
||||
[metabase.util.malli.schema :as ms]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private CardCreateSchema
|
||||
"Schema for creating a new card - simplified version to avoid circular dependencies"
|
||||
[:map
|
||||
[:name ms/NonBlankString]
|
||||
[:dataset_query ms/Map]
|
||||
[:entity_id {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:parameters {:optional true} [:maybe [:sequential ms/Map]]]
|
||||
[:parameter_mappings {:optional true} [:maybe [:sequential ms/Map]]]
|
||||
[:description {:optional true} [:maybe ms/NonBlankString]]
|
||||
[:display ms/NonBlankString]
|
||||
[:visualization_settings ms/Map]
|
||||
[:result_metadata {:optional true} [:maybe [:sequential ms/Map]]]
|
||||
[:cache_ttl {:optional true} [:maybe ms/PositiveInt]]])
|
||||
|
||||
(def ^:private DocumentCreateOptions
|
||||
[:map
|
||||
[:name m.document/DocumentName]
|
||||
[:document :any]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:cards {:optional true} [:maybe [:map-of [:int {:max -1}] CardCreateSchema]]]])
|
||||
|
||||
(def ^:private DocumentUpdateOptions
|
||||
[:map
|
||||
[:name {:optional true} m.document/DocumentName]
|
||||
[:document {:optional true} :any]
|
||||
[:collection_id {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:collection_position {:optional true} [:maybe ms/PositiveInt]]
|
||||
[:cards {:optional true} [:maybe [:map-of :int CardCreateSchema]]]
|
||||
[:archived {:optional true} [:maybe :boolean]]])
|
||||
|
||||
(defn- create-card!
|
||||
"Checks that the query is runnable by the current user then saves"
|
||||
[{query :dataset_query :as card} creator]
|
||||
(query-perms/check-run-permissions-for-query query)
|
||||
(card/create-card! (assoc card :type :question :dashboard_id nil) creator))
|
||||
|
||||
(mu/defn- update-cards-in-ast :- [:map [:document :any]
|
||||
[:content_type :string]]
|
||||
|
||||
[document :- [:map
|
||||
[:document :any]
|
||||
[:content_type :string]]
|
||||
card-id-map :- [:maybe [:map-of :int ms/PositiveInt]]]
|
||||
(cond-> document
|
||||
(map? document)
|
||||
(prose-mirror/update-ast (fn match-card-to-update [{:keys [type attrs]}]
|
||||
(and (= type prose-mirror/card-embed-type)
|
||||
(contains? card-id-map (:id attrs))))
|
||||
(fn update-card-id [embed]
|
||||
(update-in embed [:attrs :id] card-id-map)))))
|
||||
|
||||
(mu/defn- create-cards-for-document! :- [:map-of ms/NegativeInt ms/PositiveInt]
|
||||
"Creates cards for a document from the cards map.
|
||||
Returns a mapping from the original negative integer keys to the newly created card IDs.
|
||||
|
||||
Args:
|
||||
- cards-to-create: Map of negative-int -> CardCreateSchema data
|
||||
- document-id: ID of the document these cards belong to
|
||||
- document-collection-id: Collection ID of the document (for inheritance)
|
||||
- creator: User creating the cards
|
||||
|
||||
Returns:
|
||||
- Map of negative-int -> actual-card-id"
|
||||
[cards-to-create :- [:map-of [:int {:max -1}] CardCreateSchema]
|
||||
document-id :- ms/PositiveInt
|
||||
document-collection-id :- [:or :nil ms/PositiveInt]
|
||||
creator :- [:map [:id ms/PositiveInt]]]
|
||||
(when (seq cards-to-create)
|
||||
(reduce-kv
|
||||
(fn [result-map original-key card-data]
|
||||
(let [;; Merge document info into card data
|
||||
;; Cards inherit document's collection_id if not explicitly specified
|
||||
merged-card-data (-> card-data
|
||||
(assoc :document_id document-id)
|
||||
(cond-> (nil? (:collection_id card-data))
|
||||
(assoc :collection_id document-collection-id)))
|
||||
;; Create the card using the queries core function
|
||||
new-card (create-card! merged-card-data creator)]
|
||||
(assoc result-map original-key (:id new-card))))
|
||||
{}
|
||||
cards-to-create)))
|
||||
|
||||
(mu/defn- clone-cards-in-document! :- [:map-of ms/PositiveInt ms/PositiveInt]
|
||||
"Finds all cards in the document that are not associated with the document and clones the cards.
|
||||
|
||||
Args:
|
||||
- document: the document model to clone cards within
|
||||
|
||||
Returns:
|
||||
- map of old-card-id -> cloned-card-id"
|
||||
[{:keys [id collection_id] :as document}]
|
||||
(let [card-ids (prose-mirror/collect-ast document #(when (and (= prose-mirror/card-embed-type (:type %))
|
||||
(pos? (-> % :attrs :id)))
|
||||
(-> % :attrs :id)))
|
||||
to-clone (when (seq card-ids)
|
||||
(t2/select :model/Card {:where [:and [:in :id card-ids]
|
||||
[:or [:<> :document_id id]
|
||||
[:= :document_id nil]]]}))]
|
||||
(reduce (fn [accum card]
|
||||
(api/read-check card)
|
||||
(assoc accum
|
||||
(:id card)
|
||||
(:id (create-card! (assoc card :document_id id :collection_id collection_id)
|
||||
@api/*current-user*))))
|
||||
{}
|
||||
to-clone)))
|
||||
|
||||
(defn get-document
|
||||
"Get document by id checking if the current user has permission to access and if the document exists."
|
||||
[id]
|
||||
(u/prog1 (api/check-404
|
||||
(api/read-check
|
||||
(t2/hydrate (t2/select-one :model/Document :id id) :creator :can_write :can_delete :can_restore)))
|
||||
(events/publish-event! :event/document-read
|
||||
{:object-id id
|
||||
:user-id api/*current-user-id*})))
|
||||
|
||||
(api.macros/defendpoint :get "/"
|
||||
"Gets existing `Documents`."
|
||||
[_route-params
|
||||
_query-params]
|
||||
{:items (t2/hydrate (t2/select :model/Document {:where [:and
|
||||
(collection/visible-collection-filter-clause)
|
||||
[:= :archived false]]})
|
||||
:creator :can_write)})
|
||||
|
||||
(api.macros/defendpoint :post "/"
|
||||
"Create a new `Document`."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [name document collection_id collection_position cards]} :- DocumentCreateOptions]
|
||||
(collection/check-write-perms-for-collection collection_id)
|
||||
(let [document-id (t2/with-transaction [_conn]
|
||||
(when collection_position
|
||||
(api/maybe-reconcile-collection-position! {:collection_id collection_id
|
||||
:collection_position collection_position}))
|
||||
(let [document-id (t2/insert-returning-pk! :model/Document {:name name
|
||||
:collection_id collection_id
|
||||
:collection_position collection_position
|
||||
:document document
|
||||
:content_type prose-mirror/prose-mirror-content-type
|
||||
:creator_id api/*current-user-id*})
|
||||
cards-to-update-in-ast (merge (clone-cards-in-document! {:id document-id
|
||||
:collection_id collection_id
|
||||
:document document
|
||||
:content_type prose-mirror/prose-mirror-content-type})
|
||||
(when-not (empty? cards)
|
||||
(create-cards-for-document! cards document-id collection_id @api/*current-user*)))]
|
||||
(when (seq cards-to-update-in-ast)
|
||||
(t2/update! :model/Document :id document-id
|
||||
(update-cards-in-ast
|
||||
{:document document
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
cards-to-update-in-ast)))
|
||||
document-id))
|
||||
created-document (get-document document-id)]
|
||||
;; Publish event after successful creation
|
||||
(events/publish-event! :event/document-create
|
||||
{:object created-document
|
||||
:user-id api/*current-user-id*})
|
||||
created-document))
|
||||
|
||||
(api.macros/defendpoint :get "/:document-id"
|
||||
"Returns an existing Document by ID."
|
||||
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
|
||||
(api/read-check (get-document document-id)))
|
||||
|
||||
(api.macros/defendpoint :put "/:document-id"
|
||||
"Updates an existing `Document`."
|
||||
[{:keys [document-id]} :- [:map
|
||||
[:document-id ms/PositiveInt]]
|
||||
_query-params
|
||||
{:keys [name document collection_id collection_position cards] :as body} :- DocumentUpdateOptions]
|
||||
(let [existing-document (api/check-404 (get-document document-id))]
|
||||
(api/check-403 (mi/can-write? existing-document))
|
||||
(when (api/column-will-change? :collection_id existing-document body)
|
||||
(m.document/validate-collection-move-permissions (:collection_id existing-document) collection_id))
|
||||
|
||||
;; Handle archiving logic
|
||||
(let [document-updates (dissoc (api/updates-with-archived-directly existing-document body) :cards)]
|
||||
(t2/with-transaction [_conn]
|
||||
(when collection_position
|
||||
(api/maybe-reconcile-collection-position! existing-document {:collection_id (if (contains? body :collection_id)
|
||||
collection_id
|
||||
(:collection_id existing-document))
|
||||
:collection_position collection_position}))
|
||||
(t2/update! :model/Document document-id
|
||||
(cond-> document-updates
|
||||
document (merge (update-cards-in-ast
|
||||
{:document document
|
||||
:content_type (:content_type existing-document)}
|
||||
(merge
|
||||
(clone-cards-in-document! (assoc existing-document :document document))
|
||||
(when-not (empty? cards) (create-cards-for-document! cards document-id collection_id @api/*current-user*)))))
|
||||
name (assoc :name name)
|
||||
(contains? body :collection_id) (assoc :collection_id collection_id))))
|
||||
(let [updated-document (get-document document-id)]
|
||||
;; Publish appropriate events
|
||||
(if (:archived document-updates)
|
||||
(events/publish-event! :event/document-delete
|
||||
{:object updated-document
|
||||
:user-id api/*current-user-id*})
|
||||
(events/publish-event! :event/document-update
|
||||
{:object updated-document
|
||||
:user-id api/*current-user-id*}))
|
||||
updated-document))))
|
||||
|
||||
(api.macros/defendpoint :delete "/:document-id"
|
||||
"Permanently deletes an archived Document."
|
||||
[{:keys [document-id]} :- [:map [:document-id ms/PositiveInt]]]
|
||||
(let [document (api/check-404 (t2/select-one :model/Document :id document-id))]
|
||||
(api/check-403 (mi/can-write? document))
|
||||
(when-not (:archived document)
|
||||
(let [msg (tru "Document must be archived before it can be deleted.")]
|
||||
(throw (ex-info msg {:status-code 400, :errors {:archived msg}}))))
|
||||
(t2/delete! :model/Document :id document-id)
|
||||
(events/publish-event! :event/document-delete
|
||||
{:object document
|
||||
:user-id api/*current-user-id*})
|
||||
api/generic-204-no-content))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/document/` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -0,0 +1,15 @@
|
||||
(ns metabase-enterprise.documents.core
|
||||
(:require
|
||||
[metabase-enterprise.documents.api.document]
|
||||
[metabase-enterprise.documents.recent-views]
|
||||
[potemkin :as p]))
|
||||
|
||||
(comment
|
||||
metabase-enterprise.documents.api.document/keep-me
|
||||
metabase-enterprise.documents.recent-views/keep-me)
|
||||
|
||||
(p/import-vars
|
||||
[metabase-enterprise.documents.api.document
|
||||
get-document]
|
||||
[metabase-enterprise.documents.recent-views
|
||||
select-documents-for-recents])
|
||||
@@ -0,0 +1,5 @@
|
||||
(ns metabase-enterprise.documents.init
|
||||
(:require
|
||||
[metabase-enterprise.documents.recent-views]
|
||||
[metabase-enterprise.documents.revisions.impl]
|
||||
[metabase-enterprise.documents.view-log]))
|
||||
@@ -0,0 +1,220 @@
|
||||
(ns metabase-enterprise.documents.models.document
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[metabase-enterprise.documents.prose-mirror :as prose-mirror]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.search.spec :as search.spec]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.i18n :refer [deferred-tru]]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.util.malli :as mu]
|
||||
[methodical.core :as methodical]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(methodical/defmethod t2/table-name :model/Document [_model] :document)
|
||||
|
||||
(t2/deftransforms :model/Document
|
||||
{:document mi/transform-json})
|
||||
|
||||
(doto :model/Document
|
||||
(derive :metabase/model)
|
||||
(derive :perms/use-parent-collection-perms)
|
||||
(derive :hook/timestamped?)
|
||||
(derive :hook/entity-id))
|
||||
|
||||
(def DocumentName
|
||||
"Validations for the name of a document"
|
||||
(mu/with-api-error-message
|
||||
[:and
|
||||
{:error/message "invalid document name"
|
||||
:json-schema {:type "string" :minLength 1 :maxLength 254}}
|
||||
[:string {:min 1 :max 254}]
|
||||
[:fn
|
||||
{:error/message "invalid document name"}
|
||||
(complement str/blank?)]]
|
||||
(deferred-tru "value must be a non-blank string between 1 and 254 characters.")))
|
||||
|
||||
(defn validate-collection-move-permissions
|
||||
"Validates that the current user has write permissions for both old and new collections
|
||||
when moving a document. Uses the same permission pattern as check-allowed-to-change-collection.
|
||||
Throws 403 exception if permissions are insufficient."
|
||||
[old-collection-id new-collection-id]
|
||||
(when old-collection-id
|
||||
(collection/check-write-perms-for-collection old-collection-id))
|
||||
(when new-collection-id
|
||||
(collection/check-write-perms-for-collection new-collection-id))
|
||||
(when new-collection-id
|
||||
(api/check-400 (t2/exists? :model/Collection :id new-collection-id :archived false))))
|
||||
|
||||
(methodical/defmethod t2/batched-hydrate [:model/Document :creator]
|
||||
"Hydrate the creator (user) of a document based on the creator_id."
|
||||
[_model k documents]
|
||||
(mi/instances-with-hydrated-data
|
||||
documents k
|
||||
#(-> (t2/select [:model/User :id :email :first_name :last_name] :id (keep :creator_id documents))
|
||||
(map (juxt :id identity))
|
||||
(into {}))
|
||||
:creator_id {:default {}}))
|
||||
|
||||
(defn sync-document-cards-collection!
|
||||
"Updates all cards associated with a document to match the document's collection.
|
||||
If the document is archived, also archives all associated cards. "
|
||||
[document-id collection-id & {:keys [archived archived-directly]}]
|
||||
(let [update-map {:collection_id collection-id
|
||||
:archived (boolean archived)
|
||||
:archived_directly (boolean archived-directly)}]
|
||||
(t2/update! :model/Card
|
||||
:document_id document-id
|
||||
update-map)))
|
||||
|
||||
(t2/define-after-update :model/Document
|
||||
[{:keys [id collection_id archived archived_directly]}]
|
||||
(sync-document-cards-collection! id collection_id
|
||||
:archived archived
|
||||
:archived-directly archived_directly))
|
||||
|
||||
;;; ------------------------------------------------ Serdes Hashing -------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/Document
|
||||
[_table]
|
||||
[:name (serdes/hydrated-hash :collection) :created-at])
|
||||
|
||||
(defmethod serdes/make-spec "Document"
|
||||
[_model-name _opts]
|
||||
{:copy [:archived :archived_directly :collection_position :description :entity_id :name :view_count]
|
||||
:skip [;; instance-specific stats
|
||||
:last_viewed_at
|
||||
;; skip until we implement serdes for documents
|
||||
:document_id]
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:collection_id (serdes/fk :model/Collection)
|
||||
:creator_id (serdes/fk :model/User)
|
||||
:document :skip}})
|
||||
|
||||
;;; ----------------------------------------------- Search ----------------------------------------------------------
|
||||
|
||||
(search.spec/define-spec "document"
|
||||
{:model :model/Document
|
||||
:attrs {:archived true
|
||||
:collection-id :collection_id
|
||||
:creator-id :creator_id
|
||||
:view-count :view_count
|
||||
:created-at :created_at
|
||||
:updated-at :updated_at
|
||||
:last-viewed-at :last_viewed_at
|
||||
:pinned [:> [:coalesce :collection_position [:inline 0]] [:inline 0]]}
|
||||
:search-terms [:name]
|
||||
:render-terms {:document-name :name
|
||||
:document-id :id
|
||||
:collection-position true}})
|
||||
|
||||
;;; ----------------------------------------------- Serdes Hashing -------------------------------------------------
|
||||
|
||||
(defmethod serdes/hash-fields :model/Document
|
||||
[_table]
|
||||
[:name (serdes/hydrated-hash :collection) :created-at])
|
||||
|
||||
;;; ---------------------------------------------- Serialization --------------------------------------------------
|
||||
|
||||
(def ^:private ast-model->db-model
|
||||
{"card" :model/Card
|
||||
"dataset" :model/Card
|
||||
"table" :model/Table
|
||||
"dashboard" :model/Dashboard})
|
||||
|
||||
(def ^:private model->serdes-model
|
||||
{"card" "Card"
|
||||
"dataset" "Card"
|
||||
"dashboard" "Dashboard"
|
||||
"table" "Table"})
|
||||
|
||||
(defn- id->entity-id
|
||||
[{{:keys [model] :or {model "card"} :as attrs} :attrs type :type :as node}]
|
||||
(let [id-key (if (= prose-mirror/smart-link-type type) :entityId :id)
|
||||
id (id-key attrs)]
|
||||
(if-let [db-model (t2/select-one (ast-model->db-model model) :id id)]
|
||||
(assoc-in node [:attrs id-key] (mapv #(dissoc % :label) (serdes/generate-path (model->serdes-model model) db-model)))
|
||||
(u/prog1 node
|
||||
(log/warnf "entity_id not found for %s at id: %s" model id)))))
|
||||
|
||||
(defn- entity-id->id
|
||||
[{:keys [attrs type] :as node}]
|
||||
(let [id-key (if (= prose-mirror/smart-link-type type) :entityId :id)
|
||||
id (:id (serdes/load-find-local (id-key attrs)))]
|
||||
(if id
|
||||
(assoc-in node [:attrs id-key] id)
|
||||
(u/prog1 node
|
||||
(log/warn "Model not found at path" (id-key attrs))))))
|
||||
|
||||
(defn- export-document-content
|
||||
"Transform cardEmbed/smartLink nodes to use entity IDs instead of database IDs"
|
||||
[document serdes-key _]
|
||||
(serdes-key
|
||||
(if (= (:content_type document) prose-mirror/prose-mirror-content-type)
|
||||
(prose-mirror/update-ast
|
||||
document
|
||||
#(contains? #{prose-mirror/smart-link-type prose-mirror/card-embed-type} (:type %))
|
||||
id->entity-id)
|
||||
document)))
|
||||
|
||||
(defn- import-document-content
|
||||
"Transform cardEmbed/smartLink nodes to use database IDs instead of entity IDs"
|
||||
[document serdes-key _]
|
||||
(serdes-key
|
||||
(if (= (:content_type document) prose-mirror/prose-mirror-content-type)
|
||||
(prose-mirror/update-ast
|
||||
document
|
||||
#(contains? #{prose-mirror/smart-link-type prose-mirror/card-embed-type} (:type %))
|
||||
entity-id->id)
|
||||
document)))
|
||||
|
||||
(defmethod serdes/make-spec "Document"
|
||||
[_model-name _opts]
|
||||
{:copy [:archived :archived_directly :content_type :entity_id :name :collection_position]
|
||||
:skip [:view_count :last_viewed_at]
|
||||
:transform {:created_at (serdes/date)
|
||||
:updated_at (serdes/date)
|
||||
:document {:export-with-context export-document-content
|
||||
:import-with-context import-document-content}
|
||||
:collection_id (serdes/fk :model/Collection)
|
||||
:creator_id (serdes/fk :model/User)}})
|
||||
|
||||
(defn- document-deps
|
||||
[{:keys [content_type] :as document}]
|
||||
(when (= content_type prose-mirror/prose-mirror-content-type)
|
||||
(set (prose-mirror/collect-ast document (fn document-deps [{:keys [type attrs]}]
|
||||
(cond
|
||||
(and (= prose-mirror/smart-link-type type)
|
||||
(contains? model->serdes-model (:model attrs)))
|
||||
(:entityId attrs)
|
||||
|
||||
(= prose-mirror/card-embed-type type)
|
||||
(:id attrs)
|
||||
|
||||
:else
|
||||
nil))))))
|
||||
|
||||
(defmethod serdes/dependencies "Document"
|
||||
[{:keys [collection_id] :as document}]
|
||||
(set (concat
|
||||
(document-deps document)
|
||||
(when collection_id #{[{:model "Collection" :id collection_id}]}))))
|
||||
|
||||
(defmethod serdes/descendants "Document"
|
||||
[_model-name id]
|
||||
(when-let [document (t2/select-one :model/Document :id id)]
|
||||
(when (= prose-mirror/prose-mirror-content-type (:content_type document))
|
||||
(merge
|
||||
(into {}
|
||||
(for [embedded-card-id (prose-mirror/card-ids document)]
|
||||
{["Card" embedded-card-id] {"Document" id}}))
|
||||
(into {}
|
||||
(for [{model :model link-id :id} (prose-mirror/collect-ast document
|
||||
#(when (= prose-mirror/smart-link-type (:type %))
|
||||
(:attrs %)))
|
||||
:when (contains? model->serdes-model model)]
|
||||
{[(model->serdes-model model) link-id] {"Document" id}}))))))
|
||||
@@ -0,0 +1,64 @@
|
||||
(ns metabase-enterprise.documents.prose-mirror
|
||||
"Manipulate the prose mirror ast for documents"
|
||||
(:require
|
||||
[clojure.walk :as walk]))
|
||||
|
||||
(def card-embed-type
|
||||
"Type of a card-embed node"
|
||||
"cardEmbed")
|
||||
|
||||
(def smart-link-type
|
||||
"Type of a smart-link node"
|
||||
"smartLink")
|
||||
|
||||
(def prose-mirror-content-type
|
||||
"The vendored 'mime-type' for documents saved using the prose-mirror ast."
|
||||
"application/json+vnd.prose-mirror")
|
||||
|
||||
(defn- assert-prose-mirror
|
||||
"Asserts the content-type is correct for the document or throw"
|
||||
[{:keys [content_type]}]
|
||||
(when-not (= content_type prose-mirror-content-type)
|
||||
(throw (ex-info "Document does not have the prose mirror content-type"
|
||||
{:content-type content_type
|
||||
:status-code 400}))))
|
||||
|
||||
(defn update-ast
|
||||
"Update a node that matches a predicate using a post-walk.
|
||||
|
||||
Args:
|
||||
- document - a :model/Document, this will check that content type is valid for prose mirror
|
||||
- predicate - a one-arg function returning true a given node should be updated
|
||||
- updater - a one-arg function taking the node and returning the new node
|
||||
|
||||
Returns:
|
||||
- the updated prose-mirror ast"
|
||||
[{:keys [document] :as doc} predicate updater]
|
||||
(assert-prose-mirror doc)
|
||||
(assoc doc :document
|
||||
(walk/postwalk (fn ast-walker
|
||||
[node]
|
||||
(cond-> node
|
||||
(predicate node) updater))
|
||||
document)))
|
||||
|
||||
(defn collect-ast
|
||||
"Collect values from the ast lazily removes nils
|
||||
|
||||
Args:
|
||||
- document - a :model/Document, this will check that the content-type is valid for prose mirror
|
||||
- collector - a function that extracts values from a given node
|
||||
|
||||
Returns:
|
||||
- a lazy seq of results from collector"
|
||||
[{:keys [document] :as doc} collector]
|
||||
(assert-prose-mirror doc)
|
||||
(->> (tree-seq :content :content document)
|
||||
(keep collector)))
|
||||
|
||||
(defn card-ids
|
||||
"Get all card-ids"
|
||||
[document]
|
||||
(collect-ast document #(when (and (= card-embed-type (:type %))
|
||||
(pos? (-> % :attrs :id)))
|
||||
(-> % :attrs :id))))
|
||||
@@ -0,0 +1,70 @@
|
||||
(ns metabase-enterprise.documents.recent-views
|
||||
"Enterprise multimethod implementations for documents in recent views."
|
||||
(:require
|
||||
[metabase.activity-feed.core :as activity-feed]
|
||||
[metabase.collections.models.collection.root :as root]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.premium-features.core :refer [defenterprise]]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defenterprise select-documents-for-recents
|
||||
"Query to select recent document data. Returns documents with their collection information
|
||||
for use in recent views display."
|
||||
:feature :documents
|
||||
[document-ids]
|
||||
(if-not (seq document-ids)
|
||||
[]
|
||||
(let [documents (t2/select :model/Document
|
||||
{:select [:d.id
|
||||
:d.name
|
||||
:d.archived
|
||||
[:d.collection_id :entity-coll-id]
|
||||
[:c.id :collection_id]
|
||||
[:c.name :collection_name]
|
||||
[:c.authority_level :collection_authority_level]]
|
||||
:from [[:document :d]]
|
||||
:where [:in :d.id document-ids]
|
||||
:left-join [[:collection :c]
|
||||
[:and
|
||||
[:= :c.id :d.collection_id]
|
||||
[:= :c.archived false]]]})]
|
||||
documents)))
|
||||
|
||||
(defn- fill-parent-coll
|
||||
"Fill in parent collection information for a document, following the same pattern
|
||||
as other recent view implementations."
|
||||
[model-object]
|
||||
(if (:collection_id model-object)
|
||||
{:id (:collection_id model-object)
|
||||
:name (:collection_name model-object)
|
||||
:authority_level (some-> (:collection_authority_level model-object) name)}
|
||||
(select-keys
|
||||
(root/root-collection-with-ui-details {})
|
||||
[:id :name :authority_level])))
|
||||
|
||||
(defn- parent-collection-valid?
|
||||
"Returns true when a parent collection actually exists for this document.
|
||||
Follows the same pattern as cards - returns false when the collection has a
|
||||
collection_id but no collection_name (indicating the collection no longer exists)."
|
||||
[{:keys [collection_id entity-coll-id]}]
|
||||
(not (and entity-coll-id (nil? collection_id))))
|
||||
|
||||
(defn- ellide-archived
|
||||
"Returns the model when it's not archived.
|
||||
We use this to ensure that archived models are not returned in the recent views."
|
||||
[model]
|
||||
(when (false? (:archived model)) model))
|
||||
|
||||
(defmethod activity-feed/fill-recent-view-info :document
|
||||
[{:keys [_model model_id timestamp model_object]}]
|
||||
(when-let [document (and
|
||||
(mi/can-read? model_object)
|
||||
(parent-collection-valid? model_object)
|
||||
(ellide-archived model_object))]
|
||||
{:id model_id
|
||||
:name (:name document)
|
||||
:description ""
|
||||
:model :document
|
||||
:can_write (mi/can-write? document)
|
||||
:timestamp (str timestamp)
|
||||
:parent_collection (fill-parent-coll document)}))
|
||||
@@ -0,0 +1,27 @@
|
||||
(ns metabase-enterprise.documents.revisions.impl
|
||||
"Document revision serialization implementation.
|
||||
|
||||
Implements Document revision serialization following the Card exclusion pattern,
|
||||
excluding metadata columns while preserving content, name, and other Document-specific fields."
|
||||
(:require
|
||||
[metabase.revisions.core :as revisions]))
|
||||
|
||||
(def ^:private excluded-columns-for-document-revision
|
||||
"Columns to exclude from Document revision serialization.
|
||||
|
||||
Excludes metadata columns (timestamps, IDs, collection_id, creator_id, etc.)
|
||||
following the same pattern established for Card revisions.
|
||||
|
||||
The :document field contains the actual content and is preserved."
|
||||
#{:id
|
||||
:creator_id
|
||||
:created_at
|
||||
:updated_at
|
||||
:view_count
|
||||
:last_viewed_at
|
||||
:collection_id
|
||||
:collection_position})
|
||||
|
||||
(defmethod revisions/serialize-instance :model/Document
|
||||
[_model _id instance]
|
||||
(apply dissoc instance excluded-columns-for-document-revision))
|
||||
@@ -0,0 +1,65 @@
|
||||
(ns metabase-enterprise.documents.view-log
|
||||
"Integrates view logging events for documents"
|
||||
(:require
|
||||
[java-time.api :as t]
|
||||
[metabase.activity-feed.core :as activity-feed]
|
||||
[metabase.app-db.cluster-lock :as cluster-lock]
|
||||
[metabase.batch-processing.core :as grouper]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.util.log :as log]
|
||||
[metabase.view-log.core :as view-log]
|
||||
[methodical.core :as m]
|
||||
[steffan-westcott.clj-otel.api.trace.span :as span]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private document-statistics-lock
|
||||
"keyword to use for locking document updates that can deadlock"
|
||||
::document-statistics-lock)
|
||||
|
||||
(derive ::document-read :metabase/event)
|
||||
(derive :event/document-read ::document-read)
|
||||
|
||||
(def ^:private update-document-last-viewed-at-interval-seconds 20)
|
||||
|
||||
(defn- update-document-last-viewed-at!* [document-id-timestamps]
|
||||
(let [document-id->timestamp (update-vals (group-by :id document-id-timestamps)
|
||||
(fn [xs] (apply t/max (map :timestamp xs))))]
|
||||
(try
|
||||
(cluster-lock/with-cluster-lock document-statistics-lock
|
||||
(t2/update! :model/Document :id [:in (keys document-id->timestamp)]
|
||||
{:last_viewed_at (into [:case]
|
||||
(mapcat (fn [[id timestamp]]
|
||||
[[:= :id id] [:greatest [:coalesce :last_viewed_at (t/offset-date-time 0)] timestamp]])
|
||||
document-id->timestamp))
|
||||
:updated_at :updated_at})) ;; setting last_viewed_at should not update the updated_at column
|
||||
(catch Exception e
|
||||
(log/error e "Failed to update document last_viewed_at")))))
|
||||
|
||||
(def ^:private update-document-last-viewed-at-queue
|
||||
(delay (grouper/start!
|
||||
#'update-document-last-viewed-at!*
|
||||
:capacity 500
|
||||
:interval (* update-document-last-viewed-at-interval-seconds 1000))))
|
||||
|
||||
(defn- update-document-last-viewed-at!
|
||||
"Update the `last_viewed_at` of a document asynchronously"
|
||||
[document-id]
|
||||
(let [now (t/offset-date-time)]
|
||||
(grouper/submit! @update-document-last-viewed-at-queue {:id document-id
|
||||
:timestamp now})))
|
||||
|
||||
(m/defmethod events/publish-event! ::document-read
|
||||
"Handle processing for the document read event. Logs the document view and increments view count."
|
||||
[topic {:keys [object-id user-id] :as event}]
|
||||
(span/with-span!
|
||||
{:name "view-log-document-read"
|
||||
:topic topic
|
||||
:user-id user-id}
|
||||
(try
|
||||
(view-log/increment-view-counts! :model/Document object-id)
|
||||
(update-document-last-viewed-at! object-id)
|
||||
(view-log/record-views! (view-log/generate-view :model :model/Document event))
|
||||
;; Update recent views alongside existing view log functionality
|
||||
(activity-feed/update-users-recent-views! user-id :model/Document object-id :view)
|
||||
(catch Throwable e
|
||||
(log/warnf e "Failed to process document view event. %s" topic)))))
|
||||
@@ -3,6 +3,7 @@
|
||||
(:require
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.api.document]
|
||||
[metabase-enterprise.metabot-v3.api.metabot]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
@@ -93,4 +94,5 @@
|
||||
(handlers/routes
|
||||
(api.macros/ns-handler *ns* +auth)
|
||||
(handlers/route-map-handler
|
||||
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes})))
|
||||
{"/metabot" metabase-enterprise.metabot-v3.api.metabot/routes
|
||||
"/document" metabase-enterprise.metabot-v3.api.document/routes})))
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
(ns metabase-enterprise.metabot-v3.api.document
|
||||
"`/api/ee/metabot-v3/document` routes"
|
||||
(:require
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.api.macros :as api.macros]
|
||||
[metabase.api.routes.common :refer [+auth]]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(api.macros/defendpoint :post "/generate-content"
|
||||
"Create a new piece of content to insert into the document."
|
||||
[_route-params
|
||||
_query-params
|
||||
|
||||
body :- [:map
|
||||
[:instructions ms/NonBlankString]
|
||||
[:references {:optional true} ms/Map]]]
|
||||
(metabot-v3.client/document-generate-content {:instructions (:instructions body)
|
||||
:references (:references body)
|
||||
:user_id api/*current-user-id*
|
||||
:conversation_id (str (random-uuid))}))
|
||||
|
||||
(def ^{:arglists '([request respond raise])} routes
|
||||
"`/api/ee/metabot-v3/document` routes."
|
||||
(api.macros/ns-handler *ns* +auth))
|
||||
@@ -1,12 +1,16 @@
|
||||
(ns metabase-enterprise.metabot-v3.client
|
||||
(:require
|
||||
[buddy.core.hash :as buddy-hash]
|
||||
[buddy.sign.jwt :as jwt]
|
||||
[clj-http.client :as http]
|
||||
[clj-time.core :as time]
|
||||
[clojure.java.io :as io]
|
||||
[clojure.set :as set]
|
||||
[clojure.string :as str]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[metabase-enterprise.metabot-v3.client.schema :as metabot-v3.client.schema]
|
||||
[metabase-enterprise.metabot-v3.config :as metabot-v3.config]
|
||||
[metabase-enterprise.metabot-v3.context :as metabot-v3.context]
|
||||
[metabase-enterprise.metabot-v3.settings :as metabot-v3.settings]
|
||||
[metabase-enterprise.metabot-v3.util :as metabot-v3.u]
|
||||
@@ -26,6 +30,18 @@
|
||||
|
||||
(def ^:dynamic ^:private *debug* false)
|
||||
|
||||
(defn get-ai-service-token
|
||||
"Get the token for the AI service."
|
||||
([]
|
||||
(get-ai-service-token api/*current-user-id* metabot-v3.config/internal-metabot-id))
|
||||
|
||||
([user-id metabot-id]
|
||||
(let [secret (buddy-hash/sha256 (metabot-v3.settings/site-uuid-for-metabot-tools))
|
||||
claims {:user user-id
|
||||
:exp (time/plus (time/now) (time/seconds (metabot-v3.settings/metabot-ai-service-token-ttl)))
|
||||
:metabot-id metabot-id}]
|
||||
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256}))))
|
||||
|
||||
(defn- ->json-bytes ^bytes [x]
|
||||
(with-open [os (java.io.ByteArrayOutputStream.)
|
||||
w (java.io.OutputStreamWriter. os)]
|
||||
@@ -91,6 +107,9 @@
|
||||
(defn- example-question-generation-endpoint []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/example-question-generation/batch"))
|
||||
|
||||
(defn- document-generate-content-endpoint []
|
||||
(str (metabot-v3.settings/ai-service-base-url) "/v1/document/generate-content"))
|
||||
|
||||
(mu/defn request :- ::metabot-v3.client.schema/ai-service.response
|
||||
"Make a V2 request to the AI Service."
|
||||
[{:keys [context messages profile-id conversation-id session-id state]}
|
||||
@@ -280,6 +299,23 @@
|
||||
{:request (assoc options :body body)
|
||||
:response response})))))
|
||||
|
||||
(mu/defn document-generate-content
|
||||
"Ask the AI service to generate a new node for a document."
|
||||
[body :- [:map
|
||||
[:instructions :string]]]
|
||||
(let [url (document-generate-content-endpoint)
|
||||
options (build-request-options body)
|
||||
headers (merge (:headers options) {"x-metabase-session-token" (get-ai-service-token)
|
||||
"x-metabase-url" (system/site-url)})
|
||||
options (assoc options :headers headers)
|
||||
response (post! url options)]
|
||||
(if (= (:status response) 200)
|
||||
(:body response)
|
||||
(throw (ex-info (format "Error in request to AI service: unexpected status code: %d %s"
|
||||
(:status response) (:reason-phrase response))
|
||||
{:request (assoc options :body body)
|
||||
:response response})))))
|
||||
|
||||
(def chart-analysis-schema
|
||||
"Schema for chart analysis data input."
|
||||
[:map
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
(ns metabase-enterprise.metabot-v3.dummy-tools
|
||||
(:require
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.documents.core :as documents]
|
||||
[metabase-enterprise.metabot-v3.tools.util :as metabot-v3.tools.u]
|
||||
[metabase.api.common :as api]
|
||||
[metabase.legacy-mbql.normalize :as mbql.normalize]
|
||||
@@ -9,11 +10,14 @@
|
||||
[metabase.lib.metadata :as lib.metadata]
|
||||
[metabase.lib.types.isa :as lib.types.isa]
|
||||
[metabase.lib.util :as lib.util]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.util :as u]
|
||||
[metabase.util.humanization :as u.humanization]
|
||||
[metabase.warehouse-schema.models.field-values :as field-values]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn get-current-user
|
||||
"Get information about the current user."
|
||||
[]
|
||||
@@ -289,6 +293,55 @@
|
||||
{:structured-output details}
|
||||
{:output (or details "report not found")}))))
|
||||
|
||||
(defn get-document-details
|
||||
"Get information about the document with ID `document-id`."
|
||||
[{:keys [document-id]}]
|
||||
(if (int? document-id)
|
||||
(try
|
||||
(if-let [doc (documents/get-document document-id)]
|
||||
{:structured-output {:id (:id doc)
|
||||
:name (:name doc)
|
||||
:document (:document doc)}}
|
||||
{:output "report not found"})
|
||||
(catch Exception e
|
||||
{:output (str "error fetching report: " (.getMessage e))}))
|
||||
{:output "invalid document_id"}))
|
||||
|
||||
(defn- base-type->type [field]
|
||||
(case field
|
||||
:type/Integer "number"
|
||||
:type/Float "number"
|
||||
:type/Text "string"
|
||||
:type/Date "date"
|
||||
:type/DateTime "datetime"
|
||||
:type/Time "time"
|
||||
:type/Boolean "boolean"
|
||||
:type/Null "null"
|
||||
"string"))
|
||||
|
||||
(defn get-tables
|
||||
"Get information about the tables in a database."
|
||||
[{:keys [database-id] :or {database-id 1}}]
|
||||
(if (int? database-id)
|
||||
(let [db (t2/select-one [:model/Database :id :name :description :engine] database-id)
|
||||
tables (t2/select [:model/Table :id :name :description]
|
||||
{:where [:and
|
||||
[:= :db_id database-id]
|
||||
[:= :active true]
|
||||
(mi/visible-filter-clause :model/Table
|
||||
:id
|
||||
{:user-id api/*current-user-id*
|
||||
:is-superuser? api/*is-superuser?*}
|
||||
{:perms/view-data :unrestricted
|
||||
:perms/create-queries :query-builder-and-native})]})
|
||||
columns (group-by :table_id (map #(-> %
|
||||
(assoc :type (base-type->type (:base_type %)))
|
||||
(dissoc :base_type))
|
||||
(t2/select [:model/Field :id :table_id :name :description :base_type])))]
|
||||
{:structured-output {:database db
|
||||
:tables (map #(assoc % :columns (get columns (:id %) [])) tables)}})
|
||||
{:output "invalid database_id"}))
|
||||
|
||||
(defn- execute-query
|
||||
[query-id legacy-query]
|
||||
(let [legacy-query (mbql.normalize/normalize legacy-query)
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
(:require
|
||||
[buddy.core.hash :as buddy-hash]
|
||||
[buddy.sign.jwt :as jwt]
|
||||
[clj-time.core :as time]
|
||||
[clojure.set :as set]
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
@@ -35,14 +34,6 @@
|
||||
[metabase.util.malli.registry :as mr]
|
||||
[metabase.util.malli.schema :as ms]))
|
||||
|
||||
(defn- get-ai-service-token
|
||||
[user-id metabot-id]
|
||||
(let [secret (buddy-hash/sha256 (metabot-v3.settings/site-uuid-for-metabot-tools))
|
||||
claims {:user user-id
|
||||
:exp (time/plus (time/now) (time/seconds (metabot-v3.settings/metabot-ai-service-token-ttl)))
|
||||
:metabot-id metabot-id}]
|
||||
(jwt/encrypt claims secret {:alg :dir, :enc :a128cbc-hs256})))
|
||||
|
||||
(defn- decode-ai-service-token
|
||||
[token]
|
||||
(try
|
||||
@@ -55,7 +46,7 @@
|
||||
(defn handle-envelope
|
||||
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
|
||||
[{:keys [metabot-id] :as e}]
|
||||
(let [session-id (get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(try
|
||||
(metabot-v3.client/request (assoc e :session-id session-id))
|
||||
(catch Exception ex
|
||||
@@ -69,7 +60,7 @@
|
||||
(defn streaming-handle-envelope
|
||||
"Executes the AI loop in the context of a new session. Returns the response of the AI service."
|
||||
[{:keys [metabot-id] :as e}]
|
||||
(let [session-id (get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(let [session-id (metabot-v3.client/get-ai-service-token api/*current-user-id* metabot-id)]
|
||||
(metabot-v3.client/streaming-request (assoc e :session-id session-id))))
|
||||
|
||||
(mr/def ::bucket
|
||||
@@ -540,6 +531,53 @@
|
||||
[:result_columns ::columns]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-document-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:document_id :int]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:document_id :document-id})}]])
|
||||
|
||||
(mr/def ::get-document-details-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:document :string]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-tables-arguments
|
||||
[:and
|
||||
[:map
|
||||
[:database_id :int]]
|
||||
[:map {:encode/tool-api-request
|
||||
#(set/rename-keys % {:database_id :database-id})}]])
|
||||
|
||||
(mr/def ::get-tables-result
|
||||
[:or
|
||||
[:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:structured_output [:map
|
||||
{:decode/tool-api-response #(update-keys % metabot-v3.u/safe->snake_case_en)}
|
||||
[:database [:map
|
||||
[:id :int]
|
||||
[:engine :string]
|
||||
[:name :string]
|
||||
[:description :string]]]
|
||||
[:tables [:sequential [:map
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:columns [:sequential [:map
|
||||
[:id :int]
|
||||
[:name :string]
|
||||
[:description :string]
|
||||
[:type :string]]]]]]]]]]
|
||||
[:map [:output :string]]])
|
||||
|
||||
(mr/def ::get-table-details-arguments
|
||||
[:and
|
||||
[:map
|
||||
@@ -777,6 +815,21 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-document-details" :- [:merge ::get-document-details-result ::tool-request]
|
||||
"Get information about a given report."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::get-document-details-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-document-details) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::get-document-details-arguments arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::get-document-details-result
|
||||
(metabot-v3.dummy-tools/get-document-details arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-table-details" :- [:merge ::get-table-details-result ::tool-request]
|
||||
"Get information about a given table or model."
|
||||
[_route-params
|
||||
@@ -792,6 +845,21 @@
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/get-tables" :- [:merge ::get-tables-result ::tool-request]
|
||||
"Get information about the tables in a given database."
|
||||
[_route-params
|
||||
_query-params
|
||||
{:keys [arguments conversation_id] :as body} :- [:merge
|
||||
[:map [:arguments ::get-tables-arguments]]
|
||||
::tool-request]]
|
||||
(metabot-v3.context/log (assoc body :api :get-tables) :llm.log/llm->be)
|
||||
(let [arguments (mc/encode ::get-tables-arguments arguments (mtx/transformer {:name :tool-api-request}))]
|
||||
(doto (-> (mc/decode ::get-tables-result
|
||||
(metabot-v3.dummy-tools/get-tables arguments)
|
||||
(mtx/transformer {:name :tool-api-response}))
|
||||
(assoc :conversation_id conversation_id))
|
||||
(metabot-v3.context/log :llm.log/be->llm))))
|
||||
|
||||
(api.macros/defendpoint :post "/query-metric" :- [:merge ::filtering-result ::tool-request]
|
||||
"Construct a query from a metric."
|
||||
[_route-params
|
||||
|
||||
@@ -25,7 +25,8 @@
|
||||
keys to remove from the model so that we'll be able to successfully load it. You can remove keys in vectors using :* to
|
||||
indicate that all items in that vector should have a key removed."
|
||||
{"Dashboard" #{:dashcards}
|
||||
"Card" #{:dashboard_id}})
|
||||
"Document" #{:document}
|
||||
"Card" #{:dashboard_id :document_id}})
|
||||
|
||||
(defn- keys-to-strip [ingested]
|
||||
(let [model (-> ingested :serdes/meta last :model)]
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
"Card"
|
||||
"Collection"
|
||||
"Dashboard"
|
||||
"Document"
|
||||
"NativeQuerySnippet"
|
||||
"Timeline"])
|
||||
|
||||
@@ -57,6 +58,7 @@
|
||||
"DatabaseRouter"
|
||||
"DashboardBookmark"
|
||||
"DataPermissions"
|
||||
"DocumentBookmark"
|
||||
"GroupTableAccessPolicy"
|
||||
"HTTPAction"
|
||||
"ImplicitAction"
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
(ns metabase-enterprise.bookmarks.api-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.models.interface :as mi]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn bookmark-models [user-id & models]
|
||||
(doseq [model models]
|
||||
(cond
|
||||
(mi/instance-of? :model/Card model)
|
||||
(t2/insert! :model/CardBookmark
|
||||
{:user_id user-id
|
||||
:card_id (u/the-id model)})
|
||||
|
||||
(mi/instance-of? :model/Document model)
|
||||
(t2/insert! :model/DocumentBookmark
|
||||
{:user_id user-id
|
||||
:document_id (u/the-id model)})
|
||||
|
||||
:else
|
||||
(throw (ex-info "Unknown type" {:user-id user-id :model model})))))
|
||||
|
||||
(deftest document-bookmarks-test
|
||||
(testing "Document bookmarks with enterprise features"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
|
||||
:model/Document document {:name "Test Document" :collection_id coll-id}]
|
||||
(testing "can bookmark a document when enterprise enabled"
|
||||
(is (= (u/the-id document)
|
||||
(->> (mt/user-http-request :rasta :post 200 (str "bookmark/document/" (u/the-id document)))
|
||||
:document_id))))
|
||||
|
||||
(testing "document appears in bookmark list"
|
||||
(let [result (mt/user-http-request :rasta :get 200 "bookmark")
|
||||
document-bookmark (first (filter #(= (:type %) "document") result))]
|
||||
(is (some? document-bookmark))
|
||||
(is (= "Test Document" (:name document-bookmark)))
|
||||
(is (= (u/the-id document) (:item_id document-bookmark)))))
|
||||
|
||||
(testing "can delete document bookmark"
|
||||
(mt/user-http-request :rasta :delete 204 (str "bookmark/document/" (u/the-id document)))
|
||||
(is (empty? (filter #(= (:type %) "document")
|
||||
(mt/user-http-request :rasta :get 200 "bookmark")))))
|
||||
|
||||
(testing "document bookmarks are included in ordering"
|
||||
(mt/with-temp [:model/Card card {:name "Test Card"}]
|
||||
(mt/with-model-cleanup [:model/BookmarkOrdering]
|
||||
(bookmark-models (mt/user->id :rasta) document card)
|
||||
(mt/user-http-request :rasta :put 204 "bookmark/ordering"
|
||||
{:orderings [{:type "document" :item_id (u/the-id document)}
|
||||
{:type "card" :item_id (u/the-id card)}]})
|
||||
(is (= ["document" "card"]
|
||||
(map :type (mt/user-http-request :rasta :get 200 "bookmark"))))))))))
|
||||
|
||||
(testing "Document bookmarks without enterprise features"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
|
||||
:model/Document document {:name "Test Document" :collection_id coll-id}]
|
||||
(testing "cannot bookmark documents when enterprise disabled"
|
||||
(mt/user-http-request :rasta :post 402 (str "bookmark/document/" (u/the-id document))))
|
||||
|
||||
(testing "document model not included in allowed models"
|
||||
(let [response (mt/user-http-request :rasta :post 402 (str "bookmark/document/" (u/the-id document)))]
|
||||
(is (=? {:message #"Documents is a paid feature.*"}
|
||||
response))))))))
|
||||
|
||||
(deftest document-bookmarks-archived-test
|
||||
(testing "Document bookmarks on archived documents"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
|
||||
:model/Document archived-document {:name "Archived Document"
|
||||
:collection_id coll-id
|
||||
:archived true}]
|
||||
(bookmark-models (mt/user->id :rasta) archived-document)
|
||||
(testing "archived documents don't appear in bookmark list"
|
||||
(is (empty? (filter #(= (:type %) "document")
|
||||
(mt/user-http-request :rasta :get 200 "bookmark")))))))))
|
||||
@@ -0,0 +1,294 @@
|
||||
(ns metabase-enterprise.documents.api.collection-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.test :as mt]))
|
||||
|
||||
(deftest document-cards-do-not-appear-in-collection-items
|
||||
(testing "GET /api/collection/:id/items excludes cards with document_id"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {doc-id :id} {}
|
||||
:model/Card {normal-card-id :id} {:collection_id coll-id}
|
||||
:model/Card _ {:collection_id coll-id
|
||||
:document_id doc-id}
|
||||
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
|
||||
(testing "Normal cards and dashboards appear, but in_document cards do not"
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items"))))))))
|
||||
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items?show_dashboard_questions=true"))))))))))))
|
||||
|
||||
(deftest document-cards-do-not-appear-in-root-items
|
||||
(testing "GET /api/collection/root/items excludes cards with document_id"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {}
|
||||
:model/Card {normal-card-id :id} {:collection_id nil
|
||||
:name "Normal Root Card"}
|
||||
:model/Card {in-document-card-id :id} {:collection_id nil
|
||||
:document_id doc-id
|
||||
:name "In Document Root Card"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id nil
|
||||
:name "Root Dashboard"}]
|
||||
(testing "Normal cards and dashboards in root appear, but in_document cards do not"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
|
||||
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items))))))
|
||||
(testing "Even with show_dashboard_questions=true, in_document cards do not appear"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items?show_dashboard_questions=true")
|
||||
root-test-items (filter #(#{normal-card-id dash-id in-document-card-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items))))))))))
|
||||
|
||||
(deftest document-cards-do-not-appear-in-root-items-without-premium
|
||||
(testing "GET /api/collection/root/items excludes cards with document_id when documents feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Card {normal-card-id :id} {:collection_id nil
|
||||
:name "Normal Root Card"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id nil
|
||||
:name "Root Dashboard"}]
|
||||
(testing "Normal cards and dashboards in root appear, no documents"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
|
||||
root-test-items (filter #(#{normal-card-id dash-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[normal-card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items))))))))))
|
||||
|
||||
(deftest documents-appear-in-collection-items
|
||||
(testing "GET /api/collection/:id/items includes documents"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {doc-id :id} {:collection_id coll-id
|
||||
:name "Test Document"}
|
||||
:model/Card {card-id :id} {:collection_id coll-id}
|
||||
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
|
||||
(testing "Documents appear alongside cards and dashboards"
|
||||
(is (= #{[doc-id "document"]
|
||||
[card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items"))))))))))))
|
||||
|
||||
(deftest documents-do-not-appear-in-collection-items-without-premium
|
||||
(testing "GET /api/collection/:id/items excludes documents when documents feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Card {card-id :id} {:collection_id coll-id}
|
||||
:model/Document _ {:collection_id coll-id
|
||||
:name "Test Document"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id coll-id}]
|
||||
(testing "Only cards and dashboards appear, no documents"
|
||||
(is (= #{[card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model)
|
||||
(:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items"))))))))))))
|
||||
|
||||
(deftest documents-appear-in-root-items
|
||||
(testing "GET /api/collection/root/items includes documents"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:collection_id nil
|
||||
:name "Root Document"}
|
||||
:model/Card {card-id :id} {:collection_id nil
|
||||
:name "Root Card"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id nil
|
||||
:name "Root Dashboard"}]
|
||||
(testing "Documents appear alongside cards and dashboards in root"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
|
||||
root-test-items (filter #(#{doc-id card-id dash-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[doc-id "document"]
|
||||
[card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items))))))))))
|
||||
|
||||
(deftest documents-do-not-appear-in-root-items-without-premium
|
||||
(testing "GET /api/collection/root/items excludes documents when documents feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Card {card-id :id} {:collection_id nil
|
||||
:name "Root Card"}
|
||||
:model/Document _ {:collection_id nil
|
||||
:name "Test Document"}
|
||||
:model/Dashboard {dash-id :id} {:collection_id nil
|
||||
:name "Root Dashboard"}]
|
||||
(testing "Only cards and dashboards appear in root, no documents"
|
||||
(let [items (mt/user-http-request :rasta :get 200 "collection/root/items")
|
||||
root-test-items (filter #(#{card-id dash-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[card-id "card"]
|
||||
[dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) root-test-items))))))))))
|
||||
|
||||
(deftest archived-documents-appear-in-trash-items
|
||||
(testing "GET /api/collection/trash/items includes documents with archived_directly true"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {archived-doc-id :id} {:collection_id nil
|
||||
:name "Archived Document"
|
||||
:archived true
|
||||
:archived_directly true}
|
||||
:model/Document {normal-doc-id :id} {:collection_id nil
|
||||
:name "Normal Document"
|
||||
:archived_directly false}
|
||||
:model/Card {archived-card-id :id} {:collection_id nil
|
||||
:name "Archived Card"
|
||||
:archived true
|
||||
:archived_directly true}
|
||||
:model/Dashboard {archived-dash-id :id} {:collection_id nil
|
||||
:name "Archived Dashboard"
|
||||
:archived true
|
||||
:archived_directly true}]
|
||||
(testing "Archived documents appear alongside other archived items in trash"
|
||||
(let [items (mt/user-http-request :rasta :get 200 (format "collection/%d/items" (collection/trash-collection-id)))
|
||||
trash-test-items (filter #(#{archived-doc-id normal-doc-id archived-card-id archived-dash-id} (:id %))
|
||||
(:data items))]
|
||||
(is (= #{[archived-doc-id "document" true true]
|
||||
[archived-card-id "card" true true]
|
||||
[archived-dash-id "dashboard" true true]}
|
||||
(set (map (juxt :id :model :can_delete :can_restore) trash-test-items))))
|
||||
(testing "Non-archived documents do not appear in trash"
|
||||
(is (not (some #(= normal-doc-id (:id %)) trash-test-items))))))))))
|
||||
|
||||
(deftest archived-documents-do-not-appear-in-trash-items-without-premium
|
||||
(testing "GET /api/collection/trash/items excludes documents when documents feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Document _ {:collection_id nil
|
||||
:name "Archived Document"
|
||||
:archived true
|
||||
:archived_directly true}
|
||||
:model/Card {archived-card-id :id} {:collection_id nil
|
||||
:name "Archived Card"
|
||||
:archived true
|
||||
:archived_directly true}
|
||||
:model/Dashboard {archived-dash-id :id} {:collection_id nil
|
||||
:name "Archived Dashboard"
|
||||
:archived true
|
||||
:archived_directly true}]
|
||||
(testing "Only archived cards and dashboards appear in trash, no documents"
|
||||
(let [items (mt/user-http-request :rasta :get 200 (format "collection/%d/items" (collection/trash-collection-id)))
|
||||
trash-test-items (:data items)]
|
||||
(is (= #{[archived-card-id "card"]
|
||||
[archived-dash-id "dashboard"]}
|
||||
(set (map (juxt :id :model) trash-test-items))))))))))
|
||||
|
||||
(deftest document-pinning-collection-items
|
||||
(testing "GET /api/collection/:id/items supports pinned_state parameter for documents"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {pinned-doc-id :id} {:collection_id coll-id
|
||||
:name "Pinned Document"
|
||||
:collection_position 1}
|
||||
:model/Document {unpinned-doc-id :id} {:collection_id coll-id
|
||||
:name "Unpinned Document"
|
||||
:collection_position nil}
|
||||
:model/Card {pinned-card-id :id} {:collection_id coll-id
|
||||
:name "Pinned Card"
|
||||
:collection_position 2}
|
||||
:model/Card {unpinned-card-id :id} {:collection_id coll-id
|
||||
:name "Unpinned Card"
|
||||
:collection_position nil}]
|
||||
(testing "pinned_state=is_pinned returns only pinned documents and cards"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items?pinned_state=is_pinned")))
|
||||
item-ids (set (map :id items))]
|
||||
(is (contains? item-ids pinned-doc-id))
|
||||
(is (contains? item-ids pinned-card-id))
|
||||
(is (not (contains? item-ids unpinned-doc-id)))
|
||||
(is (not (contains? item-ids unpinned-card-id)))))
|
||||
|
||||
(testing "pinned_state=is_not_pinned returns only unpinned documents and cards"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items?pinned_state=is_not_pinned")))
|
||||
item-ids (set (map :id items))]
|
||||
(is (not (contains? item-ids pinned-doc-id)))
|
||||
(is (not (contains? item-ids pinned-card-id)))
|
||||
(is (contains? item-ids unpinned-doc-id))
|
||||
(is (contains? item-ids unpinned-card-id))))
|
||||
|
||||
(testing "pinned_state=all returns all documents and cards"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items?pinned_state=all")))
|
||||
item-ids (set (map :id items))]
|
||||
(is (contains? item-ids pinned-doc-id))
|
||||
(is (contains? item-ids pinned-card-id))
|
||||
(is (contains? item-ids unpinned-doc-id))
|
||||
(is (contains? item-ids unpinned-card-id))))))))
|
||||
|
||||
(deftest document-pinning-root-items
|
||||
(testing "GET /api/collection/root/items supports pinned_state parameter for documents"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {pinned-doc-id :id} {:collection_id nil
|
||||
:name "Pinned Root Document"
|
||||
:collection_position 1}
|
||||
:model/Document {unpinned-doc-id :id} {:collection_id nil
|
||||
:name "Unpinned Root Document"
|
||||
:collection_position nil}
|
||||
:model/Card {pinned-card-id :id} {:collection_id nil
|
||||
:name "Pinned Root Card"
|
||||
:collection_position 2}
|
||||
:model/Card {unpinned-card-id :id} {:collection_id nil
|
||||
:name "Unpinned Root Card"
|
||||
:collection_position nil}]
|
||||
(testing "pinned_state=is_pinned returns only pinned root documents and cards"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200 "collection/root/items?pinned_state=is_pinned"))
|
||||
test-item-ids (set (filter #{pinned-doc-id unpinned-doc-id pinned-card-id unpinned-card-id}
|
||||
(map :id items)))]
|
||||
(is (contains? test-item-ids pinned-doc-id))
|
||||
(is (contains? test-item-ids pinned-card-id))
|
||||
(is (not (contains? test-item-ids unpinned-doc-id)))
|
||||
(is (not (contains? test-item-ids unpinned-card-id)))))
|
||||
|
||||
(testing "pinned_state=is_not_pinned returns only unpinned root documents and cards"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200 "collection/root/items?pinned_state=is_not_pinned"))
|
||||
test-item-ids (set (filter #{pinned-doc-id unpinned-doc-id pinned-card-id unpinned-card-id}
|
||||
(map :id items)))]
|
||||
(is (not (contains? test-item-ids pinned-doc-id)))
|
||||
(is (not (contains? test-item-ids pinned-card-id)))
|
||||
(is (contains? test-item-ids unpinned-doc-id))
|
||||
(is (contains? test-item-ids unpinned-card-id))))))))
|
||||
|
||||
(deftest mixed-pinned-unpinned-documents-collection-view
|
||||
(testing "Integration test for mixed pinned/unpinned documents in collection view"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {doc1-id :id} {:collection_id coll-id
|
||||
:name "Document A"
|
||||
:collection_position 1}
|
||||
:model/Document _ {:collection_id coll-id
|
||||
:name "Document B"
|
||||
:collection_position nil}
|
||||
:model/Document {doc3-id :id} {:collection_id coll-id
|
||||
:name "Document C"
|
||||
:collection_position 3}]
|
||||
(testing "Default view includes all documents with collection_position field"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items")))
|
||||
docs (filter #(= "document" (:model %)) items)
|
||||
doc-positions (map :collection_position docs)]
|
||||
(is (= 3 (count docs)))
|
||||
;; Verify collection_position is included in the response
|
||||
(is (some #(= 1 %) doc-positions))
|
||||
(is (some nil? doc-positions))
|
||||
(is (some #(= 3 %) doc-positions))))
|
||||
|
||||
(testing "Pinned documents have higher collection_position values and appear before unpinned"
|
||||
(let [items (:data (mt/user-http-request :rasta :get 200
|
||||
(str "collection/" coll-id "/items?pinned_state=is_pinned")))
|
||||
pinned-docs (filter #(= "document" (:model %)) items)]
|
||||
(is (= 2 (count pinned-docs)))
|
||||
;; Verify all pinned docs have non-nil collection_position
|
||||
(is (every? #(some? (:collection_position %)) pinned-docs))
|
||||
(is (= #{doc1-id doc3-id} (set (map :id pinned-docs))))))))))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,68 @@
|
||||
(ns backend.test.metabase-enterprise.documents.models.dashboard-card-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase.dashboards.models.dashboard-card :as dashboard-card]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest prevent-in-report-cards-from-being-added-to-dashboards-test
|
||||
(testing "Cannot add a card with document_id to dashboard"
|
||||
(mt/with-temp [:model/Dashboard dashboard {}
|
||||
:model/Document document {}
|
||||
:model/Card normal-card {:name "Normal Card" :type :question}
|
||||
:model/Card report-card {:name "Report Card" :type :question :document_id (:id document)}]
|
||||
(testing "Adding a normal card works fine"
|
||||
(let [result (dashboard-card/create-dashboard-cards!
|
||||
[{:dashboard_id (:id dashboard)
|
||||
:card_id (:id normal-card)
|
||||
:size_x 4
|
||||
:size_y 3
|
||||
:row 0
|
||||
:col 0}])]
|
||||
(is (= 1 (count result)))
|
||||
(is (= (:id normal-card) (:card_id (first result))))))
|
||||
|
||||
(testing "Adding an in_document card throws an exception"
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Cards with 'document_id' cannot be added to dashboards"
|
||||
(dashboard-card/create-dashboard-cards!
|
||||
[{:dashboard_id (:id dashboard)
|
||||
:card_id (:id report-card)
|
||||
:size_x 4
|
||||
:size_y 3
|
||||
:row 1
|
||||
:col 0}]))))))
|
||||
|
||||
(testing "Cannot add multiple cards if any of them has a document_id"
|
||||
(mt/with-temp [:model/Dashboard dashboard {}
|
||||
:model/Document document {}
|
||||
:model/Card normal-card1 {:name "Normal Card 1" :type :question}
|
||||
:model/Card normal-card2 {:name "Normal Card 2" :type :model}
|
||||
:model/Card report-card1 {:name "Report Card" :type :question :document_id (:id document)}]
|
||||
(testing "Batch creation fails if any card is in_document type"
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Cards with 'document_id' cannot be added to dashboards"
|
||||
(dashboard-card/create-dashboard-cards!
|
||||
[{:dashboard_id (:id dashboard)
|
||||
:card_id (:id normal-card1)
|
||||
:size_x 4
|
||||
:size_y 3
|
||||
:row 0
|
||||
:col 0}
|
||||
{:dashboard_id (:id dashboard)
|
||||
:card_id (:id report-card1)
|
||||
:size_x 4
|
||||
:size_y 3
|
||||
:row 0
|
||||
:col 4}
|
||||
{:dashboard_id (:id dashboard)
|
||||
:card_id (:id normal-card2)
|
||||
:size_x 4
|
||||
:size_y 3
|
||||
:row 0
|
||||
:col 8}])))
|
||||
|
||||
;; Verify that no cards were added due to transaction rollback
|
||||
(is (= 0 (t2/count :model/DashboardCard :dashboard_id (:id dashboard))))))))
|
||||
@@ -0,0 +1,726 @@
|
||||
(ns metabase-enterprise.documents.models.document-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.documents.models.document :as document]
|
||||
[metabase.collections.models.collection :as collection]
|
||||
[metabase.models.serialization :as serdes]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :test-users))
|
||||
|
||||
(deftest sync-document-cards-collection-matching-cards-test
|
||||
(testing "should only update cards with matching document_id"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Test Document"}
|
||||
:model/Card {card1-id :id} {:name "Document Card 1"
|
||||
:document_id document-id
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}
|
||||
:model/Card {card2-id :id} {:name "Document Card 2"
|
||||
:document_id document-id
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}
|
||||
:model/Card {other-card-id :id} {:name "Other Card"
|
||||
:type :question
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}]
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should update exactly 2 cards
|
||||
(is (= 2 updated-count))
|
||||
|
||||
;; Verify the document cards were updated
|
||||
(let [updated-card1 (t2/select-one :model/Card :id card1-id)
|
||||
updated-card2 (t2/select-one :model/Card :id card2-id)
|
||||
other-card (t2/select-one :model/Card :id other-card-id)]
|
||||
(is (= collection-id (:collection_id updated-card1)))
|
||||
(is (= collection-id (:collection_id updated-card2)))
|
||||
;; Other card should not be affected
|
||||
(is (nil? (:collection_id other-card))))))))
|
||||
|
||||
(deftest sync-document-cards-collection-nil-document-document-id-test
|
||||
(testing "should not affect cards with nil document_id"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Test Document"}
|
||||
:model/Card {nil-document-card-id :id} {:name "Nil Document Card"
|
||||
:document_id nil
|
||||
:collection_id nil
|
||||
:dataset_query (mt/mbql-query venues)}]
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should update 0 cards since no cards have matching document_id
|
||||
(is (= 0 updated-count))
|
||||
|
||||
;; Verify the card with nil document_id was not affected
|
||||
(let [unchanged-card (t2/select-one :model/Card :id nil-document-card-id)]
|
||||
(is (nil? (:collection_id unchanged-card))))))))
|
||||
|
||||
(deftest sync-document-cards-collection-empty-result-sets-test
|
||||
(testing "should handle empty result sets gracefully"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Target Collection"}
|
||||
:model/Document {document-id :id} {:name "Empty Document"}]
|
||||
;; No cards associated with this document
|
||||
(let [updated-count (document/sync-document-cards-collection! document-id collection-id)]
|
||||
;; Should return 0 for no updates
|
||||
(is (= 0 updated-count))))))
|
||||
|
||||
(deftest document-collection-sync-hook-triggers-on-collection-change-test
|
||||
(testing "Hook is called when document collection_id changes"
|
||||
(mt/with-temp
|
||||
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/Document {document-id :id} {:collection_id old-collection-id
|
||||
:name "Test Document"}
|
||||
:model/Card {card1-id :id} {:name "Card 1"
|
||||
:document_id document-id
|
||||
:collection_id old-collection-id}
|
||||
:model/Card {card2-id :id} {:name "Card 2"
|
||||
:document_id document-id
|
||||
:collection_id old-collection-id}]
|
||||
|
||||
;; Update the document's collection_id
|
||||
(t2/update! :model/Document document-id {:collection_id new-collection-id})
|
||||
|
||||
;; Verify that associated cards were updated to match the new collection
|
||||
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card1-id))))
|
||||
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id card2-id)))))))
|
||||
|
||||
(deftest document-collection-sync-hook-handles-nil-collections-test
|
||||
(testing "Hook correctly handles nil collection values"
|
||||
(mt/with-temp
|
||||
[:model/Collection {collection-id :id} {:name "Test Collection"}
|
||||
:model/Document {document-id :id} {:collection_id collection-id
|
||||
:name "Test Document"}
|
||||
:model/Card {card-id :id} {:name "Card"
|
||||
:document_id document-id
|
||||
:collection_id collection-id}]
|
||||
|
||||
;; Move document to no collection (nil)
|
||||
(t2/update! :model/Document document-id {:collection_id nil})
|
||||
|
||||
;; Verify that the card's collection_id was updated to nil
|
||||
(is (nil? (:collection_id (t2/select-one :model/Card :id card-id)))))))
|
||||
|
||||
(deftest document-collection-sync-hook-only-affects-cards-test
|
||||
(testing "Hook only updates cards with matching document_id"
|
||||
(mt/with-temp
|
||||
[:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/Document {document-id :id} {:collection_id old-collection-id
|
||||
:name "Test Document"}
|
||||
;; Card that should be updated (correct type and document_id)
|
||||
:model/Card {in-document-card-id :id} {:name "In-Document Card"
|
||||
:type :question
|
||||
:document_id document-id
|
||||
:collection_id old-collection-id}
|
||||
;; Card that should be updated (wrong type)
|
||||
:model/Card {question-card-id :id} {:name "Question Card"
|
||||
:type :model
|
||||
:collection_id old-collection-id}
|
||||
;; Card that should NOT be updated (no document_id)
|
||||
:model/Card {regular-card-id :id} {:name "Regular Card"
|
||||
:collection_id old-collection-id}]
|
||||
|
||||
;; Update the document's collection_id
|
||||
(t2/update! :model/Document document-id {:collection_id new-collection-id})
|
||||
|
||||
;; Verify only the correct card was updated
|
||||
(is (= new-collection-id (:collection_id (t2/select-one :model/Card :id in-document-card-id))))
|
||||
|
||||
;; Verify other cards were NOT updated
|
||||
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id question-card-id))))
|
||||
(is (= old-collection-id (:collection_id (t2/select-one :model/Card :id regular-card-id)))))))
|
||||
|
||||
(deftest personal-collection-edge-cases-test
|
||||
(testing "Personal collection handling"
|
||||
(binding [collection/*allow-deleting-personal-collections* true]
|
||||
(mt/with-temp [:model/User {user-id :id} {:first_name "Test" :last_name "User" :email "test@example.com"}
|
||||
:model/Collection {personal-collection-id :id} {:name "Personal Collection"
|
||||
:personal_owner_id user-id}
|
||||
:model/Collection {regular-collection-id :id} {:name "Regular Collection"}
|
||||
:model/Document {document-id :id} {:collection_id regular-collection-id
|
||||
:name "Personal Test Document"}
|
||||
:model/Card {card-id :id} {:name "Personal Card"
|
||||
:document_id document-id
|
||||
:collection_id regular-collection-id
|
||||
:dataset_query (mt/mbql-query venues)}]
|
||||
|
||||
(testing "moving document to personal collection moves associated cards"
|
||||
(mt/with-current-user user-id
|
||||
;; As the personal collection owner, update should succeed
|
||||
(t2/update! :model/Document document-id {:collection_id personal-collection-id})
|
||||
|
||||
;; Verify both document and card moved to personal collection
|
||||
(is (= personal-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
|
||||
(is (= personal-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))
|
||||
|
||||
(testing "moving document from personal collection works"
|
||||
(mt/with-current-user user-id
|
||||
;; Move back to regular collection
|
||||
(t2/update! :model/Document document-id {:collection_id regular-collection-id})
|
||||
|
||||
;; Verify both document and card moved back
|
||||
(is (= regular-collection-id (:collection_id (t2/select-one :model/Document :id document-id))))
|
||||
(is (= regular-collection-id (:collection_id (t2/select-one :model/Card :id card-id))))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-with-both-permissions-test
|
||||
(testing "allows move when user has write permissions for both collections"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permissions to both collections
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
|
||||
|
||||
;; Should not throw any exception
|
||||
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-403-missing-old-permission-test
|
||||
(testing "throws 403 when user lacks write permission for old collection"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission only to new collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
|
||||
|
||||
;; Should throw 403 exception
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-403-missing-new-permission-test
|
||||
(testing "throws 403 when user lacks write permission for new collection"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission only to old collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
|
||||
;; Should throw 403 exception
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-403-no-permissions-test
|
||||
(testing "throws 403 when user lacks write permissions for both collections"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; No permissions granted
|
||||
|
||||
;; Should throw 403 exception
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-from-root-test
|
||||
(testing "allows move when old collection is nil (moving from root)"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {new-collection-id :id} {:name "New Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission to new collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) new-collection-id)
|
||||
|
||||
;; Should not throw any exception when old collection is nil
|
||||
(is (true? (document/validate-collection-move-permissions nil new-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-to-root-test
|
||||
(testing "allows move when new collection is nil (moving to root)"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission to old collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
|
||||
;; Should not throw any exception when new collection is nil
|
||||
(is (nil? (document/validate-collection-move-permissions old-collection-id nil))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-allows-move-both-nil-test
|
||||
(testing "allows move when both collections are nil"
|
||||
(mt/with-temp [:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Should not throw any exception when both collections are nil
|
||||
(is (nil? (document/validate-collection-move-permissions nil nil)))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-400-nonexistent-collection-test
|
||||
(testing "throws 400 when new collection does not exist"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permission to old collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
|
||||
;; Use a non-existent collection ID
|
||||
(let [non-existent-id 999999]
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"You do not have curate permissions for this Collection."
|
||||
(document/validate-collection-move-permissions old-collection-id non-existent-id)))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-throws-400-archived-collection-test
|
||||
(testing "throws 400 when new collection is archived"
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {archived-collection-id :id} {:name "Archived Collection" :archived true}
|
||||
:model/User {user-id :id} {}]
|
||||
(mt/with-current-user user-id
|
||||
;; Grant write permissions to both collections
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) old-collection-id)
|
||||
(perms/grant-collection-readwrite-permissions! (perms/all-users-group) archived-collection-id)
|
||||
|
||||
;; Should throw 400 exception for archived collection
|
||||
(is (thrown-with-msg? clojure.lang.ExceptionInfo #"Invalid"
|
||||
(document/validate-collection-move-permissions old-collection-id archived-collection-id))))))))
|
||||
|
||||
(deftest validate-collection-move-permissions-superuser-test
|
||||
(testing "superuser can move between any collections"
|
||||
(mt/with-temp [:model/Collection {old-collection-id :id} {:name "Old Collection"}
|
||||
:model/Collection {new-collection-id :id} {:name "New Collection"}]
|
||||
(mt/with-current-user (mt/user->id :crowberto)
|
||||
;; Superuser should be able to move without explicit permissions
|
||||
(is (true? (document/validate-collection-move-permissions old-collection-id new-collection-id)))))))
|
||||
|
||||
(deftest hydrate-document-creator-test
|
||||
(testing "hydrates document creator correctly"
|
||||
(mt/with-temp [:model/User {user-id :id} {:first_name "John"
|
||||
:last_name "Doe"
|
||||
:email "john.doe@example.com"}
|
||||
:model/Document {document-id :id} {:name "Test Document"
|
||||
:creator_id user-id}]
|
||||
(let [hydrated-doc (t2/hydrate (t2/select-one :model/Document :id document-id) :creator)]
|
||||
(testing "creator is hydrated with correct user data"
|
||||
(is (some? (:creator hydrated-doc)))
|
||||
(is (= user-id (get-in hydrated-doc [:creator :id])))
|
||||
(is (= "John" (get-in hydrated-doc [:creator :first_name])))
|
||||
(is (= "Doe" (get-in hydrated-doc [:creator :last_name])))
|
||||
(is (= "john.doe@example.com" (get-in hydrated-doc [:creator :email]))))))))
|
||||
|
||||
(deftest hydrate-multiple-documents-creator-test
|
||||
(testing "hydrates creators for multiple documents efficiently"
|
||||
(mt/with-temp [:model/User {user1-id :id} {:first_name "Alice"
|
||||
:last_name "Smith"
|
||||
:email "alice@example.com"}
|
||||
:model/User {user2-id :id} {:first_name "Bob"
|
||||
:last_name "Jones"
|
||||
:email "bob@example.com"}
|
||||
:model/Document {doc1-id :id} {:name "Document 1"
|
||||
:creator_id user1-id}
|
||||
:model/Document {doc2-id :id} {:name "Document 2"
|
||||
:creator_id user2-id}
|
||||
:model/Document {doc3-id :id} {:name "Document 3"
|
||||
:creator_id user1-id}] ; Same creator as doc1
|
||||
(let [documents (t2/select :model/Document :id [:in [doc1-id doc2-id doc3-id]])
|
||||
hydrated-docs (t2/hydrate documents :creator)]
|
||||
(testing "all documents have their creators hydrated"
|
||||
(is (= 3 (count hydrated-docs)))
|
||||
(doseq [doc hydrated-docs]
|
||||
(is (some? (:creator doc)))
|
||||
(is (some? (get-in doc [:creator :id])))
|
||||
(is (some? (get-in doc [:creator :first_name])))
|
||||
(is (some? (get-in doc [:creator :last_name])))
|
||||
(is (some? (get-in doc [:creator :email])))))
|
||||
|
||||
(testing "creators are correctly matched to documents"
|
||||
(let [doc1 (first (filter #(= doc1-id (:id %)) hydrated-docs))
|
||||
doc2 (first (filter #(= doc2-id (:id %)) hydrated-docs))
|
||||
doc3 (first (filter #(= doc3-id (:id %)) hydrated-docs))]
|
||||
(is (= user1-id (get-in doc1 [:creator :id])))
|
||||
(is (= "Alice" (get-in doc1 [:creator :first_name])))
|
||||
|
||||
(is (= user2-id (get-in doc2 [:creator :id])))
|
||||
(is (= "Bob" (get-in doc2 [:creator :first_name])))
|
||||
|
||||
(is (= user1-id (get-in doc3 [:creator :id])))
|
||||
(is (= "Alice" (get-in doc3 [:creator :first_name])))))))))
|
||||
|
||||
(deftest document-collection-position-field-handling-test
|
||||
(testing "Document model supports collection_position field"
|
||||
(mt/with-temp [:model/Collection {collection-id :id} {:name "Test Collection"}
|
||||
:model/Document {document-id :id} {:name "Positioned Document"
|
||||
:collection_id collection-id
|
||||
:collection_position 5}]
|
||||
(let [document (t2/select-one :model/Document :id document-id)]
|
||||
(testing "collection_position is stored and retrieved correctly"
|
||||
(is (= 5 (:collection_position document)))))
|
||||
|
||||
(testing "collection_position can be updated"
|
||||
(t2/update! :model/Document document-id {:collection_position 10})
|
||||
(let [updated-document (t2/select-one :model/Document :id document-id)]
|
||||
(is (= 10 (:collection_position updated-document)))))
|
||||
|
||||
(testing "collection_position can be set to nil"
|
||||
(t2/update! :model/Document document-id {:collection_position nil})
|
||||
(let [updated-document (t2/select-one :model/Document :id document-id)]
|
||||
(is (nil? (:collection_position updated-document))))))))
|
||||
|
||||
;;; ------------------------------------------------- Serialization Tests -------------------------------------------
|
||||
|
||||
(deftest document-serdes-spec-test
|
||||
(testing "Document serialization spec includes all required fields"
|
||||
(let [spec (serdes/make-spec "Document" {})]
|
||||
(is (= [:archived :archived_directly :content_type :entity_id :name :collection_position]
|
||||
(:copy spec)))
|
||||
(is (= [:view_count :last_viewed_at] (:skip spec)))
|
||||
(is (contains? (:transform spec) :created_at))
|
||||
(is (contains? (:transform spec) :document))
|
||||
(is (contains? (:transform spec) :updated_at))
|
||||
(is (contains? (:transform spec) :collection_id))
|
||||
(is (contains? (:transform spec) :creator_id))
|
||||
(testing "foreign key transformers are properly configured"
|
||||
(is (get-in spec [:transform :collection_id ::serdes/fk]))
|
||||
(is (get-in spec [:transform :creator_id ::serdes/fk]))))))
|
||||
|
||||
(deftest document-serdes-dependencies-test
|
||||
(testing "Document dependencies method works correctly"
|
||||
(testing "with collection and creator"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (= #{[{:model "Collection" :id 123}]}
|
||||
deps))))))
|
||||
|
||||
(deftest document-serdes-smartlink-single-reference-test
|
||||
(testing "single smartLink card reference"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 789}]
|
||||
:model "card"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 789}]))
|
||||
(is (contains? deps [{:model "Collection" :id 123}])))))
|
||||
|
||||
(deftest document-serdes-smartlink-multiple-references-test
|
||||
(testing "multiple smartLink references of different types"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 789}]
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Dashboard" :id 456}]
|
||||
:model "dashboard"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Table" :id 321}]
|
||||
:model "table"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 789}]))
|
||||
(is (contains? deps [{:model "Dashboard" :id 456}]))
|
||||
(is (contains? deps [{:model "Table" :id 321}]))
|
||||
(is (contains? deps [{:model "Collection" :id 123}])))))
|
||||
|
||||
(deftest document-serdes-smartlink-nested-structure-test
|
||||
(testing "nested smartLink in complex prose mirror structure"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text" :text "Some text"}]}
|
||||
{:type "bulletList"
|
||||
:content [{:type "listItem"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 999}]
|
||||
:model "card"}}]}]}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 999}]))
|
||||
(is (contains? deps [{:model "Collection" :id 123}])))))
|
||||
|
||||
(deftest document-serdes-smartlink-no-smartlinks-test
|
||||
(testing "document with no smartLinks"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text" :text "Plain text only"}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (= #{[{:model "Collection" :id 123}]}
|
||||
deps)))))
|
||||
|
||||
(deftest document-serdes-smartlink-unknown-model-test
|
||||
(testing "unknown smartLink model type is ignored"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Unknown" :id 789}]
|
||||
:model "unknown-model"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 456}]
|
||||
:model "card"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 456}]))
|
||||
(is (not (some #(= (:model (first %)) "unknown-model") deps))))))
|
||||
|
||||
(deftest document-serdes-smartlink-missing-entity-id-test
|
||||
(testing "smartLink with missing entityId is ignored"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 456}]
|
||||
:model "card"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 456}]))
|
||||
(is (= 2 (count deps)))))) ; collection and one valid card
|
||||
|
||||
(deftest document-serdes-smartlink-duplicate-references-test
|
||||
(testing "duplicate smartLink references are deduplicated"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 789}]
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 789}]
|
||||
:model "card"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 789}]))
|
||||
(is (= 2 (count deps)))))) ; collection and one unique card
|
||||
|
||||
(deftest document-serdes-smartlink-empty-content-test
|
||||
(testing "empty document content"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content []}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (= #{[{:model "Collection" :id 123}]}
|
||||
deps)))))
|
||||
|
||||
(deftest document-serdes-smartlink-missing-model-test
|
||||
(testing "smartLink with missing model is ignored"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 789}]}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 456}]
|
||||
:model "card"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 456}]))
|
||||
(is (= 2 (count deps)))))) ; collection and one valid card
|
||||
|
||||
(deftest document-serdes-smartlink-nil-attrs-test
|
||||
(testing "smartLink with nil attrs is ignored"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs nil}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 456}]
|
||||
:model "card"}}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 456}]))
|
||||
(is (= 2 (count deps)))))) ; collection and one valid card
|
||||
|
||||
(deftest document-serdes-smartlink-mixed-content-test
|
||||
(testing "mix of smartLinks and other node types"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text" :text "Check out this "}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 789}]
|
||||
:model "card"}}
|
||||
{:type "text" :text " and this "}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Dashboard" :id 456}]
|
||||
:model "dashboard"}}]}
|
||||
{:type "heading"
|
||||
:attrs {:level 2}
|
||||
:content [{:type "text" :text "Section with table"}]}
|
||||
{:type "table"
|
||||
:content [{:type "tableRow"
|
||||
:content [{:type "tableCell"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Table" :id 321}]
|
||||
:model "table"}}]}]}]}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Card" :id 789}]))
|
||||
(is (contains? deps [{:model "Dashboard" :id 456}]))
|
||||
(is (contains? deps [{:model "Table" :id 321}]))
|
||||
(is (contains? deps [{:model "Collection" :id 123}]))
|
||||
(is (= 4 (count deps)))))) ; 3 smartLinks + collection
|
||||
|
||||
(deftest document-serdes-smartlink-non-prose-mirror-test
|
||||
(testing "non-prose-mirror content type documents don't extract smartLinks"
|
||||
(let [document {:collection_id 123
|
||||
:creator_id 456
|
||||
:document {:type "doc"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id 456}]
|
||||
:model "card"}}]}
|
||||
:content_type "application/json" ; Not prose-mirror
|
||||
:serdes/meta [{:model "Document" :id "test-entity-id"}]}
|
||||
deps (serdes/dependencies document)]
|
||||
(is (contains? deps [{:model "Collection" :id 123}])))))
|
||||
|
||||
(deftest document-serdes-descendants-embedded-cards-test
|
||||
(testing "Document descendants includes embedded cards"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "cardEmbed"
|
||||
:attrs {:id 456}}
|
||||
{:type "cardEmbed"
|
||||
:attrs {:id 789}}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
(is (= {["Card" 456] {"Document" document-id}
|
||||
["Card" 789] {"Document" document-id}}
|
||||
descendants))))))
|
||||
|
||||
(deftest document-serdes-descendants-smart-links-test
|
||||
(testing "Document descendants includes smart links"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:id 456
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 789
|
||||
:model "dashboard"}}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
(is (= {["Card" 456] {"Document" document-id}
|
||||
["Dashboard" 789] {"Document" document-id}}
|
||||
descendants))))))
|
||||
|
||||
(deftest document-serdes-descendants-mixed-content-test
|
||||
(testing "Document descendants includes both embedded cards and smart links"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "cardEmbed"
|
||||
:attrs {:id 111}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 222
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 333
|
||||
:model "table"}}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
(is (= {["Card" 111] {"Document" document-id}
|
||||
["Card" 222] {"Document" document-id}
|
||||
["Table" 333] {"Document" document-id}}
|
||||
descendants))))))
|
||||
|
||||
(deftest document-serdes-descendants-empty-document-test
|
||||
(testing "Document descendants handles document with no embedded content"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text" :text "Plain text only"}]}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
(is (= {} descendants))))))
|
||||
|
||||
(deftest document-serdes-descendants-nonexistent-document-test
|
||||
(testing "Document descendants returns nil for non-existent document"
|
||||
(let [descendants (serdes/descendants "Document" 99999999)]
|
||||
(is (nil? descendants)))))
|
||||
|
||||
(deftest document-serdes-descendants-unknown-smart-link-model-test
|
||||
(testing "Document descendants ignores smart links with unknown model types"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:id 456
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 789
|
||||
:model "unknown-model"}}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
;; Should only include the known model type
|
||||
(is (= {["Card" 456] {"Document" document-id}}
|
||||
descendants))
|
||||
;; Should not include unknown model
|
||||
(is (not (contains? descendants ["Unknown" 789])))))))
|
||||
|
||||
(deftest document-serdes-descendants-duplicate-references-test
|
||||
(testing "Document descendants handles duplicate references correctly"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "cardEmbed"
|
||||
:attrs {:id 456}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 456
|
||||
:model "card"}}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
;; Should merge duplicate references - same card referenced twice
|
||||
(is (= {["Card" 456] {"Document" document-id}}
|
||||
descendants))
|
||||
;; Should only have one entry for the card
|
||||
(is (= 1 (count descendants)))))))
|
||||
|
||||
(deftest document-serdes-descendants-non-prose-mirror-test
|
||||
(testing "Document descendants handles non-prose-mirror documents"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:some "other format"}
|
||||
:content_type "application/json"}] ; Not prose-mirror
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
(is (nil? descendants))))))
|
||||
|
||||
(deftest document-serdes-descendants-all-model-types-test
|
||||
(testing "Document descendants correctly maps all supported smart link model types"
|
||||
(mt/with-temp [:model/Document {document-id :id} {:document {:type "doc"
|
||||
:content [{:type "smartLink"
|
||||
:attrs {:id 111
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 222
|
||||
:model "dashboard"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:id 333
|
||||
:model "table"}}]}
|
||||
:content_type "application/json+vnd.prose-mirror"}]
|
||||
(let [descendants (serdes/descendants "Document" document-id)]
|
||||
(is (= {["Card" 111] {"Document" document-id}
|
||||
["Dashboard" 222] {"Document" document-id}
|
||||
["Table" 333] {"Document" document-id}}
|
||||
descendants))
|
||||
;; Verify all supported model types are included
|
||||
(is (contains? descendants ["Card" 111]))
|
||||
(is (contains? descendants ["Dashboard" 222]))
|
||||
(is (contains? descendants ["Table" 333]))))))
|
||||
@@ -0,0 +1,319 @@
|
||||
(ns metabase-enterprise.documents.prose-mirror-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.documents.prose-mirror :as prose-mirror]))
|
||||
|
||||
(deftest ^:parallel update-ast-basic-test
|
||||
(testing "updates nodes that match predicate"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Hello"}]}
|
||||
{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "World"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate #(and (map? %) (= (:type %) "text"))
|
||||
updater #(assoc % :text (str (:text %) "!"))
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (= "Hello!" (get-in result [:document :content 0 :content 0 :text])))
|
||||
(is (= "World!" (get-in result [:document :content 1 :content 0 :text]))))))
|
||||
|
||||
(deftest ^:parallel update-ast-non-matching-test
|
||||
(testing "leaves non-matching nodes unchanged"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Hello"}]}
|
||||
{:type "heading"
|
||||
:content [{:type "text"
|
||||
:text "Title"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate #(and (map? %) (= (:type %) "paragraph"))
|
||||
updater #(assoc % :modified true)
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (true? (get-in result [:document :content 0 :modified])))
|
||||
(is (nil? (get-in result [:document :content 1 :modified]))))))
|
||||
|
||||
(deftest ^:parallel update-ast-empty-document-test
|
||||
(testing "handles empty document"
|
||||
(let [doc {:document {}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate #(= (:type %) "text")
|
||||
updater #(assoc % :updated true)
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (= {} (:document result))))))
|
||||
|
||||
(deftest ^:parallel update-ast-nested-structures-test
|
||||
(testing "handles nested structures"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "table"
|
||||
:content [{:type "table_row"
|
||||
:content [{:type "table_cell"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Cell"}]}]}]}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate #(and (map? %) (= (:type %) "text"))
|
||||
updater #(update % :text str "-updated")
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (= "Cell-updated"
|
||||
(get-in result [:document :content 0 :content 0 :content 0 :content 0 :content 0 :text]))))))
|
||||
|
||||
(deftest ^:parallel update-ast-all-node-types-test
|
||||
(testing "predicate receives all node types including non-maps"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content ["string-node"
|
||||
42
|
||||
{:type "paragraph"}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
visited-nodes (atom [])
|
||||
predicate (fn [node]
|
||||
(swap! visited-nodes conj node)
|
||||
false)
|
||||
updater identity]
|
||||
(prose-mirror/update-ast doc predicate updater)
|
||||
(is (some string? @visited-nodes))
|
||||
(is (some number? @visited-nodes))
|
||||
(is (some map? @visited-nodes)))))
|
||||
|
||||
(deftest ^:parallel update-ast-updater-function-test
|
||||
(testing "updater function is applied correctly"
|
||||
(let [doc {:document {:type "doc"
|
||||
:attrs {:level 1}}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate #(and (map? %) (contains? % :attrs))
|
||||
updater #(update-in % [:attrs :level] inc)
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (= 2 (get-in result [:document :attrs :level]))))))
|
||||
|
||||
(deftest ^:parallel update-ast-invalid-content-type-test
|
||||
(testing "throws exception for invalid content-type"
|
||||
(let [doc {:document {:type "doc"}
|
||||
:content_type "text/plain"}
|
||||
predicate identity
|
||||
updater identity]
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Document does not have the prose mirror content-type"
|
||||
(prose-mirror/update-ast doc predicate updater))))))
|
||||
|
||||
(deftest ^:parallel update-ast-exception-data-test
|
||||
(testing "exception contains correct data"
|
||||
(let [doc {:document {:type "doc"}
|
||||
:content_type "application/json"}
|
||||
predicate identity
|
||||
updater identity]
|
||||
(try
|
||||
(prose-mirror/update-ast doc predicate updater)
|
||||
(is false "Should have thrown exception")
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(let [data (ex-data e)]
|
||||
(is (= "application/json" (:content-type data)))
|
||||
(is (= 400 (:status-code data)))))))))
|
||||
|
||||
(deftest ^:parallel update-ast-preserve-fields-test
|
||||
(testing "preserves other document fields"
|
||||
(let [doc {:document {:type "doc"}
|
||||
:content_type prose-mirror/prose-mirror-content-type
|
||||
:id 123
|
||||
:name "Test Doc"
|
||||
:other-field "value"}
|
||||
predicate (constantly false)
|
||||
updater identity
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (= 123 (:id result)))
|
||||
(is (= "Test Doc" (:name result)))
|
||||
(is (= "value" (:other-field result))))))
|
||||
|
||||
(deftest ^:parallel update-ast-nil-document-test
|
||||
(testing "handles nil document"
|
||||
(let [doc {:document nil
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate identity
|
||||
updater identity
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (nil? (:document result))))))
|
||||
|
||||
(deftest ^:parallel update-ast-multiple-updates-test
|
||||
(testing "multiple updates on same node"
|
||||
(let [doc {:document {:type "text" :text "hello"}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate #(and (map? %) (= (:type %) "text"))
|
||||
updater #(-> %
|
||||
(update :text str "!")
|
||||
(assoc :modified true))
|
||||
result (prose-mirror/update-ast doc predicate updater)]
|
||||
(is (= "hello!" (get-in result [:document :text])))
|
||||
(is (true? (get-in result [:document :modified]))))))
|
||||
|
||||
(deftest ^:parallel update-ast-content-type-constant-test
|
||||
(testing "works with valid prose-mirror content-type constant"
|
||||
(let [doc {:document {:type "doc"}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
predicate (constantly false)
|
||||
updater identity]
|
||||
(is (some? (prose-mirror/update-ast doc predicate updater))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-basic-test
|
||||
(testing "collects values from matching nodes"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Hello"}]}
|
||||
{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "World"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (and (map? %) (= (:type %) "text"))
|
||||
(:text %))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (= ["Hello" "World"] (vec result))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-empty-results-test
|
||||
(testing "returns empty sequence when no matches"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Hello"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (= (:type %) "nonexistent") %)
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (empty? result)))))
|
||||
|
||||
(deftest ^:parallel collect-ast-nil-filtering-test
|
||||
(testing "removes nils from results"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Hello"}]}
|
||||
{:type "heading"
|
||||
:content [{:type "text"
|
||||
:text "Title"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (and (map? %) (= (:type %) "text") (= (:text %) "Hello"))
|
||||
(:text %))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (= ["Hello"] (vec result))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-nested-structures-test
|
||||
(testing "works with nested structures"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "table"
|
||||
:content [{:type "table_row"
|
||||
:content [{:type "table_cell"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Cell1"}]}]}
|
||||
{:type "table_cell"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Cell2"}]}]}]}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (and (map? %) (= (:type %) "text"))
|
||||
(:text %))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (= ["Cell1" "Cell2"] (vec result))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-traversal-order-test
|
||||
(testing "collector receives all nodes in tree traversal order"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "First"}]}
|
||||
{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text "Second"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
visited-types (atom [])
|
||||
collector (fn [node]
|
||||
(when (map? node)
|
||||
(swap! visited-types conj (:type node)))
|
||||
nil)]
|
||||
(doall (prose-mirror/collect-ast doc collector))
|
||||
(is (= ["doc" "paragraph" "text" "paragraph" "text"] @visited-types)))))
|
||||
|
||||
(deftest ^:parallel collect-ast-lazy-sequence-test
|
||||
(testing "returns lazy sequence"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "text" :text "test"}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (map? %) (:type %))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (instance? clojure.lang.LazySeq result)))))
|
||||
|
||||
(deftest ^:parallel collect-ast-empty-document-test
|
||||
(testing "handles empty document"
|
||||
(let [doc {:document {}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (map? %) (:type %))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (empty? result)))))
|
||||
|
||||
(deftest ^:parallel collect-ast-nil-document-test
|
||||
(testing "handles nil document"
|
||||
(let [doc {:document nil
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector identity
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (empty? result)))))
|
||||
|
||||
(deftest ^:parallel collect-ast-complex-objects-test
|
||||
(testing "collector can return complex objects"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:attrs {:id "p1"}
|
||||
:content [{:type "text"
|
||||
:text "Hello"}]}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (and (map? %) (= (:type %) "paragraph"))
|
||||
{:id (get-in % [:attrs :id])
|
||||
:type (:type %)})
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (= [{:id "p1" :type "paragraph"}] (vec result))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-invalid-content-type-test
|
||||
(testing "throws exception for invalid content-type"
|
||||
(let [doc {:document {:type "doc"}
|
||||
:content_type "text/plain"}
|
||||
collector identity]
|
||||
(is (thrown-with-msg?
|
||||
clojure.lang.ExceptionInfo
|
||||
#"Document does not have the prose mirror content-type"
|
||||
(prose-mirror/collect-ast doc collector))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-exception-data-test
|
||||
(testing "exception contains correct data for collect-ast"
|
||||
(let [doc {:document {:type "doc"}
|
||||
:content_type "application/xml"}
|
||||
collector identity]
|
||||
(try
|
||||
(doall (prose-mirror/collect-ast doc collector))
|
||||
(is false "Should have thrown exception")
|
||||
(catch clojure.lang.ExceptionInfo e
|
||||
(let [data (ex-data e)]
|
||||
(is (= "application/xml" (:content-type data)))
|
||||
(is (= 400 (:status-code data)))))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-card-embed-test
|
||||
(testing "works with card embed nodes"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type prose-mirror/card-embed-type
|
||||
:attrs {:cardId 123}}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (and (map? %) (= (:type %) prose-mirror/card-embed-type))
|
||||
(get-in % [:attrs :cardId]))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (= [123] (vec result))))))
|
||||
|
||||
(deftest ^:parallel collect-ast-order-preservation-test
|
||||
(testing "preserves order of collection"
|
||||
(let [doc {:document {:type "doc"
|
||||
:content [{:type "text" :text "first"}
|
||||
{:type "text" :text "second"}
|
||||
{:type "text" :text "third"}]}
|
||||
:content_type prose-mirror/prose-mirror-content-type}
|
||||
collector #(when (and (map? %) (= (:type %) "text"))
|
||||
(:text %))
|
||||
result (prose-mirror/collect-ast doc collector)]
|
||||
(is (= ["first" "second" "third"] (vec result))))))
|
||||
@@ -0,0 +1,204 @@
|
||||
(ns metabase-enterprise.documents.recent-views-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.documents.recent-views :as recent-views]
|
||||
[metabase.activity-feed.core :as activity-feed]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- most-recent-view
|
||||
[user-id model-id model-type]
|
||||
(t2/select-one [:model/RecentViews :user_id :model :model_id]
|
||||
:user_id user-id
|
||||
:model_id model-id
|
||||
:model model-type
|
||||
{:order-by [[:id :desc]]}))
|
||||
|
||||
(deftest card-query-test
|
||||
(mt/with-test-user :rasta
|
||||
(testing "document cards should not be counted"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {}
|
||||
:model/Card card-3 {:creator_id (mt/user->id :rasta)
|
||||
:document_id doc-id}]
|
||||
(events/publish-event! :event/card-query {:card-id (:id card-3)
|
||||
:user-id (mt/user->id :rasta)
|
||||
:context :question})
|
||||
(is (nil? (most-recent-view (mt/user->id :rasta) (:id card-3) "card")))))))
|
||||
|
||||
(deftest legacy-card-read-test
|
||||
(testing "in_document cards should not be counted even with context :question"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {}
|
||||
:model/Card card {:creator_id (mt/user->id :rasta)
|
||||
:document_id doc-id}]
|
||||
(mt/with-test-user :rasta
|
||||
(events/publish-event! :event/card-read {:object-id (:id card)
|
||||
:user-id (mt/user->id :rasta)
|
||||
:context :question})
|
||||
(is (nil? (most-recent-view (mt/user->id :rasta) (:id card) "card")))))))
|
||||
|
||||
(deftest select-documents-for-recents-empty-input-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "returns empty vector when given empty document-ids"
|
||||
(is (= [] (recent-views/select-documents-for-recents [])))
|
||||
(is (= [] (recent-views/select-documents-for-recents nil))))))
|
||||
|
||||
(deftest select-documents-for-recents-with-collection-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "returns documents with collection information"
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"
|
||||
:authority_level "official"
|
||||
:archived false}
|
||||
:model/Document {doc1-id :id} {:name "Document 1"
|
||||
:collection_id coll-id
|
||||
:archived false}
|
||||
:model/Document {doc2-id :id} {:name "Document 2"
|
||||
:collection_id coll-id
|
||||
:archived false}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc1-id doc2-id])]
|
||||
(is (= 2 (count results)))
|
||||
|
||||
;; Check first document
|
||||
(let [doc1 (first (filter #(= (:id %) doc1-id) results))]
|
||||
(is (= "Document 1" (:name doc1)))
|
||||
(is (= false (:archived doc1)))
|
||||
(is (= coll-id (:entity-coll-id doc1)))
|
||||
(is (= coll-id (:collection_id doc1)))
|
||||
(is (= "Test Collection" (:collection_name doc1)))
|
||||
(is (= "official" (:collection_authority_level doc1))))
|
||||
|
||||
;; Check second document
|
||||
(let [doc2 (first (filter #(= (:id %) doc2-id) results))]
|
||||
(is (= "Document 2" (:name doc2)))
|
||||
(is (= false (:archived doc2)))))))))
|
||||
|
||||
(deftest select-documents-for-recents-without-collection-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "returns documents without collection when collection_id is nil"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Orphan Document"
|
||||
:collection_id nil
|
||||
:archived false}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc-id])]
|
||||
(is (= 1 (count results)))
|
||||
(let [doc (first results)]
|
||||
(is (= "Orphan Document" (:name doc)))
|
||||
(is (= false (:archived doc)))
|
||||
(is (nil? (:entity-coll-id doc)))
|
||||
(is (nil? (:collection_id doc)))
|
||||
(is (nil? (:collection_name doc)))
|
||||
(is (nil? (:collection_authority_level doc)))))))))
|
||||
|
||||
(deftest select-documents-for-recents-archived-collections-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "excludes documents from archived collections via left join"
|
||||
(mt/with-temp [:model/Collection {archived-coll-id :id} {:name "Archived Collection"
|
||||
:authority_level "official"
|
||||
:archived true}
|
||||
:model/Collection {active-coll-id :id} {:name "Active Collection"
|
||||
:authority_level "official"
|
||||
:archived false}
|
||||
:model/Document {doc1-id :id} {:name "Document in Archived Collection"
|
||||
:collection_id archived-coll-id
|
||||
:archived false}
|
||||
:model/Document {doc2-id :id} {:name "Document in Active Collection"
|
||||
:collection_id active-coll-id
|
||||
:archived false}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc1-id doc2-id])]
|
||||
(is (= 2 (count results)))
|
||||
|
||||
;; Document from archived collection should have nil collection info
|
||||
(let [doc1 (first (filter #(= (:id %) doc1-id) results))]
|
||||
(is (= "Document in Archived Collection" (:name doc1)))
|
||||
(is (= archived-coll-id (:entity-coll-id doc1)))
|
||||
(is (nil? (:collection_id doc1)))
|
||||
(is (nil? (:collection_name doc1)))
|
||||
(is (nil? (:collection_authority_level doc1))))
|
||||
|
||||
;; Document from active collection should have collection info
|
||||
(let [doc2 (first (filter #(= (:id %) doc2-id) results))]
|
||||
(is (= "Document in Active Collection" (:name doc2)))
|
||||
(is (= active-coll-id (:entity-coll-id doc2)))
|
||||
(is (= active-coll-id (:collection_id doc2)))
|
||||
(is (= "Active Collection" (:collection_name doc2)))
|
||||
(is (= "official" (:collection_authority_level doc2)))))))))
|
||||
|
||||
(deftest select-documents-for-recents-archived-documents-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "includes archived documents in results"
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"
|
||||
:archived false}
|
||||
:model/Document {doc-id :id} {:name "Archived Document"
|
||||
:collection_id coll-id
|
||||
:archived true}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc-id])]
|
||||
(is (= 1 (count results)))
|
||||
(let [doc (first results)]
|
||||
(is (= "Archived Document" (:name doc)))
|
||||
(is (true? (:archived doc)))))))))
|
||||
|
||||
(deftest select-documents-for-recents-nonexistent-ids-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "handles non-existent document IDs gracefully"
|
||||
(let [results (recent-views/select-documents-for-recents [999999 888888])]
|
||||
(is (= [] results))))))
|
||||
|
||||
(deftest select-documents-for-recents-mixed-ids-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "handles mixed existing and non-existing document IDs"
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Existing Document"
|
||||
:archived false}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc-id 999999])]
|
||||
(is (= 1 (count results)))
|
||||
(is (= "Existing Document" (:name (first results)))))))))
|
||||
|
||||
(deftest select-documents-for-recents-duplicates-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "preserves order and handles duplicates in document-ids"
|
||||
(mt/with-temp [:model/Document {doc1-id :id} {:name "Document 1" :archived false}
|
||||
:model/Document {doc2-id :id} {:name "Document 2" :archived false}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc1-id doc2-id doc1-id])]
|
||||
;; Should return unique documents even if IDs are duplicated
|
||||
(is (= 2 (count results)))
|
||||
(is (= #{doc1-id doc2-id} (set (map :id results)))))))))
|
||||
|
||||
(deftest recents-api-with-documents-premium-feature-test
|
||||
(testing "/recents API returns documents when :document premium feature is enabled"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
|
||||
:model/Document {doc-id :id} {:name "Test Document"
|
||||
:collection_id coll-id
|
||||
:archived false}]
|
||||
;; Add document to recent views
|
||||
(activity-feed/update-users-recent-views! (mt/user->id :rasta) :model/Document doc-id :view)
|
||||
|
||||
;; Call the API
|
||||
(let [response (mt/user-http-request :rasta :get 200 "activity/recents" :context [:views])]
|
||||
(is (some #(and (= (:model %) "document")
|
||||
(= (:id %) doc-id)
|
||||
(= (:name %) "Test Document"))
|
||||
(:recents response))
|
||||
"Document should be present in recents when premium feature is enabled"))))))
|
||||
|
||||
(deftest select-documents-for-recents-without-premium-feature-test
|
||||
(testing "returns empty vector when :documents premium feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
|
||||
:archived false}]
|
||||
(let [results (recent-views/select-documents-for-recents [doc-id])]
|
||||
(is (= [] results)
|
||||
"Should return empty vector when premium feature is disabled"))))))
|
||||
|
||||
(deftest recents-api-without-documents-premium-feature-test
|
||||
(testing "/recents API does not return documents when :document premium feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {:name "Test Collection"}
|
||||
:model/Document {doc-id :id} {:name "Test Document"
|
||||
:collection_id coll-id
|
||||
:archived false}]
|
||||
;; Add document to recent views
|
||||
(activity-feed/update-users-recent-views! (mt/user->id :rasta) :model/Document doc-id :view)
|
||||
|
||||
;; Call the API
|
||||
(let [response (mt/user-http-request :rasta :get 200 "activity/recents" :context [:views])]
|
||||
(is (not-any? #(= (:model %) :document) (:recents response))
|
||||
"No documents should be present in recents when premium feature is disabled"))))))
|
||||
@@ -0,0 +1,103 @@
|
||||
(ns metabase-enterprise.documents.revisions.impl-test
|
||||
"Unit tests for Document revision serialization and reversion functionality."
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.documents.revisions.impl]
|
||||
[metabase.revisions.models.revision :as revision]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(deftest serialize-instance-document-test
|
||||
(testing "Document revision serialization excludes metadata fields"
|
||||
(let [document {:id 123
|
||||
:name "Test Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
|
||||
:creator_id 456
|
||||
:created_at #t "2023-01-01T00:00:00Z"
|
||||
:updated_at #t "2023-01-02T00:00:00Z"
|
||||
:collection_id 789
|
||||
:archived false}
|
||||
serialized (revision/serialize-instance :model/Document 123 document)]
|
||||
(testing "includes Document-specific fields"
|
||||
(is (= "Test Document" (:name serialized)))
|
||||
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
|
||||
(:document serialized)))
|
||||
(is (= false (:archived serialized))))
|
||||
(testing "excludes metadata fields"
|
||||
(is (not (contains? serialized :id)))
|
||||
(is (not (contains? serialized :creator_id)))
|
||||
(is (not (contains? serialized :created_at)))
|
||||
(is (not (contains? serialized :updated_at)))
|
||||
(is (not (contains? serialized :collection_id)))))))
|
||||
|
||||
(deftest serialize-instance-document-with-id-test
|
||||
(testing "Document revision serialization works with different IDs"
|
||||
(let [document {:id 123
|
||||
:name "Test Document"
|
||||
:document {:type "doc" :content []}
|
||||
:creator_id 456
|
||||
:created_at #t "2023-01-01T00:00:00Z"
|
||||
:updated_at #t "2023-01-02T00:00:00Z"
|
||||
:collection_id 789}]
|
||||
(testing "works with matching ID"
|
||||
(let [serialized (revision/serialize-instance :model/Document 123 document)]
|
||||
(is (= "Test Document" (:name serialized)))
|
||||
(is (not (contains? serialized :id)))))
|
||||
(testing "works with different ID"
|
||||
(let [serialized (revision/serialize-instance :model/Document 999 document)]
|
||||
(is (= "Test Document" (:name serialized)))
|
||||
(is (not (contains? serialized :id))))))))
|
||||
|
||||
(deftest serialize-instance-document-minimal-test
|
||||
(testing "Document revision serialization works with minimal document"
|
||||
(let [document {:id 1
|
||||
:name "Minimal Doc"
|
||||
:document {:type "doc"}
|
||||
:creator_id 2
|
||||
:created_at #t "2023-01-01T00:00:00Z"
|
||||
:updated_at #t "2023-01-01T00:00:00Z"
|
||||
:collection_id nil}
|
||||
serialized (revision/serialize-instance :model/Document 1 document)]
|
||||
(is (= "Minimal Doc" (:name serialized)))
|
||||
(is (= {:type "doc"} (:document serialized)))
|
||||
(is (nil? (:collection_id serialized)) "collection_id should be excluded even when nil")
|
||||
(is (not (contains? serialized :id)))
|
||||
(is (not (contains? serialized :creator_id))))))
|
||||
|
||||
(deftest revert-to-revision-document-test
|
||||
(testing "Document reversion uses default implementation"
|
||||
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Original Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
|
||||
:creator_id (mt/user->id :crowberto)}]
|
||||
(let [original-serialized (revision/serialize-instance :model/Document doc-id document)
|
||||
_ (t2/update! :model/Document doc-id {:name "Updated Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}})
|
||||
updated-document (t2/select-one :model/Document :id doc-id)]
|
||||
|
||||
(testing "document was updated"
|
||||
(is (= "Updated Document" (:name updated-document)))
|
||||
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}
|
||||
(:document updated-document))))
|
||||
|
||||
(testing "reversion restores original state"
|
||||
(revision/revert-to-revision! :model/Document doc-id (mt/user->id :crowberto) original-serialized)
|
||||
(let [reverted-document (t2/select-one :model/Document :id doc-id)]
|
||||
(is (= "Original Document" (:name reverted-document)))
|
||||
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
|
||||
(:document reverted-document)))
|
||||
(testing "preserves metadata fields not in serialization"
|
||||
(is (= doc-id (:id reverted-document)))
|
||||
(is (= (mt/user->id :crowberto) (:creator_id reverted-document)))
|
||||
(is (:created_at reverted-document))
|
||||
(is (:updated_at reverted-document)))))))))
|
||||
|
||||
(deftest excluded-columns-test
|
||||
(testing "excluded columns constant contains expected fields"
|
||||
(let [excluded-columns @#'metabase-enterprise.documents.revisions.impl/excluded-columns-for-document-revision]
|
||||
(is (contains? excluded-columns :id))
|
||||
(is (contains? excluded-columns :creator_id))
|
||||
(is (contains? excluded-columns :created_at))
|
||||
(is (contains? excluded-columns :updated_at))
|
||||
(is (contains? excluded-columns :collection_id))
|
||||
(is (contains? excluded-columns :collection_position))
|
||||
(is (= 8 (count excluded-columns)) "Should exclude exactly 5 metadata fields"))))
|
||||
@@ -0,0 +1,270 @@
|
||||
(ns metabase-enterprise.documents.revisions.integration-test
|
||||
"Integration tests for Document revision history including API endpoints, events, and permissions."
|
||||
(:require
|
||||
[clojure.string :as str]
|
||||
[clojure.test :refer :all]
|
||||
[metabase-enterprise.documents.revisions.impl]
|
||||
[metabase.config.core :as config]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.revisions.models.revision :as revision]
|
||||
[metabase.test :as mt]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(def ^:private rasta-revision-info
|
||||
(delay {:id (mt/user->id :rasta)
|
||||
:first_name "Rasta"
|
||||
:last_name "Toucan"
|
||||
:common_name "Rasta Toucan"}))
|
||||
|
||||
(defn- create-document-revision!
|
||||
"Helper function to create a Document revision manually for testing."
|
||||
[document-id is-creation? user]
|
||||
(revision/push-revision!
|
||||
{:object (t2/select-one :model/Document :id document-id)
|
||||
:entity :model/Document
|
||||
:id document-id
|
||||
:user-id (mt/user->id user)
|
||||
:is-creation? is-creation?}))
|
||||
|
||||
(defn- get-document-revisions
|
||||
"Helper function to get revisions for a Document via API."
|
||||
[document-id]
|
||||
(mt/user-http-request :rasta :get 200 (format "revision/document/%d" document-id)))
|
||||
|
||||
(deftest document-create-event-triggers-revision-test
|
||||
(testing "Document creation triggers revision creation via events"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Test Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]}
|
||||
:creator_id (mt/user->id :crowberto)}]
|
||||
(events/publish-event! :event/document-create {:object document :user-id (mt/user->id :crowberto)})
|
||||
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)]
|
||||
(is revision "Revision should be created")
|
||||
(is (= "Document" (:model revision)))
|
||||
(is (= doc-id (:model_id revision)))
|
||||
(is (= (mt/user->id :crowberto) (:user_id revision)))
|
||||
(is (:is_creation revision))
|
||||
(is (not (:is_reversion revision)))
|
||||
(testing "serialized object excludes metadata fields"
|
||||
(let [object (:object revision)]
|
||||
(is (= "Test Document" (:name object)))
|
||||
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Hello World"}]}]} (:document object)))
|
||||
(is (not (contains? object :id)))
|
||||
(is (not (contains? object :creator_id)))
|
||||
(is (not (contains? object :created_at)))
|
||||
(is (not (contains? object :updated_at)))
|
||||
(is (not (contains? object :collection_id))))))))))
|
||||
|
||||
(deftest document-update-event-triggers-revision-test
|
||||
(testing "Document update triggers revision creation via events"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Original Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
|
||||
:creator_id (mt/user->id :crowberto)}]
|
||||
(let [updated-document (assoc document
|
||||
:name "Updated Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]})]
|
||||
(events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)})
|
||||
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id :user_id (mt/user->id :rasta))]
|
||||
(is revision "Revision should be created for update")
|
||||
(is (= "Document" (:model revision)))
|
||||
(is (= doc-id (:model_id revision)))
|
||||
(is (= (mt/user->id :rasta) (:user_id revision)))
|
||||
(is (not (:is_creation revision)))
|
||||
(is (not (:is_reversion revision)))
|
||||
(testing "serialized object contains updated data"
|
||||
(let [object (:object revision)]
|
||||
(is (= "Updated Document" (:name object)))
|
||||
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]} (:document object)))))))))))
|
||||
|
||||
(deftest document-revision-api-endpoints-test
|
||||
(testing "Document revision API endpoints work correctly"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Test Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Initial content"}]}]}
|
||||
:creator_id (mt/user->id :rasta)}]
|
||||
(create-document-revision! doc-id true :rasta)
|
||||
|
||||
(testing "GET /api/revision/document/:id returns Document revisions"
|
||||
(let [revisions (get-document-revisions doc-id)]
|
||||
(is (= 1 (count revisions)))
|
||||
(let [revision (first revisions)]
|
||||
(is (:is_creation revision))
|
||||
(is (not (:is_reversion revision)))
|
||||
(is (= "created this." (:description revision)))
|
||||
(is (=? @rasta-revision-info (:user revision)))
|
||||
(is (= config/mb-version-string (:metabase_version revision))))))
|
||||
|
||||
(testing "Multiple revisions are returned in correct order"
|
||||
(t2/update! :model/Document doc-id {:name "Updated Document"})
|
||||
(create-document-revision! doc-id false :rasta)
|
||||
|
||||
(let [revisions (get-document-revisions doc-id)]
|
||||
(is (= 2 (count revisions)))
|
||||
(let [[latest-revision creation-revision] revisions]
|
||||
(is (not (:is_creation latest-revision)))
|
||||
(is (:is_creation creation-revision))
|
||||
(is (= "created this." (:description creation-revision)))
|
||||
(is (not= "created this." (:description latest-revision))))))))))
|
||||
|
||||
(deftest document-reversion-api-test
|
||||
(testing "Document reversion via API works correctly"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Original Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
|
||||
:creator_id (mt/user->id :rasta)}]
|
||||
(create-document-revision! doc-id true :rasta)
|
||||
|
||||
(testing "Document can be updated and reverted"
|
||||
(t2/update! :model/Document doc-id {:name "Updated Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Updated content"}]}]}})
|
||||
(create-document-revision! doc-id false :rasta)
|
||||
|
||||
(let [revisions (revision/revisions :model/Document doc-id)
|
||||
[_ {original-revision-id :id}] revisions]
|
||||
|
||||
(testing "Revert via API endpoint"
|
||||
(let [revert-response (mt/user-http-request :rasta :post "revision/revert"
|
||||
{:entity :document
|
||||
:id doc-id
|
||||
:revision_id original-revision-id})]
|
||||
(is (:is_reversion revert-response))
|
||||
(is (= "reverted to an earlier version." (:description revert-response)))
|
||||
(is (=? @rasta-revision-info (:user revert-response)))))
|
||||
|
||||
(testing "Document state is restored"
|
||||
(let [reverted-document (t2/select-one :model/Document :id doc-id)]
|
||||
(is (= "Original Document" (:name reverted-document)))
|
||||
(is (= {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
|
||||
(:document reverted-document)))))
|
||||
|
||||
(testing "Reversion creates new revision entry"
|
||||
(let [final-revisions (get-document-revisions doc-id)]
|
||||
(is (= 3 (count final-revisions)))
|
||||
(let [latest-revision (first final-revisions)]
|
||||
(is (:is_reversion latest-revision))
|
||||
(is (= "reverted to an earlier version." (:description latest-revision))))))))))))
|
||||
|
||||
(deftest document-revision-permission-checks-test
|
||||
(testing "Document revision API respects permissions"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(mt/with-temp [:model/Collection collection {:name "Private collection"}
|
||||
:model/Document document {:collection_id (u/the-id collection)
|
||||
:name "Private document"
|
||||
:document {:type "doc" :content []}
|
||||
:creator_id (mt/user->id :crowberto)}]
|
||||
(create-document-revision! (:id document) true :crowberto)
|
||||
(t2/update! :model/Document (:id document) {:name "Updated Private Document"})
|
||||
(create-document-revision! (:id document) false :crowberto)
|
||||
|
||||
(let [document-id (u/the-id document)
|
||||
[_ {prev-rev-id :id}] (revision/revisions :model/Document document-id)]
|
||||
|
||||
(testing "User without permissions cannot access revisions"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :get 403 (format "revision/document/%d" document-id)))))
|
||||
|
||||
(testing "User without permissions cannot revert"
|
||||
(is (= "You don't have permissions to do that."
|
||||
(mt/user-http-request :rasta :post 403 "revision/revert"
|
||||
{:entity :document
|
||||
:id document-id
|
||||
:revision_id prev-rev-id}))))))))))
|
||||
|
||||
(deftest document-revision-cross-boundary-integration-test
|
||||
(testing "Document revisions integrate properly with OSS revision system"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Cross-boundary Document"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Enterprise content"}]}]}
|
||||
:creator_id (mt/user->id :rasta)}]
|
||||
|
||||
(testing "Document revisions work with OSS revision/revisions function"
|
||||
(create-document-revision! doc-id true :rasta)
|
||||
(let [revisions (revision/revisions :model/Document doc-id)]
|
||||
(is (seq revisions))
|
||||
(is (= 1 (count revisions)))
|
||||
(let [revision (first revisions)]
|
||||
(is (= doc-id (:model_id revision)))
|
||||
(is (= "Document" (:model revision))))))
|
||||
|
||||
(testing "Document revisions work with OSS revision API routes"
|
||||
(let [revisions-response (mt/user-http-request :rasta :get (format "revision/document/%d" doc-id))]
|
||||
(is (seq revisions-response))
|
||||
(is (every? #(contains? % :is_creation) revisions-response))
|
||||
(is (every? #(contains? % :description) revisions-response))))
|
||||
|
||||
(testing "Document reversion integrates with OSS revert endpoint"
|
||||
(t2/update! :model/Document doc-id {:name "Modified Document"})
|
||||
(create-document-revision! doc-id false :rasta)
|
||||
|
||||
(let [[_ {original-rev-id :id}] (revision/revisions :model/Document doc-id)
|
||||
revert-response (mt/user-http-request :rasta :post "revision/revert"
|
||||
{:entity :document
|
||||
:id doc-id
|
||||
:revision_id original-rev-id})]
|
||||
(is (:is_reversion revert-response))
|
||||
(let [reverted-doc (t2/select-one :model/Document :id doc-id)]
|
||||
(is (= "Cross-boundary Document" (:name reverted-doc))))))))))
|
||||
|
||||
(deftest document-revision-event-handler-integration-test
|
||||
(testing "Document revision events are published and handled correctly"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(let [original-event events/publish-event!
|
||||
events-received (atom [])]
|
||||
(with-redefs [events/publish-event! (fn [topic event]
|
||||
(swap! events-received conj [topic event])
|
||||
(original-event topic event))]
|
||||
(mt/with-temp [:model/Document {doc-id :id, :as document} {:name "Event Test Document"
|
||||
:document {:type "doc" :content []}
|
||||
:creator_id (mt/user->id :rasta)}]
|
||||
|
||||
(testing "Create event triggers revision creation"
|
||||
(events/publish-event! :event/document-create {:object document :user-id (mt/user->id :rasta)})
|
||||
(let [revision (t2/select-one :model/Revision :model "Document" :model_id doc-id)]
|
||||
(is revision)
|
||||
(is (:is_creation revision))))
|
||||
|
||||
(testing "Update event triggers revision creation"
|
||||
(let [updated-document (assoc document :name "Updated Event Document")]
|
||||
(events/publish-event! :event/document-update {:object updated-document :user-id (mt/user->id :rasta)})
|
||||
(let [revisions (t2/select :model/Revision :model "Document" :model_id doc-id {:order-by [[:id :desc]]})]
|
||||
(is (= 2 (count revisions)))
|
||||
(let [latest-revision (first revisions)]
|
||||
(is (not (:is_creation latest-revision)))
|
||||
(is (= "Updated Event Document" (get-in latest-revision [:object :name])))))))
|
||||
|
||||
(testing "Events were published correctly"
|
||||
(is (>= (count @events-received) 2))
|
||||
(is (some #(= :event/document-create (first %)) @events-received))
|
||||
(is (some #(= :event/document-update (first %)) @events-received)))))))))
|
||||
|
||||
(deftest document-revision-diff-and-descriptions-test
|
||||
(testing "Document revision diffs and descriptions work correctly"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Original Title"
|
||||
:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "Original content"}]}]}
|
||||
:creator_id (mt/user->id :rasta)}]
|
||||
(create-document-revision! doc-id true :rasta)
|
||||
|
||||
(testing "Name change produces correct diff and description"
|
||||
(t2/update! :model/Document doc-id {:name "Updated Title"})
|
||||
(create-document-revision! doc-id false :rasta)
|
||||
|
||||
(let [revisions (get-document-revisions doc-id)
|
||||
update-revision (first revisions)]
|
||||
(is (= "Original Title" (get-in update-revision [:diff :before :name])))
|
||||
(is (= "Updated Title" (get-in update-revision [:diff :after :name])))
|
||||
(is (str/includes? (:description update-revision) "renamed"))))
|
||||
|
||||
(testing "Content change produces diff"
|
||||
(t2/update! :model/Document doc-id {:document {:type "doc" :content [{:type "paragraph" :content [{:type "text" :text "New content"}]}]}})
|
||||
(create-document-revision! doc-id false :rasta)
|
||||
|
||||
(let [revisions (get-document-revisions doc-id)
|
||||
content-revision (first revisions)]
|
||||
(is (= {:content [{:content [{:text "Original content"}]}]}
|
||||
(get-in content-revision [:diff :before :document])))
|
||||
(is (= {:content [{:content [{:text "New content"}]}]}
|
||||
(get-in content-revision [:diff :after :document])))))))))
|
||||
@@ -0,0 +1,176 @@
|
||||
(ns metabase-enterprise.documents.search-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.permissions.core :as perms]
|
||||
[metabase.permissions.models.permissions-group :as perms-group]
|
||||
[metabase.search.test-util :as search.tu]
|
||||
[metabase.test :as mt]
|
||||
[metabase.test.fixtures :as fixtures]
|
||||
[metabase.util :as u]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(defn- text->prose-mirror-ast
|
||||
"Convert plain text to a ProseMirror AST structure."
|
||||
[text]
|
||||
(if (empty? text)
|
||||
{:type "doc" :content []}
|
||||
{:type "doc"
|
||||
:content [{:type "paragraph"
|
||||
:content [{:type "text"
|
||||
:text text}]}]}))
|
||||
|
||||
(use-fixtures :once (fixtures/initialize :db :test-users))
|
||||
|
||||
(defn card-with-name-and-query
|
||||
([]
|
||||
(card-with-name-and-query (mt/random-name)))
|
||||
([card-name]
|
||||
{:name card-name
|
||||
:display "scalar"
|
||||
:dataset_query (mt/mbql-query venues)
|
||||
:visualization_settings {:global {:title nil}}}))
|
||||
|
||||
(deftest card-search-exclusion-test
|
||||
(testing "Cards with document_id are excluded from search indexing"
|
||||
(let [card-name (mt/random-name)
|
||||
regular-card-name (str card-name "-regular")]
|
||||
(search.tu/with-temp-index-table
|
||||
(mt/with-temp [:model/Document document {:name "Test Document"
|
||||
:document (text->prose-mirror-ast "")}
|
||||
:model/Card document-card (-> (card-with-name-and-query card-name)
|
||||
(assoc :document_id (u/the-id document)))
|
||||
:model/Card regular-card (card-with-name-and-query regular-card-name)]
|
||||
|
||||
(testing "Search API excludes document cards"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "search" :q card-name :models "card")]
|
||||
(is (not (some #(= (:id %) (u/the-id document-card)) (:data results)))
|
||||
"Document card should not appear in search results"))
|
||||
(let [regular-results (mt/user-http-request :crowberto :get 200 "search" :q regular-card-name :models "card")]
|
||||
(is (some #(= (:id %) (u/the-id regular-card)) (:data regular-results))
|
||||
"Regular card should appear in search results"))))))))
|
||||
|
||||
(deftest document-permission-filtering-test
|
||||
(testing "Document search respects collection-based permissions"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-non-admin-groups-no-root-collection-perms
|
||||
(search.tu/with-temp-index-table
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Collection {private-coll-id :id} {}
|
||||
:model/Document {doc-in-public-coll :id} {:name "Public Document"
|
||||
:collection_id coll-id
|
||||
:document (text->prose-mirror-ast "content")}
|
||||
:model/Document {doc-in-private-coll :id} {:name "Private Document"
|
||||
:collection_id private-coll-id
|
||||
:document (text->prose-mirror-ast "content")}
|
||||
:model/Document {doc-archived :id} {:name "Archived Document"
|
||||
:collection_id coll-id
|
||||
:archived true
|
||||
:document (text->prose-mirror-ast "content")}]
|
||||
;; Give user read access to first collection only
|
||||
(perms/grant-collection-read-permissions! (perms-group/all-users) coll-id)
|
||||
|
||||
(testing "Regular user sees only documents in accessible collections"
|
||||
(let [results (mt/user-http-request :rasta :get 200 "search" :q "Document" :models "document")]
|
||||
(is (= #{doc-in-public-coll}
|
||||
(set (map :id (:data results))))
|
||||
"Should only see document in accessible collection")))
|
||||
|
||||
(testing "Regular user cannot see archived documents (no write perms)"
|
||||
(let [results (mt/user-http-request :rasta :get 200 "search" :q "Archived" :archived true :models "document")]
|
||||
(is (empty? (:data results))
|
||||
"Should not see archived document without write permissions")))
|
||||
|
||||
(testing "Admin can see all documents including archived"
|
||||
(let [regular-results (mt/user-http-request :crowberto :get 200 "search" :q "Document" :models "document")
|
||||
archived-results (mt/user-http-request :crowberto :get 200 "search" :q "Archived" :archived true :models "document")]
|
||||
(is (= #{doc-in-public-coll doc-in-private-coll}
|
||||
(set (map :id (:data regular-results))))
|
||||
"Admin should see all non-archived documents")
|
||||
(is (= #{doc-archived}
|
||||
(set (map :id (:data archived-results))))
|
||||
"Admin should see archived documents")))
|
||||
|
||||
(testing "User with write permissions can see archived documents"
|
||||
;; Give user write access to first collection
|
||||
(perms/grant-collection-readwrite-permissions! (perms-group/all-users) coll-id)
|
||||
(let [archived-results (mt/user-http-request :rasta :get 200 "search" :q "Archived" :archived true :models "document")]
|
||||
(is (= #{doc-archived}
|
||||
(set (map :id (:data archived-results))))
|
||||
"User with write permissions should see archived documents in accessible collections")))))))))
|
||||
|
||||
(deftest document-search-without-premium-feature-test
|
||||
(testing "Documents are not returned from search when documents premium feature is disabled"
|
||||
(mt/with-premium-features #{}
|
||||
(search.tu/with-temp-index-table
|
||||
(mt/with-temp [:model/Collection {coll-id :id} {}
|
||||
:model/Document {doc-id :id} {:name "Test Document"
|
||||
:collection_id coll-id
|
||||
:document (text->prose-mirror-ast "searchable content")}
|
||||
:model/Card {card-id :id} (-> (card-with-name-and-query "Test Card")
|
||||
(assoc :collection_id coll-id))]
|
||||
|
||||
(testing "Search for documents returns empty results"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "search" :q "Test Document" :models "document")]
|
||||
(is (empty? (:data results))
|
||||
"Should not return any documents when premium feature is disabled")))
|
||||
|
||||
(testing "Search for cards still works normally"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "search" :q "Test Card" :models "card")]
|
||||
(is (= #{card-id}
|
||||
(set (map :id (:data results))))
|
||||
"Should still return cards when documents feature is disabled")))
|
||||
|
||||
(testing "General search excludes documents but includes other models"
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "search" :q "Test")]
|
||||
(is (some #(= (:id %) card-id) (:data results))
|
||||
"Should include cards in general search")
|
||||
(is (not (some #(= (:id %) doc-id) (:data results)))
|
||||
"Should not include documents in general search when feature is disabled"))))))))
|
||||
|
||||
(deftest document-view-tracking-integration-test
|
||||
(testing "Document view tracking integrates with search"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Document {doc-id :id} {:name "Viewed Document"
|
||||
:document (text->prose-mirror-ast "content")
|
||||
:view_count 0}]
|
||||
|
||||
(testing "Document has initial state"
|
||||
(let [doc (t2/select-one :model/Document :id doc-id)]
|
||||
(is (= 0 (:view_count doc)))
|
||||
;; last_viewed_at has a default timestamp from migration, not nil
|
||||
(is (some? (:last_viewed_at doc)))))
|
||||
|
||||
(testing "Publishing document-read event works without errors"
|
||||
;; The actual view count increment is batched and asynchronous
|
||||
;; So we test that the event publishes successfully
|
||||
(is (some? (events/publish-event! :event/document-read
|
||||
{:object-id doc-id
|
||||
:user-id (mt/user->id :crowberto)}))
|
||||
"Document read event should publish successfully"))
|
||||
|
||||
(testing "Document with higher view count appears in search"
|
||||
;; Manually set view count to test search integration
|
||||
(t2/update! :model/Document doc-id {:view_count 5
|
||||
:last_viewed_at (t/offset-date-time)})
|
||||
(search.tu/with-temp-index-table
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "search" {:q "Viewed" :models "document"})
|
||||
doc-results (filter #(= "document" (:model %)) (:data results))]
|
||||
(when (seq doc-results)
|
||||
(let [doc-result (first doc-results)]
|
||||
(is (= doc-id (:id doc-result)))
|
||||
;; View count affects scoring but isn't directly in result
|
||||
(is (some #(= "view-count" (:name %)) (:scores doc-result))))))))
|
||||
|
||||
(testing "Document with recent view appears in search results"
|
||||
(let [recent-time (t/minus (t/offset-date-time) (t/minutes 5))]
|
||||
(t2/update! :model/Document doc-id {:last_viewed_at recent-time})
|
||||
(search.tu/with-temp-index-table
|
||||
(let [results (mt/user-http-request :crowberto :get 200 "search" {:q "Viewed" :models "document"})
|
||||
doc-results (filter #(= "document" (:model %)) (:data results))]
|
||||
(when (seq doc-results)
|
||||
(let [doc-result (first doc-results)]
|
||||
(is (= doc-id (:id doc-result)))
|
||||
;; User recency affects scoring
|
||||
(is (some #(= "user-recency" (:name %)) (:scores doc-result)))))))))))))
|
||||
@@ -0,0 +1,149 @@
|
||||
(ns metabase-enterprise.documents.view-log-test
|
||||
(:require
|
||||
[clojure.test :refer :all]
|
||||
[java-time.api :as t]
|
||||
[metabase-enterprise.documents.view-log :as documents.view-log]
|
||||
[metabase.events.core :as events]
|
||||
[metabase.test :as mt]
|
||||
[toucan2.core :as t2]))
|
||||
|
||||
(set! *warn-on-reflection* true)
|
||||
|
||||
(defn latest-view
|
||||
"Returns the most recent view for a given user and document ID"
|
||||
[user-id document-id]
|
||||
(t2/select-one :model/ViewLog
|
||||
:user_id user-id
|
||||
:model "document"
|
||||
:model_id document-id
|
||||
{:order-by [[:id :desc]]}))
|
||||
|
||||
(deftest document-read-ee-test
|
||||
(mt/with-premium-features #{:audit-app :documents}
|
||||
(mt/with-temp [:model/Collection collection {}
|
||||
:model/User user {}
|
||||
:model/Document document {:collection_id (:id collection)
|
||||
:name "Test Document"
|
||||
:document "{\"type\":\"doc\",\"content\":[]}"
|
||||
:creator_id (:id user)}]
|
||||
(testing "A basic document read event is recorded in EE"
|
||||
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
|
||||
(is (partial=
|
||||
{:user_id (:id user)
|
||||
:model "document"
|
||||
:model_id (:id document)
|
||||
:has_access true
|
||||
:context nil}
|
||||
(latest-view (:id user) (:id document))))))))
|
||||
|
||||
(deftest document-read-oss-no-view-logging-test
|
||||
(mt/with-premium-features #{:documents} ; documents feature but not audit-app
|
||||
(mt/with-temp [:model/Collection collection {}
|
||||
:model/User user {}
|
||||
:model/Document document {:collection_id (:id collection)
|
||||
:name "Test Document"
|
||||
:document "{\"type\":\"doc\",\"content\":[]}"
|
||||
:creator_id (:id user)}]
|
||||
(testing "A basic document read event is not recorded without audit-app"
|
||||
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
|
||||
(is (nil? (latest-view (:id user) (:id document)))
|
||||
"view log entries should not be made without audit-app feature")))))
|
||||
|
||||
(deftest document-read-view-count-test
|
||||
(mt/test-helpers-set-global-values!
|
||||
(mt/with-temporary-setting-values [synchronous-batch-updates true]
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection collection {}
|
||||
:model/User user {}
|
||||
:model/Document document {:collection_id (:id collection)
|
||||
:name "Test Document"
|
||||
:document "{\"type\":\"doc\",\"content\":[]}"
|
||||
:creator_id (:id user)
|
||||
:view_count 0}]
|
||||
(testing "Document read events are recorded by a document's view_count"
|
||||
(is (= 0 (:view_count document))
|
||||
"view_count should be 0 before the event is published")
|
||||
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
|
||||
(is (= 1 (t2/select-one-fn :view_count :model/Document (:id document))))
|
||||
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
|
||||
(is (= 2 (t2/select-one-fn :view_count :model/Document (:id document))))))))))
|
||||
|
||||
(deftest update-document-last-viewed-at-test
|
||||
(let [now (-> (t/offset-date-time)
|
||||
(.withNano 0))
|
||||
one-hour-ago (t/minus now (t/hours 1))
|
||||
two-hours-ago (t/minus now (t/hours 2))]
|
||||
(testing "update with multiple documents of the same IDs will set timestamp to the latest"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection collection {}
|
||||
:model/User user {}
|
||||
:model/Document document {:collection_id (:id collection)
|
||||
:name "Test Document"
|
||||
:document "{\"type\":\"doc\",\"content\":[]}"
|
||||
:creator_id (:id user)
|
||||
:last_viewed_at two-hours-ago}]
|
||||
(#'documents.view-log/update-document-last-viewed-at!* [{:id (:id document) :timestamp one-hour-ago}
|
||||
{:id (:id document) :timestamp two-hours-ago}])
|
||||
(is (= one-hour-ago
|
||||
(-> (t2/select-one-fn :last_viewed_at :model/Document (:id document))
|
||||
t/offset-date-time
|
||||
(.withNano 0)))))))
|
||||
|
||||
(testing "if the existing last_viewed_at is greater than the updating values, do not override it"
|
||||
(mt/with-premium-features #{:documents}
|
||||
(mt/with-temp [:model/Collection collection {}
|
||||
:model/User user {}
|
||||
:model/Document document {:collection_id (:id collection)
|
||||
:name "Test Document"
|
||||
:document "{\"type\":\"doc\",\"content\":[]}"
|
||||
:creator_id (:id user)
|
||||
:last_viewed_at now}]
|
||||
(#'documents.view-log/update-document-last-viewed-at!* [{:id (:id document) :timestamp one-hour-ago}])
|
||||
(is (= now
|
||||
(-> (t2/select-one-fn :last_viewed_at :model/Document (:id document))
|
||||
t/offset-date-time
|
||||
(.withNano 0)))))))))
|
||||
|
||||
(deftest document-event-derivation-test
|
||||
(testing "Document events are properly derived from base events"
|
||||
(is (isa? :metabase-enterprise.documents.view-log/document-read :metabase/event))
|
||||
(is (isa? :event/document-read :metabase-enterprise.documents.view-log/document-read))))
|
||||
|
||||
(deftest document-read-error-handling-test
|
||||
(mt/with-premium-features #{:documents}
|
||||
(testing "Document read event handles missing document gracefully"
|
||||
;; This should not throw an exception
|
||||
(is (some? (events/publish-event! :event/document-read {:object-id 999999 :user-id (mt/user->id :rasta)}))))))
|
||||
|
||||
(deftest document-statistics-lock-test
|
||||
(testing "Document statistics lock is properly defined"
|
||||
(is (= :metabase-enterprise.documents.view-log/document-statistics-lock
|
||||
@#'documents.view-log/document-statistics-lock))))
|
||||
|
||||
(deftest document-read-updates-recent-views-test
|
||||
(mt/with-premium-features #{:audit-app :documents}
|
||||
(mt/with-temp [:model/Collection collection {}
|
||||
:model/User user {}
|
||||
:model/Document document {:collection_id (:id collection)
|
||||
:name "Test Document"
|
||||
:document "{\"type\":\"doc\",\"content\":[]}"
|
||||
:creator_id (:id user)}]
|
||||
(testing "Document read event updates user's recent views"
|
||||
;; Verify no recent views initially
|
||||
(is (empty? (t2/select :model/RecentViews
|
||||
:user_id (:id user)
|
||||
:model "document"
|
||||
:model_id (:id document))))
|
||||
|
||||
;; Publish document read event
|
||||
(events/publish-event! :event/document-read {:object-id (:id document) :user-id (:id user)})
|
||||
|
||||
;; Verify recent view was created
|
||||
(let [recent-view (t2/select-one :model/RecentViews
|
||||
:user_id (:id user)
|
||||
:model "document"
|
||||
:model_id (:id document))]
|
||||
(is (some? recent-view) "Recent view should be created")
|
||||
(is (= (:id user) (:user_id recent-view)))
|
||||
(is (= "document" (:model recent-view)))
|
||||
(is (= (:id document) (:model_id recent-view))))))))
|
||||
@@ -4,6 +4,7 @@
|
||||
[malli.core :as mc]
|
||||
[malli.transform :as mtx]
|
||||
[medley.core :as m]
|
||||
[metabase-enterprise.metabot-v3.client :as metabot-v3.client]
|
||||
[metabase-enterprise.metabot-v3.tools.api :as metabot-v3.tools.api]
|
||||
[metabase-enterprise.metabot-v3.tools.create-dashboard-subscription :as metabot-v3.tools.create-dashboard-subscription]
|
||||
[metabase-enterprise.metabot-v3.tools.filters :as metabot-v3.tools.filters]
|
||||
@@ -37,7 +38,7 @@
|
||||
([] (ai-session-token :rasta (str (random-uuid))))
|
||||
([metabot-id] (ai-session-token :rasta metabot-id))
|
||||
([user metabot-id]
|
||||
(-> user mt/user->id (#'metabot-v3.tools.api/get-ai-service-token metabot-id))))
|
||||
(-> user mt/user->id (#'metabot-v3.client/get-ai-service-token metabot-id))))
|
||||
|
||||
(deftest create-dashboard-subscription-test
|
||||
(mt/with-premium-features #{:metabot-v3}
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
:model/DashboardBookmark
|
||||
:model/DataPermissions
|
||||
:model/DatabaseRouter
|
||||
:model/DocumentBookmark
|
||||
:model/CollectionPermissionGraphRevision
|
||||
:model/DashboardCardSeries
|
||||
:model/LoginHistory
|
||||
|
||||
@@ -1832,6 +1832,70 @@
|
||||
(is (= #{[{:model "Collection" :id model-eid}] [{:model "Card" :id card-eid}]}
|
||||
(set (serdes/dependencies ser))))))))))
|
||||
|
||||
(deftest document-test
|
||||
(mt/with-empty-h2-app-db!
|
||||
(ts/with-temp-dpc [:model/Collection collection {}
|
||||
:model/User user {:first_name "Mark"
|
||||
:last_name "Knopfler"
|
||||
:email "mark@direstrai.ts"}
|
||||
:model/Document document {:name "Test Document"
|
||||
:document {}
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:collection_id (:id collection)
|
||||
:creator_id (:id user)}
|
||||
:model/Card card {:name "Dependent Card"
|
||||
:document_id (u/the-id document)}
|
||||
:model/Card linked-card {:name "Linked Card"}
|
||||
:model/Dashboard dashboard {:name "Smart Linked Dashboard"}
|
||||
:model/Table table {:name "linked_table"}]
|
||||
|
||||
(t2/update! :model/Document :id (u/the-id document) {:document {:type "doc"
|
||||
:content [{:type "cardEmbed"
|
||||
:attrs {:id (u/the-id card)}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId (u/the-id linked-card)
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId (u/the-id table)
|
||||
:model "table"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId (u/the-id dashboard)
|
||||
:model "dashboard"}}]}})
|
||||
(testing "document extraction"
|
||||
(let [ser (ts/extract-one "Document" (u/the-id document))]
|
||||
(is (=? {:serdes/meta [{:model "Document" :id (:entity_id document)}]
|
||||
:name "Test Document"
|
||||
:entity_id (:entity_id document)
|
||||
:document {:type "doc"
|
||||
:content [{:type "cardEmbed"
|
||||
:attrs {:id [{:model "Card" :id (:entity_id card)}]}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Card" :id (:entity_id linked-card)}]
|
||||
:model "card"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId (serdes/generate-path "Table" table)
|
||||
:model "table"}}
|
||||
{:type "smartLink"
|
||||
:attrs {:entityId [{:model "Dashboard" :id (:entity_id dashboard)}]
|
||||
:model "dashboard"}}]}
|
||||
:archived false
|
||||
:archived_directly false
|
||||
:creator_id (:email user)
|
||||
:collection_id (:entity_id collection)
|
||||
:content_type "application/json+vnd.prose-mirror"
|
||||
:updated_at string?
|
||||
:created_at string?}
|
||||
ser))
|
||||
(is (not (contains? ser :id)))
|
||||
|
||||
(testing "depends on its collection, cardEmbeds and smarkLinks "
|
||||
(is (= #{[{:model "Collection" :id (:entity_id collection)}]
|
||||
[{:model "Card" :id (:entity_id card)}]
|
||||
[{:model "Dashboard" :id (:entity_id dashboard)}]
|
||||
[{:model "Card" :id (:entity_id linked-card)}]
|
||||
(serdes/generate-path "Table" table)}
|
||||
(set (serdes/dependencies ser))))))))))
|
||||
|
||||
(deftest visualizer-dashboard-card-settings-test
|
||||
(testing "visualizer settings transform entity IDs <-> card IDs"
|
||||
(let [card-entity-id "WcMlLFNVcy0iO49mKW3WH"
|
||||
|
||||
54
enterprise/frontend/src/metabase-enterprise/api/document.ts
Normal file
54
enterprise/frontend/src/metabase-enterprise/api/document.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
CreateDocumentRequest,
|
||||
DeleteDocumentRequest,
|
||||
Document,
|
||||
GetDocumentRequest,
|
||||
UpdateDocumentRequest,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import { EnterpriseApi } from "./api";
|
||||
import { idTag, listTag } from "./tags";
|
||||
|
||||
export const documentApi = EnterpriseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getDocument: builder.query<Document, GetDocumentRequest>({
|
||||
query: ({ id }) => ({
|
||||
method: "GET",
|
||||
url: `/api/ee/document/${id}`,
|
||||
}),
|
||||
providesTags: (result, error, { id }) =>
|
||||
!error ? [idTag("document", id)] : [],
|
||||
}),
|
||||
createDocument: builder.mutation<Document, CreateDocumentRequest>({
|
||||
query: (body) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/document",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_, error) => (error ? [] : [listTag("document")]), // TODO: invalidate parent collection?
|
||||
}),
|
||||
updateDocument: builder.mutation<Document, UpdateDocumentRequest>({
|
||||
query: (document) => ({
|
||||
method: "PUT",
|
||||
url: `/api/ee/document/${document.id}`,
|
||||
body: document,
|
||||
}),
|
||||
invalidatesTags: (_, error, { id }) =>
|
||||
!error ? [listTag("document"), idTag("document", id)] : [],
|
||||
}),
|
||||
deleteDocument: builder.mutation<void, DeleteDocumentRequest>({
|
||||
query: (document) => ({
|
||||
method: "DELETE",
|
||||
url: `/api/ee/document/${document.id}`,
|
||||
}),
|
||||
invalidatesTags: (_, error, { id }) =>
|
||||
!error ? [listTag("document"), idTag("document", id)] : [],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export const {
|
||||
useGetDocumentQuery,
|
||||
useCreateDocumentMutation,
|
||||
useUpdateDocumentMutation,
|
||||
} = documentApi;
|
||||
@@ -3,10 +3,11 @@ export * from "./ai-sql-generation";
|
||||
export * from "./audit-info";
|
||||
export * from "./api";
|
||||
export * from "./billing-info";
|
||||
export * from "./metabot";
|
||||
export * from "./content-translation";
|
||||
export * from "./gdrive";
|
||||
export * from "./db-routing";
|
||||
export * from "./gdrive";
|
||||
export * from "./metabot";
|
||||
export * from "./document";
|
||||
export * from "./saml";
|
||||
export * from "./scim";
|
||||
export * from "./tags";
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
DeleteSuggestedMetabotPromptRequest,
|
||||
MetabotApiEntity,
|
||||
MetabotDocumentNodeRequest,
|
||||
MetabotDocumentNodeResponse,
|
||||
MetabotEntity,
|
||||
MetabotId,
|
||||
MetabotInfo,
|
||||
@@ -111,6 +113,16 @@ export const metabotApi = EnterpriseApi.injectEndpoints({
|
||||
invalidatesTags: (_, error, metabot_id) =>
|
||||
!error ? [idTag("metabot-prompt-suggestions", metabot_id)] : [],
|
||||
}),
|
||||
metabotDocumentNode: builder.query<
|
||||
MetabotDocumentNodeResponse,
|
||||
MetabotDocumentNodeRequest
|
||||
>({
|
||||
query: (params) => ({
|
||||
method: "POST",
|
||||
url: "/api/ee/metabot-v3/document/generate-content",
|
||||
params,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -122,4 +134,5 @@ export const {
|
||||
useGetSuggestedMetabotPromptsQuery,
|
||||
useDeleteSuggestedMetabotPromptMutation,
|
||||
useRegenerateSuggestedMetabotPromptsMutation,
|
||||
useLazyMetabotDocumentNodeQuery,
|
||||
} = metabotApi;
|
||||
|
||||
@@ -6,6 +6,7 @@ export const ENTERPRISE_TAG_TYPES = [
|
||||
"metabot-entities-list",
|
||||
"metabot-prompt-suggestions",
|
||||
"gsheets-status",
|
||||
"document",
|
||||
] as const;
|
||||
|
||||
export type EnterpriseTagType = (typeof ENTERPRISE_TAG_TYPES)[number];
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { push } from "react-router-redux";
|
||||
|
||||
import type { Dispatch } from "metabase-types/store";
|
||||
|
||||
export const navigateBackToDocument =
|
||||
(documentId: number) => (dispatch: Dispatch) => {
|
||||
dispatch(push(`/document/${documentId}`));
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { deletePermanently } from "metabase/archive/actions";
|
||||
import { ArchivedEntityBanner } from "metabase/archive/components/ArchivedEntityBanner/ArchivedEntityBanner";
|
||||
import Bookmarks from "metabase/entities/bookmarks";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import Documents from "metabase-enterprise/entities/document";
|
||||
|
||||
import { useDocumentsSelector } from "../redux-utils";
|
||||
import { getCurrentDocument } from "../selectors";
|
||||
|
||||
export const DocumentArchivedEntityBanner = () => {
|
||||
const dispatch = useDispatch();
|
||||
const document = useDocumentsSelector(getCurrentDocument);
|
||||
|
||||
if (!document) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ArchivedEntityBanner
|
||||
name={document.name}
|
||||
entityType={t`document`}
|
||||
canMove={document.can_write}
|
||||
canRestore={document.can_restore}
|
||||
canDelete={document.can_delete}
|
||||
onUnarchive={async () => {
|
||||
await dispatch(Documents.actions.setArchived(document, false));
|
||||
await dispatch(Bookmarks.actions.invalidateLists());
|
||||
}}
|
||||
onMove={({ id }) =>
|
||||
dispatch(Documents.actions.setCollection(document, { id }))
|
||||
}
|
||||
onDeletePermanently={() => {
|
||||
const deleteAction = Documents.actions.delete({ id: document.id });
|
||||
dispatch(deletePermanently(deleteAction));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { type HTMLAttributes, useCallback } from "react";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { ActionIcon, type ActionIconProps, Icon, Tooltip } from "metabase/ui";
|
||||
import { navigateBackToDocument } from "metabase-enterprise/documents/actions";
|
||||
import { useDocumentsSelector } from "metabase-enterprise/documents/redux-utils";
|
||||
import {
|
||||
getDocument,
|
||||
getShowNavigateBackToDocumentButton,
|
||||
} from "metabase-enterprise/documents/selectors";
|
||||
|
||||
export type DocumentBackButtonProps = {
|
||||
noLink?: boolean;
|
||||
onClick?: () => void;
|
||||
documentOverride?: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
} & ActionIconProps &
|
||||
HTMLAttributes<HTMLButtonElement>;
|
||||
|
||||
export function DocumentBackButton({
|
||||
noLink,
|
||||
onClick,
|
||||
documentOverride,
|
||||
...actionIconProps
|
||||
}: DocumentBackButtonProps) {
|
||||
const documentState = useDocumentsSelector(getDocument);
|
||||
const showButton = useDocumentsSelector(getShowNavigateBackToDocumentButton);
|
||||
const document = documentOverride ?? documentState;
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (document) {
|
||||
dispatch(navigateBackToDocument(document.id));
|
||||
}
|
||||
|
||||
onClick?.();
|
||||
}, [document, dispatch, onClick]);
|
||||
|
||||
if (!document || !showButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const label = t`Back to ${document.name}`;
|
||||
|
||||
return (
|
||||
<Tooltip label={label}>
|
||||
<ActionIcon
|
||||
variant="outline"
|
||||
radius="xl"
|
||||
size="2.625rem"
|
||||
color="border"
|
||||
aria-label={label}
|
||||
onClick={handleClick}
|
||||
component={noLink ? undefined : Link}
|
||||
to={`/document/${document.id}`}
|
||||
{...actionIconProps}
|
||||
>
|
||||
<Icon c="brand" name="arrow_left" />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { DocumentBackButton } from "./DocumentBackButton";
|
||||
@@ -0,0 +1,148 @@
|
||||
.documentPage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - var(--nav-sidebar-height, 60px));
|
||||
background-color: var(--mb-color-bg-white);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 0;
|
||||
background-color: var(--mb-color-bg-white);
|
||||
gap: 1rem;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.titleInput {
|
||||
flex: 1;
|
||||
max-width: 600px;
|
||||
border: none;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
color: var(--mb-color-text-dark);
|
||||
font-family: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.titleInput::placeholder {
|
||||
color: var(--mb-color-text-light);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.titleInput:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.contentArea {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
padding: 0 2rem;
|
||||
}
|
||||
|
||||
.documentContainer {
|
||||
width: 100%;
|
||||
padding: 0 2rem 4rem;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
body:has(.documentContainer) {
|
||||
visibility: hidden;
|
||||
|
||||
/* side nav */
|
||||
aside {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.documentContainer {
|
||||
position: absolute;
|
||||
visibility: visible;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
|
||||
.header {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.node-cardEmbed) {
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
[data-hide-on-print] {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 360px;
|
||||
flex-shrink: 0;
|
||||
background-color: var(--mb-color-bg-light);
|
||||
border-left: 1px solid var(--mb-color-border);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.cardEmbed {
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.cardEmbed:hover {
|
||||
background-color: var(--mb-color-bg-medium);
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
animation: highlight 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
background-color: var(--mb-color-brand-light);
|
||||
box-shadow: 0 0 0 4px var(--mb-color-brand-light);
|
||||
}
|
||||
|
||||
100% {
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.metadataItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
color: var(--mb-color-text-secondary);
|
||||
}
|
||||
|
||||
.hidable {
|
||||
visibility: hidden;
|
||||
width: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 200ms;
|
||||
|
||||
&.visible {
|
||||
visibility: visible;
|
||||
width: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import type { Editor as TiptapEditor } from "@tiptap/core";
|
||||
import cx from "classnames";
|
||||
import dayjs from "dayjs";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Route } from "react-router";
|
||||
import { push, replace } from "react-router-redux";
|
||||
import { usePrevious, useUnmount } from "react-use";
|
||||
import useBeforeUnload from "react-use/lib/useBeforeUnload";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import {
|
||||
skipToken,
|
||||
useCreateBookmarkMutation,
|
||||
useDeleteBookmarkMutation,
|
||||
useListBookmarksQuery,
|
||||
} from "metabase/api";
|
||||
import { canonicalCollectionId } from "metabase/collections/utils";
|
||||
import DateTime, {
|
||||
getFormattedTime,
|
||||
} from "metabase/common/components/DateTime";
|
||||
import { LeaveRouteConfirmModal } from "metabase/common/components/LeaveConfirmModal";
|
||||
import { CollectionPickerModal } from "metabase/common/components/Pickers/CollectionPicker";
|
||||
import { useToast } from "metabase/common/hooks";
|
||||
import { useCallbackEffect } from "metabase/common/hooks/use-callback-effect";
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { extractEntityId } from "metabase/lib/urls";
|
||||
import { setErrorPage } from "metabase/redux/app";
|
||||
import {
|
||||
ActionIcon,
|
||||
Box,
|
||||
Button,
|
||||
Flex,
|
||||
Icon,
|
||||
Menu,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from "metabase/ui";
|
||||
import {
|
||||
useCreateDocumentMutation,
|
||||
useGetDocumentQuery,
|
||||
useUpdateDocumentMutation,
|
||||
} from "metabase-enterprise/api";
|
||||
import type {
|
||||
Card,
|
||||
CollectionId,
|
||||
RegularCollectionId,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import {
|
||||
clearDraftCards,
|
||||
closeSidebar,
|
||||
openVizSettingsSidebar,
|
||||
resetDocuments,
|
||||
setCurrentDocument,
|
||||
} from "../documents.slice";
|
||||
import { useDocumentState, useRegisterDocumentMetabotContext } from "../hooks";
|
||||
import { useDocumentsSelector } from "../redux-utils";
|
||||
import {
|
||||
getDraftCards,
|
||||
getSelectedEmbedIndex,
|
||||
getSelectedQuestionId,
|
||||
} from "../selectors";
|
||||
|
||||
import { DocumentArchivedEntityBanner } from "./DocumentArchivedEntityBanner";
|
||||
import styles from "./DocumentPage.module.css";
|
||||
import { Editor } from "./Editor";
|
||||
import { EmbedQuestionSettingsSidebar } from "./EmbedQuestionSettingsSidebar";
|
||||
|
||||
export const DocumentPage = ({
|
||||
params: { entityId },
|
||||
route,
|
||||
}: {
|
||||
params: { entityId?: string };
|
||||
location?: { query?: { version?: string } };
|
||||
route: Route;
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const selectedQuestionId = useDocumentsSelector(getSelectedQuestionId);
|
||||
const selectedEmbedIndex = useDocumentsSelector(getSelectedEmbedIndex);
|
||||
const draftCards = useDocumentsSelector(getDraftCards);
|
||||
const [editorInstance, setEditorInstance] = useState<TiptapEditor | null>(
|
||||
null,
|
||||
);
|
||||
const [hasUnsavedEditorChanges, setHasUnsavedEditorChanges] = useState(false);
|
||||
const [createDocument, { isLoading: isCreating }] =
|
||||
useCreateDocumentMutation();
|
||||
const [updateDocument, { isLoading: isUpdating }] =
|
||||
useUpdateDocumentMutation();
|
||||
const [collectionPickerMode, setCollectionPickerMode] = useState<
|
||||
"save" | "move" | null
|
||||
>(null);
|
||||
const [sendToast] = useToast();
|
||||
|
||||
const documentId = entityId === "new" ? "new" : extractEntityId(entityId);
|
||||
const previousDocumentId = usePrevious(documentId);
|
||||
const [isNavigationScheduled, scheduleNavigation] = useCallbackEffect();
|
||||
const isNewDocument = documentId === "new";
|
||||
|
||||
const { data: bookmarks = [] } = useListBookmarksQuery(undefined, {
|
||||
skip: isNewDocument,
|
||||
});
|
||||
const [createBookmark] = useCreateBookmarkMutation();
|
||||
const [deleteBookmark] = useDeleteBookmarkMutation();
|
||||
|
||||
const isBookmarked = Boolean(
|
||||
bookmarks.find(
|
||||
({ type, item_id }) => type === "document" && item_id === documentId,
|
||||
),
|
||||
);
|
||||
|
||||
let {
|
||||
data: documentData,
|
||||
isLoading: isDocumentLoading,
|
||||
error,
|
||||
} = useGetDocumentQuery(
|
||||
documentId && !isNewDocument ? { id: documentId } : skipToken,
|
||||
);
|
||||
if (documentId !== documentData?.id) {
|
||||
documentData = undefined;
|
||||
}
|
||||
|
||||
const canWrite = isNewDocument ? true : documentData?.can_write;
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
dispatch(setErrorPage(error));
|
||||
}
|
||||
}, [dispatch, error]);
|
||||
|
||||
const {
|
||||
documentTitle,
|
||||
setDocumentTitle,
|
||||
documentContent,
|
||||
setDocumentContent,
|
||||
updateCardEmbeds,
|
||||
} = useDocumentState(documentData);
|
||||
|
||||
// This is important as it will affect collection breadcrumbs in the appbar
|
||||
useUnmount(() => {
|
||||
dispatch(resetDocuments());
|
||||
});
|
||||
|
||||
useRegisterDocumentMetabotContext();
|
||||
useBeforeUnload(() => {
|
||||
// warn if you try to navigate away with unsaved changes
|
||||
return hasUnsavedChanges();
|
||||
});
|
||||
|
||||
// Reset state when document changes
|
||||
useEffect(() => {
|
||||
if (documentId !== previousDocumentId) {
|
||||
setHasUnsavedEditorChanges(false);
|
||||
if (isNewDocument && previousDocumentId !== "new") {
|
||||
setDocumentTitle("");
|
||||
setDocumentContent(null);
|
||||
dispatch(resetDocuments());
|
||||
}
|
||||
}
|
||||
}, [
|
||||
documentId,
|
||||
previousDocumentId,
|
||||
isNewDocument,
|
||||
setDocumentTitle,
|
||||
setDocumentContent,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
// Reset dirty state when document content loads from API
|
||||
useEffect(() => {
|
||||
if (documentContent && !isNewDocument) {
|
||||
setHasUnsavedEditorChanges(false);
|
||||
}
|
||||
}, [documentContent, isNewDocument]);
|
||||
|
||||
useEffect(() => {
|
||||
// Set current document when document loads (includes collection_id and all other data)
|
||||
if (documentData && !isNewDocument) {
|
||||
dispatch(setCurrentDocument(documentData));
|
||||
} else if (isNewDocument) {
|
||||
dispatch(setCurrentDocument(null));
|
||||
}
|
||||
}, [documentData, documentId, dispatch, isNewDocument]);
|
||||
|
||||
const hasUnsavedChanges = useCallback(() => {
|
||||
const currentTitle = documentTitle.trim();
|
||||
const originalTitle = documentData?.name || "";
|
||||
const titleChanged = currentTitle !== originalTitle;
|
||||
|
||||
// Check if there are any draft cards
|
||||
const hasDraftCards = Object.keys(draftCards).length > 0;
|
||||
|
||||
// For new documents, show Save if there's title or editor changes or draft cards
|
||||
if (isNewDocument) {
|
||||
return (
|
||||
currentTitle.length > 0 || hasUnsavedEditorChanges || hasDraftCards
|
||||
);
|
||||
}
|
||||
|
||||
// For existing documents, use simple change tracking
|
||||
return titleChanged || hasUnsavedEditorChanges || hasDraftCards;
|
||||
}, [
|
||||
documentTitle,
|
||||
isNewDocument,
|
||||
documentData,
|
||||
hasUnsavedEditorChanges,
|
||||
draftCards,
|
||||
]);
|
||||
|
||||
const isSaving = isCreating || isUpdating;
|
||||
const showSaveButton = hasUnsavedChanges() && canWrite && !isSaving;
|
||||
|
||||
const handleContentChanged = useCallback(() => {
|
||||
if (!editorInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compare current content with original content
|
||||
const currentContent = editorInstance.getJSON();
|
||||
const originalContent = documentContent;
|
||||
|
||||
// For new documents, any content means changes
|
||||
if (isNewDocument) {
|
||||
setHasUnsavedEditorChanges(!editorInstance.isEmpty);
|
||||
return;
|
||||
}
|
||||
|
||||
// For existing documents, compare with original content
|
||||
const hasChanges = !_.isEqual(currentContent, originalContent);
|
||||
setHasUnsavedEditorChanges(hasChanges);
|
||||
}, [editorInstance, documentContent, isNewDocument]);
|
||||
|
||||
const handleToggleBookmark = useCallback(() => {
|
||||
if (!documentId) {
|
||||
return;
|
||||
}
|
||||
isBookmarked
|
||||
? deleteBookmark({ type: "document", id: documentId })
|
||||
: createBookmark({ type: "document", id: documentId });
|
||||
}, [isBookmarked, deleteBookmark, createBookmark, documentId]);
|
||||
|
||||
const handleSave = useCallback(
|
||||
async (collectionId: RegularCollectionId | null = null) => {
|
||||
if (!editorInstance || isSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const cardsToSave: Record<number, Card> = {};
|
||||
const processedCardIds = new Set<number>();
|
||||
|
||||
editorInstance.state.doc.descendants((node: any) => {
|
||||
if (node.type.name === "cardEmbed") {
|
||||
const cardId = node.attrs.id;
|
||||
if (!processedCardIds.has(cardId)) {
|
||||
processedCardIds.add(cardId);
|
||||
|
||||
if (cardId < 0 && draftCards[cardId]) {
|
||||
cardsToSave[cardId] = draftCards[cardId];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const documentAst = editorInstance ? editorInstance.getJSON() : null;
|
||||
const name =
|
||||
documentTitle ||
|
||||
t`Untitled document - ${dayjs().local().format("MMMM D, YYYY")}`;
|
||||
|
||||
const newDocumentData: any = {
|
||||
name,
|
||||
document: documentAst,
|
||||
cards: Object.keys(cardsToSave).length > 0 ? cardsToSave : undefined,
|
||||
};
|
||||
|
||||
const result = await (documentData?.id
|
||||
? updateDocument({ ...newDocumentData, id: documentData.id }).then(
|
||||
(response) => {
|
||||
if (response.data) {
|
||||
scheduleNavigation(() => {
|
||||
dispatch(push(`/document/${response.data.id}`));
|
||||
});
|
||||
}
|
||||
return response.data;
|
||||
},
|
||||
)
|
||||
: createDocument({
|
||||
...newDocumentData,
|
||||
collection_id: collectionId || undefined,
|
||||
}).then((response) => {
|
||||
if (response.data) {
|
||||
scheduleNavigation(() => {
|
||||
dispatch(replace(`/document/${response.data.id}`));
|
||||
});
|
||||
}
|
||||
return response.data;
|
||||
}));
|
||||
|
||||
if (result) {
|
||||
sendToast({
|
||||
message: documentData?.id ? t`Document saved` : t`Document created`,
|
||||
});
|
||||
dispatch(clearDraftCards());
|
||||
// Mark document as clean
|
||||
setHasUnsavedEditorChanges(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to save document:", error);
|
||||
sendToast({ message: t`Error saving document`, icon: "warning" });
|
||||
}
|
||||
},
|
||||
[
|
||||
editorInstance,
|
||||
isSaving,
|
||||
documentTitle,
|
||||
draftCards,
|
||||
documentData?.id,
|
||||
updateDocument,
|
||||
createDocument,
|
||||
scheduleNavigation,
|
||||
dispatch,
|
||||
sendToast,
|
||||
],
|
||||
);
|
||||
|
||||
const handleUpdate = async (payload: {
|
||||
collection_id?: CollectionId | null;
|
||||
archived?: boolean;
|
||||
}) => {
|
||||
if (documentData?.id) {
|
||||
await updateDocument({ id: documentData.id, ...payload });
|
||||
setCollectionPickerMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// Save shortcut: Cmd+S (Mac) or Ctrl+S (Windows/Linux)
|
||||
if ((event.metaKey || event.ctrlKey) && event.key === "s") {
|
||||
event.preventDefault();
|
||||
if (!hasUnsavedChanges() || !canWrite) {
|
||||
return;
|
||||
}
|
||||
|
||||
isNewDocument ? setCollectionPickerMode("save") : handleSave();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
hasUnsavedChanges,
|
||||
handleSave,
|
||||
isNewDocument,
|
||||
setCollectionPickerMode,
|
||||
canWrite,
|
||||
]);
|
||||
|
||||
const handleQuestionSelect = useCallback(
|
||||
(cardId: number | null, embedIndex?: number | null) => {
|
||||
if (
|
||||
cardId !== null &&
|
||||
embedIndex !== null &&
|
||||
embedIndex !== undefined &&
|
||||
embedIndex >= 0 &&
|
||||
selectedEmbedIndex !== null
|
||||
) {
|
||||
// Only update the selected embed index if the sidebar is already open
|
||||
dispatch(openVizSettingsSidebar({ embedIndex }));
|
||||
}
|
||||
},
|
||||
[dispatch, selectedEmbedIndex],
|
||||
);
|
||||
|
||||
const handlePrintDocument = useCallback(() => {
|
||||
window.print();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Box className={styles.documentPage}>
|
||||
{documentData?.archived && <DocumentArchivedEntityBanner />}
|
||||
<Box className={styles.contentArea}>
|
||||
<Box className={styles.mainContent}>
|
||||
<Box className={styles.documentContainer}>
|
||||
<Box className={styles.header} mt="xl" pt="xl">
|
||||
<Flex direction="column" w="100%">
|
||||
<TextInput
|
||||
aria-label={t`Document Title`}
|
||||
autoFocus={isNewDocument}
|
||||
value={documentTitle}
|
||||
onChange={(event) =>
|
||||
setDocumentTitle(event.currentTarget.value)
|
||||
}
|
||||
flex={1}
|
||||
placeholder={t`New document`}
|
||||
readOnly={!canWrite}
|
||||
styles={{
|
||||
input: {
|
||||
border: "none",
|
||||
padding: 0,
|
||||
fontSize: "2rem",
|
||||
fontWeight: "bold",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
{documentData && (
|
||||
<Flex gap="md">
|
||||
<Text className={styles.metadataItem}>
|
||||
<Icon name="person" />
|
||||
{documentData.creator.common_name}{" "}
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={getFormattedTime(
|
||||
documentData.updated_at,
|
||||
"default",
|
||||
{ local: true },
|
||||
)}
|
||||
>
|
||||
<Text className={styles.metadataItem}>
|
||||
<Icon name="clock" />
|
||||
<DateTime value={documentData.updated_at} unit="day" />
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
<Flex gap="md" align="center">
|
||||
<Box
|
||||
className={cx(styles.hidable, {
|
||||
[styles.visible]: showSaveButton,
|
||||
})}
|
||||
>
|
||||
<Button
|
||||
onClick={() => {
|
||||
isNewDocument
|
||||
? setCollectionPickerMode("save")
|
||||
: handleSave();
|
||||
}}
|
||||
variant="filled"
|
||||
data-hide-on-print
|
||||
>
|
||||
{t`Save`}
|
||||
</Button>
|
||||
</Box>
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label={t`More options`}
|
||||
data-hide-on-print
|
||||
>
|
||||
<Icon name="ellipsis" />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="document" />}
|
||||
onClick={handlePrintDocument}
|
||||
>
|
||||
{t`Print Document`}
|
||||
</Menu.Item>
|
||||
{!isNewDocument && (
|
||||
<>
|
||||
{canWrite && (
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="move" />}
|
||||
onClick={() => setCollectionPickerMode("move")}
|
||||
>
|
||||
{t`Move`}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
leftSection={<Icon name={"bookmark"} />}
|
||||
onClick={handleToggleBookmark}
|
||||
>
|
||||
{isBookmarked
|
||||
? t`Remove from Bookmarks`
|
||||
: t`Bookmark`}
|
||||
</Menu.Item>
|
||||
{canWrite && (
|
||||
<>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="trash" />}
|
||||
onClick={() => handleUpdate({ archived: true })}
|
||||
>
|
||||
{t`Move to trash`}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Editor
|
||||
onEditorReady={setEditorInstance}
|
||||
onCardEmbedsChange={updateCardEmbeds}
|
||||
onQuestionSelect={handleQuestionSelect}
|
||||
initialContent={documentContent}
|
||||
onContentChanged={handleContentChanged}
|
||||
editable={canWrite}
|
||||
isLoading={isDocumentLoading}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{selectedQuestionId &&
|
||||
selectedEmbedIndex !== null &&
|
||||
editorInstance && (
|
||||
<Box className={styles.sidebar} data-testid="document-card-sidebar">
|
||||
<EmbedQuestionSettingsSidebar
|
||||
cardId={selectedQuestionId}
|
||||
onClose={() => dispatch(closeSidebar())}
|
||||
editorInstance={editorInstance}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{collectionPickerMode && (
|
||||
<CollectionPickerModal
|
||||
title={t`Where should we save this document?`}
|
||||
onClose={() => setCollectionPickerMode(null)}
|
||||
value={{ id: "root", model: "collection" }}
|
||||
options={{
|
||||
showPersonalCollections: true,
|
||||
showRootCollection: true,
|
||||
}}
|
||||
onChange={async (collection) => {
|
||||
if (collectionPickerMode === "save") {
|
||||
handleSave(canonicalCollectionId(collection.id));
|
||||
setCollectionPickerMode(null);
|
||||
} else if (collectionPickerMode === "move") {
|
||||
(await collection) &&
|
||||
handleUpdate({
|
||||
collection_id: canonicalCollectionId(collection.id),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<LeaveRouteConfirmModal
|
||||
// `key` remounts this modal when navigating between different documents or to a new document.
|
||||
// The `route` doesn't change in that scenario which prevents the modal from closing when you confirm you want to discard your changes.
|
||||
key={documentId}
|
||||
isEnabled={hasUnsavedChanges() && !isNavigationScheduled}
|
||||
route={route}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,228 @@
|
||||
.editor {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.editorContent {
|
||||
flex: 1;
|
||||
cursor: text;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.editorContent :global(.ProseMirror) {
|
||||
padding: 1.5rem 0 2rem 0;
|
||||
min-height: 100%;
|
||||
outline: none;
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
color: var(--mb-color-text-dark);
|
||||
font-size: 1rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Apply typography styles only to elements NOT inside .editor-style-boundary */
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:where(:global(h1), :global(h2), :global(h3)):not(
|
||||
:global(.editor-style-boundary) *
|
||||
) {
|
||||
font-weight: 700;
|
||||
color: var(--mb-color-text-dark);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(h1):not(:global(.editor-style-boundary) *) {
|
||||
font-size: 1.75rem;
|
||||
margin: 1.5rem 0 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(h2):not(:global(.editor-style-boundary) *) {
|
||||
font-size: 1.375rem;
|
||||
margin: 1.25rem 0 0.75rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(h3):not(:global(.editor-style-boundary) *) {
|
||||
font-size: 1.125rem;
|
||||
margin: 1rem 0 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(p):not(:global(.editor-style-boundary) *) {
|
||||
margin: 0 0 1rem 0;
|
||||
line-height: 1.5;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:where(:global(ul), :global(ol)):not(:global(.editor-style-boundary) *) {
|
||||
margin: 0 0 1rem 0;
|
||||
padding-left: 1.5rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ul):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ol):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(li):not(:global(.editor-style-boundary) *) {
|
||||
margin: 0 0 0.25rem 0;
|
||||
line-height: 1.5;
|
||||
display: list-item;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ul ul):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: circle;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(ul ul ul):not(:global(.editor-style-boundary) *) {
|
||||
list-style-type: square;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:where(:global(li > ul), :global(li > ol)):not(
|
||||
:global(.editor-style-boundary) *
|
||||
) {
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(li p):not(:global(.editor-style-boundary) *) {
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(code):not(:global(.editor-style-boundary) *) {
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
font-family: var(--mb-default-monospace-font-family, monospace);
|
||||
font-size: 0.875rem;
|
||||
color: var(--mb-color-text-dark);
|
||||
border: 1px solid var(--mb-base-color-orion-10);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(pre):not(:global(.editor-style-boundary) *) {
|
||||
background-color: var(--mb-base-color-gray-10);
|
||||
color: var(--mb-color-text-dark);
|
||||
padding: 1rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
overflow-x: auto;
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--mb-base-color-gray-20);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(pre code):not(:global(.editor-style-boundary) *) {
|
||||
background: none;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(blockquote):not(:global(.editor-style-boundary) *) {
|
||||
border-left: 3px solid var(--mb-base-color-orion-20);
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--mb-color-text-medium);
|
||||
font-style: italic;
|
||||
background-color: var(--mb-base-color-orion-5);
|
||||
padding: 1rem;
|
||||
padding-left: 1.25rem;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
|
||||
.editorContent
|
||||
:global(.ProseMirror)
|
||||
:global(hr):not(:global(.editor-style-boundary) *) {
|
||||
border: none;
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.editorContent :global(.ProseMirror:focus) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.editorContent :global(p.is-empty:first-child:before) {
|
||||
color: var(--mb-color-text-light);
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.img {
|
||||
max-width: 80%;
|
||||
display: block;
|
||||
margin: 1rem auto;
|
||||
|
||||
&:global(.ProseMirror-selectednode) {
|
||||
box-shadow: 0 0 0 1px var(--mb-color-brand);
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
}
|
||||
}
|
||||
|
||||
.suggestionPopup {
|
||||
width: 320px;
|
||||
min-height: 50px;
|
||||
max-height: 400px;
|
||||
overflow: hidden;
|
||||
background: var(--mb-color-bg-white);
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
box-shadow: 0 2px 8px var(--mb-color-shadow);
|
||||
}
|
||||
|
||||
.suggestionScroll {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.suggestionMenuItem {
|
||||
cursor: pointer;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.suggestionMenuItem[data-selected] {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
}
|
||||
|
||||
.suggestionMenuFooter {
|
||||
border-top: 1px solid var(--mb-color-border);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Store } from "@reduxjs/toolkit";
|
||||
import type { StoryFn } from "@storybook/react/*";
|
||||
import _ from "underscore";
|
||||
|
||||
import { mockSettings } from "__support__/settings";
|
||||
import { createMockEntitiesState } from "__support__/store";
|
||||
import { Api } from "metabase/api";
|
||||
import { MetabaseReduxProvider } from "metabase/lib/redux";
|
||||
import { publicReducers } from "metabase/reducers-public";
|
||||
import { getStore } from "metabase/store";
|
||||
import type { State } from "metabase-types/store";
|
||||
import { createMockState } from "metabase-types/store/mocks";
|
||||
|
||||
import { Editor } from "./Editor";
|
||||
import Data from "./data/data.json";
|
||||
|
||||
const storeInitialState = createMockState({
|
||||
settings: mockSettings(),
|
||||
entities: createMockEntitiesState({}),
|
||||
});
|
||||
const publicReducerNames = Object.keys(publicReducers);
|
||||
const initialState = _.pick(storeInitialState, ...publicReducerNames) as State;
|
||||
const reducers = publicReducers;
|
||||
|
||||
const storeMiddleware = [Api.middleware];
|
||||
|
||||
const store = getStore(
|
||||
reducers,
|
||||
initialState,
|
||||
storeMiddleware,
|
||||
) as unknown as Store<State>;
|
||||
|
||||
const ReduxDecorator = (Story: StoryFn) => {
|
||||
return (
|
||||
<MetabaseReduxProvider store={store}>
|
||||
<Story />
|
||||
</MetabaseReduxProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const DefaultTemplate = () => <Editor initialContent={Data.markdownTest} />;
|
||||
|
||||
export default {
|
||||
title: "Components/Documents",
|
||||
component: Editor,
|
||||
decorators: [ReduxDecorator],
|
||||
layout: "fullscreen",
|
||||
};
|
||||
|
||||
export const Default = {
|
||||
render: DefaultTemplate,
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Placeholder } from "@tiptap/extension-placeholder";
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
import type { JSONContent, Editor as TiptapEditor } from "@tiptap/react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import cx from "classnames";
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { DND_IGNORE_CLASS_NAME } from "metabase/common/components/dnd";
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { getSetting } from "metabase/selectors/settings";
|
||||
import { Box, Loader } from "metabase/ui";
|
||||
import { useDocumentsStore } from "metabase-enterprise/documents/redux-utils";
|
||||
import { getMentionsCache } from "metabase-enterprise/documents/selectors";
|
||||
import type { DocumentsStoreState } from "metabase-enterprise/documents/types";
|
||||
import { getMentionsCacheKey } from "metabase-enterprise/documents/utils/mentionsUtils";
|
||||
|
||||
import styles from "./Editor.module.css";
|
||||
import { EditorBubbleMenu } from "./EditorBubbleMenu";
|
||||
import { CardEmbed } from "./extensions/CardEmbed";
|
||||
import { CommandExtension } from "./extensions/Command/CommandExtension";
|
||||
import { CommandSuggestion } from "./extensions/Command/CommandSuggestion";
|
||||
import { DisableMetabotSidebar } from "./extensions/DisableMetabotSidebar";
|
||||
import { MentionExtension } from "./extensions/Mention/MentionExtension";
|
||||
import { MentionSuggestion } from "./extensions/Mention/MentionSuggestion";
|
||||
import { MetabotNode, type PromptSerializer } from "./extensions/MetabotEmbed";
|
||||
import { MetabotMentionExtension } from "./extensions/MetabotMention/MetabotMentionExtension";
|
||||
import { MetabotMentionSuggestion } from "./extensions/MetabotMention/MetabotSuggestion";
|
||||
import { SmartLinkEmbed } from "./extensions/SmartLink";
|
||||
import { Markdown } from "./extensions/markdown/index";
|
||||
import { createSuggestionRenderer } from "./extensions/suggestionRenderer";
|
||||
import { useCardEmbedsTracking, useQuestionSelection } from "./hooks";
|
||||
import type { CardEmbedRef } from "./types";
|
||||
|
||||
const getMetabotPromptSerializer =
|
||||
(getState: () => DocumentsStoreState): PromptSerializer =>
|
||||
(node) => {
|
||||
const payload: ReturnType<PromptSerializer> = { instructions: "" };
|
||||
return node.content.content.reduce((acc, child) => {
|
||||
// Serialize @ mentions in the metabot prompt
|
||||
if (child.type.name === SmartLinkEmbed.name) {
|
||||
const { model, entityId } = child.attrs;
|
||||
const key = getMentionsCacheKey({ model, entityId });
|
||||
const value = getMentionsCache(getState())[key];
|
||||
if (!value) {
|
||||
return acc;
|
||||
}
|
||||
acc.instructions += `[${value.name}](${key})`;
|
||||
if (!acc.references) {
|
||||
acc.references = {};
|
||||
}
|
||||
acc.references[key] = value.name;
|
||||
} else {
|
||||
acc.instructions += child.textContent;
|
||||
}
|
||||
return acc;
|
||||
}, payload);
|
||||
};
|
||||
|
||||
const isMetabotBlock = (state: EditorState): boolean =>
|
||||
state.selection.$head.parent.type.name === "metabot";
|
||||
|
||||
interface EditorProps {
|
||||
onEditorReady?: (editor: TiptapEditor) => void;
|
||||
onCardEmbedsChange?: (refs: CardEmbedRef[]) => void;
|
||||
initialContent?: JSONContent | null;
|
||||
onChange?: (content: JSONContent) => void;
|
||||
onContentChanged?: () => void; // Simple dirty flag callback
|
||||
onQuestionSelect?: (cardId: number | null) => void;
|
||||
editable?: boolean;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export const Editor: React.FC<EditorProps> = ({
|
||||
onEditorReady,
|
||||
onCardEmbedsChange,
|
||||
initialContent,
|
||||
onChange,
|
||||
onContentChanged,
|
||||
editable = true,
|
||||
onQuestionSelect,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
const siteUrl = useSelector((state) => getSetting(state, "site-url"));
|
||||
const { getState } = useDocumentsStore();
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
inline: false,
|
||||
HTMLAttributes: {
|
||||
class: styles.img,
|
||||
},
|
||||
}),
|
||||
SmartLinkEmbed.configure({
|
||||
HTMLAttributes: {
|
||||
class: "smart-link",
|
||||
},
|
||||
siteUrl,
|
||||
}),
|
||||
Link.configure({
|
||||
HTMLAttributes: {
|
||||
class: CS.link,
|
||||
},
|
||||
}),
|
||||
Placeholder.configure({
|
||||
placeholder: t`Start writing, press "/" to open command palette, or "@" to insert a link...`,
|
||||
}),
|
||||
Markdown,
|
||||
CardEmbed,
|
||||
MetabotNode.configure({
|
||||
serializePrompt: getMetabotPromptSerializer(getState),
|
||||
}),
|
||||
DisableMetabotSidebar,
|
||||
MentionExtension.configure({
|
||||
suggestion: {
|
||||
allow: ({ state }) => !isMetabotBlock(state),
|
||||
render: createSuggestionRenderer(MentionSuggestion),
|
||||
},
|
||||
}),
|
||||
CommandExtension.configure({
|
||||
suggestion: {
|
||||
allow: ({ state }) => !isMetabotBlock(state),
|
||||
render: createSuggestionRenderer(CommandSuggestion),
|
||||
},
|
||||
}),
|
||||
MetabotMentionExtension.configure({
|
||||
suggestion: {
|
||||
allow: ({ state }) => isMetabotBlock(state),
|
||||
render: createSuggestionRenderer(MetabotMentionSuggestion),
|
||||
},
|
||||
}),
|
||||
],
|
||||
[siteUrl, getState],
|
||||
);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions,
|
||||
content: initialContent || "",
|
||||
autofocus: false,
|
||||
immediatelyRender: false,
|
||||
onUpdate: ({ editor }) => {
|
||||
const currentContent = editor.getJSON();
|
||||
onChange?.(currentContent);
|
||||
|
||||
// Simple content changed notification
|
||||
if (!editor.isEmpty) {
|
||||
onContentChanged?.();
|
||||
}
|
||||
},
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle content updates when initialContent changes
|
||||
useEffect(() => {
|
||||
if (editor && initialContent !== undefined) {
|
||||
// Use Promise.resolve() to avoid flushSync warning
|
||||
Promise.resolve().then(() => {
|
||||
editor.commands.setContent(initialContent || "");
|
||||
});
|
||||
}
|
||||
}, [editor, initialContent]);
|
||||
|
||||
// Notify parent when editor is ready
|
||||
useEffect(() => {
|
||||
if (editor && onEditorReady) {
|
||||
onEditorReady(editor);
|
||||
}
|
||||
}, [editor, onEditorReady]);
|
||||
|
||||
// Update editor editable state
|
||||
useEffect(() => {
|
||||
if (editor) {
|
||||
editor.setEditable(editable);
|
||||
}
|
||||
}, [editor, editable]);
|
||||
|
||||
useCardEmbedsTracking(editor, onCardEmbedsChange);
|
||||
useQuestionSelection(editor, onQuestionSelect);
|
||||
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
|
||||
<Loader />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box className={cx(styles.editor, DND_IGNORE_CLASS_NAME)}>
|
||||
<Box
|
||||
className={styles.editorContent}
|
||||
onClick={(e) => {
|
||||
// Focus editor when clicking on empty space
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
target.classList.contains(styles.editorContent) ||
|
||||
target.classList.contains("ProseMirror")
|
||||
) {
|
||||
const clickY = e.clientY;
|
||||
const proseMirrorElement = target.querySelector(".ProseMirror");
|
||||
|
||||
if (proseMirrorElement) {
|
||||
const proseMirrorRect =
|
||||
proseMirrorElement.getBoundingClientRect();
|
||||
const isClickBelowContent = clickY > proseMirrorRect.bottom;
|
||||
|
||||
if (isClickBelowContent) {
|
||||
// Only move to end if clicking below the actual content
|
||||
editor.commands.focus("end");
|
||||
} else {
|
||||
// Just focus without changing cursor position for clicks in padding areas
|
||||
editor.commands.focus();
|
||||
}
|
||||
} else {
|
||||
// Fallback: just focus without position change
|
||||
editor.commands.focus();
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<EditorContent data-testid="document-content" editor={editor} />
|
||||
<EditorBubbleMenu editor={editor} />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
/* eslint-disable import/no-unresolved */ // -- this is a valid import for Tiptap's BubbleMenu
|
||||
import type { EditorState } from "@tiptap/pm/state";
|
||||
import type { Editor as TiptapEditor } from "@tiptap/react";
|
||||
// @ts-expect-error - BubbleMenu is a Tiptap extension that is registered through @tiptap/extension-bubble-menu
|
||||
import { BubbleMenu } from "@tiptap/react/menus";
|
||||
import type React from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Flex } from "metabase/ui";
|
||||
|
||||
import { FormatButton } from "./FormatButton";
|
||||
|
||||
interface EditorBubbleMenuProps {
|
||||
editor: TiptapEditor;
|
||||
}
|
||||
|
||||
export const EditorBubbleMenu: React.FC<EditorBubbleMenuProps> = ({
|
||||
editor,
|
||||
}) => {
|
||||
return (
|
||||
<BubbleMenu
|
||||
editor={editor}
|
||||
shouldShow={({
|
||||
editor,
|
||||
state,
|
||||
from,
|
||||
to,
|
||||
}: {
|
||||
editor: TiptapEditor;
|
||||
state: EditorState;
|
||||
from: number;
|
||||
to: number;
|
||||
}) => {
|
||||
// Don't show bubble menu if nothing is selected
|
||||
const { empty } = state.selection;
|
||||
if (empty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show for code blocks
|
||||
if (editor.isActive("codeBlock")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't show bubble menu for certain node types
|
||||
const disallowedNodes = ["cardEmbed", "metabot", "smartLink", "image"];
|
||||
|
||||
// Check if any disallowed node is active
|
||||
for (const nodeName of disallowedNodes) {
|
||||
if (editor.isActive(nodeName)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if selection contains any disallowed nodes
|
||||
let hasDisallowedNode = false;
|
||||
state.doc.nodesBetween(from, to, (node) => {
|
||||
if (
|
||||
disallowedNodes.includes(node.type.name) ||
|
||||
node.type.name === "codeBlock"
|
||||
) {
|
||||
hasDisallowedNode = true;
|
||||
return false; // Stop traversing
|
||||
}
|
||||
});
|
||||
|
||||
return !hasDisallowedNode;
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
gap={4}
|
||||
bg="white"
|
||||
p="2px"
|
||||
style={{
|
||||
border: "1px solid var(--mb-color-border)",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "0 2px 12px var(--mb-color-shadow)",
|
||||
}}
|
||||
data-testid="document-formatting-menu"
|
||||
>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("bold")}
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
tooltip={t`Bold`}
|
||||
icon="text_bold"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("italic")}
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
tooltip={t`Italic`}
|
||||
icon="text_italic"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("strike")}
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
tooltip={t`Strikethrough`}
|
||||
icon="text_strike"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("code")}
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
tooltip={t`Inline code`}
|
||||
icon="format_code"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("heading", { level: 1 })}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run()
|
||||
}
|
||||
tooltip={t`Heading 1`}
|
||||
text="H1"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("heading", { level: 2 })}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run()
|
||||
}
|
||||
tooltip={t`Heading 2`}
|
||||
text="H2"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("heading", { level: 3 })}
|
||||
onClick={() =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run()
|
||||
}
|
||||
tooltip={t`Heading 3`}
|
||||
text="H3"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("bulletList")}
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
tooltip={t`Bullet list`}
|
||||
icon="list"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("orderedList")}
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
tooltip={t`Numbered list`}
|
||||
icon="ordered_list"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("blockquote")}
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
tooltip={t`Quote`}
|
||||
icon="quote"
|
||||
/>
|
||||
<FormatButton
|
||||
isActive={editor.isActive("codeBlock")}
|
||||
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
|
||||
tooltip={t`Code block`}
|
||||
icon="code_block"
|
||||
/>
|
||||
</Flex>
|
||||
</BubbleMenu>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import type React from "react";
|
||||
|
||||
import type { IconName } from "metabase/ui";
|
||||
import { Icon, Tooltip, UnstyledButton } from "metabase/ui";
|
||||
|
||||
interface FormatButtonProps {
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
tooltip: string;
|
||||
icon?: IconName;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export const FormatButton: React.FC<FormatButtonProps> = ({
|
||||
isActive,
|
||||
onClick,
|
||||
tooltip,
|
||||
icon,
|
||||
text,
|
||||
}) => {
|
||||
return (
|
||||
<Tooltip label={tooltip} position="top">
|
||||
<UnstyledButton
|
||||
style={{
|
||||
width: "32px",
|
||||
height: "32px",
|
||||
borderRadius: "4px",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
color: isActive
|
||||
? "var(--mb-color-brand)"
|
||||
: "var(--mb-color-text-dark)",
|
||||
backgroundColor: isActive
|
||||
? "var(--mb-color-bg-light)"
|
||||
: "transparent",
|
||||
fontSize: text ? "14px" : undefined,
|
||||
fontWeight: text ? "bold" : undefined,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
transition: "all 0.15s ease",
|
||||
}}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon ? <Icon name={icon} size={16} /> : text}
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// Class name used to prevent editor typography styles from affecting embedded content
|
||||
export const EDITOR_STYLE_BOUNDARY_CLASS = "editor-style-boundary";
|
||||
@@ -0,0 +1,205 @@
|
||||
{
|
||||
"markdownTest": {
|
||||
"type": "doc",
|
||||
"content": [
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 1
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading 1"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 2
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading 2"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 3
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading 3"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 4
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading 4"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 5
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading 5"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "heading",
|
||||
"attrs": {
|
||||
"level": 6
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Heading 6"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph"
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "This is a Paragraph. Inside of this, you can have "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "bold"
|
||||
}
|
||||
],
|
||||
"text": "Bold Text"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": ", "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "italic"
|
||||
}
|
||||
],
|
||||
"text": "Italic Text"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": ", and even text with "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "strike"
|
||||
}
|
||||
],
|
||||
"text": "Strike Through."
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " We can also have "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "code"
|
||||
}
|
||||
],
|
||||
"text": "inline code"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": " when we don't need a code block."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "codeBlock",
|
||||
"attrs": {
|
||||
"language": null
|
||||
},
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "Speaking of, this is what a code block would look like."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "And if you need to "
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"marks": [
|
||||
{
|
||||
"type": "link",
|
||||
"attrs": {
|
||||
"href": "https://www.metabase.com/",
|
||||
"target": "_blank",
|
||||
"rel": "noopener noreferrer nofollow",
|
||||
"class": "index__link___nQD8h"
|
||||
}
|
||||
}
|
||||
],
|
||||
"text": "link something"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"text": ", well that is easy too."
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "paragraph",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": "In the words of Sasha -"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "image",
|
||||
"attrs": {
|
||||
"src": "https://i.imgflip.com/1lccxa.jpg",
|
||||
"alt": "High Quality Hold my beer Blank Meme Template",
|
||||
"title": null,
|
||||
"width": null,
|
||||
"height": null
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "paragraph"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
.embedContainer {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.embedWrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cardEmbed {
|
||||
border: 1px solid var(--mb-color-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
margin: 1rem 0;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.cardEmbed.selected {
|
||||
border-color: var(--mb-color-brand);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cardEmbed * {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.questionResults {
|
||||
height: 400px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.errorContainer {
|
||||
padding: 1rem;
|
||||
color: var(--mb-color-error);
|
||||
}
|
||||
|
||||
.questionTimestamp {
|
||||
padding: 0 1rem;
|
||||
margin: 0 0 1rem;
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.titleContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.titleEditIcon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.titleContainer:hover .titleEditIcon {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import cx from "classnames";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useDispatch } from "metabase/lib/redux";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box, Flex, Icon, Loader, Menu, Text, TextInput } from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import ChartSkeleton from "metabase/visualizations/components/skeletons/ChartSkeleton";
|
||||
import { getGenericErrorMessage } from "metabase/visualizations/lib/errors";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import { getUrl } from "metabase-lib/v1/urls";
|
||||
import type { Card, CardDisplayType, Dataset } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
loadMetadataForDocumentCard,
|
||||
openVizSettingsSidebar,
|
||||
setShowNavigateBackToDocumentButton,
|
||||
} from "../../../../documents.slice";
|
||||
import { useCardEmbedData } from "../../../../hooks/useCardEmbedData";
|
||||
import {
|
||||
useDocumentsDispatch,
|
||||
useDocumentsSelector,
|
||||
} from "../../../../redux-utils";
|
||||
import { EDITOR_STYLE_BOUNDARY_CLASS } from "../../constants";
|
||||
import { formatCardEmbed } from "../markdown/card-embed-format";
|
||||
|
||||
import styles from "./CardEmbedNode.module.css";
|
||||
import { ModifyQuestionModal } from "./ModifyQuestionModal";
|
||||
import { NativeQueryModal } from "./NativeQueryModal";
|
||||
|
||||
const getDatasetError = (dataset: Dataset) => {
|
||||
if (dataset.error) {
|
||||
return {
|
||||
message: getGenericErrorMessage(),
|
||||
icon: "warning" as const,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface CardEmbedAttributes {
|
||||
id?: number;
|
||||
name?: string;
|
||||
class?: string;
|
||||
}
|
||||
export const CardEmbedNode: Node<{
|
||||
HTMLAttributes: CardEmbedAttributes;
|
||||
}> = Node.create({
|
||||
name: "cardEmbed",
|
||||
group: "block",
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
id: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const id = element.getAttribute("data-id");
|
||||
if (id) {
|
||||
return parseInt(id);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
},
|
||||
name: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-name"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${CardEmbedNode.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": CardEmbedNode.name,
|
||||
"data-id": node.attrs.id,
|
||||
"data-name": node.attrs.name,
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
formatCardEmbed(node.attrs as CardEmbedAttributes),
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
return formatCardEmbed(node.attrs as CardEmbedAttributes);
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return () => {
|
||||
const dom = document.createElement("div");
|
||||
dom.setAttribute("data-type", CardEmbedNode.name);
|
||||
dom.className = styles.embedContainer;
|
||||
|
||||
const content = document.createElement("div");
|
||||
dom.appendChild(content);
|
||||
|
||||
return {
|
||||
dom,
|
||||
contentDOM: content,
|
||||
};
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const CardEmbedComponent = memo(
|
||||
({ node, updateAttributes, selected, editor, getPos }: NodeViewProps) => {
|
||||
const { id, name } = node.attrs;
|
||||
const dispatch = useDispatch();
|
||||
const canWrite = editor.options.editable;
|
||||
|
||||
let embedIndex = -1;
|
||||
|
||||
if (editor && getPos) {
|
||||
const currentPos = getPos() ?? 0;
|
||||
let nodeCount = 0;
|
||||
|
||||
// Count cardEmbed nodes that appear before this position
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "cardEmbed") {
|
||||
if (pos < currentPos) {
|
||||
nodeCount++;
|
||||
} else if (pos === currentPos) {
|
||||
embedIndex = nodeCount;
|
||||
return false; // Stop traversing
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Use the custom hook for all data fetching
|
||||
const {
|
||||
card: cardToUse,
|
||||
dataset,
|
||||
isLoading,
|
||||
rawSeries,
|
||||
error,
|
||||
} = useCardEmbedData({ id });
|
||||
|
||||
const metadata = useDocumentsSelector(getMetadata);
|
||||
const datasetError = dataset && getDatasetError(dataset);
|
||||
const [isEditingTitle, setIsEditingTitle] = useState(false);
|
||||
const [editedTitle, setEditedTitle] = useState(name || "");
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
|
||||
|
||||
const displayName = name || cardToUse?.name;
|
||||
const isNativeQuestion = cardToUse?.dataset_query?.type === "native";
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditingTitle && titleInputRef.current) {
|
||||
titleInputRef.current.focus();
|
||||
titleInputRef.current.select();
|
||||
}
|
||||
}, [isEditingTitle]);
|
||||
|
||||
const handleTitleSave = () => {
|
||||
const trimmedTitle = editedTitle.trim();
|
||||
if (trimmedTitle && trimmedTitle !== cardToUse?.name) {
|
||||
updateAttributes({ name: trimmedTitle });
|
||||
} else {
|
||||
updateAttributes({ name: null });
|
||||
setEditedTitle("");
|
||||
}
|
||||
setIsEditingTitle(false);
|
||||
};
|
||||
|
||||
const handleTitleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleTitleSave();
|
||||
} else if (e.key === "Escape") {
|
||||
setEditedTitle(name || "");
|
||||
setIsEditingTitle(false);
|
||||
}
|
||||
};
|
||||
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
|
||||
// Load metadata for the card
|
||||
useEffect(() => {
|
||||
if (cardToUse) {
|
||||
documentsDispatch(loadMetadataForDocumentCard(cardToUse));
|
||||
}
|
||||
}, [cardToUse, documentsDispatch]);
|
||||
|
||||
const handleEditVisualizationSettings = () => {
|
||||
if (embedIndex !== -1) {
|
||||
dispatch(openVizSettingsSidebar({ embedIndex }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleTitleClick = () => {
|
||||
if (cardToUse && metadata) {
|
||||
try {
|
||||
dispatch(setShowNavigateBackToDocumentButton(true));
|
||||
const isDraftCard = cardToUse.id < 0;
|
||||
const question = new Question(
|
||||
isDraftCard ? { ...cardToUse, id: null } : cardToUse,
|
||||
metadata,
|
||||
);
|
||||
const url = getUrl(question, { includeDisplayIsLocked: true });
|
||||
dispatch(push(url));
|
||||
} catch (error) {
|
||||
console.error("Failed to navigate to question:", error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceQuestion = () => {
|
||||
const pos = getPos();
|
||||
|
||||
if (typeof pos === "number") {
|
||||
editor.commands.insertContentAt(
|
||||
{ from: pos, to: pos + node.nodeSize },
|
||||
"/",
|
||||
);
|
||||
editor.commands.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle drill-through navigation
|
||||
const handleChangeCardAndRun = useCallback(
|
||||
({
|
||||
nextCard,
|
||||
}: {
|
||||
nextCard: Card;
|
||||
previousCard?: Card;
|
||||
objectId?: number;
|
||||
}) => {
|
||||
if (!metadata) {
|
||||
console.warn("Metadata not available for drill-through navigation");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
dispatch(setShowNavigateBackToDocumentButton(true));
|
||||
// For drill-through, we need to ensure the card is treated as adhoc
|
||||
// Remove the ID so getUrl creates an adhoc question URL instead of navigating to saved question
|
||||
const adhocCard = { ...nextCard, id: null };
|
||||
const question = new Question(adhocCard, metadata);
|
||||
const url = getUrl(question, { includeDisplayIsLocked: true });
|
||||
dispatch(push(url));
|
||||
} catch (error) {
|
||||
console.error("Failed to create question URL:", error);
|
||||
// Fallback: navigate to a new question with the dataset_query
|
||||
if (nextCard.dataset_query) {
|
||||
dispatch(setShowNavigateBackToDocumentButton(true));
|
||||
const params = new URLSearchParams();
|
||||
params.set("dataset_query", JSON.stringify(nextCard.dataset_query));
|
||||
dispatch(push(`/question?${params.toString()}`));
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, metadata],
|
||||
);
|
||||
|
||||
if (isLoading && !cardToUse) {
|
||||
return (
|
||||
<NodeViewWrapper className={styles.embedWrapper}>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
>
|
||||
<Box className={styles.questionHeader}>
|
||||
<Flex align="center" justify="space-between" gap="0.5rem">
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text size="md" color="text-dark" fw={700}>
|
||||
{t`Loading question...`}
|
||||
</Text>
|
||||
</Box>
|
||||
</Flex>
|
||||
</Box>
|
||||
<Box className={styles.questionResults}>
|
||||
<Box className={styles.loadingContainer}>
|
||||
<Loader />
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<NodeViewWrapper className={styles.embedWrapper}>
|
||||
<Box
|
||||
className={cx(styles.errorContainer, EDITOR_STYLE_BOUNDARY_CLASS)}
|
||||
>
|
||||
<Text color="error">{t`Failed to load question`}</Text>
|
||||
</Box>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={styles.embedWrapper}
|
||||
data-testid="document-card-embed"
|
||||
>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
>
|
||||
{cardToUse && (
|
||||
<Box className={styles.questionHeader}>
|
||||
<Flex align="center" justify="space-between" gap="0.5rem">
|
||||
{isEditingTitle ? (
|
||||
<TextInput
|
||||
ref={titleInputRef}
|
||||
value={editedTitle}
|
||||
onChange={(e) => setEditedTitle(e.target.value)}
|
||||
onBlur={handleTitleSave}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
size="md"
|
||||
flex={1}
|
||||
styles={{
|
||||
input: {
|
||||
fontWeight: 700,
|
||||
fontSize: "1rem",
|
||||
border: "1px solid transparent",
|
||||
padding: 0,
|
||||
height: "auto",
|
||||
minHeight: "auto",
|
||||
lineHeight: 1.55,
|
||||
backgroundColor: "transparent",
|
||||
"&:focus": {
|
||||
border: "1px solid var(--mb-color-border)",
|
||||
backgroundColor: "var(--mb-color-bg-white)",
|
||||
padding: "0 0.25rem",
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text
|
||||
size="md"
|
||||
color="text-dark"
|
||||
fw={700}
|
||||
onClick={handleTitleClick}
|
||||
c="pointer"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
{canWrite && (
|
||||
<Icon
|
||||
name="pencil"
|
||||
size={14}
|
||||
color="var(--mb-color-text-medium)"
|
||||
className={styles.titleEditIcon}
|
||||
c="pointer"
|
||||
onClick={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setEditedTitle(displayName);
|
||||
setIsEditingTitle(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!isEditingTitle && (
|
||||
<Menu withinPortal position="bottom-end">
|
||||
<Menu.Target>
|
||||
<Flex
|
||||
component="button"
|
||||
bg="transparent"
|
||||
c="pointer"
|
||||
p="0.25rem"
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
border: "none",
|
||||
borderRadius: "4px",
|
||||
}}
|
||||
onClick={(e: React.MouseEvent) => e.stopPropagation()}
|
||||
>
|
||||
<Icon
|
||||
name="ellipsis"
|
||||
size={16}
|
||||
color="var(--mb-color-text-medium)"
|
||||
/>
|
||||
</Flex>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
onClick={handleEditVisualizationSettings}
|
||||
disabled={!canWrite}
|
||||
leftSection={<Icon name="palette" size={14} />}
|
||||
>
|
||||
{t`Edit Visualization`}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setIsModifyModalOpen(true)}
|
||||
disabled={!canWrite}
|
||||
leftSection={
|
||||
<Icon
|
||||
name={isNativeQuestion ? "sql" : "notebook"}
|
||||
size={14}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t`Edit Query`}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={handleReplaceQuestion}
|
||||
disabled={!canWrite}
|
||||
leftSection={<Icon name="refresh" size={14} />}
|
||||
>
|
||||
{t`Replace`}
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
)}
|
||||
</Flex>
|
||||
</Box>
|
||||
)}
|
||||
{rawSeries ? (
|
||||
<>
|
||||
<Box className={styles.questionResults}>
|
||||
<Visualization
|
||||
rawSeries={rawSeries}
|
||||
metadata={metadata}
|
||||
onChangeCardAndRun={handleChangeCardAndRun}
|
||||
getExtraDataForClick={() => ({})}
|
||||
isEditing={false}
|
||||
isDashboard={false}
|
||||
isDocument={true}
|
||||
showTitle={false}
|
||||
error={datasetError?.message}
|
||||
errorIcon={datasetError?.icon}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box className={styles.questionResults}>
|
||||
<ChartSkeleton
|
||||
display={(cardToUse?.display as CardDisplayType) || "table"}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{isModifyModalOpen &&
|
||||
cardToUse &&
|
||||
(isNativeQuestion ? (
|
||||
<NativeQueryModal
|
||||
card={cardToUse}
|
||||
isOpen={isModifyModalOpen}
|
||||
onClose={() => setIsModifyModalOpen(false)}
|
||||
editor={editor}
|
||||
initialDataset={dataset}
|
||||
onSave={(result) => {
|
||||
updateAttributes({
|
||||
id: result.card_id,
|
||||
name: null,
|
||||
});
|
||||
setIsModifyModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ModifyQuestionModal
|
||||
card={cardToUse}
|
||||
isOpen={isModifyModalOpen}
|
||||
onClose={() => setIsModifyModalOpen(false)}
|
||||
editor={editor}
|
||||
onSave={(result) => {
|
||||
updateAttributes({
|
||||
id: result.card_id,
|
||||
name: null,
|
||||
});
|
||||
setIsModifyModalOpen(false);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
return (
|
||||
prevProps.node.attrs.id === nextProps.node.attrs.id &&
|
||||
prevProps.node.attrs.name === nextProps.node.attrs.name &&
|
||||
prevProps.selected === nextProps.selected
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
CardEmbedComponent.displayName = "CardEmbedComponent";
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { t } from "ttag";
|
||||
import _ from "underscore";
|
||||
|
||||
import { useDispatch, useStore } from "metabase/lib/redux";
|
||||
import { Notebook } from "metabase/querying/notebook/components/Notebook";
|
||||
import { loadMetadataForCard } from "metabase/questions/actions";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box, Button, Flex, Modal, Text } from "metabase/ui";
|
||||
import * as Lib from "metabase-lib";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type { Card } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
createDraftCard,
|
||||
generateDraftCardId,
|
||||
loadMetadataForDocumentCard,
|
||||
} from "../../../../documents.slice";
|
||||
import {
|
||||
useDocumentsDispatch,
|
||||
useDocumentsSelector,
|
||||
} from "../../../../redux-utils";
|
||||
|
||||
interface ModifyQuestionModalProps {
|
||||
card: Card;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (result: { card_id: number; name: string }) => void;
|
||||
editor?: Editor;
|
||||
}
|
||||
|
||||
export const ModifyQuestionModal = ({
|
||||
card,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editor,
|
||||
}: ModifyQuestionModalProps) => {
|
||||
const store = useStore();
|
||||
const dispatch = useDispatch();
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
const metadata = useDocumentsSelector(getMetadata);
|
||||
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Load metadata for card
|
||||
useEffect(() => {
|
||||
if (isOpen && card) {
|
||||
documentsDispatch(loadMetadataForDocumentCard(card));
|
||||
}
|
||||
}, [isOpen, card, documentsDispatch]);
|
||||
|
||||
const question = useMemo(() => {
|
||||
if (!card || !metadata || !isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseQuestion = new Question(card, metadata);
|
||||
if (!modifiedQuestion) {
|
||||
setModifiedQuestion(baseQuestion);
|
||||
}
|
||||
return baseQuestion;
|
||||
}, [card, metadata, isOpen, modifiedQuestion]);
|
||||
|
||||
const handleUpdateQuestion = async (newQuestion: Question) => {
|
||||
const currentDependencies = modifiedQuestion
|
||||
? Lib.dependentMetadata(
|
||||
modifiedQuestion.query(),
|
||||
modifiedQuestion.id(),
|
||||
modifiedQuestion.type(),
|
||||
)
|
||||
: [];
|
||||
|
||||
const nextDependencies = Lib.dependentMetadata(
|
||||
newQuestion.query(),
|
||||
newQuestion.id(),
|
||||
newQuestion.type(),
|
||||
);
|
||||
|
||||
if (!_.isEqual(currentDependencies, nextDependencies)) {
|
||||
await dispatch(loadMetadataForCard(newQuestion.card()));
|
||||
const freshMetadata = getMetadata(store.getState());
|
||||
const questionWithFreshMetadata = new Question(
|
||||
newQuestion.card(),
|
||||
freshMetadata,
|
||||
);
|
||||
setModifiedQuestion(questionWithFreshMetadata);
|
||||
} else {
|
||||
setModifiedQuestion(newQuestion);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!modifiedQuestion || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modifiedData = {
|
||||
dataset_query: modifiedQuestion.datasetQuery(),
|
||||
display: modifiedQuestion.display(),
|
||||
visualization_settings:
|
||||
modifiedQuestion.card().visualization_settings ?? {},
|
||||
};
|
||||
|
||||
const newCardId = generateDraftCardId();
|
||||
|
||||
documentsDispatch(
|
||||
createDraftCard({
|
||||
originalCard: card,
|
||||
modifiedData,
|
||||
draftId: newCardId,
|
||||
}),
|
||||
);
|
||||
|
||||
onSave({ card_id: newCardId, name: card.name });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save modified question:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="80%"
|
||||
title={t`Modify question`}
|
||||
padding="lg"
|
||||
>
|
||||
{question && modifiedQuestion ? (
|
||||
<>
|
||||
<Box h="70vh" style={{ overflow: "auto" }}>
|
||||
<Notebook
|
||||
question={modifiedQuestion}
|
||||
isDirty={true}
|
||||
isRunnable={true}
|
||||
isResultDirty={true}
|
||||
reportTimezone="UTC"
|
||||
hasVisualizeButton={false}
|
||||
updateQuestion={handleUpdateQuestion}
|
||||
runQuestionQuery={async () => {}}
|
||||
/>
|
||||
</Box>
|
||||
<Flex mt="lg" justify="flex-end" gap="0.5rem">
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button variant="filled" onClick={handleSave}>
|
||||
{t`Save and use`}
|
||||
</Button>
|
||||
</Flex>
|
||||
</>
|
||||
) : (
|
||||
<Flex h="70vh" align="center" justify="center">
|
||||
<Text>{t`Failed to load question data`}</Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,571 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { c, t } from "ttag";
|
||||
|
||||
import EmptyCodeResult from "assets/img/empty-states/code.svg";
|
||||
import { skipToken } from "metabase/api";
|
||||
import { datasetApi } from "metabase/api/dataset";
|
||||
import { ErrorMessage } from "metabase/common/components/ErrorMessage";
|
||||
import { isMac } from "metabase/lib/browser";
|
||||
import NativeQueryEditor from "metabase/query_builder/components/NativeQueryEditor";
|
||||
import DataReference from "metabase/query_builder/components/dataref/DataReference";
|
||||
import { getMetadata } from "metabase/selectors/metadata";
|
||||
import { Box, Button, Flex, Loader, Modal, Stack, Text } from "metabase/ui";
|
||||
import Visualization from "metabase/visualizations/components/Visualization";
|
||||
import NoResultsView from "metabase/visualizations/components/Visualization/NoResultsView/NoResultsView";
|
||||
import Question from "metabase-lib/v1/Question";
|
||||
import type NativeQuery from "metabase-lib/v1/queries/NativeQuery";
|
||||
import type {
|
||||
Card,
|
||||
DatabaseId,
|
||||
Dataset,
|
||||
DatasetQuery,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import {
|
||||
createDraftCard,
|
||||
generateDraftCardId,
|
||||
loadMetadataForDocumentCard,
|
||||
} from "../../../../documents.slice";
|
||||
import {
|
||||
useDocumentsDispatch,
|
||||
useDocumentsSelector,
|
||||
} from "../../../../redux-utils";
|
||||
|
||||
type DataReferenceStackItem = {
|
||||
type: string;
|
||||
item: unknown;
|
||||
};
|
||||
|
||||
interface NativeQueryModalProps {
|
||||
card: Card;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (result: { card_id: number; name: string }) => void;
|
||||
editor?: Editor;
|
||||
initialDataset?: Dataset;
|
||||
}
|
||||
|
||||
const EDITOR_HEIGHT_RATIO = 0.4;
|
||||
const EDITOR_MIN_HEIGHT = 200;
|
||||
const EDITOR_HEIGHT_OFFSET = 80;
|
||||
|
||||
const MODAL_SIDEBAR_FEATURES = {
|
||||
dataReference: true,
|
||||
variables: false,
|
||||
snippets: false,
|
||||
promptInput: false,
|
||||
formatQuery: false,
|
||||
aiGeneration: false,
|
||||
} as const;
|
||||
|
||||
const getRunQueryShortcut = () => (isMac() ? t`⌘ + return` : t`Ctrl + enter`);
|
||||
|
||||
const isFailedDataset = (dataset: Dataset | null | undefined): boolean => {
|
||||
return !!(dataset?.error || dataset?.status === "failed");
|
||||
};
|
||||
|
||||
const hasEmptyResults = (dataset: Dataset | null | undefined): boolean => {
|
||||
return (
|
||||
!isFailedDataset(dataset) &&
|
||||
!!dataset?.data &&
|
||||
Array.isArray(dataset.data.rows) &&
|
||||
dataset.data.rows.length === 0
|
||||
);
|
||||
};
|
||||
|
||||
const getErrorMessage = (sqlError: any, queryError: unknown): string => {
|
||||
if (sqlError?.error) {
|
||||
return sqlError.error;
|
||||
}
|
||||
if (typeof queryError === "object" && queryError && "message" in queryError) {
|
||||
return String(queryError.message);
|
||||
}
|
||||
return t`An error occurred while running your query. Please check your query and try again.`;
|
||||
};
|
||||
|
||||
const QueryExecutionEmptyState = ({
|
||||
hasInitialData,
|
||||
}: {
|
||||
hasInitialData: boolean;
|
||||
}) => {
|
||||
if (hasInitialData) {
|
||||
return <NoResultsView />;
|
||||
}
|
||||
|
||||
const keyboardShortcut = getRunQueryShortcut();
|
||||
|
||||
return (
|
||||
<Flex w="100%" h="100%" align="center" justify="center">
|
||||
<Stack maw="25rem" gap={0} ta="center" align="center">
|
||||
<Box maw="3rem" mb="0.75rem">
|
||||
<img src={EmptyCodeResult} alt="Code prompt icon" />
|
||||
</Box>
|
||||
<Text c="text-medium">
|
||||
{c("{0} refers to the keyboard shortcut")
|
||||
.jt`To run your code, click on the Run button or type ${(
|
||||
<b key="shortcut">({keyboardShortcut})</b>
|
||||
)}`}
|
||||
</Text>
|
||||
<Text c="text-medium">{t`Here's where your results will appear`}</Text>
|
||||
</Stack>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export const NativeQueryModal = ({
|
||||
card,
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editor,
|
||||
initialDataset,
|
||||
}: NativeQueryModalProps) => {
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
const metadata = useDocumentsSelector(getMetadata);
|
||||
|
||||
const [modifiedQuestion, setModifiedQuestion] = useState<Question | null>(
|
||||
null,
|
||||
);
|
||||
const [currentQuery, setCurrentQuery] = useState<DatasetQuery | null>(null);
|
||||
const [hasExecutedQuery, setHasExecutedQuery] = useState(false);
|
||||
const [isShowingTemplateTagsEditor, setIsShowingTemplateTagsEditor] =
|
||||
useState(false);
|
||||
const [isShowingDataReference, setIsShowingDataReference] = useState(false);
|
||||
const [dataReferenceStack, setDataReferenceStack] = useState<
|
||||
DataReferenceStackItem[]
|
||||
>([]);
|
||||
|
||||
const {
|
||||
data: queryResult,
|
||||
isLoading: isQueryRunning,
|
||||
error: queryError,
|
||||
refetch: refetchQuery,
|
||||
} = datasetApi.useGetAdhocQueryQuery(currentQuery || skipToken, {
|
||||
skip: !currentQuery,
|
||||
});
|
||||
|
||||
const datasetToUse =
|
||||
queryResult || (!hasExecutedQuery ? initialDataset : null);
|
||||
const sqlError = isFailedDataset(datasetToUse) ? datasetToUse : null;
|
||||
|
||||
// Load metadata for card
|
||||
useEffect(() => {
|
||||
if (isOpen && card) {
|
||||
documentsDispatch(loadMetadataForDocumentCard(card));
|
||||
}
|
||||
}, [isOpen, card, documentsDispatch]);
|
||||
|
||||
// Question initialization
|
||||
const question = useMemo(() => {
|
||||
if (!card || !metadata || !isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseQuestion = new Question(card, metadata);
|
||||
if (!modifiedQuestion) {
|
||||
setModifiedQuestion(baseQuestion);
|
||||
}
|
||||
return baseQuestion;
|
||||
}, [card, metadata, isOpen, modifiedQuestion]);
|
||||
|
||||
// Reset query execution state when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setCurrentQuery(null);
|
||||
setHasExecutedQuery(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// Dataset query handler following QB patterns
|
||||
const setDatasetQuery = useCallback(
|
||||
async (query: NativeQuery | DatasetQuery) => {
|
||||
if (!modifiedQuestion) {
|
||||
return modifiedQuestion;
|
||||
}
|
||||
|
||||
try {
|
||||
let datasetQuery: DatasetQuery;
|
||||
const isNativeQueryObject =
|
||||
"queryText" in query && typeof query.queryText === "function";
|
||||
if (isNativeQueryObject) {
|
||||
// Handle NativeQuery object
|
||||
const nativeQuery = query as NativeQuery;
|
||||
datasetQuery = {
|
||||
type: "native",
|
||||
native: {
|
||||
query: nativeQuery.queryText() || "",
|
||||
"template-tags": nativeQuery.templateTagsMap() || {},
|
||||
},
|
||||
database: modifiedQuestion.databaseId(),
|
||||
} as DatasetQuery;
|
||||
} else {
|
||||
// Handle dataset_query object (including database changes)
|
||||
datasetQuery = query as DatasetQuery;
|
||||
}
|
||||
|
||||
const newQuestion = modifiedQuestion.setDatasetQuery(datasetQuery);
|
||||
setModifiedQuestion(newQuestion);
|
||||
return newQuestion;
|
||||
} catch (error) {
|
||||
console.error("Failed to update dataset query:", error);
|
||||
return modifiedQuestion;
|
||||
}
|
||||
},
|
||||
[modifiedQuestion],
|
||||
);
|
||||
|
||||
// Query execution handler
|
||||
const handleRunQuery = useCallback(() => {
|
||||
if (!modifiedQuestion) {
|
||||
return;
|
||||
}
|
||||
|
||||
const datasetQuery = modifiedQuestion.datasetQuery();
|
||||
|
||||
// Check if query has changed from what we currently have
|
||||
const queryChanged =
|
||||
!currentQuery ||
|
||||
JSON.stringify(currentQuery) !== JSON.stringify(datasetQuery);
|
||||
|
||||
if (queryChanged) {
|
||||
// Query changed, set new query which will trigger RTK Query
|
||||
setCurrentQuery(datasetQuery);
|
||||
} else {
|
||||
// Same query, use refetch for proper loading state
|
||||
refetchQuery();
|
||||
}
|
||||
|
||||
setHasExecutedQuery(true);
|
||||
}, [modifiedQuestion, currentQuery, refetchQuery]);
|
||||
|
||||
// Save handler
|
||||
const handleSave = async () => {
|
||||
if (!modifiedQuestion || !editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const modifiedData = {
|
||||
dataset_query: modifiedQuestion.datasetQuery(),
|
||||
display: modifiedQuestion.display(),
|
||||
visualization_settings:
|
||||
modifiedQuestion.card().visualization_settings ?? {},
|
||||
};
|
||||
|
||||
const newCardId = generateDraftCardId();
|
||||
|
||||
documentsDispatch(
|
||||
createDraftCard({
|
||||
originalCard: card,
|
||||
modifiedData,
|
||||
draftId: newCardId,
|
||||
}),
|
||||
);
|
||||
|
||||
onSave({ card_id: newCardId, name: card.name });
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save modified question:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Transform query results to visualization series following QB patterns
|
||||
const rawSeries = useMemo(() => {
|
||||
if (!modifiedQuestion || !datasetToUse || sqlError) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = datasetToUse.data || datasetToUse;
|
||||
return [
|
||||
{
|
||||
card: {
|
||||
...modifiedQuestion.card(),
|
||||
display: "table" as const, // Force table display for results
|
||||
visualization_settings: {
|
||||
...modifiedQuestion.card().visualization_settings,
|
||||
"table.pivot": false,
|
||||
"table.columns": undefined,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
rows: data.rows || [],
|
||||
cols: data.cols || [],
|
||||
results_metadata: data.results_metadata || [],
|
||||
insights: data.insights,
|
||||
rows_truncated: data.rows_truncated || 0,
|
||||
},
|
||||
json_query: datasetToUse.json_query,
|
||||
started_at: datasetToUse.started_at || new Date().toISOString(),
|
||||
running_time: datasetToUse.running_time,
|
||||
row_count: datasetToUse.row_count,
|
||||
status: "completed",
|
||||
},
|
||||
];
|
||||
} catch (error) {
|
||||
console.error("Error formatting series data:", error);
|
||||
return null;
|
||||
}
|
||||
}, [modifiedQuestion, datasetToUse, sqlError]);
|
||||
|
||||
const modalStyles = {
|
||||
content: {
|
||||
height: "90vh",
|
||||
maxHeight: "90vh",
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
display: "flex",
|
||||
flexDirection: "column" as const,
|
||||
padding: 0,
|
||||
minHeight: 0,
|
||||
overflow: "hidden" as const,
|
||||
},
|
||||
};
|
||||
|
||||
if (!question || !modifiedQuestion) {
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="95%"
|
||||
title={t`Edit SQL Query`}
|
||||
>
|
||||
<Flex h="400px" align="center" justify="center">
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={isOpen}
|
||||
onClose={onClose}
|
||||
size="95%"
|
||||
title={t`Edit SQL Query`}
|
||||
padding="lg"
|
||||
styles={modalStyles}
|
||||
>
|
||||
<Flex h="100%" direction="column">
|
||||
{/* Main content area with horizontal layout */}
|
||||
<Flex flex={1} direction="row" mih={0} style={{ overflow: "hidden" }}>
|
||||
{/* Left side - Editor and Results */}
|
||||
<Flex
|
||||
flex={1}
|
||||
direction="column"
|
||||
mih={0}
|
||||
miw={0}
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
<Box
|
||||
pos="relative"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: "1px solid var(--mb-color-border)",
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
{/* NativeQueryEditor with minimal configuration for modal context */}
|
||||
{(modifiedQuestion?.legacyNativeQuery() ||
|
||||
question?.legacyNativeQuery()) && (
|
||||
<NativeQueryEditor
|
||||
question={modifiedQuestion}
|
||||
query={
|
||||
modifiedQuestion?.legacyNativeQuery() ||
|
||||
question?.legacyNativeQuery() ||
|
||||
({} as any)
|
||||
}
|
||||
isNativeEditorOpen
|
||||
isInitiallyOpen
|
||||
hasTopBar
|
||||
hasEditingSidebar
|
||||
hasParametersList={false}
|
||||
sidebarFeatures={MODAL_SIDEBAR_FEATURES}
|
||||
viewHeight={400}
|
||||
isRunnable
|
||||
isRunning={isQueryRunning}
|
||||
isResultDirty={false}
|
||||
isShowingDataReference={isShowingDataReference}
|
||||
isShowingTemplateTagsEditor={isShowingTemplateTagsEditor}
|
||||
isShowingSnippetSidebar={false}
|
||||
setDatasetQuery={setDatasetQuery}
|
||||
runQuestionQuery={handleRunQuery}
|
||||
runQuery={handleRunQuery}
|
||||
toggleTemplateTagsEditor={() =>
|
||||
setIsShowingTemplateTagsEditor(!isShowingTemplateTagsEditor)
|
||||
}
|
||||
toggleDataReference={() => {
|
||||
if (!isShowingDataReference && modifiedQuestion) {
|
||||
const databaseId = modifiedQuestion.databaseId();
|
||||
if (databaseId) {
|
||||
setDataReferenceStack([
|
||||
{ type: "database", item: { id: databaseId } },
|
||||
]);
|
||||
}
|
||||
}
|
||||
setIsShowingDataReference(!isShowingDataReference);
|
||||
}}
|
||||
toggleSnippetSidebar={() => {}}
|
||||
setNativeEditorSelectedRange={() => {}}
|
||||
openDataReferenceAtQuestion={() => {}}
|
||||
openSnippetModalWithSelectedText={() => {}}
|
||||
insertSnippet={() => {}}
|
||||
setParameterValue={() => {}}
|
||||
onOpenModal={() => {}}
|
||||
cancelQuery={() => {}}
|
||||
closeSnippetModal={() => {}}
|
||||
handleResize={() => {}}
|
||||
canChangeDatabase
|
||||
toggleEditor={() => {}}
|
||||
onSetDatabaseId={(databaseId: DatabaseId) => {
|
||||
if (modifiedQuestion) {
|
||||
const updatedDatasetQuery = {
|
||||
...modifiedQuestion.datasetQuery(),
|
||||
database: databaseId,
|
||||
};
|
||||
const newQuestion =
|
||||
modifiedQuestion.setDatasetQuery(updatedDatasetQuery);
|
||||
setModifiedQuestion(newQuestion);
|
||||
}
|
||||
}}
|
||||
resizable
|
||||
resizableBoxProps={{
|
||||
height: Math.max(
|
||||
EDITOR_MIN_HEIGHT,
|
||||
Math.floor(
|
||||
window.innerHeight * EDITOR_HEIGHT_RATIO -
|
||||
EDITOR_HEIGHT_OFFSET,
|
||||
),
|
||||
),
|
||||
style: {
|
||||
border: "none",
|
||||
width: "100%",
|
||||
minWidth: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Results area */}
|
||||
<Flex
|
||||
id="results-container"
|
||||
flex={1}
|
||||
direction="column"
|
||||
mih={0}
|
||||
pt="0.25rem"
|
||||
pos="relative"
|
||||
style={{ overflow: "hidden" }}
|
||||
>
|
||||
{!isQueryRunning && (sqlError || queryError) ? (
|
||||
<ErrorMessage
|
||||
type="serverError"
|
||||
title={t`Query execution failed`}
|
||||
message={getErrorMessage(sqlError, queryError)}
|
||||
action={null}
|
||||
/>
|
||||
) : !isQueryRunning && hasEmptyResults(datasetToUse) ? (
|
||||
<QueryExecutionEmptyState hasInitialData={!!datasetToUse} />
|
||||
) : !isQueryRunning && rawSeries ? (
|
||||
<Box flex={1} mih="300px">
|
||||
<Visualization
|
||||
rawSeries={rawSeries}
|
||||
metadata={metadata}
|
||||
onChangeCardAndRun={() => {}}
|
||||
getExtraDataForClick={() => ({})}
|
||||
isEditing={false}
|
||||
isDashboard={false}
|
||||
showTitle={false}
|
||||
/>
|
||||
</Box>
|
||||
) : !isQueryRunning ? (
|
||||
<QueryExecutionEmptyState hasInitialData={false} />
|
||||
) : !datasetToUse ? (
|
||||
<Flex h="100%" align="center" justify="center">
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
) : null}
|
||||
|
||||
{/* Loading overlay for when rerunning queries with existing results */}
|
||||
{isQueryRunning && datasetToUse && (
|
||||
<Flex
|
||||
pos="absolute"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
bg="var(--mb-color-bg-white)"
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{ zIndex: 10 }}
|
||||
>
|
||||
<Loader size="lg" />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Data reference sidebar */}
|
||||
{isShowingDataReference && modifiedQuestion && (
|
||||
<Box
|
||||
w="350px"
|
||||
miw="350px"
|
||||
bg="var(--mb-color-bg-white)"
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderLeft: "1px solid var(--mb-color-border)",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<DataReference
|
||||
dataReferenceStack={dataReferenceStack}
|
||||
onClose={() => setIsShowingDataReference(false)}
|
||||
popDataReferenceStack={() => {
|
||||
if (dataReferenceStack.length === 1) {
|
||||
setIsShowingDataReference(false);
|
||||
} else {
|
||||
setDataReferenceStack(dataReferenceStack.slice(0, -1));
|
||||
}
|
||||
}}
|
||||
pushDataReferenceStack={(item: DataReferenceStackItem) => {
|
||||
setDataReferenceStack([...dataReferenceStack, item]);
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Footer with actions */}
|
||||
<Flex
|
||||
pos="sticky"
|
||||
bottom={0}
|
||||
justify="flex-end"
|
||||
gap="0.5rem"
|
||||
p="1rem"
|
||||
bg="var(--mb-color-bg-white)"
|
||||
style={{
|
||||
borderTop: "1px solid var(--mb-color-border)",
|
||||
flexShrink: 0,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<Button variant="subtle" onClick={onClose}>
|
||||
{t`Cancel`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="filled"
|
||||
onClick={handleSave}
|
||||
disabled={!modifiedQuestion}
|
||||
>
|
||||
{t`Save and use`}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { CardEmbedComponent, CardEmbedNode } from "./CardEmbedNode";
|
||||
|
||||
export * from "./CardEmbedNode";
|
||||
|
||||
export const CardEmbed = CardEmbedNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(CardEmbedComponent);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
.Column {
|
||||
padding: 1rem;
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
flex: 1 1 0;
|
||||
min-height: 5rem;
|
||||
background-color: var(--mb-color-bg-light);
|
||||
border-radius: 1rem;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-bg-medium);
|
||||
}
|
||||
}
|
||||
|
||||
.ColumnBlock {
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
|
||||
& > [data-node-view-content-react] {
|
||||
display: contents;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Extension,
|
||||
InputRule,
|
||||
Node,
|
||||
type NodeViewProps,
|
||||
mergeAttributes,
|
||||
} from "@tiptap/core";
|
||||
import {
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from "@tiptap/react";
|
||||
|
||||
import { Icon } from "metabase/ui";
|
||||
|
||||
import Styles from "./Columns.module.css";
|
||||
|
||||
const ColumnBlock = Node.create({
|
||||
name: "columnBlock",
|
||||
group: "block",
|
||||
content: "column{1,}",
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const attrs = mergeAttributes(HTMLAttributes);
|
||||
return ["div", attrs, 0];
|
||||
},
|
||||
|
||||
addInputRules() {
|
||||
return [
|
||||
new InputRule({
|
||||
find: /::columns/g,
|
||||
handler: ({ state, range }) => {
|
||||
state.tr.delete(range.from, range.to);
|
||||
|
||||
const columnBlockNode = state.schema.nodes.columnBlock.create({}, [
|
||||
state.schema.nodes.column.create({}, [
|
||||
state.schema.nodes.paragraph.create(),
|
||||
]),
|
||||
state.schema.nodes.column.create({}, [
|
||||
state.schema.nodes.paragraph.create(),
|
||||
]),
|
||||
state.schema.nodes.column.create({}, [
|
||||
state.schema.nodes.paragraph.create(),
|
||||
]),
|
||||
]);
|
||||
|
||||
state.tr.insert(range.from, columnBlockNode);
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ColumnBlockView);
|
||||
},
|
||||
});
|
||||
|
||||
const ColumnBlockView = () => {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<NodeViewContent className={Styles.ColumnBlock} />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const Column = Node.create({
|
||||
name: "column",
|
||||
group: "column",
|
||||
content: "(block)*",
|
||||
isolating: true,
|
||||
selectable: true,
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const attrs = mergeAttributes(HTMLAttributes);
|
||||
return ["div", attrs, 0];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ColumnView, {
|
||||
className: Styles.Column,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const ColumnView = ({ deleteNode }: NodeViewProps) => {
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Icon
|
||||
name="close"
|
||||
pos="absolute"
|
||||
top="6"
|
||||
right="6"
|
||||
cursor="pointer"
|
||||
onClick={deleteNode}
|
||||
/>
|
||||
|
||||
<NodeViewContent />
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const ColumnExtension = Extension.create({
|
||||
name: "columnExtension",
|
||||
|
||||
addExtensions() {
|
||||
const extensions = [];
|
||||
|
||||
if (this.options.column !== false) {
|
||||
extensions.push(Column);
|
||||
}
|
||||
|
||||
if (this.options.columnBlock !== false) {
|
||||
extensions.push(ColumnBlock);
|
||||
}
|
||||
|
||||
return extensions;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import { type Editor, Extension, type Range } from "@tiptap/core";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
|
||||
|
||||
export interface CommandOptions {
|
||||
suggestion: Partial<SuggestionOptions>;
|
||||
}
|
||||
|
||||
interface CommandProps {
|
||||
command?: string;
|
||||
clearQuery?: boolean;
|
||||
switchToLinkMode?: boolean;
|
||||
switchToEmbedMode?: boolean;
|
||||
selectItem?: boolean;
|
||||
embedItem?: boolean;
|
||||
entityId?: number;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export const CommandPluginKey = new PluginKey("command");
|
||||
|
||||
export const CommandExtension = Extension.create<CommandOptions>({
|
||||
name: "command",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
pluginKey: CommandPluginKey,
|
||||
allowSpaces: true,
|
||||
items: ({ query: _query }: { query: string }) => [],
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
props: CommandProps;
|
||||
}) => {
|
||||
if (
|
||||
props.clearQuery &&
|
||||
(props.switchToLinkMode || props.switchToEmbedMode)
|
||||
) {
|
||||
const startPos = range.from + 1;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange({ from: startPos, to: range.to })
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
if (props.embedItem && props.entityId && props.model) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: props.entityId,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
if (props.selectItem && props.entityId && props.model) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "smartLink",
|
||||
attrs: {
|
||||
entityId: props.entityId,
|
||||
model: props.model,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.command === "linkTo" || props.command === "embedQuestion") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.command === "metabot") {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "metabot",
|
||||
attrs: {},
|
||||
})
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
switch (props.command) {
|
||||
case "heading1":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleHeading({ level: 1 })
|
||||
.run();
|
||||
break;
|
||||
case "heading2":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleHeading({ level: 2 })
|
||||
.run();
|
||||
break;
|
||||
case "heading3":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleHeading({ level: 3 })
|
||||
.run();
|
||||
break;
|
||||
case "bulletList":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleBulletList()
|
||||
.run();
|
||||
break;
|
||||
case "orderedList":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleOrderedList()
|
||||
.run();
|
||||
break;
|
||||
case "blockquote":
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.toggleBlockquote()
|
||||
.run();
|
||||
break;
|
||||
case "codeBlock":
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run();
|
||||
break;
|
||||
}
|
||||
},
|
||||
selectItem: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
props: CommandProps;
|
||||
}) => {
|
||||
if (props.entityId && props.model) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "smartLink",
|
||||
attrs: {
|
||||
entityId: props.entityId,
|
||||
model: props.model,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import {
|
||||
Box,
|
||||
Divider,
|
||||
Group,
|
||||
Icon,
|
||||
type IconName,
|
||||
Stack,
|
||||
Text,
|
||||
UnstyledButton,
|
||||
} from "metabase/ui";
|
||||
import type { RecentItem, SearchResult } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
MenuItemComponent,
|
||||
SearchResultsFooter,
|
||||
} from "../../shared/MenuComponents";
|
||||
import S from "../../shared/MenuItems.module.css";
|
||||
import {
|
||||
LoadingSuggestionPaper,
|
||||
SuggestionPaper,
|
||||
} from "../../shared/SuggestionPaper";
|
||||
import { EntitySearchSection } from "../shared/EntitySearchSection";
|
||||
import { EMBED_SEARCH_MODELS } from "../shared/constants";
|
||||
import { useEntitySearch } from "../shared/useEntitySearch";
|
||||
import { useEntitySuggestions } from "../shared/useEntitySuggestions";
|
||||
|
||||
interface CommandSuggestionProps {
|
||||
items: SearchResult[];
|
||||
command: (item: CommandItem) => void;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface CommandItem {
|
||||
command?: string;
|
||||
clearQuery?: boolean;
|
||||
switchToLinkMode?: boolean;
|
||||
switchToEmbedMode?: boolean;
|
||||
selectItem?: boolean;
|
||||
embedItem?: boolean;
|
||||
entityId?: number | string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface SuggestionRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
interface CommandOption {
|
||||
icon?: IconName;
|
||||
text?: string;
|
||||
label: string;
|
||||
command: string;
|
||||
}
|
||||
|
||||
interface CommandSection {
|
||||
title?: string;
|
||||
items: CommandOption[];
|
||||
}
|
||||
|
||||
const CommandMenuItem = forwardRef<
|
||||
HTMLButtonElement,
|
||||
{
|
||||
option: CommandOption;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
}
|
||||
>(function CommandMenuItem({ option, isSelected, onClick }, ref) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
ref={ref}
|
||||
className={S.menuItem}
|
||||
onClick={onClick}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
aria-label={option.label}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap" align="center">
|
||||
{option.icon ? (
|
||||
<Icon name={option.icon} size={16} color="inherit" />
|
||||
) : option.text ? (
|
||||
<Box
|
||||
w={16}
|
||||
h={16}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
<Text size="xs" fw={700} c="inherit">
|
||||
{option.text}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Stack gap={2} style={{ flex: 1 }}>
|
||||
<Text size="md" lh="lg" c="inherit">
|
||||
{option.label}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
);
|
||||
});
|
||||
|
||||
const CommandSuggestionComponent = forwardRef<
|
||||
SuggestionRef,
|
||||
CommandSuggestionProps
|
||||
>(function CommandSuggestionComponent({ command, editor, query }, ref) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [showLinkSearch, setShowLinkSearch] = useState(false);
|
||||
const [showEmbedSearch, setShowEmbedSearch] = useState(false);
|
||||
const [pendingLinkMode, setPendingLinkMode] = useState(false);
|
||||
const [pendingEmbedMode, setPendingEmbedMode] = useState(false);
|
||||
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingLinkMode) {
|
||||
setShowLinkSearch(true);
|
||||
setPendingLinkMode(false);
|
||||
}
|
||||
if (pendingEmbedMode) {
|
||||
setShowEmbedSearch(true);
|
||||
setPendingEmbedMode(false);
|
||||
}
|
||||
}, [pendingLinkMode, pendingEmbedMode]);
|
||||
|
||||
const effectiveQuery = query;
|
||||
|
||||
const allCommandSections: CommandSection[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
items: [
|
||||
{
|
||||
icon: "metabot",
|
||||
label: t`Ask Metabot`,
|
||||
command: "metabot",
|
||||
},
|
||||
{
|
||||
icon: "lineandbar",
|
||||
label: t`Chart`,
|
||||
command: "embedQuestion",
|
||||
},
|
||||
{
|
||||
icon: "link",
|
||||
label: t`Link`,
|
||||
command: "linkTo",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t`Formatting`,
|
||||
items: [
|
||||
{
|
||||
text: "H1",
|
||||
label: t`Heading 1`,
|
||||
command: "heading1",
|
||||
},
|
||||
{
|
||||
text: "H2",
|
||||
label: t`Heading 2`,
|
||||
command: "heading2",
|
||||
},
|
||||
{
|
||||
text: "H3",
|
||||
label: t`Heading 3`,
|
||||
command: "heading3",
|
||||
},
|
||||
{
|
||||
icon: "list",
|
||||
label: t`Bullet list`,
|
||||
command: "bulletList",
|
||||
},
|
||||
{
|
||||
icon: "ordered_list",
|
||||
label: t`Numbered list`,
|
||||
command: "orderedList",
|
||||
},
|
||||
{
|
||||
icon: "quote",
|
||||
label: t`Quote`,
|
||||
command: "blockquote",
|
||||
},
|
||||
{
|
||||
icon: "code_block",
|
||||
label: t`Code block`,
|
||||
command: "codeBlock",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const allCommandOptions = useMemo(
|
||||
() => allCommandSections.flatMap((section) => section.items),
|
||||
[allCommandSections],
|
||||
);
|
||||
|
||||
// Filter command options based on query when not in link search mode
|
||||
const commandOptions = useMemo(() => {
|
||||
if (showLinkSearch || showEmbedSearch) {
|
||||
return allCommandOptions;
|
||||
}
|
||||
|
||||
if (!query) {
|
||||
return allCommandOptions;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return allCommandOptions.filter((option) =>
|
||||
option.label.toLowerCase().includes(lowerQuery),
|
||||
);
|
||||
}, [allCommandOptions, query, showLinkSearch, showEmbedSearch]);
|
||||
|
||||
const onSelectLinkEntity = useCallback(
|
||||
(item: { id: number | string; model: string }) => {
|
||||
if (showEmbedSearch) {
|
||||
command({
|
||||
embedItem: true,
|
||||
entityId: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
} else {
|
||||
command({
|
||||
selectItem: true,
|
||||
entityId: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
}
|
||||
},
|
||||
[command, showEmbedSearch],
|
||||
);
|
||||
|
||||
const executeCommand = (commandName: string) => {
|
||||
if (commandName === "linkTo") {
|
||||
setPendingLinkMode(true);
|
||||
command({
|
||||
clearQuery: true,
|
||||
switchToLinkMode: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandName === "embedQuestion") {
|
||||
setPendingEmbedMode(true);
|
||||
command({
|
||||
clearQuery: true,
|
||||
switchToEmbedMode: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (commandName === "metabot") {
|
||||
command({
|
||||
command: "metabot",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
command({
|
||||
command: commandName,
|
||||
});
|
||||
};
|
||||
|
||||
// Use shared entity suggestions for link/embed mode and browse all functionality
|
||||
const entitySuggestions = useEntitySuggestions({
|
||||
query: effectiveQuery,
|
||||
editor,
|
||||
onSelectEntity: onSelectLinkEntity,
|
||||
enabled: showLinkSearch || showEmbedSearch || !!query,
|
||||
searchModels: showEmbedSearch ? EMBED_SEARCH_MODELS : undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
menuItems: linkMenuItems,
|
||||
isLoading: isLinkSearchLoading,
|
||||
searchResults,
|
||||
selectedIndex: entitySelectedIndex,
|
||||
modal: entityModal,
|
||||
handlers: entityHandlers,
|
||||
} = entitySuggestions;
|
||||
|
||||
const { menuItems: searchMenuItems } = useEntitySearch({
|
||||
query,
|
||||
onSelectRecent: useCallback(
|
||||
(item: RecentItem) => {
|
||||
command({
|
||||
embedItem: true,
|
||||
entityId: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
},
|
||||
[command],
|
||||
),
|
||||
onSelectSearchResult: useCallback(
|
||||
(item: SearchResult) => {
|
||||
command({
|
||||
embedItem: true,
|
||||
entityId: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
},
|
||||
[command],
|
||||
),
|
||||
enabled: !showLinkSearch && !showEmbedSearch && !!query,
|
||||
searchModels: EMBED_SEARCH_MODELS,
|
||||
});
|
||||
|
||||
const currentItems = useMemo(() => {
|
||||
if (showLinkSearch || showEmbedSearch) {
|
||||
return linkMenuItems;
|
||||
}
|
||||
|
||||
// When searching in command mode, combine search results with matching commands
|
||||
if (query && searchMenuItems.length > 0) {
|
||||
// Show search results (questions) followed by matching commands
|
||||
return [...searchMenuItems, ...commandOptions];
|
||||
}
|
||||
|
||||
return commandOptions;
|
||||
}, [
|
||||
showLinkSearch,
|
||||
showEmbedSearch,
|
||||
linkMenuItems,
|
||||
query,
|
||||
searchMenuItems,
|
||||
commandOptions,
|
||||
]);
|
||||
let totalItems = currentItems.length;
|
||||
|
||||
if (showLinkSearch || showEmbedSearch) {
|
||||
totalItems = linkMenuItems.length + 1;
|
||||
} else if (currentItems.length === 0 && query) {
|
||||
totalItems = 1; // Just the browse all footer
|
||||
}
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
if (showLinkSearch || showEmbedSearch) {
|
||||
entityHandlers.selectItem(index);
|
||||
} else {
|
||||
// When searching in command mode, handle both entity results and commands
|
||||
if (query && searchMenuItems.length > 0) {
|
||||
if (index < searchMenuItems.length) {
|
||||
searchMenuItems[index].action();
|
||||
} else {
|
||||
const commandIndex = index - searchMenuItems.length;
|
||||
if (commandIndex < commandOptions.length) {
|
||||
executeCommand(commandOptions[commandIndex].command);
|
||||
}
|
||||
}
|
||||
} else if (currentItems.length === 0 && query && index === 0) {
|
||||
// Handle browse all when no results
|
||||
// Switch to embed mode so selected questions get embedded
|
||||
setShowEmbedSearch(true);
|
||||
entityHandlers.openModal();
|
||||
} else {
|
||||
if (index < commandOptions.length) {
|
||||
executeCommand(commandOptions[index].command);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((prev) => (prev + 1) % totalItems);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [
|
||||
currentItems.length,
|
||||
showLinkSearch,
|
||||
showEmbedSearch,
|
||||
searchMenuItems.length,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const selectedItem = itemRefs.current[selectedIndex];
|
||||
if (selectedItem) {
|
||||
selectedItem.scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||
}
|
||||
}, [selectedIndex]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (showLinkSearch || showEmbedSearch) {
|
||||
return entityHandlers.onKeyDown({ event });
|
||||
}
|
||||
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
if ((showLinkSearch || showEmbedSearch) && isLinkSearchLoading) {
|
||||
return <LoadingSuggestionPaper aria-label={t`Command Dialog`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SuggestionPaper aria-label={t`Command Dialog`}>
|
||||
{showLinkSearch || showEmbedSearch ? (
|
||||
<EntitySearchSection
|
||||
menuItems={linkMenuItems}
|
||||
selectedIndex={entitySelectedIndex}
|
||||
onItemSelect={entityHandlers.selectItem}
|
||||
onFooterClick={entityHandlers.openModal}
|
||||
query={effectiveQuery}
|
||||
searchResults={searchResults}
|
||||
modal={entityModal}
|
||||
onModalSelect={entityHandlers.handleModalSelect}
|
||||
onModalClose={entityHandlers.handleModalClose}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{commandOptions.length > 0 ||
|
||||
(query && searchMenuItems.length > 0) ? (
|
||||
<>
|
||||
{query && searchMenuItems.length > 0 ? (
|
||||
// When searching, show question search results first, then matching commands
|
||||
<>
|
||||
{searchMenuItems.map((item, index) => (
|
||||
<MenuItemComponent
|
||||
key={`search-${index}`}
|
||||
item={item}
|
||||
isSelected={selectedIndex === index}
|
||||
onClick={() => selectItem(index)}
|
||||
/>
|
||||
))}
|
||||
{searchMenuItems.length > 0 && commandOptions.length > 0 && (
|
||||
<Divider my="sm" mx="sm" />
|
||||
)}
|
||||
{commandOptions.map((option, cmdIndex) => {
|
||||
const index = searchMenuItems.length + cmdIndex;
|
||||
return (
|
||||
<CommandMenuItem
|
||||
key={`cmd-${cmdIndex}`}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
option={option}
|
||||
isSelected={selectedIndex === index}
|
||||
onClick={() => selectItem(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
// When not searching, show sections
|
||||
allCommandSections.map((section, sectionIndex) => {
|
||||
const filteredItems = section.items.filter((item) =>
|
||||
commandOptions.includes(item),
|
||||
);
|
||||
if (filteredItems.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box key={sectionIndex}>
|
||||
{section.title && sectionIndex > 0 && (
|
||||
<Box>
|
||||
<Divider my="sm" mx="sm" />
|
||||
<Text
|
||||
size="sm"
|
||||
c="text-primary"
|
||||
px="sm"
|
||||
pt="xs"
|
||||
pb="xs"
|
||||
>
|
||||
{section.title}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{filteredItems.map((option) => {
|
||||
const index = commandOptions.indexOf(option);
|
||||
return (
|
||||
<CommandMenuItem
|
||||
key={index}
|
||||
ref={(el) => (itemRefs.current[index] = el)}
|
||||
option={option}
|
||||
isSelected={selectedIndex === index}
|
||||
onClick={() => selectItem(index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Box p="sm" ta="center">
|
||||
<Text size="md" c="text-medium">{t`No results found`}</Text>
|
||||
</Box>
|
||||
{query && (
|
||||
<>
|
||||
<Divider my="sm" mx="sm" />
|
||||
<SearchResultsFooter
|
||||
isSelected={selectedIndex === currentItems.length}
|
||||
onClick={() => {
|
||||
// Switch to embed mode so selected questions get embedded
|
||||
setShowEmbedSearch(true);
|
||||
entityHandlers.openModal();
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</SuggestionPaper>
|
||||
);
|
||||
});
|
||||
|
||||
export const CommandSuggestion = CommandSuggestionComponent;
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Plugin } from "prosemirror-state";
|
||||
|
||||
export const DisableMetabotSidebar = Extension.create({
|
||||
name: "take-back-mod-b",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handleKeyDown: (view, event) => {
|
||||
if (event.key === "b" && (event.metaKey || event.ctrlKey)) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { type VirtualElement, computePosition } from "@floating-ui/dom";
|
||||
import Emoji, { gitHubEmojis } from "@tiptap/extension-emoji";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
|
||||
import { EmojiList, type EmojiListProps } from "./EmojiList";
|
||||
|
||||
export const EmojiExtension = Emoji.configure({
|
||||
emojis: gitHubEmojis,
|
||||
enableEmoticons: true,
|
||||
suggestion: {
|
||||
items: ({ editor, query }) => {
|
||||
return editor.storage.emoji.emojis
|
||||
.filter(({ shortcodes, tags }) => {
|
||||
return (
|
||||
shortcodes.find((shortcode) =>
|
||||
shortcode.startsWith(query.toLowerCase()),
|
||||
) || tags.find((tag) => tag.startsWith(query.toLowerCase()))
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
},
|
||||
|
||||
allowSpaces: false,
|
||||
|
||||
render: () => {
|
||||
let component: ReactRenderer | undefined;
|
||||
|
||||
function repositionComponent(clientRect: DOMRect | null | undefined) {
|
||||
if (!component || !component.element || !clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const virtualElement: VirtualElement = {
|
||||
getBoundingClientRect() {
|
||||
return clientRect;
|
||||
},
|
||||
};
|
||||
|
||||
computePosition(virtualElement, component.element as HTMLElement, {
|
||||
placement: "bottom-start",
|
||||
}).then((pos) => {
|
||||
Object.assign((component?.element as HTMLElement).style, {
|
||||
left: `${pos.x}px`,
|
||||
top: `${pos.y}px`,
|
||||
position: pos.strategy === "fixed" ? "fixed" : "absolute",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
onStart: (props) => {
|
||||
component = new ReactRenderer<unknown, EmojiListProps>(EmojiList, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
document.body.appendChild(component.element);
|
||||
repositionComponent(props.clientRect?.());
|
||||
},
|
||||
|
||||
onUpdate(props) {
|
||||
component?.updateProps(props);
|
||||
repositionComponent(props.clientRect?.());
|
||||
},
|
||||
|
||||
onKeyDown(props) {
|
||||
if (props.event.key === "Escape") {
|
||||
if (component) {
|
||||
document.body.removeChild(component.element);
|
||||
component.destroy();
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error -- not sure what should be here
|
||||
return component?.ref?.onKeyDown(props);
|
||||
},
|
||||
|
||||
onExit() {
|
||||
if (component) {
|
||||
if (document.body.contains(component.element)) {
|
||||
document.body.removeChild(component.element);
|
||||
}
|
||||
component.destroy();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
.buttonRoot {
|
||||
&:hover {
|
||||
background-color: var(--mb-color-brand-lighter) !important;
|
||||
}
|
||||
|
||||
&:hover.isSelected,
|
||||
&.isSelected {
|
||||
background-color: var(--mb-color-brand-light) !important;
|
||||
}
|
||||
|
||||
color: var(--mb-color-text-medium) !important;
|
||||
|
||||
img {
|
||||
height: 1rem;
|
||||
width: 1rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { EmojiItem } from "@tiptap/extension-emoji";
|
||||
import cx from "classnames";
|
||||
import {
|
||||
type KeyboardEvent,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
import { Button, Card } from "metabase/ui";
|
||||
|
||||
import Styles from "./EmojiList.module.css";
|
||||
|
||||
export interface EmojiListProps {
|
||||
items: EmojiItem[];
|
||||
command: (params: { name: string }) => void;
|
||||
}
|
||||
|
||||
export const EmojiList = forwardRef(function InnerEmojiList(
|
||||
{ items, command }: EmojiListProps,
|
||||
ref,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
const item = items[index];
|
||||
|
||||
if (item) {
|
||||
command({ name: item.name });
|
||||
}
|
||||
},
|
||||
[items, command],
|
||||
);
|
||||
|
||||
const upHandler = useCallback(() => {
|
||||
setSelectedIndex((index) => (index + items.length - 1) % items.length);
|
||||
}, [setSelectedIndex, items]);
|
||||
|
||||
const downHandler = useCallback(() => {
|
||||
setSelectedIndex((index) => (index + 1) % items.length);
|
||||
}, [setSelectedIndex, items]);
|
||||
|
||||
const enterHandler = useCallback(() => {
|
||||
selectItem(selectedIndex);
|
||||
}, [selectItem, selectedIndex]);
|
||||
|
||||
useEffect(() => setSelectedIndex(0), [items]);
|
||||
|
||||
useImperativeHandle(ref, () => {
|
||||
return {
|
||||
onKeyDown: (x: { event: KeyboardEvent }) => {
|
||||
if (x.event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x.event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (x.event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
};
|
||||
}, [upHandler, downHandler, enterHandler]);
|
||||
|
||||
return (
|
||||
<Card withBorder p="sm">
|
||||
{items.map((item, index) => (
|
||||
<Button
|
||||
className={cx({ [Styles.isSelected]: index === selectedIndex })}
|
||||
classNames={{
|
||||
root: Styles.buttonRoot,
|
||||
}}
|
||||
justify="start"
|
||||
key={index}
|
||||
onClick={() => selectItem(index)}
|
||||
variant="subtle"
|
||||
leftSection={
|
||||
item.fallbackImage ? <img src={item.fallbackImage} /> : item.emoji
|
||||
}
|
||||
>
|
||||
{`:${item.name}:`}
|
||||
</Button>
|
||||
))}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./EmojiExtension";
|
||||
@@ -0,0 +1,73 @@
|
||||
import { type Editor, Extension, type Range } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
|
||||
|
||||
export interface MentionOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
renderText: (props: {
|
||||
options: MentionOptions;
|
||||
node: ProseMirrorNode;
|
||||
}) => string;
|
||||
suggestion: Partial<SuggestionOptions>;
|
||||
}
|
||||
|
||||
interface MentionProps {
|
||||
type?: string;
|
||||
id?: number | string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export const MentionPluginKey = new PluginKey("mention");
|
||||
|
||||
export const MentionExtension = Extension.create<MentionOptions>({
|
||||
name: "mention",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
pluginKey: MentionPluginKey,
|
||||
allowSpaces: true,
|
||||
items: ({ query: _query }: { query: string }) => [],
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
props: MentionProps;
|
||||
}) => {
|
||||
if (props.id && props.model) {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "smartLink",
|
||||
attrs: {
|
||||
entityId: props.id,
|
||||
model: props.model,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import { forwardRef, useCallback, useImperativeHandle } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
import {
|
||||
LoadingSuggestionPaper,
|
||||
SuggestionPaper,
|
||||
} from "../../shared/SuggestionPaper";
|
||||
import { EntitySearchSection } from "../shared/EntitySearchSection";
|
||||
import { useEntitySuggestions } from "../shared/useEntitySuggestions";
|
||||
|
||||
interface MentionSuggestionProps {
|
||||
items: SearchResult[];
|
||||
command: (item: MentionCommandItem) => void;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface MentionCommandItem {
|
||||
type?: string;
|
||||
id?: number | string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface SuggestionRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
const MentionSuggestionComponent = forwardRef<
|
||||
SuggestionRef,
|
||||
MentionSuggestionProps
|
||||
>(function MentionSuggestionComponent(
|
||||
{ items: _items, command, editor, range: _range, query },
|
||||
ref,
|
||||
) {
|
||||
const onSelectEntity = useCallback(
|
||||
(item: { id: number | string; model: string }) => {
|
||||
command({
|
||||
id: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
},
|
||||
[command],
|
||||
);
|
||||
|
||||
const {
|
||||
menuItems,
|
||||
isLoading,
|
||||
searchResults,
|
||||
selectedIndex,
|
||||
modal,
|
||||
handlers,
|
||||
} = useEntitySuggestions({
|
||||
query,
|
||||
editor,
|
||||
onSelectEntity,
|
||||
});
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: handlers.onKeyDown,
|
||||
}));
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingSuggestionPaper aria-label={t`Mention Dialog`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<SuggestionPaper aria-label={t`Mention Dialog`}>
|
||||
<EntitySearchSection
|
||||
menuItems={menuItems}
|
||||
selectedIndex={selectedIndex}
|
||||
onItemSelect={handlers.selectItem}
|
||||
onFooterClick={handlers.openModal}
|
||||
query={query}
|
||||
searchResults={searchResults}
|
||||
modal={modal}
|
||||
onModalSelect={handlers.handleModalSelect}
|
||||
onModalClose={handlers.handleModalClose}
|
||||
/>
|
||||
</SuggestionPaper>
|
||||
);
|
||||
});
|
||||
|
||||
export const MentionSuggestion = MentionSuggestionComponent;
|
||||
@@ -0,0 +1,18 @@
|
||||
.codeBlockTextArea {
|
||||
color: var(--mb-color-text-primary);
|
||||
padding: var(--mantine-spacing-md);
|
||||
padding-right: var(--mantine-spacing-lg);
|
||||
height: 110px;
|
||||
|
||||
&[contenteditable="false"] {
|
||||
opacity: 0.5;
|
||||
cursor: progress;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
padding: var(--mantine-spacing-md);
|
||||
color: var(--mb-color-text-light);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { type JSONContent, Node, mergeAttributes } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { Selection } from "@tiptap/pm/state";
|
||||
import {
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from "@tiptap/react";
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useLatest } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import CS from "metabase/css/core/index.css";
|
||||
import { Box, Button, Flex, Icon, Text, Tooltip } from "metabase/ui";
|
||||
import { useLazyMetabotDocumentNodeQuery } from "metabase-enterprise/api/metabot";
|
||||
import {
|
||||
createDraftCard,
|
||||
generateDraftCardId,
|
||||
loadMetadataForDocumentCard,
|
||||
} from "metabase-enterprise/documents/documents.slice";
|
||||
import { useDocumentsDispatch } from "metabase-enterprise/documents/redux-utils";
|
||||
import MetabotThinkingStyles from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking.module.css";
|
||||
import type { Card, MetabotDocumentNodeRequest } from "metabase-types/api";
|
||||
|
||||
import Styles from "./MetabotEmbed.module.css";
|
||||
|
||||
const createTextNode = (text: string, marks?: JSONContent["marks"]) => {
|
||||
return { type: "text", text, marks };
|
||||
};
|
||||
|
||||
// unsets bold/italic/etc when user edits around `content`
|
||||
const padWithUnstyledText = (content: JSONContent): JSONContent[] => [
|
||||
createTextNode(" "),
|
||||
content,
|
||||
createTextNode(" "),
|
||||
];
|
||||
|
||||
export type PromptSerializer = (
|
||||
node: ProseMirrorNode,
|
||||
) => MetabotDocumentNodeRequest;
|
||||
|
||||
const serializePromptDefault: PromptSerializer = (node) => ({
|
||||
instructions: node.textContent,
|
||||
});
|
||||
|
||||
export const MetabotNode = Node.create<{
|
||||
serializePrompt?: PromptSerializer;
|
||||
}>({
|
||||
name: "metabot",
|
||||
group: "block",
|
||||
content: "inline*",
|
||||
marks: "",
|
||||
draggable: true,
|
||||
selectable: false,
|
||||
code: true, // disallows adding markdown (headings, blockquote, etc) to metabot block
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
serializePrompt: serializePromptDefault,
|
||||
};
|
||||
},
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
// run metabot on mod-Enter
|
||||
"mod-Enter": ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
editor.emit("runMetabot", $from.parent);
|
||||
return true;
|
||||
},
|
||||
|
||||
// exit node on arrow down
|
||||
ArrowDown: ({ editor }) => {
|
||||
const { state } = editor;
|
||||
const { selection, doc } = state;
|
||||
const { $from, empty } = selection;
|
||||
|
||||
if (!empty || $from.parent.type !== this.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isAtEnd = $from.parentOffset === $from.parent.nodeSize - 2;
|
||||
|
||||
if (!isAtEnd) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const after = $from.after();
|
||||
|
||||
if (after === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const nodeAfter = doc.nodeAt(after);
|
||||
|
||||
if (nodeAfter) {
|
||||
return editor.commands.command(({ tr }) => {
|
||||
tr.setSelection(Selection.near(doc.resolve(after)));
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
return editor.commands.exitCode();
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-type="metabot"]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(MetabotComponent);
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(HTMLAttributes, {
|
||||
"data-type": "metabot",
|
||||
}),
|
||||
0,
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
export const MetabotComponent = memo(
|
||||
({ editor, getPos, deleteNode, node, extension }: NodeViewProps) => {
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
const controllerRef = useRef<AbortController | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [errorText, setErrorText] = useState("");
|
||||
const [queryMetabot] = useLazyMetabotDocumentNodeQuery();
|
||||
|
||||
const handleRunMetabot = async () => {
|
||||
const serializePrompt =
|
||||
extension?.options?.serializePrompt || serializePromptDefault;
|
||||
const payload = serializePrompt(node);
|
||||
|
||||
if (!payload.instructions.trim()) {
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
controllerRef.current = controller;
|
||||
setIsLoading(true);
|
||||
setErrorText("");
|
||||
editor.commands.focus();
|
||||
|
||||
const res = queryMetabot(payload);
|
||||
controller.signal.addEventListener("abort", () => res.abort());
|
||||
const { data, error } = await res;
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(false);
|
||||
|
||||
if (error || !data?.draft_card) {
|
||||
setErrorText(
|
||||
data?.error || t`There was a problem connecting to Metabot`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const nodePosition = getPos();
|
||||
|
||||
if (nodePosition == null) {
|
||||
setErrorText(t`Could not find Metabot block`);
|
||||
return;
|
||||
}
|
||||
|
||||
const newCardId = generateDraftCardId();
|
||||
const card: Card = {
|
||||
...data.draft_card,
|
||||
id: newCardId,
|
||||
entity_id: "entity_id" as Card["entity_id"],
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
name: data.draft_card.name || t`Exploration`,
|
||||
description: null,
|
||||
type: "question",
|
||||
public_uuid: null,
|
||||
enable_embedding: false,
|
||||
embedding_params: null,
|
||||
can_write: false,
|
||||
can_restore: false,
|
||||
can_delete: false,
|
||||
can_manage_db: false,
|
||||
initially_published_at: null,
|
||||
collection_id: null,
|
||||
collection_position: null,
|
||||
dashboard: null,
|
||||
dashboard_id: null,
|
||||
dashboard_count: null,
|
||||
result_metadata: [],
|
||||
last_query_start: null,
|
||||
average_query_time: null,
|
||||
cache_ttl: null,
|
||||
archived: false,
|
||||
};
|
||||
|
||||
await documentsDispatch(loadMetadataForDocumentCard(card));
|
||||
|
||||
documentsDispatch(
|
||||
createDraftCard({
|
||||
originalCard: card,
|
||||
modifiedData: {},
|
||||
draftId: newCardId,
|
||||
}),
|
||||
);
|
||||
|
||||
editor.commands.insertContentAt(nodePosition, [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: newCardId,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [createTextNode(data.description)],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: padWithUnstyledText(
|
||||
createTextNode(t`Created with Metabot`, [
|
||||
{ type: "bold" },
|
||||
{ type: "italic" },
|
||||
]),
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
deleteNode();
|
||||
};
|
||||
|
||||
const handleStopMetabot = () => {
|
||||
controllerRef.current?.abort();
|
||||
setIsLoading(false);
|
||||
setErrorText("");
|
||||
};
|
||||
|
||||
const onRunMetabotRef = useLatest((target: ProseMirrorNode) => {
|
||||
if (target === node) {
|
||||
handleRunMetabot();
|
||||
}
|
||||
});
|
||||
useEffect(() => {
|
||||
const onRunMetabot = (target: ProseMirrorNode) => {
|
||||
onRunMetabotRef.current(target);
|
||||
};
|
||||
editor.on("runMetabot", onRunMetabot);
|
||||
return () => {
|
||||
editor.off("runMetabot", onRunMetabot);
|
||||
};
|
||||
}, [editor, onRunMetabotRef]);
|
||||
|
||||
return (
|
||||
<NodeViewWrapper>
|
||||
<Flex
|
||||
bg="bg-light"
|
||||
bd="1px solid var(--border-color)"
|
||||
style={{ borderRadius: "4px" }}
|
||||
pos="relative"
|
||||
direction="column"
|
||||
mb="1rem"
|
||||
>
|
||||
<Button
|
||||
variant="subtle"
|
||||
pos="absolute"
|
||||
top={0}
|
||||
right={0}
|
||||
p="sm"
|
||||
m="sm"
|
||||
size="sm"
|
||||
opacity="0.5"
|
||||
style={{ zIndex: 1 }}
|
||||
onClick={() => deleteNode()}
|
||||
>
|
||||
<Icon name="close" />
|
||||
</Button>
|
||||
<Flex flex={1} direction="column" style={{ overflow: "auto" }}>
|
||||
<Box
|
||||
className={Styles.placeholder}
|
||||
hidden={!!node.content.content.length}
|
||||
>
|
||||
{t`Ask Metabot to generate a chart for you, and use @ to select a specific Database to use`}
|
||||
</Box>
|
||||
<NodeViewContent
|
||||
contentEditable={!isLoading}
|
||||
className={Styles.codeBlockTextArea}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex px="md" pb="md" pt="sm" gap="sm">
|
||||
<Flex flex={1} my="auto">
|
||||
{isLoading ? (
|
||||
<Text
|
||||
flex={1}
|
||||
className={MetabotThinkingStyles.toolCallStarted}
|
||||
>
|
||||
{t`Working on it...`}
|
||||
</Text>
|
||||
) : errorText ? (
|
||||
<Flex gap="sm" c="text-secondary">
|
||||
<Icon name="warning" h="1.2rem" style={{ flexShrink: 0 }} />
|
||||
<Text c="text-secondary" lh={1.4}>
|
||||
{errorText}
|
||||
</Text>
|
||||
</Flex>
|
||||
) : null}
|
||||
</Flex>
|
||||
<Tooltip
|
||||
label={t`Stop generating`}
|
||||
disabled={!isLoading}
|
||||
position="bottom"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
isLoading ? handleStopMetabot() : handleRunMetabot()
|
||||
}
|
||||
classNames={{
|
||||
label: CS.flex, // ensures icon is vertically centered
|
||||
}}
|
||||
>
|
||||
{isLoading ? <Icon name="close" /> : t`Run`}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MetabotComponent.displayName = "MetabotComponent";
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./MetabotEmbed";
|
||||
@@ -0,0 +1,71 @@
|
||||
import { type Editor, Extension, type Range } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import { PluginKey } from "@tiptap/pm/state";
|
||||
import Suggestion, { type SuggestionOptions } from "@tiptap/suggestion";
|
||||
|
||||
export interface MentionOptions {
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
renderText: (props: {
|
||||
options: MentionOptions;
|
||||
node: ProseMirrorNode;
|
||||
}) => string;
|
||||
suggestion: Partial<SuggestionOptions>;
|
||||
}
|
||||
|
||||
interface MentionProps {
|
||||
type?: string;
|
||||
id?: number | string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export const MetabotMentionPluginKey = new PluginKey("metabot-mention");
|
||||
|
||||
export const MetabotMentionExtension = Extension.create<MentionOptions>({
|
||||
name: "metabot-mention",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
HTMLAttributes: {},
|
||||
renderText({ options, node }) {
|
||||
return `${options.suggestion.char}${node.attrs.label ?? node.attrs.id}`;
|
||||
},
|
||||
suggestion: {
|
||||
char: "@",
|
||||
pluginKey: MetabotMentionPluginKey,
|
||||
allowSpaces: true,
|
||||
items: ({ query: _query }: { query: string }) => [],
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
props: MentionProps;
|
||||
}) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "smartLink",
|
||||
attrs: {
|
||||
entityId: props.id,
|
||||
model: props.model,
|
||||
},
|
||||
})
|
||||
.run();
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import type { Editor, Range } from "@tiptap/core";
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
} from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { useListDatabasesQuery } from "metabase/api";
|
||||
import type {
|
||||
Item,
|
||||
Section,
|
||||
} from "metabase/common/components/AccordionList/types";
|
||||
import { searchFilter } from "metabase/common/components/AccordionList/utils";
|
||||
import { Box, Group, type IconName, Loader, Text } from "metabase/ui";
|
||||
import type { Database, SearchResult } from "metabase-types/api";
|
||||
|
||||
import type { MenuItem } from "../../shared/MenuComponents";
|
||||
import { MenuItemComponent } from "../../shared/MenuComponents";
|
||||
import { SuggestionPaper } from "../../shared/SuggestionPaper";
|
||||
import { buildDbMenuItems } from "../shared/suggestionUtils";
|
||||
|
||||
interface MentionSuggestionProps {
|
||||
items: SearchResult[];
|
||||
command: (item: MentionCommandItem) => void;
|
||||
editor: Editor;
|
||||
range: Range;
|
||||
query: string;
|
||||
}
|
||||
|
||||
interface MentionCommandItem {
|
||||
type?: string;
|
||||
id?: number | string;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
interface SuggestionRef {
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
}
|
||||
|
||||
interface MentionItem {
|
||||
icon: IconName;
|
||||
iconColor?: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
action: () => void;
|
||||
id: number | string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const MetabotMentionSuggestionComponent = forwardRef<
|
||||
SuggestionRef,
|
||||
MentionSuggestionProps
|
||||
>(function MetabotMentionSuggestionComponent(
|
||||
{ items: _items, command, range: _range, query },
|
||||
ref,
|
||||
) {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const { data: dbsResponse, isLoading } = useListDatabasesQuery();
|
||||
|
||||
const handleDbSelect = useCallback(
|
||||
(item: Database) => {
|
||||
command({
|
||||
id: item.id,
|
||||
model: "database",
|
||||
});
|
||||
},
|
||||
[command],
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: Array<MentionItem | MenuItem> = [];
|
||||
|
||||
if (dbsResponse?.data) {
|
||||
const sections: Section<Item & { database: Database }>[] = [];
|
||||
|
||||
sections.push({
|
||||
items: dbsResponse.data.map((database, index) => ({
|
||||
name: database.name,
|
||||
index,
|
||||
database,
|
||||
})),
|
||||
});
|
||||
|
||||
const sorted = searchFilter<
|
||||
Item & { database: Database },
|
||||
(typeof sections)[number]
|
||||
>({
|
||||
sections,
|
||||
searchText: query,
|
||||
})[0];
|
||||
const dbs = sorted?.items?.map((item) => item.item.database) || [];
|
||||
items.push(...buildDbMenuItems(dbs, handleDbSelect));
|
||||
}
|
||||
|
||||
return items as MentionItem[];
|
||||
}, [dbsResponse?.data, handleDbSelect, query]);
|
||||
|
||||
const totalItems = menuItems.length;
|
||||
|
||||
const selectItem = (index: number) => {
|
||||
menuItems[index].action();
|
||||
};
|
||||
|
||||
const upHandler = () => {
|
||||
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
};
|
||||
|
||||
const downHandler = () => {
|
||||
setSelectedIndex((prev) => (prev + 1) % totalItems);
|
||||
};
|
||||
|
||||
const enterHandler = () => {
|
||||
selectItem(selectedIndex);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [menuItems.length]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<SuggestionPaper aria-label={t`Metabot dialog`}>
|
||||
{isLoading ? (
|
||||
<Group justify="center" p="sm">
|
||||
<Loader size="sm" />
|
||||
</Group>
|
||||
) : (
|
||||
<>
|
||||
{menuItems.map((item, index) => (
|
||||
<MenuItemComponent
|
||||
key={index}
|
||||
item={item}
|
||||
isSelected={selectedIndex === index}
|
||||
onClick={() => selectItem(index)}
|
||||
/>
|
||||
))}
|
||||
{query.length > 0 && totalItems === 0 && !isLoading ? (
|
||||
<Box p="sm">
|
||||
<Text size="md" c="text-medium" ta="center">
|
||||
{t`No results found`}
|
||||
</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</SuggestionPaper>
|
||||
);
|
||||
});
|
||||
|
||||
export const MetabotMentionSuggestion = MetabotMentionSuggestionComponent;
|
||||
@@ -0,0 +1,20 @@
|
||||
.smartLink {
|
||||
color: var(--mb-color-brand);
|
||||
font-weight: 700;
|
||||
padding: 0.125rem;
|
||||
border-radius: var(--mantine-radius-sm);
|
||||
transition: 200ms background-color;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-background-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: -0.125rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
|
||||
.smartLinkInner {
|
||||
border-bottom: 1px solid var(--mb-color-brand-light);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import { Node, mergeAttributes, nodePasteRule } from "@tiptap/core";
|
||||
import { type NodeViewProps, NodeViewWrapper } from "@tiptap/react";
|
||||
import { memo, useEffect } from "react";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { cardApi } from "metabase/api";
|
||||
import { collectionApi } from "metabase/api/collection";
|
||||
import { dashboardApi } from "metabase/api/dashboard";
|
||||
import { databaseApi } from "metabase/api/database";
|
||||
import { tableApi } from "metabase/api/table";
|
||||
import {
|
||||
type IconModel,
|
||||
type ObjectWithModel,
|
||||
getIcon,
|
||||
} from "metabase/lib/icon";
|
||||
import { type UrlableModel, modelToUrl } from "metabase/lib/urls/modelToUrl";
|
||||
import { extractEntityId } from "metabase/lib/urls/utils";
|
||||
import { Icon } from "metabase/ui";
|
||||
import { updateMentionsCache } from "metabase-enterprise/documents/documents.slice";
|
||||
import { useDocumentsDispatch } from "metabase-enterprise/documents/redux-utils";
|
||||
import type {
|
||||
Card,
|
||||
CardDisplayType,
|
||||
Collection,
|
||||
Dashboard,
|
||||
Database,
|
||||
SearchModel,
|
||||
Table,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import styles from "./SmartLinkNode.module.css";
|
||||
|
||||
type SmartLinkEntity = Card | Dashboard | Collection | Table | Database;
|
||||
|
||||
// Utility function to parse entity URLs and extract entityId and model
|
||||
export function parseEntityUrl(
|
||||
url: string,
|
||||
siteUrl?: string,
|
||||
): { entityId: number; model: SearchModel } | null {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
|
||||
// Validate URL matches the site-url if provided
|
||||
if (siteUrl) {
|
||||
const siteUrlObj = new URL(siteUrl);
|
||||
|
||||
// Check if origins match
|
||||
if (urlObj.origin !== siteUrlObj.origin) {
|
||||
return null; // URL is not from this Metabase instance
|
||||
}
|
||||
|
||||
// Check if the path starts with the site-url path
|
||||
const siteUrlPath = siteUrlObj.pathname;
|
||||
if (!urlObj.pathname.startsWith(siteUrlPath)) {
|
||||
return null; // URL is not under the correct path
|
||||
}
|
||||
} else {
|
||||
// Fallback: validate against current window origin
|
||||
if (urlObj.origin !== window.location.origin) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove site-url path prefix if present to get the clean pathname
|
||||
let cleanPathname = urlObj.pathname;
|
||||
if (siteUrl) {
|
||||
const siteUrlPath = new URL(siteUrl).pathname;
|
||||
// Remove the site-url path prefix, ensuring we don't create double slashes
|
||||
cleanPathname = urlObj.pathname.replace(siteUrlPath, "");
|
||||
// Ensure the pathname starts with /
|
||||
if (!cleanPathname.startsWith("/")) {
|
||||
cleanPathname = "/" + cleanPathname;
|
||||
}
|
||||
}
|
||||
|
||||
// Match different entity URL patterns
|
||||
const patterns = [
|
||||
{ pattern: /^\/question\/(\d+)/, model: "card" as SearchModel },
|
||||
{ pattern: /^\/model\/(\d+)/, model: "dataset" as SearchModel },
|
||||
{ pattern: /^\/dashboard\/(\d+)/, model: "dashboard" as SearchModel },
|
||||
{ pattern: /^\/collection\/(\d+)/, model: "collection" as SearchModel },
|
||||
{
|
||||
pattern: /^\/browse\/(\d+)\/table\/(\d+)/,
|
||||
model: "table" as SearchModel,
|
||||
idIndex: 2,
|
||||
},
|
||||
{ pattern: /^\/browse\/(\d+)/, model: "database" as SearchModel },
|
||||
];
|
||||
|
||||
for (const { pattern, model, idIndex = 1 } of patterns) {
|
||||
const match = cleanPathname.match(pattern);
|
||||
if (match) {
|
||||
const entityId = extractEntityId(match[idIndex]);
|
||||
if (entityId) {
|
||||
return { entityId, model };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const SmartLinkNode = Node.create<{
|
||||
HTMLAttributes: Record<string, unknown>;
|
||||
siteUrl?: string;
|
||||
}>({
|
||||
name: "smartLink",
|
||||
group: "inline",
|
||||
inline: true,
|
||||
atom: true,
|
||||
priority: 1000, // Higher priority than Link extension (default 100)
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
entityId: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const id = element.getAttribute("data-entity-id");
|
||||
return id ? parseInt(id, 10) : null;
|
||||
},
|
||||
},
|
||||
model: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-model"),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: 'span[data-type="smart-link"]',
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
const { entityId, model } = node.attrs;
|
||||
|
||||
return [
|
||||
"span",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": "smart-link",
|
||||
"data-entity-id": entityId,
|
||||
"data-model": model,
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
`{% entity id="${entityId}" model="${model}" %}`,
|
||||
];
|
||||
},
|
||||
|
||||
renderText({ node }) {
|
||||
const { entityId, model } = node.attrs;
|
||||
|
||||
return `{% entity id="${entityId}" model="${model}" %}`;
|
||||
},
|
||||
|
||||
addPasteRules() {
|
||||
return [
|
||||
nodePasteRule({
|
||||
find: /https?:\/\/[^\s]+/g,
|
||||
type: this.type,
|
||||
getAttributes: (match) => {
|
||||
const url = match[0];
|
||||
const parsedEntity = parseEntityUrl(url, this.options.siteUrl);
|
||||
|
||||
if (parsedEntity) {
|
||||
return {
|
||||
entityId: parsedEntity.entityId,
|
||||
model: parsedEntity.model,
|
||||
};
|
||||
}
|
||||
|
||||
return null; // Return null to prevent node creation
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const useEntityData = (entityId: number | null, model: SearchModel | null) => {
|
||||
const cardQuery = cardApi.useGetCardQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || (model !== "card" && model !== "dataset") },
|
||||
);
|
||||
|
||||
const dashboardQuery = dashboardApi.useGetDashboardQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "dashboard" },
|
||||
);
|
||||
|
||||
const collectionQuery = collectionApi.useGetCollectionQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "collection" },
|
||||
);
|
||||
|
||||
const tableQuery = tableApi.useGetTableQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "table" },
|
||||
);
|
||||
|
||||
const databaseQuery = databaseApi.useGetDatabaseQuery(
|
||||
{ id: entityId! },
|
||||
{ skip: !entityId || model !== "database" },
|
||||
);
|
||||
|
||||
// Determine which query is active and return its state
|
||||
if (model === "card" || model === "dataset") {
|
||||
return {
|
||||
entity: cardQuery.data,
|
||||
isLoading: cardQuery.isLoading,
|
||||
error: cardQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "dashboard") {
|
||||
return {
|
||||
entity: dashboardQuery.data,
|
||||
isLoading: dashboardQuery.isLoading,
|
||||
error: dashboardQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "collection") {
|
||||
return {
|
||||
entity: collectionQuery.data,
|
||||
isLoading: collectionQuery.isLoading,
|
||||
error: collectionQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "table") {
|
||||
return {
|
||||
entity: tableQuery.data,
|
||||
isLoading: tableQuery.isLoading,
|
||||
error: tableQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
if (model === "database") {
|
||||
return {
|
||||
entity: databaseQuery.data,
|
||||
isLoading: databaseQuery.isLoading,
|
||||
error: databaseQuery.error,
|
||||
};
|
||||
}
|
||||
|
||||
return { entity: null, isLoading: false, error: null };
|
||||
};
|
||||
|
||||
export const SmartLinkComponent = memo(
|
||||
({ node }: NodeViewProps) => {
|
||||
const { entityId, model } = node.attrs;
|
||||
const { entity, isLoading, error } = useEntityData(entityId, model);
|
||||
|
||||
const documentsDispatch = useDocumentsDispatch();
|
||||
useEffect(() => {
|
||||
if (entity?.name) {
|
||||
documentsDispatch(
|
||||
updateMentionsCache({ entityId, model, name: entity.name }),
|
||||
);
|
||||
}
|
||||
}, [documentsDispatch, entity?.name, entityId, model]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<span className={styles.smartLink}>
|
||||
<span className={styles.smartLinkInner}>
|
||||
<Icon name="hourglass" className={styles.icon} />
|
||||
{t`Loading ${model}...`}
|
||||
</span>
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !entity) {
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<span className={styles.smartLink}>
|
||||
<span className={styles.smartLinkInner}>
|
||||
<Icon name="warning" className={styles.icon} />
|
||||
{error ? t`Failed to load` : t`Unknown`} {model}
|
||||
</span>
|
||||
</span>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
const entityUrlableModel = entityToUrlableModel(entity, model);
|
||||
const entityUrl = modelToUrl(entityUrlableModel);
|
||||
|
||||
const iconData = getIcon(entityToObjectWithModel(entity, model));
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="span">
|
||||
<a
|
||||
href={entityUrl || "#"}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
onMouseUp={(e) => {
|
||||
// Stop tiptap from opening this link twice
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={styles.smartLink}
|
||||
>
|
||||
<span className={styles.smartLinkInner}>
|
||||
<Icon name={iconData.name} className={styles.icon} />
|
||||
{entity.name}
|
||||
</span>
|
||||
</a>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// Custom comparison function to prevent re-renders
|
||||
// Only re-render if these specific props change
|
||||
return (
|
||||
prevProps.node.attrs.entityId === nextProps.node.attrs.entityId &&
|
||||
prevProps.node.attrs.model === nextProps.node.attrs.model &&
|
||||
prevProps.selected === nextProps.selected
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SmartLinkComponent.displayName = "SmartLinkComponent";
|
||||
|
||||
function entityToUrlableModel(
|
||||
entity: SmartLinkEntity,
|
||||
model: SearchModel | null,
|
||||
): UrlableModel {
|
||||
const result: UrlableModel = {
|
||||
id: entity.id as number, // it is string | number in reality, but then gets casted to a string in "modelToUrl"
|
||||
model: (entity as Dashboard).model || model || "",
|
||||
name: entity.name,
|
||||
};
|
||||
|
||||
if ("db_id" in entity && entity.db_id) {
|
||||
result.database = {
|
||||
id: entity.db_id,
|
||||
};
|
||||
}
|
||||
|
||||
if ("database_id" in entity && entity.database_id) {
|
||||
result.database = { id: entity.database_id };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function entityToObjectWithModel(
|
||||
entity: SmartLinkEntity,
|
||||
model: SearchModel | null,
|
||||
): ObjectWithModel {
|
||||
return {
|
||||
model: ((entity as Dashboard).model || model || "") as IconModel,
|
||||
display: (entity as Card).display as CardDisplayType,
|
||||
is_personal: (entity as Collection).is_personal,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { ReactNodeViewRenderer } from "@tiptap/react";
|
||||
|
||||
import { SmartLinkComponent, SmartLinkNode } from "./SmartLinkNode";
|
||||
|
||||
export * from "./SmartLinkNode";
|
||||
|
||||
export const SmartLinkEmbed = SmartLinkNode.extend({
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(SmartLinkComponent);
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
// Shared utility for generating card embed markdown format
|
||||
import type { CardEmbedAttributes } from "metabase-enterprise/documents/components/Editor/extensions/CardEmbed";
|
||||
|
||||
export function formatCardEmbed(attrs: CardEmbedAttributes): string {
|
||||
if (attrs.name) {
|
||||
return `{% card id=${attrs.id} name="${attrs.name}" %}`;
|
||||
} else {
|
||||
return `{% card id=${attrs.id} %}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Shared utility for generating smart link markdown format
|
||||
export function formatSmartLink(attrs: {
|
||||
entityId?: number;
|
||||
model?: string;
|
||||
}): string {
|
||||
return `{% entity id="${attrs.entityId}" model="${attrs.model}" %}`;
|
||||
}
|
||||
|
||||
// Shared utility for generating spacer markdown format
|
||||
export function formatSpacer(lines: number): string {
|
||||
return `{% spacer lines=${lines} %}`;
|
||||
}
|
||||
|
||||
// Regex pattern for matching card embeds in markdown
|
||||
export const CARD_EMBED_PATTERN =
|
||||
/{%\s*card\s+id=(\d+)(?:\s+name="([^"]*)")?\s*%}/g;
|
||||
|
||||
// Regex pattern for matching smart links in markdown
|
||||
export const SMART_LINK_PATTERN =
|
||||
/{%\s*entity\s+id="(\d+)"\s+model="([^"]+)"\s*%}/g;
|
||||
|
||||
// Regex pattern for matching spacers in markdown
|
||||
export const SPACER_PATTERN = /{%\s*spacer\s+lines=(\d+)\s*%}/g;
|
||||
@@ -0,0 +1,84 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { StateBlock } from "markdown-it";
|
||||
|
||||
export function cardPlugin(md: MarkdownIt): void {
|
||||
md.block.ruler.before("paragraph", "card", cardBlock, {
|
||||
alt: ["paragraph", "reference", "blockquote", "list"],
|
||||
});
|
||||
}
|
||||
|
||||
function cardBlock(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
_endLine: number,
|
||||
silent: boolean,
|
||||
): boolean {
|
||||
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
|
||||
// Check if line starts with {% card
|
||||
if (pos + 7 > max) {
|
||||
return false;
|
||||
}
|
||||
if (state.src.slice(pos, pos + 7) !== "{% card") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the closing %}
|
||||
const closePos = state.src.indexOf("%}", pos + 7);
|
||||
if (closePos === -1 || closePos > max) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract attributes
|
||||
const content = state.src.slice(pos + 7, closePos).trim();
|
||||
const attrs = parseCardAttributes(content);
|
||||
|
||||
if (!attrs.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const oldParent = state.parentType;
|
||||
const oldLineMax = state.lineMax;
|
||||
state.parentType = "card" as StateBlock.ParentType;
|
||||
|
||||
// Create card token
|
||||
const token = state.push("card", "div", 0);
|
||||
token.attrSet("id", attrs.id);
|
||||
if (attrs.name) {
|
||||
token.attrSet("name", attrs.name);
|
||||
}
|
||||
|
||||
token.map = [startLine, startLine + 1];
|
||||
|
||||
state.parentType = oldParent;
|
||||
state.lineMax = oldLineMax;
|
||||
state.line = startLine + 1;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseCardAttributes(content: string): {
|
||||
id?: string;
|
||||
name?: string;
|
||||
} {
|
||||
const attrs: { id?: string; name?: string } = {};
|
||||
|
||||
// Match id=123
|
||||
const idMatch = content.match(/id=(\d+)/);
|
||||
if (idMatch) {
|
||||
attrs.id = idMatch[1];
|
||||
}
|
||||
|
||||
// Match name="..." (with proper quote handling)
|
||||
const nameMatch = content.match(/name="([^"]*)"/);
|
||||
if (nameMatch) {
|
||||
attrs.name = nameMatch[1];
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
|
||||
import MarkdownIt from "markdown-it";
|
||||
import {
|
||||
MarkdownParser,
|
||||
MarkdownSerializer,
|
||||
type ParseSpec,
|
||||
} from "prosemirror-markdown";
|
||||
|
||||
import { cardPlugin } from "./cardPlugin";
|
||||
import { linkPlugin } from "./linkPlugin";
|
||||
import { createParserTokens } from "./markdown-to-prosemirror";
|
||||
import {
|
||||
createMarkSerializers,
|
||||
createNodeSerializers,
|
||||
} from "./prosemirror-to-markdown";
|
||||
import { spacerPlugin } from "./spacerPlugin";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
interface Commands<ReturnType> {
|
||||
markdown: {
|
||||
getMarkdown: () => string extends ReturnType ? string : ReturnType;
|
||||
setMarkdown: (markdown: string) => ReturnType;
|
||||
};
|
||||
}
|
||||
|
||||
interface Storage {
|
||||
markdown: MarkdownStorage;
|
||||
}
|
||||
|
||||
interface EditorEvents {
|
||||
runMetabot: ProseMirrorNode; // the metabot node in the AST
|
||||
}
|
||||
}
|
||||
|
||||
export interface MarkdownStorage {
|
||||
getMarkdown: () => string;
|
||||
setMarkdown: (markdown: string, emit?: boolean) => void;
|
||||
parser: MarkdownParser | null;
|
||||
serializer: MarkdownSerializer | null;
|
||||
}
|
||||
|
||||
export const Markdown = Extension.create<
|
||||
{
|
||||
extraNodeSerializers?: MarkdownSerializer["nodes"];
|
||||
extraTokenSpecs?: Record<string, ParseSpec>;
|
||||
extraMarkSerializers?: MarkdownSerializer["marks"];
|
||||
},
|
||||
MarkdownStorage
|
||||
>({
|
||||
name: "markdown",
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
extraNodeSerializers: {},
|
||||
extraTokenSpecs: {},
|
||||
extraMarkSerializers: {},
|
||||
};
|
||||
},
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
parser: null,
|
||||
serializer: null,
|
||||
getMarkdown: () => "",
|
||||
setMarkdown: () => {},
|
||||
} as MarkdownStorage;
|
||||
},
|
||||
|
||||
onBeforeCreate() {
|
||||
const getSerializer = () => {
|
||||
if (this.storage.serializer) {
|
||||
return this.storage.serializer;
|
||||
}
|
||||
|
||||
const schema = this.editor.state.schema;
|
||||
const nodeSerializers = createNodeSerializers(
|
||||
this.options.extraNodeSerializers,
|
||||
);
|
||||
const markSerializers = createMarkSerializers(
|
||||
this.options.extraMarkSerializers,
|
||||
);
|
||||
|
||||
// Validate that all nodes in schema have serializers
|
||||
const missingNodeSerializers = Object.keys(schema.nodes).filter(
|
||||
(nodeName) => !nodeSerializers[nodeName],
|
||||
);
|
||||
if (missingNodeSerializers.length > 0) {
|
||||
console.error(
|
||||
`Missing serializers for nodes: ${missingNodeSerializers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Validate that all marks in schema have serializers
|
||||
const missingMarkSerializers = Object.keys(schema.marks).filter(
|
||||
(markName) => !markSerializers[markName],
|
||||
);
|
||||
if (missingMarkSerializers.length > 0) {
|
||||
throw new Error(
|
||||
`Missing serializers for marks: ${missingMarkSerializers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.storage.serializer = new MarkdownSerializer(
|
||||
nodeSerializers,
|
||||
markSerializers,
|
||||
);
|
||||
|
||||
return this.storage.serializer;
|
||||
};
|
||||
|
||||
const getParser = () => {
|
||||
if (this.storage.parser) {
|
||||
return this.storage.parser;
|
||||
}
|
||||
|
||||
const schema = this.editor.state.schema;
|
||||
const md = new MarkdownIt("commonmark", {
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: false,
|
||||
});
|
||||
|
||||
md.use(spacerPlugin);
|
||||
md.use(cardPlugin);
|
||||
md.use(linkPlugin);
|
||||
|
||||
// Add a rule to expand spacer tokens to multiple empty paragraphs
|
||||
md.core.ruler.after("block", "expand_spacers", (state) => {
|
||||
const tokens = state.tokens;
|
||||
const newTokens = [];
|
||||
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
|
||||
if (token.type === "spacer") {
|
||||
const lines = parseInt(token.attrGet("lines") ?? "1", 10);
|
||||
|
||||
// Create multiple empty paragraph tokens
|
||||
for (let j = 0; j < lines; j++) {
|
||||
const pOpen = new state.Token("paragraph_open", "p", 1);
|
||||
pOpen.block = true;
|
||||
pOpen.map = token.map; // Preserve source mapping
|
||||
|
||||
const inline = new state.Token("inline", "", 0);
|
||||
inline.content = "";
|
||||
inline.children = [];
|
||||
inline.nesting = 0;
|
||||
|
||||
const pClose = new state.Token("paragraph_close", "p", -1);
|
||||
pClose.block = true;
|
||||
|
||||
newTokens.push(pOpen, inline, pClose);
|
||||
}
|
||||
} else {
|
||||
newTokens.push(token);
|
||||
}
|
||||
}
|
||||
|
||||
state.tokens = newTokens;
|
||||
});
|
||||
|
||||
const tokens = createParserTokens(this.options.extraTokenSpecs);
|
||||
|
||||
this.storage.parser = new MarkdownParser(schema, md, tokens);
|
||||
return this.storage.parser;
|
||||
};
|
||||
|
||||
this.storage.getMarkdown = () => {
|
||||
return getSerializer().serialize(this.editor.state.doc, {
|
||||
tightLists: false, // Allow loose lists to preserve spacing
|
||||
});
|
||||
};
|
||||
|
||||
this.storage.setMarkdown = (markdown: string) => {
|
||||
try {
|
||||
if (!markdown || typeof markdown !== "string") {
|
||||
console.warn("Invalid markdown content provided:", markdown);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = getParser().parse(markdown);
|
||||
if (!doc) {
|
||||
console.warn("Failed to parse markdown into document");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.editor || this.editor.isDestroyed) {
|
||||
console.warn("Editor is not available or destroyed");
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if the editor is ready and has a valid schema
|
||||
if (!this.editor.state || !this.editor.state.schema) {
|
||||
console.warn("Editor not ready for content update");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate that the document matches the schema
|
||||
try {
|
||||
const docJSON = doc.toJSON();
|
||||
this.editor.chain().setContent(docJSON).run();
|
||||
} catch (validationError) {
|
||||
console.error("Document validation failed:", validationError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse and set markdown content:", error);
|
||||
}
|
||||
};
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
return {
|
||||
setMarkdown: (markdown: string) => () => {
|
||||
this.storage.setMarkdown(markdown);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { StateInline } from "markdown-it";
|
||||
|
||||
export function linkPlugin(md: MarkdownIt): void {
|
||||
md.inline.ruler.before("text", "smartlink", smartLinkInline);
|
||||
}
|
||||
|
||||
function smartLinkInline(state: StateInline, silent: boolean): boolean {
|
||||
const pos = state.pos;
|
||||
const max = state.posMax;
|
||||
|
||||
// Check if we have enough characters for {% entity
|
||||
if (pos + 9 > max) {
|
||||
return false;
|
||||
}
|
||||
if (state.src.slice(pos, pos + 9) !== "{% entity") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the closing %}
|
||||
const closePos = state.src.indexOf("%}", pos + 9);
|
||||
if (closePos === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract attributes
|
||||
const content = state.src.slice(pos + 9, closePos).trim();
|
||||
const attrs = parseEntityAttributes(content);
|
||||
|
||||
if (!attrs.id || !attrs.model) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
const token = state.push("smartlink", "", 0);
|
||||
token.attrSet("id", attrs.id);
|
||||
token.attrSet("model", attrs.model);
|
||||
}
|
||||
|
||||
state.pos = closePos + 2;
|
||||
return true;
|
||||
}
|
||||
|
||||
function parseEntityAttributes(content: string): {
|
||||
id?: string;
|
||||
model?: string;
|
||||
} {
|
||||
const attrs: { id?: string; model?: string } = {};
|
||||
|
||||
// Match id="..."
|
||||
const idMatch = content.match(/id="([^"]*)"/);
|
||||
if (idMatch) {
|
||||
attrs.id = idMatch[1];
|
||||
}
|
||||
|
||||
// Match model="..."
|
||||
const modelMatch = content.match(/model="([^"]*)"/);
|
||||
if (modelMatch) {
|
||||
attrs.model = modelMatch[1];
|
||||
}
|
||||
|
||||
return attrs;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
import Bold from "@tiptap/extension-bold";
|
||||
import BulletList from "@tiptap/extension-bullet-list";
|
||||
import Code from "@tiptap/extension-code";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Italic from "@tiptap/extension-italic";
|
||||
import ListItem from "@tiptap/extension-list-item";
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Strike from "@tiptap/extension-strike";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import type { Token } from "markdown-it";
|
||||
import type { ParseSpec } from "prosemirror-markdown";
|
||||
|
||||
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
|
||||
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
|
||||
|
||||
// Parser tokens - how markdown tokens are converted to ProseMirror nodes/marks
|
||||
export const PARSER_TOKENS: Record<string, ParseSpec> = {
|
||||
// Basic blocks
|
||||
paragraph: { block: Paragraph.name },
|
||||
blockquote: { block: Blockquote.name },
|
||||
heading: {
|
||||
block: Heading.name,
|
||||
getAttrs: (tok: Token) => ({ level: +tok.tag.slice(1) }),
|
||||
},
|
||||
|
||||
// Lists
|
||||
bullet_list: { block: BulletList.name },
|
||||
ordered_list: {
|
||||
block: OrderedList.name,
|
||||
getAttrs: (tok: Token) => ({ order: +(tok.attrGet("start") ?? 1) }),
|
||||
},
|
||||
list_item: { block: ListItem.name },
|
||||
|
||||
// Code
|
||||
code_block: { block: CodeBlock.name, noCloseToken: true },
|
||||
fence: {
|
||||
block: CodeBlock.name,
|
||||
getAttrs: (tok: Token) => ({ params: tok.info || "" }),
|
||||
noCloseToken: true,
|
||||
},
|
||||
|
||||
// Other nodes
|
||||
hr: { node: HorizontalRule.name },
|
||||
hardbreak: { node: HardBreak.name },
|
||||
image: {
|
||||
node: Image.name,
|
||||
getAttrs: (tok: Token) => ({
|
||||
src: tok.attrGet("src"),
|
||||
alt: tok.content || null,
|
||||
title: tok.attrGet("title") || null,
|
||||
}),
|
||||
},
|
||||
|
||||
// Marks
|
||||
strong: { mark: Bold.name },
|
||||
em: { mark: Italic.name },
|
||||
code_inline: { mark: Code.name, noCloseToken: true },
|
||||
link: {
|
||||
mark: "link",
|
||||
getAttrs: (tok: Token) => ({
|
||||
href: tok.attrGet("href"),
|
||||
title: tok.attrGet("title") || null,
|
||||
}),
|
||||
},
|
||||
s: { mark: Strike.name },
|
||||
u: { mark: Underline.name },
|
||||
|
||||
// Custom nodes
|
||||
card: {
|
||||
node: CardEmbedNode.name,
|
||||
getAttrs: (tok: Token) => ({
|
||||
id: parseInt(tok.attrGet("id") || "0", 10),
|
||||
name: tok.attrGet("name") || null,
|
||||
}),
|
||||
},
|
||||
smartlink: {
|
||||
node: SmartLinkNode.name,
|
||||
getAttrs: (tok: Token) => ({
|
||||
entityId: parseInt(tok.attrGet("id") || "0", 10),
|
||||
model: tok.attrGet("model"),
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export function createParserTokens(
|
||||
extraTokens: Record<string, ParseSpec> = {},
|
||||
): Record<string, ParseSpec> {
|
||||
return { ...PARSER_TOKENS, ...extraTokens };
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import Blockquote from "@tiptap/extension-blockquote";
|
||||
import Bold from "@tiptap/extension-bold";
|
||||
import BulletList from "@tiptap/extension-bullet-list";
|
||||
import Code from "@tiptap/extension-code";
|
||||
import CodeBlock from "@tiptap/extension-code-block";
|
||||
import HardBreak from "@tiptap/extension-hard-break";
|
||||
import Heading from "@tiptap/extension-heading";
|
||||
import HorizontalRule from "@tiptap/extension-horizontal-rule";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Italic from "@tiptap/extension-italic";
|
||||
import ListItem from "@tiptap/extension-list-item";
|
||||
import OrderedList from "@tiptap/extension-ordered-list";
|
||||
import Paragraph from "@tiptap/extension-paragraph";
|
||||
import Strike from "@tiptap/extension-strike";
|
||||
import Text from "@tiptap/extension-text";
|
||||
import Underline from "@tiptap/extension-underline";
|
||||
import {
|
||||
type MarkdownSerializer,
|
||||
defaultMarkdownSerializer,
|
||||
} from "prosemirror-markdown";
|
||||
|
||||
import { CardEmbedNode } from "../CardEmbed/CardEmbedNode";
|
||||
import { SmartLinkNode } from "../SmartLink/SmartLinkNode";
|
||||
|
||||
import {
|
||||
formatCardEmbed,
|
||||
formatSmartLink,
|
||||
formatSpacer,
|
||||
} from "./card-embed-format";
|
||||
|
||||
export const NODE_SERIALIZERS: MarkdownSerializer["nodes"] = {
|
||||
doc: (state, node) => {
|
||||
state.renderContent(node);
|
||||
},
|
||||
|
||||
// Text nodes
|
||||
[Text.name]: defaultMarkdownSerializer.nodes.text,
|
||||
[Paragraph.name]: (state, node, parent, index) => {
|
||||
// Handle empty paragraphs at document level
|
||||
if (parent.type.name === "doc" && node.content.size === 0) {
|
||||
// Look ahead to find consecutive empty paragraphs
|
||||
let emptyCount = 1; // Current paragraph is empty
|
||||
let nextIndex = index + 1;
|
||||
|
||||
while (nextIndex < parent.childCount) {
|
||||
const nextChild = parent.child(nextIndex);
|
||||
if (
|
||||
nextChild.type.name === "paragraph" &&
|
||||
nextChild.content.size === 0
|
||||
) {
|
||||
emptyCount++;
|
||||
nextIndex++;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Only output spacer for the FIRST empty paragraph in a sequence
|
||||
// The rest will be skipped by checking if we're in the middle of a sequence
|
||||
let isFirstInSequence = true;
|
||||
if (index > 0) {
|
||||
const prevChild = parent.child(index - 1);
|
||||
if (
|
||||
prevChild.type.name === "paragraph" &&
|
||||
prevChild.content.size === 0
|
||||
) {
|
||||
isFirstInSequence = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isFirstInSequence) {
|
||||
state.write(formatSpacer(emptyCount));
|
||||
state.closeBlock(node);
|
||||
}
|
||||
// Skip subsequent empty paragraphs in the sequence
|
||||
} else {
|
||||
// Handle non-empty paragraphs or paragraphs in nested contexts normally
|
||||
defaultMarkdownSerializer.nodes.paragraph(state, node, parent, index);
|
||||
}
|
||||
},
|
||||
|
||||
// Headings
|
||||
[Heading.name]: defaultMarkdownSerializer.nodes.heading,
|
||||
|
||||
// Lists
|
||||
[BulletList.name]: defaultMarkdownSerializer.nodes.bullet_list,
|
||||
[OrderedList.name]: defaultMarkdownSerializer.nodes.ordered_list,
|
||||
[ListItem.name]: defaultMarkdownSerializer.nodes.list_item,
|
||||
|
||||
// Blocks
|
||||
[Blockquote.name]: defaultMarkdownSerializer.nodes.blockquote,
|
||||
[CodeBlock.name]: defaultMarkdownSerializer.nodes.code_block,
|
||||
[HorizontalRule.name]: defaultMarkdownSerializer.nodes.horizontal_rule,
|
||||
|
||||
// Breaks
|
||||
[HardBreak.name]: defaultMarkdownSerializer.nodes.hard_break,
|
||||
|
||||
// Image (from TipTap Image extension)
|
||||
[Image.name]: defaultMarkdownSerializer.nodes.image,
|
||||
|
||||
// Card embed nodes
|
||||
[CardEmbedNode.name]: (state, node) => {
|
||||
state.write(formatCardEmbed(node.attrs));
|
||||
state.closeBlock(node);
|
||||
},
|
||||
|
||||
// Smart link nodes
|
||||
[SmartLinkNode.name]: (state, node) => {
|
||||
state.write(formatSmartLink(node.attrs));
|
||||
},
|
||||
};
|
||||
|
||||
// Mark serializers - how ProseMirror marks are converted to markdown
|
||||
export const MARK_SERIALIZERS: MarkdownSerializer["marks"] = {
|
||||
[Bold.name]: defaultMarkdownSerializer.marks.strong,
|
||||
[Italic.name]: defaultMarkdownSerializer.marks.em,
|
||||
[Code.name]: defaultMarkdownSerializer.marks.code,
|
||||
link: defaultMarkdownSerializer.marks.link,
|
||||
[Strike.name]: {
|
||||
open: "~~",
|
||||
close: "~~",
|
||||
mixable: true,
|
||||
expelEnclosingWhitespace: true,
|
||||
},
|
||||
[Underline.name]: {
|
||||
open: "<u>",
|
||||
close: "</u>",
|
||||
mixable: true,
|
||||
expelEnclosingWhitespace: true,
|
||||
},
|
||||
};
|
||||
|
||||
export function createNodeSerializers(
|
||||
extraSerializers: MarkdownSerializer["nodes"] = {},
|
||||
): MarkdownSerializer["nodes"] {
|
||||
return { ...NODE_SERIALIZERS, ...extraSerializers };
|
||||
}
|
||||
|
||||
export function createMarkSerializers(
|
||||
extraSerializers: MarkdownSerializer["marks"] = {},
|
||||
): MarkdownSerializer["marks"] {
|
||||
return { ...MARK_SERIALIZERS, ...extraSerializers };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import type MarkdownIt from "markdown-it";
|
||||
import type { StateBlock } from "markdown-it";
|
||||
|
||||
export const spacerPlugin = (md: MarkdownIt) => {
|
||||
const RE = /^\{%[ \t]*spacer[ \t]+lines=(\d+)[ \t]*%}[ \t]*$/;
|
||||
|
||||
function spacerRule(
|
||||
state: StateBlock,
|
||||
startLine: number,
|
||||
_endLine: number,
|
||||
silent: boolean,
|
||||
) {
|
||||
const pos = state.bMarks[startLine] + state.tShift[startLine];
|
||||
const max = state.eMarks[startLine];
|
||||
const lineStr = state.src.slice(pos, max);
|
||||
|
||||
const m = RE.exec(lineStr);
|
||||
if (!m) {
|
||||
return false;
|
||||
}
|
||||
if (silent) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const token = state.push("spacer", "", 0);
|
||||
token.block = true;
|
||||
token.map = [startLine, startLine + 1];
|
||||
token.attrSet("lines", m[1]);
|
||||
|
||||
state.line = startLine + 1;
|
||||
return true;
|
||||
}
|
||||
|
||||
md.block.ruler.before("fence", "spacer", spacerRule, {
|
||||
alt: ["paragraph", "blockquote", "list"],
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
|
||||
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
|
||||
import { Box, Divider, Text } from "metabase/ui";
|
||||
import type { SearchResult } from "metabase-types/api";
|
||||
|
||||
import type { MenuItem } from "../../shared/MenuComponents";
|
||||
import {
|
||||
MenuItemComponent,
|
||||
SearchResultsFooter,
|
||||
} from "../../shared/MenuComponents";
|
||||
|
||||
interface EntitySearchSectionProps {
|
||||
menuItems: MenuItem[];
|
||||
selectedIndex: number;
|
||||
onItemSelect: (index: number) => void;
|
||||
onFooterClick: () => void;
|
||||
query: string;
|
||||
searchResults: SearchResult[];
|
||||
modal: "question-picker" | null;
|
||||
onModalSelect: (item: QuestionPickerValueItem) => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
export function EntitySearchSection({
|
||||
menuItems,
|
||||
selectedIndex,
|
||||
onItemSelect,
|
||||
onFooterClick,
|
||||
query,
|
||||
searchResults,
|
||||
modal,
|
||||
onModalSelect,
|
||||
onModalClose,
|
||||
}: EntitySearchSectionProps) {
|
||||
return (
|
||||
<>
|
||||
{menuItems.map((item, index) => (
|
||||
<MenuItemComponent
|
||||
key={index}
|
||||
item={item}
|
||||
isSelected={selectedIndex === index}
|
||||
onClick={() => onItemSelect(index)}
|
||||
/>
|
||||
))}
|
||||
{query.length > 0 && searchResults.length === 0 ? (
|
||||
<Box p="sm" ta="center">
|
||||
<Text size="md" c="text-medium">{t`No results found`}</Text>
|
||||
</Box>
|
||||
) : null}
|
||||
<Divider my="sm" mx="sm" />
|
||||
<SearchResultsFooter
|
||||
isSelected={selectedIndex === menuItems.length}
|
||||
onClick={onFooterClick}
|
||||
/>
|
||||
|
||||
{modal === "question-picker" && (
|
||||
<QuestionPickerModal onChange={onModalSelect} onClose={onModalClose} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { SearchModel } from "metabase-types/api";
|
||||
|
||||
export const LINK_SEARCH_MODELS: SearchModel[] = [
|
||||
"card",
|
||||
"dataset",
|
||||
"metric",
|
||||
"dashboard",
|
||||
"database",
|
||||
"table",
|
||||
"collection",
|
||||
];
|
||||
|
||||
export const EMBED_SEARCH_MODELS: SearchModel[] = ["card", "dataset"];
|
||||
|
||||
export const LINK_SEARCH_LIMIT = 5;
|
||||
@@ -0,0 +1,71 @@
|
||||
import { getIcon } from "metabase/lib/icon";
|
||||
import { getName } from "metabase/lib/name";
|
||||
import type {
|
||||
Database,
|
||||
RecentCollectionItem,
|
||||
RecentItem,
|
||||
SearchModel,
|
||||
SearchResult,
|
||||
} from "metabase-types/api";
|
||||
|
||||
import type { MenuItem } from "../../shared/MenuComponents";
|
||||
|
||||
export const isRecentQuestion = (
|
||||
item: RecentItem,
|
||||
): item is RecentCollectionItem & { model: "card" | "dataset" } =>
|
||||
item.model === "card" || item.model === "dataset";
|
||||
|
||||
export function buildSearchMenuItems(
|
||||
searchResults: SearchResult[],
|
||||
onSelect: (result: SearchResult) => void,
|
||||
): MenuItem[] {
|
||||
return searchResults.map((result) => {
|
||||
const iconData = getIcon({
|
||||
model: result.model,
|
||||
// display is optional in SearchResult, pass through when present
|
||||
display: result.display ?? undefined,
|
||||
});
|
||||
return {
|
||||
icon: iconData.name,
|
||||
label: result.name,
|
||||
id: result.id,
|
||||
model: result.model as SearchModel,
|
||||
action: () => onSelect(result),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildRecentsMenuItems(
|
||||
recents: Array<RecentCollectionItem & { model: "card" | "dataset" }>,
|
||||
onSelect: (recent: RecentCollectionItem) => void,
|
||||
): MenuItem[] {
|
||||
return recents.map((recent) => {
|
||||
const iconData = getIcon({
|
||||
model: recent.model,
|
||||
display: recent.display ?? undefined,
|
||||
});
|
||||
return {
|
||||
icon: iconData.name,
|
||||
label: getName(recent),
|
||||
id: recent.id,
|
||||
model: recent.model as SearchModel,
|
||||
action: () => onSelect(recent),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function buildDbMenuItems(
|
||||
dbs: Database[],
|
||||
onSelect: (db: Database) => void,
|
||||
): MenuItem[] {
|
||||
return dbs.map((db) => {
|
||||
const iconData = getIcon({ model: "database" });
|
||||
return {
|
||||
icon: iconData.name,
|
||||
label: db.name,
|
||||
id: db.id,
|
||||
model: "database",
|
||||
action: () => onSelect(db),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useListRecentsQuery, useSearchQuery } from "metabase/api";
|
||||
import type { RecentItem, SearchModel, SearchResult } from "metabase-types/api";
|
||||
|
||||
import type { MenuItem } from "../../shared/MenuComponents";
|
||||
|
||||
import { LINK_SEARCH_LIMIT, LINK_SEARCH_MODELS } from "./constants";
|
||||
import {
|
||||
buildRecentsMenuItems,
|
||||
buildSearchMenuItems,
|
||||
isRecentQuestion,
|
||||
} from "./suggestionUtils";
|
||||
|
||||
interface UseEntitySearchOptions {
|
||||
query: string;
|
||||
onSelectRecent: (item: RecentItem) => void;
|
||||
onSelectSearchResult: (item: SearchResult) => void;
|
||||
enabled?: boolean;
|
||||
searchModels?: SearchModel[];
|
||||
}
|
||||
|
||||
interface UseEntitySearchResult {
|
||||
menuItems: MenuItem[];
|
||||
isLoading: boolean;
|
||||
searchResults: SearchResult[];
|
||||
}
|
||||
|
||||
export function useEntitySearch({
|
||||
query,
|
||||
onSelectRecent,
|
||||
onSelectSearchResult,
|
||||
enabled = true,
|
||||
searchModels = LINK_SEARCH_MODELS,
|
||||
}: UseEntitySearchOptions): UseEntitySearchResult {
|
||||
const shouldFetchRecents = enabled && query.length === 0;
|
||||
|
||||
const { data: recents = [], isLoading: isRecentsLoading } =
|
||||
useListRecentsQuery(undefined, {
|
||||
refetchOnMountOrArgChange: true,
|
||||
skip: !shouldFetchRecents,
|
||||
});
|
||||
|
||||
const filteredRecents = useMemo(
|
||||
() =>
|
||||
shouldFetchRecents
|
||||
? recents.filter(isRecentQuestion).slice(0, LINK_SEARCH_LIMIT)
|
||||
: [],
|
||||
[recents, shouldFetchRecents],
|
||||
);
|
||||
|
||||
const { data: searchResponse, isLoading: isSearchLoading } = useSearchQuery(
|
||||
{
|
||||
q: query,
|
||||
models: searchModels,
|
||||
limit: LINK_SEARCH_LIMIT,
|
||||
},
|
||||
{
|
||||
skip: !enabled || !query || query.length === 0,
|
||||
},
|
||||
);
|
||||
|
||||
const searchResults = useMemo(
|
||||
() => (searchResponse?.data as SearchResult[]) ?? [],
|
||||
[searchResponse],
|
||||
);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items: MenuItem[] = [];
|
||||
|
||||
if (query.length > 0) {
|
||||
// When there's a query, always show search results (even if empty)
|
||||
// Don't show recents when searching
|
||||
if (!isSearchLoading) {
|
||||
items.push(
|
||||
...buildSearchMenuItems(searchResults, onSelectSearchResult),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Only show recents when there's no query
|
||||
if (!isRecentsLoading && filteredRecents.length > 0) {
|
||||
items.push(...buildRecentsMenuItems(filteredRecents, onSelectRecent));
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [
|
||||
query,
|
||||
searchResults,
|
||||
isSearchLoading,
|
||||
filteredRecents,
|
||||
isRecentsLoading,
|
||||
onSelectRecent,
|
||||
onSelectSearchResult,
|
||||
]);
|
||||
|
||||
const isLoading =
|
||||
(shouldFetchRecents && isRecentsLoading) ||
|
||||
(query.length > 0 && isSearchLoading);
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
isLoading,
|
||||
searchResults,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import type { Editor } from "@tiptap/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
|
||||
import type { RecentItem, SearchModel, SearchResult } from "metabase-types/api";
|
||||
|
||||
import { useEntitySearch } from "./useEntitySearch";
|
||||
|
||||
interface UseEntitySuggestionsOptions {
|
||||
query: string;
|
||||
editor: Editor;
|
||||
onSelectEntity: (item: { id: number | string; model: string }) => void;
|
||||
enabled?: boolean;
|
||||
searchModels?: SearchModel[];
|
||||
}
|
||||
|
||||
interface UseEntitySuggestionsResult {
|
||||
menuItems: ReturnType<typeof useEntitySearch>["menuItems"];
|
||||
isLoading: boolean;
|
||||
searchResults: SearchResult[];
|
||||
selectedIndex: number;
|
||||
modal: "question-picker" | null;
|
||||
totalItems: number;
|
||||
handlers: {
|
||||
selectItem: (index: number) => void;
|
||||
upHandler: () => void;
|
||||
downHandler: () => void;
|
||||
enterHandler: () => void;
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
|
||||
handleModalSelect: (item: QuestionPickerValueItem) => void;
|
||||
handleModalClose: () => void;
|
||||
openModal: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
export function useEntitySuggestions({
|
||||
query,
|
||||
editor,
|
||||
onSelectEntity,
|
||||
enabled = true,
|
||||
searchModels,
|
||||
}: UseEntitySuggestionsOptions): UseEntitySuggestionsResult {
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [modal, setModal] = useState<"question-picker" | null>(null);
|
||||
|
||||
const handleRecentSelect = useCallback(
|
||||
(item: RecentItem) => {
|
||||
onSelectEntity({
|
||||
id: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
},
|
||||
[onSelectEntity],
|
||||
);
|
||||
|
||||
const handleSearchResultSelect = useCallback(
|
||||
(item: SearchResult) => {
|
||||
onSelectEntity({
|
||||
id: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
},
|
||||
[onSelectEntity],
|
||||
);
|
||||
|
||||
const { menuItems, isLoading, searchResults } = useEntitySearch({
|
||||
query,
|
||||
onSelectRecent: handleRecentSelect,
|
||||
onSelectSearchResult: handleSearchResultSelect,
|
||||
enabled,
|
||||
searchModels,
|
||||
});
|
||||
|
||||
const totalItems = menuItems.length + 1;
|
||||
|
||||
const selectItem = useCallback(
|
||||
(index: number) => {
|
||||
if (index < menuItems.length) {
|
||||
menuItems[index].action();
|
||||
} else {
|
||||
setModal("question-picker");
|
||||
}
|
||||
},
|
||||
[menuItems],
|
||||
);
|
||||
|
||||
const upHandler = useCallback(() => {
|
||||
setSelectedIndex((prev) => (prev - 1 + totalItems) % totalItems);
|
||||
}, [totalItems]);
|
||||
|
||||
const downHandler = useCallback(() => {
|
||||
setSelectedIndex((prev) => (prev + 1) % totalItems);
|
||||
}, [totalItems]);
|
||||
|
||||
const enterHandler = useCallback(() => {
|
||||
selectItem(selectedIndex);
|
||||
}, [selectItem, selectedIndex]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
({ event }: { event: KeyboardEvent }) => {
|
||||
if (event.key === "ArrowUp") {
|
||||
upHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "ArrowDown") {
|
||||
downHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Enter") {
|
||||
enterHandler();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
[upHandler, downHandler, enterHandler],
|
||||
);
|
||||
|
||||
const handleModalSelect = useCallback(
|
||||
(item: QuestionPickerValueItem) => {
|
||||
onSelectEntity({
|
||||
id: item.id,
|
||||
model: item.model,
|
||||
});
|
||||
setModal(null);
|
||||
},
|
||||
[onSelectEntity],
|
||||
);
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
setModal(null);
|
||||
setTimeout(() => {
|
||||
editor.commands.focus();
|
||||
}, 0);
|
||||
}, [editor]);
|
||||
|
||||
const openModal = useCallback(() => {
|
||||
setModal("question-picker");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
}, [menuItems.length]);
|
||||
|
||||
return {
|
||||
menuItems,
|
||||
isLoading,
|
||||
searchResults,
|
||||
selectedIndex,
|
||||
modal,
|
||||
totalItems,
|
||||
handlers: {
|
||||
selectItem,
|
||||
upHandler,
|
||||
downHandler,
|
||||
enterHandler,
|
||||
onKeyDown,
|
||||
handleModalSelect,
|
||||
handleModalClose,
|
||||
openModal,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { type VirtualElement, computePosition } from "@floating-ui/dom";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import type {
|
||||
SuggestionKeyDownProps,
|
||||
SuggestionProps,
|
||||
} from "@tiptap/suggestion";
|
||||
import type React from "react";
|
||||
|
||||
export const createSuggestionRenderer = <I = unknown, TSelected = unknown>(
|
||||
SuggestionComponent: React.ComponentType<SuggestionProps<I, TSelected>>,
|
||||
) => {
|
||||
return () => {
|
||||
let component: ReactRenderer | undefined;
|
||||
let currentClientRect: (() => DOMRect | null) | null | undefined;
|
||||
|
||||
function repositionComponent(clientRect: DOMRect | null) {
|
||||
if (!component || !component.element) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!clientRect) {
|
||||
return;
|
||||
}
|
||||
|
||||
const element = component.element;
|
||||
if (!(element instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const virtualElement: VirtualElement = {
|
||||
getBoundingClientRect() {
|
||||
return clientRect;
|
||||
},
|
||||
};
|
||||
|
||||
computePosition(virtualElement, element, {
|
||||
placement: "bottom-start",
|
||||
}).then((pos) => {
|
||||
if (component?.element instanceof HTMLElement) {
|
||||
Object.assign(component.element.style, {
|
||||
left: `${pos.x}px`,
|
||||
top: `${pos.y}px`,
|
||||
position: pos.strategy === "fixed" ? "fixed" : "absolute",
|
||||
zIndex: "3",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (currentClientRect) {
|
||||
repositionComponent(currentClientRect());
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
onStart: (props: SuggestionProps<I, TSelected>) => {
|
||||
component = new ReactRenderer(SuggestionComponent, {
|
||||
props,
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
if (component.element instanceof HTMLElement) {
|
||||
document.body.appendChild(component.element);
|
||||
}
|
||||
currentClientRect = props.clientRect;
|
||||
repositionComponent(props.clientRect?.() ?? null);
|
||||
|
||||
// Add scroll listeners to reposition on scroll
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleScroll);
|
||||
},
|
||||
|
||||
onUpdate(props: SuggestionProps<I, TSelected>) {
|
||||
component?.updateProps(props);
|
||||
currentClientRect = props.clientRect;
|
||||
repositionComponent(props.clientRect?.() ?? null);
|
||||
},
|
||||
|
||||
onKeyDown(props: SuggestionKeyDownProps) {
|
||||
if (props.event.key === "Escape") {
|
||||
if (
|
||||
component?.element instanceof HTMLElement &&
|
||||
document.body.contains(component.element)
|
||||
) {
|
||||
document.body.removeChild(component.element);
|
||||
}
|
||||
component?.destroy();
|
||||
return true;
|
||||
}
|
||||
|
||||
const ref = component?.ref;
|
||||
if (
|
||||
ref &&
|
||||
typeof ref === "object" &&
|
||||
"onKeyDown" in ref &&
|
||||
typeof ref.onKeyDown === "function"
|
||||
) {
|
||||
return ref.onKeyDown(props);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
onExit() {
|
||||
// Remove scroll listeners
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleScroll);
|
||||
|
||||
if (
|
||||
component?.element instanceof HTMLElement &&
|
||||
document.body.contains(component.element)
|
||||
) {
|
||||
document.body.removeChild(component.element);
|
||||
}
|
||||
component?.destroy();
|
||||
currentClientRect = undefined;
|
||||
},
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useCardEmbedSelection as useQuestionSelection } from "./useCardEmbedSelection";
|
||||
export { useCardEmbedsTracking } from "./useCardEmbedsTracking";
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user