Document Layout - add charts side-to-side (#63101)
* Add working table with 2 columns * Table layout and dnd polishing * ResizeNode * Refactor to use grid container * Add Card node title ellipsification * cleanup, add min height * Fix FlexContainer to work with ResizeNode * Fix empty ResizeNode after card dnd * Document layout no dnd kit (#63363) * using tiptap drag and drop * Fix some issues with dnd * Add support for resize node, fix styles * Remove comments --------- Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com> * Fixes after merge comments * Fix issues with dnd logic * Fix issue where rearranging columns within a flexContainer didn't respect which side of a cardEmbed we dropped; it always inserted dragged column to the left. * Fix issue where moving a cardEmbed out of a flexContainer and placing it right after doesn't wrap the card in a resizer * Fix issues with dropzones and card id * Fix issue with column resize handlers * Fix issues with cards previews on dnd * Feat: UXW-717 Reconcile comments with document layout (#63593) * UXW-717 Add comment option to cardEmbed menu * UXW-717 Wires up comment option in cardEmbed menu * UXW-717 Stubs out CommentsMenu reorganization so logic can be reused inside cardEmbeds (button and menu item) * UXW-717 Design tweaks per design feedback (and fixes ts error) * UXW-717 Updates hover state of cardEmbed ellipsis menu trigger per design feedback * Fix issues with funnel chart and resizer * Fix issues with column rearrange * Fix adding third column position * UXW-739 Adds ResizeNode wrapper to charts added by metabot * UXW-749 [1/2] Fix: Cutting/copying a chart from a group then pasting it (cmd+v) results in a chart without a resizeNode * UXW-749 [2/2] Fix: Copying and pasting from one chart to another doesn't preserve the structure correctly * Refactor dnd logic - consolidate all use cases in HandleEditorDrop * remove redundant message and add_message icons * actually remove redundant message icons * replace references to old icons with new icons * Fix issue with dropping a card into existing flexContainer * Fix issue with moving a card from one flexContainer into another * UXW-748 - Fix broken document when typing with a card selected * Handle dropping cards onto paragraphs (#63754) * Handle dropping cards onto paragraphs * PR Feedback * cleanup flex containers --------- Co-authored-by: Denis Berezin <denis.berezin@metabase.com> * small feedbacks * breaking up document drop handler * UXW-689 - add basic e2e tests * type fix * loki diff * Add more e2e tests * Fix typecheck * happy types * passing document.cy.spec * Fix e2e tests * e2e test for card resizing * flex container column resize tests * Add node deletion e2e tests * Fix review issues * Try to fix typecheck * Refactor useCommentsButtonProps to a react component * final PR feedback * fix removing a card via menu --------- Co-authored-by: Nick Fitzpatrick <nickfitz.582@gmail.com> Co-authored-by: Brad Anderson <brad@metabase.com> Co-authored-by: Maz Ameli <maz@metabase.com> Co-authored-by: Nick Fitzpatrick <nick@metabase.com> Co-authored-by: Kamil Mielnik <kamil@kamilmielnik.com>
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 58 KiB |
165
e2e/support/document-initial-data.ts
Normal file
165
e2e/support/document-initial-data.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
ORDERS_BY_YEAR_QUESTION_ID,
|
||||
ORDERS_COUNT_QUESTION_ID,
|
||||
ORDERS_QUESTION_ID,
|
||||
} from "e2e/support/cypress_sample_instance_data";
|
||||
|
||||
export const DOCUMENT_WITH_TWO_CARDS = {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Testing drag and drop functionality",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "2",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2a",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Some text between cards",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "4",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_COUNT_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "4a",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
_id: "5",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
};
|
||||
|
||||
export const DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS = {
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Advanced drag and drop scenarios",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "2",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "flexContainer",
|
||||
attrs: {
|
||||
_id: "2a",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2a1",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_COUNT_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2a2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Standalone card below",
|
||||
},
|
||||
],
|
||||
attrs: {
|
||||
_id: "3",
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
_id: "4",
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_BY_YEAR_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "4a",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: {
|
||||
_id: "5",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "doc",
|
||||
};
|
||||
@@ -21,16 +21,22 @@ function getDocumentNodeButton({
|
||||
targetId,
|
||||
childTargetId,
|
||||
hasComments,
|
||||
isCardEmbedNode,
|
||||
}: {
|
||||
targetId: DocumentId;
|
||||
childTargetId: string;
|
||||
hasComments?: boolean;
|
||||
isCardEmbedNode?: boolean;
|
||||
}) {
|
||||
const threadUrl = `/document/${targetId}/comments/${childTargetId}${hasComments ? "" : "?new=true"}`;
|
||||
|
||||
return getDocumentNodeButtons().filter(
|
||||
(_, element) => element.getAttribute("href") === threadUrl,
|
||||
);
|
||||
if (isCardEmbedNode) {
|
||||
return cy.findByRole("button", { name: "Comments" });
|
||||
} else {
|
||||
return getDocumentNodeButtons().filter(
|
||||
(_, element) => element.getAttribute("href") === threadUrl,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getDocumentNodeButtons() {
|
||||
|
||||
@@ -37,10 +37,22 @@ export const documentMetabotSuggestionItem = (name: RegExp | string) =>
|
||||
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 getDocumentCard = (name: string, expectCardExists = true) => {
|
||||
const cardsResult = documentContent()
|
||||
.findAllByTestId("card-embed-title")
|
||||
.filter((index, el) => {
|
||||
// Filter elements based on custom logic, e.g., text content, class, etc.
|
||||
return el.innerText === name;
|
||||
})
|
||||
.should("have.length", expectCardExists ? 1 : 0);
|
||||
|
||||
return expectCardExists
|
||||
? cardsResult.closest('[data-testid="document-card-embed"]')
|
||||
: cardsResult;
|
||||
};
|
||||
|
||||
export const getDocumentCardResizeContainer = (name: string) =>
|
||||
getDocumentCard(name).closest('[data-type="resizeNode"]');
|
||||
|
||||
export const assertDocumentCardVizType = (name: string, type: string) =>
|
||||
getDocumentCard(name).find(`[data-viz-ui-name=${type}]`);
|
||||
@@ -102,3 +114,124 @@ const visitDocumentById = (id: DocumentId) => {
|
||||
|
||||
cy.wait(`@${alias}`);
|
||||
};
|
||||
|
||||
export function dragAndDropCardOnAnotherCard(
|
||||
sourceCardTitle: string,
|
||||
targetCardTitle: string,
|
||||
{
|
||||
side = "left",
|
||||
}: {
|
||||
side?: "left" | "right";
|
||||
} = {},
|
||||
) {
|
||||
// Perform drag and drop: drag sourceCard card onto targetCard
|
||||
getDocumentCard(sourceCardTitle)
|
||||
.should("exist")
|
||||
.then(($ordersCard) => {
|
||||
getDocumentCard(targetCardTitle)
|
||||
.should("exist")
|
||||
.then(($ordersCountCard) => {
|
||||
const sourceRect = $ordersCard[0].getBoundingClientRect();
|
||||
|
||||
// Start drag from center of Orders card
|
||||
getDocumentCard(sourceCardTitle).trigger("mousedown", {
|
||||
x: 10,
|
||||
y: 10,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
const dataTransfer = new DataTransfer();
|
||||
getDocumentCard(sourceCardTitle).trigger("dragstart", {
|
||||
dataTransfer,
|
||||
clientX: sourceRect.left + 10,
|
||||
clientY: sourceRect.top + 10,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const targetRect = $ordersCountCard[0].getBoundingClientRect();
|
||||
// Calculate position for target card side drop (20% from left or 20% from right based on 'side' param)
|
||||
const sideX =
|
||||
targetRect.left + targetRect.width * (side === "left" ? 0.2 : 0.8);
|
||||
const centerY = targetRect.top + 10;
|
||||
documentContent().trigger("mousemove", {
|
||||
clientX: sideX,
|
||||
clientY: centerY,
|
||||
scrollBehavior: false,
|
||||
});
|
||||
getDocumentCard(targetCardTitle).trigger("dragover", {
|
||||
clientX: sideX,
|
||||
clientY: centerY,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
|
||||
getDocumentCard(sourceCardTitle).realMouseUp({
|
||||
scrollBehavior: false,
|
||||
});
|
||||
getDocumentCard(targetCardTitle).trigger("drop", {
|
||||
dataTransfer,
|
||||
clientX: sideX,
|
||||
clientY: centerY,
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
getDocumentCard(sourceCardTitle).trigger("dragend", {
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function documentUndo() {
|
||||
const macOSX = Cypress.platform === "darwin";
|
||||
|
||||
documentContent()
|
||||
.get('[contenteditable="true"]')
|
||||
.click()
|
||||
.type(macOSX ? "{cmd+z}" : "{ctrl+z}", {
|
||||
scrollBehavior: false,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
|
||||
export function getDragHandleForDocumentResizeNode(element: Cypress.Chainable) {
|
||||
return element.findByTestId("resize-node-drag-handle");
|
||||
}
|
||||
|
||||
export function documentDoDrag(
|
||||
_handle: Cypress.Chainable<JQuery<HTMLElement>>,
|
||||
diff: { x?: number; y?: number },
|
||||
) {
|
||||
_handle.then((handle) => {
|
||||
const { x: deltaX = 0, y: deltaY = 0 } = diff;
|
||||
|
||||
const rect = handle[0].getBoundingClientRect();
|
||||
|
||||
cy.log(`x: ${rect.x}, y: ${rect.y}, diff: ${diff}`);
|
||||
|
||||
cy.wrap(handle).trigger("mousedown", {
|
||||
button: 0,
|
||||
clientX: rect.x,
|
||||
clientY: rect.y,
|
||||
force: true,
|
||||
});
|
||||
cy.get("body")
|
||||
.trigger("mousemove", {
|
||||
button: 0,
|
||||
clientX: rect.x + deltaX,
|
||||
clientY: rect.y + deltaY,
|
||||
force: true,
|
||||
})
|
||||
.trigger("mouseup");
|
||||
});
|
||||
}
|
||||
|
||||
export function getFlexContainerForCard(name: string) {
|
||||
return getDocumentCard(name).closest('[data-type="flexContainer"]');
|
||||
}
|
||||
|
||||
export function getResizeHandlesForFlexContianer(element: Cypress.Chainable) {
|
||||
return element.findAllByTestId("flex-container-drag-handle");
|
||||
}
|
||||
|
||||
593
e2e/test/scenarios/documents/card-embed-node.cy.spec.ts
Normal file
593
e2e/test/scenarios/documents/card-embed-node.cy.spec.ts
Normal file
@@ -0,0 +1,593 @@
|
||||
import {
|
||||
DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
|
||||
DOCUMENT_WITH_TWO_CARDS,
|
||||
} from "e2e/support/document-initial-data";
|
||||
|
||||
const { H } = cy;
|
||||
|
||||
describe("documents card embed node custom logic", () => {
|
||||
beforeEach(() => {
|
||||
H.restore();
|
||||
cy.signInAsAdmin();
|
||||
H.activateToken("bleeding-edge");
|
||||
});
|
||||
|
||||
describe("cardEmbed drag and drop", () => {
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
document: DOCUMENT_WITH_TWO_CARDS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
});
|
||||
|
||||
it("should create a flexContainer when dropping one cardEmbed onto another standalone cardEmbed", () => {
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify no flexContainer exists initially
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("not.exist");
|
||||
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count");
|
||||
|
||||
// Verify flexContainer was created
|
||||
H.documentContent().find('[data-type="flexContainer"]').should("exist");
|
||||
|
||||
// Verify both cards are now inside the flexContainer
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders", "Orders, Count"]);
|
||||
});
|
||||
|
||||
// Verify the flexContainer is wrapped in a resizeNode
|
||||
H.documentContent()
|
||||
.find('[data-type="resizeNode"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
cy.get('[data-type="flexContainer"]').should("exist");
|
||||
});
|
||||
|
||||
// Verify that originally separate cards are now side by side
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle drag and drop with proper drop side positioning", () => {
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "right",
|
||||
});
|
||||
|
||||
// Verify flexContainer was created with correct order
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
// Orders, Count should be first (left), Orders should be second (right)
|
||||
assertFlexContainerCardsOrder(["Orders, Count", "Orders"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should prevent dropping a card onto itself", () => {
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial state - no flexContainer
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("not.exist");
|
||||
|
||||
// Attempt to drag and drop the card onto itself
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders");
|
||||
|
||||
// Verify no flexContainer was created
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("not.exist");
|
||||
|
||||
// Verify the card is still standalone
|
||||
H.getDocumentCard("Orders").should("exist");
|
||||
});
|
||||
});
|
||||
|
||||
describe("advanced flexContainer scenarios", () => {
|
||||
beforeEach(() => {
|
||||
H.createDocument({
|
||||
name: "Advanced DnD Test Document",
|
||||
document: DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
});
|
||||
|
||||
it("should allow you to resize the cards inside a flex container", () => {
|
||||
getCardWidths(["Orders", "Orders, Count"], (first, second) => {
|
||||
cy.wrap(first).as("ogWidth1");
|
||||
cy.wrap(second).as("ogWidth2");
|
||||
expect(first).to.be.closeTo(second, 3);
|
||||
});
|
||||
|
||||
H.getDocumentCard("Orders").as("ORDERS_CARD");
|
||||
H.getDocumentCard("Orders, Count").as("ORDERS_COUNT_CARD");
|
||||
|
||||
const flexContainer = H.getFlexContainerForCard("Orders");
|
||||
|
||||
const handles = H.getResizeHandlesForFlexContianer(flexContainer);
|
||||
|
||||
H.documentDoDrag(handles.eq(0), { x: 100 });
|
||||
|
||||
getCardWidths(["Orders", "Orders, Count"], (first, second) => {
|
||||
cy.get("@ogWidth1").then((_first) => {
|
||||
cy.get("@ogWidth2").then((_second) => {
|
||||
cy.log("compare that changes are close to the drag distance");
|
||||
expect((_first as unknown as number) + 100).to.be.closeTo(first, 3);
|
||||
expect((_second as unknown as number) - 100).to.be.closeTo(
|
||||
second,
|
||||
3,
|
||||
);
|
||||
|
||||
expect(first).to.be.closeTo(second + 200, 3);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should add a third card to an existing flexContainer with 2 cards", () => {
|
||||
// Wait for all cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count, Grouped by Created At (year)")
|
||||
.should("be.visible")
|
||||
.findByTestId("chart-container")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial state - flexContainer exists with 2 cards
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 2);
|
||||
});
|
||||
|
||||
// Drag the standalone card (Orders by Year) onto one of the cards in the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "left" },
|
||||
);
|
||||
|
||||
// Verify the flexContainer now has 3 cards
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder([
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
]);
|
||||
});
|
||||
|
||||
H.documentUndo();
|
||||
|
||||
// Drag the standalone card (Orders by Year) onto one of the cards in the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder([
|
||||
"Orders",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders, Count",
|
||||
]);
|
||||
});
|
||||
|
||||
H.documentUndo();
|
||||
|
||||
// Drag the standalone card (Orders by Year) onto one of the cards in the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders, Count",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder([
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
]);
|
||||
});
|
||||
|
||||
cy.log("changing the widths of 2 cards should leave the 3rd alone");
|
||||
|
||||
const cardNames = [
|
||||
"Orders",
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
];
|
||||
|
||||
getCardWidths(cardNames, (first, second, third) => {
|
||||
cy.wrap(first).as("_first");
|
||||
cy.wrap(second).as("_second");
|
||||
cy.wrap(third).as("_third");
|
||||
expect(first).to.be.closeTo(second, 10);
|
||||
expect(first).to.be.closeTo(third as unknown as number, 10);
|
||||
});
|
||||
|
||||
const flexContainer = H.getFlexContainerForCard("Orders");
|
||||
|
||||
const handles = H.getResizeHandlesForFlexContianer(flexContainer);
|
||||
|
||||
H.documentDoDrag(handles.eq(1), { x: 50 });
|
||||
|
||||
getCardWidths(cardNames, (first, second, third) => {
|
||||
cy.get("@_first").then((_first) => {
|
||||
cy.get("@_second").then((_second) => {
|
||||
cy.get("@_third").then((_third) => {
|
||||
expect(first).to.be.closeTo(_first as unknown as number, 3);
|
||||
expect(second).to.be.greaterThan(_second as unknown as number);
|
||||
expect(third).to.be.lessThan(_third as unknown as number);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should prevent adding a fourth card to a flexContainer with 3 cards", () => {
|
||||
// Wait for all cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// First, add the third card to reach the limit
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
// Verify we have 3 cards in the flexContainer
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
// Add another card to try to exceed the limit
|
||||
addNewStandaloneCard("Orders Model", "model");
|
||||
|
||||
// Wait for the new card to be added
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 4); // 3 in flexContainer + 1 new standalone
|
||||
|
||||
// Try to drag the new standalone card onto the flexContainer
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "left",
|
||||
});
|
||||
|
||||
// Verify the flexContainer still has only 3 cards (drop should be rejected)
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
// Verify the standalone card is still separate
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 4); // Still 4 total, with 1 standalone
|
||||
});
|
||||
|
||||
it("should reorder cards within the same flexContainer", () => {
|
||||
// Wait for all cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial order: Orders | Orders, Count
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders", "Orders, Count"]);
|
||||
});
|
||||
|
||||
// Drag Orders to the right side of Orders, Count to reorder them
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "right",
|
||||
});
|
||||
|
||||
// Verify new order: Orders, Count | Orders
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders, Count", "Orders"]);
|
||||
});
|
||||
|
||||
H.dragAndDropCardOnAnotherCard("Orders", "Orders, Count", {
|
||||
side: "left",
|
||||
});
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
assertFlexContainerCardsOrder(["Orders", "Orders, Count"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle moving cards between different flexContainers", () => {
|
||||
// First create another flexContainer by dragging the standalone card onto a new location
|
||||
// Add another standalone card first
|
||||
addNewStandaloneCard("Orders Model", "model");
|
||||
|
||||
// Wait for the new card
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 4);
|
||||
|
||||
// Create a second flexContainer by dropping Orders by Year onto the new Orders card
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders Model",
|
||||
);
|
||||
|
||||
// Verify we now have 2 flexContainers
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("have.length", 2);
|
||||
|
||||
// Move a card from the first flexContainer to the second one
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count",
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
// Verify the first flexContainer now has only 1 card (should be unwrapped)
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("have.length", 1);
|
||||
|
||||
// Verify the remaining flexContainer has 3 cards
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("have.length", 2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleting a cardEmbed", () => {
|
||||
it("should delete a cardEmbed when selected and Backspace is pressed", () => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
document: DOCUMENT_WITH_TWO_CARDS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
|
||||
// Wait for cards to load
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Verify initial state - we have 2 standalone cards
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 2);
|
||||
|
||||
// Click on the Orders card to select it
|
||||
H.getDocumentCard("Orders").realClick({ position: "top" });
|
||||
|
||||
// Press Backspace to delete the selected card
|
||||
cy.realPress("Backspace");
|
||||
|
||||
// Verify the Orders card has been deleted
|
||||
H.getDocumentCard("Orders", false).should("not.exist");
|
||||
|
||||
// Verify only one card remains
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 1);
|
||||
|
||||
// Verify the remaining card is Orders, Count
|
||||
H.getDocumentCard("Orders, Count").should("exist").and("be.visible");
|
||||
});
|
||||
|
||||
it("should delete a cardEmbed from a flexContainer when selected and Backspace is pressed", () => {
|
||||
H.createDocument({
|
||||
name: "DnD Test Document",
|
||||
document: DOCUMENT_WITH_THREE_CARDS_AND_COLUMNS,
|
||||
collection_id: null,
|
||||
alias: "document",
|
||||
idAlias: "documentId",
|
||||
});
|
||||
|
||||
cy.get("@documentId").then((id) => cy.visit(`/document/${id}`));
|
||||
|
||||
// First create a flexContainer by dropping one card onto another
|
||||
H.getDocumentCard("Orders")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
H.getDocumentCard("Orders, Count")
|
||||
.should("be.visible")
|
||||
.findByTestId("table-root")
|
||||
.should("exist");
|
||||
|
||||
// Create flexContainer
|
||||
H.dragAndDropCardOnAnotherCard(
|
||||
"Orders, Count, Grouped by Created At (year)",
|
||||
"Orders",
|
||||
{ side: "right" },
|
||||
);
|
||||
|
||||
// Verify flexContainer was created with 2 cards
|
||||
H.documentContent()
|
||||
.find('[data-type="flexContainer"]')
|
||||
.should("exist")
|
||||
.within(() => {
|
||||
cy.findAllByTestId("document-card-embed").should("have.length", 3);
|
||||
});
|
||||
|
||||
// Click on one of the cards in the flexContainer to select it
|
||||
H.getDocumentCard("Orders").realClick({ position: "top" });
|
||||
|
||||
// Press Backspace to delete the selected card
|
||||
cy.realPress("Backspace");
|
||||
|
||||
// Verify the Orders card has been deleted from the flexContainer
|
||||
H.getDocumentCard("Orders", false).should("not.exist");
|
||||
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 2);
|
||||
|
||||
H.documentContent().get('[data-type="flexContainer"]').should("exist");
|
||||
|
||||
// Click on one of the cards in the flexContainer to select it
|
||||
H.getDocumentCard("Orders, Count").realClick({ position: "top" });
|
||||
|
||||
// Press Backspace to delete the selected card
|
||||
cy.realPress("Backspace");
|
||||
|
||||
// Verify the flexContainer now has only 1 card and should be unwrapped back to standalone
|
||||
H.documentContent()
|
||||
.get('[data-type="flexContainer"]')
|
||||
.should("not.exist"); // FlexContainer should be unwrapped when only 1 card remains
|
||||
|
||||
// Verify only the Orders, Count card remains as a standalone card
|
||||
H.documentContent()
|
||||
.findAllByTestId("document-card-embed")
|
||||
.should("have.length", 1);
|
||||
H.getDocumentCard("Orders, Count, Grouped by Created At (year)").should(
|
||||
"exist",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function assertFlexContainerCardsOrder(expectedCardTitles: string[]) {
|
||||
for (let i = 0; i < expectedCardTitles.length; i++) {
|
||||
cy.findAllByTestId("document-card-embed")
|
||||
.should("have.length", expectedCardTitles.length)
|
||||
.eq(i)
|
||||
.findByTestId("card-embed-title")
|
||||
.should("contain.text", expectedCardTitles[i]);
|
||||
}
|
||||
}
|
||||
|
||||
function addNewStandaloneCard(
|
||||
cardName: string,
|
||||
cardType: "question" | "model",
|
||||
) {
|
||||
cy.get(".node-paragraph.is-empty").click();
|
||||
H.addToDocument("/", false);
|
||||
H.commandSuggestionItem("Chart").click();
|
||||
H.entityPickerModalTab(
|
||||
cardType === "question" ? "Questions" : "Models",
|
||||
).click();
|
||||
H.entityPickerModalItem(1, cardName).click();
|
||||
}
|
||||
|
||||
function getCardWidths(
|
||||
cardNames: string[],
|
||||
cb: (val1: number, val2: number, val3?: number) => void,
|
||||
) {
|
||||
const [firstCardName, secondCardName, thirdCardName] = cardNames;
|
||||
H.getDocumentCard(firstCardName).then((firstCard) => {
|
||||
H.getDocumentCard(secondCardName).then((secondCard) => {
|
||||
if (thirdCardName) {
|
||||
H.getDocumentCard(thirdCardName).then((thirdCard) => {
|
||||
cb(
|
||||
firstCard.width() as number,
|
||||
secondCard.width() as number,
|
||||
thirdCard.width() as number,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
cb(firstCard.width() as number, secondCard.width() as number);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -50,27 +50,38 @@ H.describeWithSnowplowEE("document comments", () => {
|
||||
testCommentingOnNode(documentId, BLOCKQUOTE_ID, getBlockquote);
|
||||
testCommentingOnNode(documentId, ORDERED_LIST_ID, getOrderedList);
|
||||
testCommentingOnNode(documentId, CODE_BLOCK_ID, getCodeBlock);
|
||||
testCommentingOnNode(documentId, CARD_EMBED_ID, getEmbed);
|
||||
testCommentingOnNode(documentId, CARD_EMBED_ID, getEmbed, {
|
||||
isCardEmbedNode: true,
|
||||
});
|
||||
});
|
||||
|
||||
function testCommentingOnNode<E extends HTMLElement>(
|
||||
targetId: DocumentId,
|
||||
childTargetId: string,
|
||||
getNodeElement: () => Cypress.Chainable<JQuery<E>>,
|
||||
{ isCardEmbedNode = false } = {},
|
||||
) {
|
||||
cy.get("body").click(0, 0);
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId }).should(
|
||||
"not.be.visible",
|
||||
);
|
||||
|
||||
getNodeElement()
|
||||
.closest("[data-node-view-wrapper]")
|
||||
.should("have.attr", "aria-expanded", "false");
|
||||
getNodeElement().scrollIntoView();
|
||||
getNodeElement().realHover();
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId })
|
||||
.should("be.visible")
|
||||
.click();
|
||||
if (isCardEmbedNode) {
|
||||
getNodeElement().scrollIntoView();
|
||||
getNodeElement().icon("ellipsis").click();
|
||||
|
||||
H.menu().findByText("Comment").click();
|
||||
} else {
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId }).should(
|
||||
"not.be.visible",
|
||||
);
|
||||
|
||||
getNodeElement()
|
||||
.closest("[data-node-view-wrapper]")
|
||||
.should("have.attr", "aria-expanded", "false");
|
||||
getNodeElement().scrollIntoView();
|
||||
getNodeElement().realHover();
|
||||
Comments.getDocumentNodeButton({ targetId, childTargetId })
|
||||
.should("be.visible")
|
||||
.click();
|
||||
}
|
||||
|
||||
Comments.getSidebar().within(() => {
|
||||
cy.findByRole("heading", { name: "Comments about this" }).should(
|
||||
@@ -95,6 +106,7 @@ H.describeWithSnowplowEE("document comments", () => {
|
||||
targetId,
|
||||
childTargetId,
|
||||
hasComments: true,
|
||||
isCardEmbedNode,
|
||||
})
|
||||
.should("be.visible")
|
||||
.and("contain.text", "1");
|
||||
@@ -112,6 +124,7 @@ H.describeWithSnowplowEE("document comments", () => {
|
||||
targetId,
|
||||
childTargetId,
|
||||
hasComments: true,
|
||||
isCardEmbedNode,
|
||||
}).should("be.visible");
|
||||
}
|
||||
});
|
||||
@@ -1801,12 +1814,21 @@ function createLoremIpsumDocument() {
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: CARD_EMBED_ID,
|
||||
height: 350,
|
||||
minHeight: 280,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: CARD_EMBED_ID,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
|
||||
@@ -193,12 +193,21 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "cardEmbed",
|
||||
type: "resizeNode",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2",
|
||||
height: 442,
|
||||
minHeight: 280,
|
||||
},
|
||||
content: [
|
||||
{
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: ORDERS_QUESTION_ID,
|
||||
name: null,
|
||||
_id: "2",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: "paragraph",
|
||||
@@ -217,12 +226,16 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
|
||||
it("renders a 'not found' message if the copied card has been permanently deleted", () => {
|
||||
cy.get<Document>("@document").then(({ id, document: { content } }) => {
|
||||
const cardEmbed = content?.find((n) => n.type === "cardEmbed");
|
||||
const resizeNode = content?.find((n) => n.type === "resizeNode");
|
||||
const cardEmbed = resizeNode?.content?.[0];
|
||||
const clonedCardId = cardEmbed?.attrs?.id;
|
||||
cy.request("DELETE", `/api/card/${clonedCardId}`);
|
||||
cy.visit(`/document/${id}`);
|
||||
});
|
||||
H.getDocumentCard("Couldn't find this chart.").should("exist");
|
||||
cy.findByTestId("document-card-embed").should(
|
||||
"have.text",
|
||||
"Couldn't find this chart.",
|
||||
);
|
||||
});
|
||||
|
||||
it("read only access", () => {
|
||||
@@ -684,12 +697,48 @@ H.describeWithSnowplowEE("documents", () => {
|
||||
});
|
||||
});
|
||||
|
||||
H.getDocumentCard(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name).should(
|
||||
"not.exist",
|
||||
);
|
||||
H.documentContent()
|
||||
.findAllByTestId("card-embed-title")
|
||||
.contains(ORDERS_COUNT_BY_PRODUCT_CATEGORY.name)
|
||||
.should("not.exist");
|
||||
|
||||
H.getDocumentCard("Orders").should("exist");
|
||||
});
|
||||
|
||||
it("should support resizing cards", () => {
|
||||
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).then((el) => {
|
||||
const ogHeight = el.height();
|
||||
const resizeNode = H.getDocumentCardResizeContainer(
|
||||
ACCOUNTS_COUNT_BY_CREATED_AT.name,
|
||||
);
|
||||
|
||||
H.documentDoDrag(H.getDragHandleForDocumentResizeNode(resizeNode), {
|
||||
y: 200,
|
||||
});
|
||||
|
||||
H.getDocumentCard(ACCOUNTS_COUNT_BY_CREATED_AT.name).then((el) => {
|
||||
const newHeight = el.height();
|
||||
|
||||
cy.log(`${ogHeight}, ${newHeight}`);
|
||||
|
||||
expect(newHeight).to.be.lessThan(ogHeight as number);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should copy an added card on save", () => {
|
||||
cy.intercept({
|
||||
method: "PUT",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
In this directory we have a rich text editor based on tiptap. We implemented custom nodes - like CardEmbedNode, FlexContainer and ResizeNode.
|
||||
Also, we added custom drag'n'drop logic to handle these custom nodes. Every CardEmbedNode is wrapped into a ResizeNode to make it resizable vertically.
|
||||
|
||||
It is possible to drop a CardEmbedNode onto another CardEmbedNode, this leads into creating a FlexContainer that contains both CardEmbedNodes side-by-side. Result FlexContainer is wrapped into a ResizeNode.
|
||||
FlexContainer should not contain more than 3 child nodes like CardEmbedNode.
|
||||
@@ -143,7 +143,7 @@ export const DocumentHeader = ({
|
||||
aria-label={t`Show all comments`}
|
||||
data-hide-on-print
|
||||
>
|
||||
<Icon name="message" />
|
||||
<Icon name="comment" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
@@ -152,12 +152,11 @@ export const DocumentHeader = ({
|
||||
className={S.commentsIcon}
|
||||
component={Link}
|
||||
to={`/document/${document.id}/comments/all`}
|
||||
variant="subtle"
|
||||
size="md"
|
||||
aria-label={t`Show all comments`}
|
||||
data-hide-on-print
|
||||
>
|
||||
<Icon name="message" />
|
||||
<Icon name="comment" />
|
||||
</ActionIcon>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import cx from "classnames";
|
||||
import { type CSSProperties, forwardRef, useMemo } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Link } from "react-router";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Box, Button, Icon, rem } from "metabase/ui";
|
||||
import { ForwardRefLink } from "metabase/common/components/Link";
|
||||
import { Box, rem } from "metabase/ui";
|
||||
import type { CommentThread } from "metabase-enterprise/comments/types";
|
||||
import { CommentsButton } from "metabase-enterprise/rich_text_editing/tiptap/components/CommentsButton";
|
||||
|
||||
import S from "./CommentsMenu.module.css";
|
||||
|
||||
@@ -18,18 +18,23 @@ interface Props {
|
||||
threads: CommentThread[];
|
||||
}
|
||||
|
||||
export const getUnresolvedComments = (
|
||||
threads: CommentThread[],
|
||||
): CommentThread["comments"] => {
|
||||
return threads
|
||||
.filter((thread) => !thread.comments[0]?.is_resolved)
|
||||
.flatMap((thread) =>
|
||||
thread.comments.filter((comment) => !comment.deleted_at),
|
||||
);
|
||||
};
|
||||
|
||||
export const CommentsMenu = forwardRef<HTMLDivElement, Props>(
|
||||
function CommentsMenu(
|
||||
{ active, disabled, href, show, style, threads }: Props,
|
||||
ref,
|
||||
) {
|
||||
const unresolvedCommentsCount = useMemo(
|
||||
() =>
|
||||
threads
|
||||
.filter((thread) => !thread.comments[0]?.is_resolved)
|
||||
.flatMap((thread) =>
|
||||
thread.comments.filter((comment) => !comment.deleted_at),
|
||||
).length,
|
||||
() => getUnresolvedComments(threads).length,
|
||||
[threads],
|
||||
);
|
||||
const hasUnresolvedComments = unresolvedCommentsCount > 0;
|
||||
@@ -48,31 +53,18 @@ export const CommentsMenu = forwardRef<HTMLDivElement, Props>(
|
||||
ref={ref}
|
||||
style={style}
|
||||
>
|
||||
{disabled ? (
|
||||
<Button
|
||||
disabled
|
||||
leftSection={<Icon name="message" />}
|
||||
px="sm"
|
||||
size="xs"
|
||||
aria-label={t`Comments`}
|
||||
variant={active ? "filled" : "default"}
|
||||
>
|
||||
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
component={Link}
|
||||
// If no existing unresolved comments comments, add query param to auto-open new comment form
|
||||
to={hasUnresolvedComments ? href : `${href}?new=true`}
|
||||
leftSection={<Icon name="message" />}
|
||||
px="sm"
|
||||
size="xs"
|
||||
aria-label={t`Comments`}
|
||||
variant={active ? "filled" : "default"}
|
||||
>
|
||||
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
|
||||
</Button>
|
||||
)}
|
||||
<CommentsButton
|
||||
disabled={disabled}
|
||||
variant={active ? "filled" : "default"}
|
||||
unresolvedCommentsCount={unresolvedCommentsCount}
|
||||
{...(disabled
|
||||
? undefined
|
||||
: {
|
||||
component: ForwardRefLink,
|
||||
// If no existing unresolved comments comments, add query param to auto-open new comment form
|
||||
to: unresolvedCommentsCount > 0 ? href : `${href}?new=true`,
|
||||
})}
|
||||
/>
|
||||
</Box>,
|
||||
document.body,
|
||||
);
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
padding: 1.5rem 0 2rem 0;
|
||||
min-height: 100%;
|
||||
outline: none;
|
||||
max-width: 900px;
|
||||
max-width: 56.25rem;
|
||||
margin: 0 auto;
|
||||
color: var(--mb-color-text-dark);
|
||||
font-size: 1rem;
|
||||
|
||||
@@ -19,11 +19,16 @@ import type { DocumentsStoreState } from "metabase-enterprise/documents/types";
|
||||
import { isMetabotBlock } from "metabase-enterprise/documents/utils/editorNodeUtils";
|
||||
import { getMentionsCacheKey } from "metabase-enterprise/documents/utils/mentionsUtils";
|
||||
import { EditorBubbleMenu } from "metabase-enterprise/rich_text_editing/tiptap/components/EditorBubbleMenu/EditorBubbleMenu";
|
||||
import { CardEmbed } from "metabase-enterprise/rich_text_editing/tiptap/extensions/CardEmbed/CardEmbedNode";
|
||||
import {
|
||||
CardEmbed,
|
||||
DROP_ZONE_COLOR,
|
||||
} from "metabase-enterprise/rich_text_editing/tiptap/extensions/CardEmbed/CardEmbedNode";
|
||||
import { CommandExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Command/CommandExtension";
|
||||
import { CommandSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Command/CommandSuggestion";
|
||||
import { CustomStarterKit } from "metabase-enterprise/rich_text_editing/tiptap/extensions/CustomStarterKit/CustomStarterKit";
|
||||
import { DisableMetabotSidebar } from "metabase-enterprise/rich_text_editing/tiptap/extensions/DisableMetabotSidebar";
|
||||
import { FlexContainer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/FlexContainer/FlexContainer";
|
||||
import { HandleEditorDrop } from "metabase-enterprise/rich_text_editing/tiptap/extensions/HandleEditorDrop/HandleEditorDrop";
|
||||
import { MentionExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionExtension";
|
||||
import { MentionSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/Mention/MentionSuggestion";
|
||||
import {
|
||||
@@ -32,6 +37,7 @@ import {
|
||||
} from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotEmbed";
|
||||
import { MetabotMentionExtension } from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotMention/MetabotMentionExtension";
|
||||
import { MetabotMentionSuggestion } from "metabase-enterprise/rich_text_editing/tiptap/extensions/MetabotMention/MetabotSuggestion";
|
||||
import { ResizeNode } from "metabase-enterprise/rich_text_editing/tiptap/extensions/ResizeNode/ResizeNode";
|
||||
import { SmartLink } from "metabase-enterprise/rich_text_editing/tiptap/extensions/SmartLink/SmartLinkNode";
|
||||
import { createSuggestionRenderer } from "metabase-enterprise/rich_text_editing/tiptap/extensions/suggestionRenderer";
|
||||
|
||||
@@ -44,6 +50,7 @@ const BUBBLE_MENU_DISALLOWED_NODES: string[] = [
|
||||
MetabotNode.name,
|
||||
SmartLink.name,
|
||||
Image.name,
|
||||
FlexContainer.name,
|
||||
"codeBlock",
|
||||
];
|
||||
|
||||
@@ -96,7 +103,12 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [
|
||||
CustomStarterKit,
|
||||
CustomStarterKit.configure({
|
||||
dropcursor: {
|
||||
color: DROP_ZONE_COLOR,
|
||||
width: 2,
|
||||
},
|
||||
}),
|
||||
Image.configure({
|
||||
inline: false,
|
||||
HTMLAttributes: {
|
||||
@@ -118,6 +130,7 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
placeholder: t`Start writing, type "/" to list commands, or "@" to mention an item...`,
|
||||
}),
|
||||
CardEmbed,
|
||||
FlexContainer,
|
||||
MentionExtension.configure({
|
||||
suggestion: {
|
||||
allow: ({ state }) => !isMetabotBlock(state),
|
||||
@@ -140,6 +153,8 @@ export const Editor: React.FC<EditorProps> = ({
|
||||
render: createSuggestionRenderer(MetabotMentionSuggestion),
|
||||
},
|
||||
}),
|
||||
ResizeNode,
|
||||
HandleEditorDrop,
|
||||
],
|
||||
[siteUrl, getState],
|
||||
);
|
||||
|
||||
@@ -12,18 +12,28 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "cardEmbed",
|
||||
"attrs": {
|
||||
"id": 114,
|
||||
"name": null
|
||||
}
|
||||
"type": "resizeNode",
|
||||
"content": [
|
||||
{
|
||||
"type": "cardEmbed",
|
||||
"attrs": {
|
||||
"id": 114,
|
||||
"name": null
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "cardEmbed",
|
||||
"attrs": {
|
||||
"id": 115,
|
||||
"name": null
|
||||
}
|
||||
"type": "resizeNode",
|
||||
"content": [
|
||||
{
|
||||
"type": "cardEmbed",
|
||||
"attrs": {
|
||||
"id": 115,
|
||||
"name": null
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Button, type ButtonProps, Icon } from "metabase/ui";
|
||||
|
||||
interface Props extends ButtonProps {
|
||||
unresolvedCommentsCount: number;
|
||||
}
|
||||
|
||||
export const CommentsButton = ({
|
||||
unresolvedCommentsCount,
|
||||
...props
|
||||
}: Props) => {
|
||||
return (
|
||||
<Button
|
||||
aria-label={t`Comments`}
|
||||
bd={0}
|
||||
leftSection={
|
||||
<Icon name={unresolvedCommentsCount > 0 ? "comment" : "add_comment"} />
|
||||
}
|
||||
px="sm"
|
||||
size="xs"
|
||||
{...props}
|
||||
>
|
||||
{unresolvedCommentsCount > 0 ? unresolvedCommentsCount : null}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
.embedWrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cardEmbed {
|
||||
@@ -15,6 +16,8 @@
|
||||
transition: border-color 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--mb-color-background);
|
||||
}
|
||||
|
||||
.cardEmbed.selected {
|
||||
@@ -29,13 +32,18 @@
|
||||
-ms-user-select: none;
|
||||
}
|
||||
|
||||
.cardEmbed div[data-element-id="data-grid-measure-root"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.questionHeader {
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
|
||||
.questionResults {
|
||||
height: 400px;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
overflow: hidden; /* needed to constraint funnel chart */
|
||||
}
|
||||
|
||||
.loadingContainer {
|
||||
@@ -51,11 +59,19 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
overflow: hidden;
|
||||
flex-shrink: 1;
|
||||
}
|
||||
|
||||
.titleText {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.titleEditIcon {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.titleContainer:hover .titleEditIcon {
|
||||
@@ -63,6 +79,11 @@
|
||||
}
|
||||
|
||||
.menuButton {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: var(--mantine-radius-xs);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--mb-color-bg-light);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { autoUpdate, useFloating } from "@floating-ui/react";
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import {
|
||||
type NodeViewProps,
|
||||
@@ -7,8 +6,12 @@ import {
|
||||
} from "@tiptap/react";
|
||||
import cx from "classnames";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { push } from "react-router-redux";
|
||||
import { useMount, useUnmount } from "react-use";
|
||||
import { t } from "ttag";
|
||||
|
||||
import { Ellipsified } from "metabase/common/components/Ellipsified";
|
||||
import { ForwardRefLink } from "metabase/common/components/Link";
|
||||
import { QuestionPickerModal } from "metabase/common/components/Pickers/QuestionPicker/components/QuestionPickerModal";
|
||||
import type { QuestionPickerValueItem } from "metabase/common/components/Pickers/QuestionPicker/types";
|
||||
import { useDispatch, useSelector } from "metabase/lib/redux";
|
||||
@@ -22,7 +25,7 @@ import { useListCommentsQuery } from "metabase-enterprise/api";
|
||||
import { getTargetChildCommentThreads } from "metabase-enterprise/comments/utils";
|
||||
import { navigateToCardFromDocument } from "metabase-enterprise/documents/actions";
|
||||
import { trackDocumentReplaceCard } from "metabase-enterprise/documents/analytics";
|
||||
import { CommentsMenu } from "metabase-enterprise/documents/components/Editor/CommentsMenu";
|
||||
import { getUnresolvedComments } from "metabase-enterprise/documents/components/Editor/CommentsMenu";
|
||||
import { EDITOR_STYLE_BOUNDARY_CLASS } from "metabase-enterprise/documents/components/Editor/constants";
|
||||
import {
|
||||
loadMetadataForDocumentCard,
|
||||
@@ -40,6 +43,11 @@ import Question from "metabase-lib/v1/Question";
|
||||
import { getUrl } from "metabase-lib/v1/urls";
|
||||
import type { Card, CardDisplayType, Dataset } from "metabase-types/api";
|
||||
|
||||
import { CommentsButton } from "../../components/CommentsButton";
|
||||
import {
|
||||
cleanupFlexContainerNodes,
|
||||
findNodeParentAndPos,
|
||||
} from "../HandleEditorDrop/utils";
|
||||
import { createIdAttribute, createProseMirrorPlugin } from "../NodeIds";
|
||||
import CS from "../extensions.module.css";
|
||||
|
||||
@@ -47,6 +55,39 @@ import styles from "./CardEmbedNode.module.css";
|
||||
import { ModifyQuestionModal } from "./ModifyQuestionModal";
|
||||
import { NativeQueryModal } from "./NativeQueryModal";
|
||||
|
||||
export const DROP_ZONE_COLOR = "var(--mb-color-brand)";
|
||||
const DRAG_LEAVE_TIMEOUT = 300;
|
||||
|
||||
interface DropZoneProps {
|
||||
isOver: boolean;
|
||||
side: "left" | "right";
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DropZone = ({ isOver, side, disabled }: DropZoneProps) => {
|
||||
if (disabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: "0.25rem",
|
||||
[side]: "-0.625rem",
|
||||
borderRadius: "0.125rem",
|
||||
backgroundColor: isOver
|
||||
? "var(--mb-base-color-blue-30)"
|
||||
: "transparent",
|
||||
zIndex: 10,
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
function formatCardEmbed(attrs: CardEmbedAttributes): string {
|
||||
if (attrs.name) {
|
||||
return `{% card id=${attrs.id} name="${attrs.name}" %}`;
|
||||
@@ -77,6 +118,7 @@ export const CardEmbed: Node<{
|
||||
atom: true,
|
||||
draggable: true,
|
||||
selectable: true,
|
||||
disableDropCursor: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
@@ -150,9 +192,9 @@ export const CardEmbedComponent = memo(
|
||||
const { data: commentsData } = useListCommentsQuery(
|
||||
getListCommentsQuery(document),
|
||||
);
|
||||
|
||||
const comments = commentsData?.comments;
|
||||
const hasUnsavedChanges = useSelector(getHasUnsavedChanges);
|
||||
const [hovered, setHovered] = useState(false);
|
||||
const { _id } = node.attrs;
|
||||
const isOpen = childTargetId === _id;
|
||||
const isHovered = hoveredChildTargetId === _id;
|
||||
@@ -160,16 +202,19 @@ export const CardEmbedComponent = memo(
|
||||
() => getTargetChildCommentThreads(comments, _id),
|
||||
[comments, _id],
|
||||
);
|
||||
const { refs, floatingStyles } = useFloating({
|
||||
placement: "right-start",
|
||||
whileElementsMounted: autoUpdate,
|
||||
strategy: "fixed",
|
||||
});
|
||||
|
||||
const unresolvedCommentsCount = useMemo(
|
||||
() => getUnresolvedComments(threads).length,
|
||||
[threads],
|
||||
);
|
||||
const commentsPath = document
|
||||
? `/document/${document.id}/comments/${_id}`
|
||||
: "";
|
||||
const { id, name } = node.attrs;
|
||||
const dispatch = useDispatch();
|
||||
const canWrite = editor.options.editable;
|
||||
|
||||
const isMountedRef = useRef(false);
|
||||
|
||||
let embedIndex = -1;
|
||||
|
||||
if (editor && getPos) {
|
||||
@@ -198,6 +243,14 @@ export const CardEmbedComponent = memo(
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isModifyModalOpen, setIsModifyModalOpen] = useState(false);
|
||||
const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false);
|
||||
const [dragState, setDragState] = useState<{
|
||||
isDraggedOver: boolean;
|
||||
side: "left" | "right" | null;
|
||||
}>({ isDraggedOver: false, side: null });
|
||||
const draggedOverTimeoutRef = useRef<number | undefined>();
|
||||
const cardEmbedRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isBeingDragged = editor.view.draggingNode === node;
|
||||
|
||||
const displayName = name || card?.name;
|
||||
const isNativeQuestion = card?.dataset_query?.type === "native";
|
||||
@@ -209,6 +262,14 @@ export const CardEmbedComponent = memo(
|
||||
}
|
||||
}, [isEditingTitle]);
|
||||
|
||||
useMount(() => {
|
||||
isMountedRef.current = true;
|
||||
});
|
||||
|
||||
useUnmount(() => {
|
||||
isMountedRef.current = false;
|
||||
});
|
||||
|
||||
const handleTitleSave = () => {
|
||||
const trimmedTitle = editedTitle.trim();
|
||||
if (trimmedTitle && trimmedTitle !== card?.name) {
|
||||
@@ -279,9 +340,22 @@ export const CardEmbedComponent = memo(
|
||||
);
|
||||
|
||||
const handleRemoveNode = useCallback(() => {
|
||||
deleteNode();
|
||||
const nodeParentResult = findNodeParentAndPos(editor.view, node);
|
||||
|
||||
if (
|
||||
nodeParentResult &&
|
||||
nodeParentResult.parent.type.name === "resizeNode"
|
||||
) {
|
||||
const { parent, parentPos } = nodeParentResult;
|
||||
editor.view.dispatch(
|
||||
editor.state.tr.delete(parentPos, parentPos + parent.nodeSize),
|
||||
);
|
||||
} else {
|
||||
deleteNode();
|
||||
}
|
||||
cleanupFlexContainerNodes(editor.view);
|
||||
editor.chain().focus();
|
||||
}, [deleteNode, editor]);
|
||||
}, [deleteNode, editor, node]);
|
||||
|
||||
// Handle drill-through navigation
|
||||
const handleChangeCardAndRun = useCallback(
|
||||
@@ -322,6 +396,68 @@ export const CardEmbedComponent = memo(
|
||||
[dispatch, metadata, document],
|
||||
);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const draggingNode = editor.view.draggingNode;
|
||||
if (
|
||||
draggingNode &&
|
||||
draggingNode.type.name === "cardEmbed" &&
|
||||
cardEmbedRef.current
|
||||
) {
|
||||
// Check if this cardEmbed is in a flexContainer that already has 3 children
|
||||
const pos = getPos();
|
||||
if (pos) {
|
||||
const resolvedPos = editor.state.doc.resolve(pos);
|
||||
const { parent } = resolvedPos;
|
||||
|
||||
if (
|
||||
parent.type.name === "flexContainer" &&
|
||||
parent.content.childCount >= 3
|
||||
) {
|
||||
let containsDraggedNode = false;
|
||||
|
||||
for (let i = 0; i < parent.content.childCount; i++) {
|
||||
const child = parent.child(i);
|
||||
containsDraggedNode =
|
||||
containsDraggedNode || child === draggingNode;
|
||||
}
|
||||
|
||||
if (!containsDraggedNode) {
|
||||
// Don't show drop zones if flexContainer is already at max capacity
|
||||
setDragState({ isDraggedOver: false, side: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rect = cardEmbedRef.current.getBoundingClientRect();
|
||||
const relativeX = e.clientX - rect.left;
|
||||
const nodeWidth = rect.width;
|
||||
|
||||
// Determine which side based on cursor position
|
||||
let side: "left" | "right" | null = null;
|
||||
if (relativeX < nodeWidth * 0.5) {
|
||||
side = "left";
|
||||
} else if (relativeX >= nodeWidth * 0.5) {
|
||||
side = "right";
|
||||
}
|
||||
|
||||
setDragState({ isDraggedOver: true, side });
|
||||
|
||||
window.clearTimeout(draggedOverTimeoutRef.current);
|
||||
|
||||
draggedOverTimeoutRef.current = window.setTimeout(() => {
|
||||
if (isMountedRef.current) {
|
||||
setDragState({ isDraggedOver: false, side: null });
|
||||
}
|
||||
}, DRAG_LEAVE_TIMEOUT);
|
||||
}
|
||||
},
|
||||
[editor.view.draggingNode, getPos, editor.state.doc],
|
||||
);
|
||||
|
||||
if (isLoading && !card) {
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
@@ -329,6 +465,7 @@ export const CardEmbedComponent = memo(
|
||||
className={cx(styles.embedWrapper, CS.root, {
|
||||
[CS.open]: isOpen || isHovered,
|
||||
})}
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
@@ -362,6 +499,7 @@ export const CardEmbedComponent = memo(
|
||||
[CS.open]: isOpen || isHovered,
|
||||
})}
|
||||
data-testid="document-card-embed"
|
||||
style={{ position: "relative" }}
|
||||
>
|
||||
<Box
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
@@ -390,11 +528,26 @@ export const CardEmbedComponent = memo(
|
||||
[CS.open]: isOpen || isHovered,
|
||||
})}
|
||||
data-testid="document-card-embed"
|
||||
ref={refs.setReference}
|
||||
onMouseOver={() => setHovered(true)}
|
||||
onMouseOut={() => setHovered(false)}
|
||||
data-drag-handle
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={() => setDragState({ isDraggedOver: false, side: null })}
|
||||
>
|
||||
{canWrite && id && (
|
||||
<>
|
||||
<DropZone
|
||||
isOver={dragState.isDraggedOver && dragState.side === "left"}
|
||||
side="left"
|
||||
disabled={isBeingDragged}
|
||||
/>
|
||||
<DropZone
|
||||
isOver={dragState.isDraggedOver && dragState.side === "right"}
|
||||
side="right"
|
||||
disabled={isBeingDragged}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<Box
|
||||
ref={cardEmbedRef}
|
||||
className={cx(styles.cardEmbed, EDITOR_STYLE_BOUNDARY_CLASS, {
|
||||
[styles.selected]: selected,
|
||||
})}
|
||||
@@ -431,15 +584,20 @@ export const CardEmbedComponent = memo(
|
||||
/>
|
||||
) : (
|
||||
<Box className={styles.titleContainer}>
|
||||
<Text
|
||||
size="md"
|
||||
color="text-dark"
|
||||
fw={700}
|
||||
onClick={handleTitleClick}
|
||||
c="pointer"
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
<Ellipsified lines={1} tooltip={displayName}>
|
||||
<Text
|
||||
className={styles.titleText}
|
||||
data-testid="card-embed-title"
|
||||
size="md"
|
||||
color="text-dark"
|
||||
fw={700}
|
||||
c="pointer"
|
||||
truncate="end"
|
||||
onClick={handleTitleClick}
|
||||
>
|
||||
{displayName}
|
||||
</Text>
|
||||
</Ellipsified>
|
||||
{canWrite && (
|
||||
<Icon
|
||||
name="pencil"
|
||||
@@ -456,13 +614,26 @@ export const CardEmbedComponent = memo(
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{!isEditingTitle &&
|
||||
document &&
|
||||
unresolvedCommentsCount > 0 && (
|
||||
<Box data-hide-on-print my="-sm" ml="auto">
|
||||
<CommentsButton
|
||||
// don't use Link component here since it messes with tiptap's link handling
|
||||
disabled={hasUnsavedChanges || !commentsPath}
|
||||
variant={isOpen ? "filled" : "default"}
|
||||
unresolvedCommentsCount={unresolvedCommentsCount}
|
||||
onClick={() => {
|
||||
dispatch(push(commentsPath));
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
{!isEditingTitle && (
|
||||
<Menu withinPortal position="bottom-end" data-hide-on-print>
|
||||
<Menu.Target>
|
||||
<Flex
|
||||
component="button"
|
||||
bg="transparent"
|
||||
c="pointer"
|
||||
p="0.25rem"
|
||||
align="center"
|
||||
justify="center"
|
||||
@@ -477,6 +648,26 @@ export const CardEmbedComponent = memo(
|
||||
</Flex>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
<Menu.Item
|
||||
leftSection={<Icon name="add_comment" size={14} />}
|
||||
component={ForwardRefLink}
|
||||
to={
|
||||
// If no existing unresolved comments comments, add query param to auto-open new comment form
|
||||
unresolvedCommentsCount > 0
|
||||
? commentsPath
|
||||
: `${commentsPath}?new=true`
|
||||
}
|
||||
// actually stop the navigation from happening
|
||||
onClick={(e) => {
|
||||
if (!commentsPath || hasUnsavedChanges) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
// purely for presentation
|
||||
disabled={!commentsPath || hasUnsavedChanges}
|
||||
>
|
||||
{t`Comment`}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={handleEditVisualizationSettings}
|
||||
disabled={!canWrite}
|
||||
@@ -578,18 +769,6 @@ export const CardEmbedComponent = memo(
|
||||
/>
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
|
||||
{document && (
|
||||
<CommentsMenu
|
||||
active={isOpen}
|
||||
disabled={hasUnsavedChanges}
|
||||
href={`/document/${document.id}/comments/${_id}`}
|
||||
ref={refs.setFloating}
|
||||
show={isOpen || hovered}
|
||||
threads={threads}
|
||||
style={floatingStyles}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
} from "metabase-enterprise/documents/analytics";
|
||||
import type { Document } from "metabase-types/api";
|
||||
|
||||
import { wrapCardEmbed } from "../shared/layout";
|
||||
|
||||
export interface CommandOptions {
|
||||
suggestion: Partial<SuggestionOptions>;
|
||||
}
|
||||
@@ -65,12 +67,14 @@ export const CommandExtension = Extension.create<CommandOptions>({
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertContent({
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: props.entityId,
|
||||
},
|
||||
})
|
||||
.insertContent(
|
||||
wrapCardEmbed({
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: props.entityId,
|
||||
},
|
||||
}),
|
||||
)
|
||||
.setTextSelection(range.from + 1)
|
||||
.run();
|
||||
if (props.document) {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { AnyExtension } from "@tiptap/core";
|
||||
import {
|
||||
type AnyExtension,
|
||||
callOrReturn,
|
||||
getExtensionField,
|
||||
} from "@tiptap/core";
|
||||
import { Blockquote } from "@tiptap/extension-blockquote";
|
||||
import { Bold } from "@tiptap/extension-bold";
|
||||
import { BulletList } from "@tiptap/extension-bullet-list";
|
||||
@@ -15,6 +19,13 @@ import { CustomHeading } from "../Heading";
|
||||
import { CustomOrderedList } from "../OrderedList";
|
||||
import { CustomParagraph } from "../Paragraph";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
// This adds a new configuration option to the NodeConfig
|
||||
interface NodeConfig {
|
||||
disableDropCursor?: boolean | null;
|
||||
}
|
||||
}
|
||||
|
||||
const CustomBold = Bold.extend({
|
||||
addKeyboardShortcuts() {
|
||||
// "Mod-b" (sans shift key) is fine, "Mod-B" (with shift key) should not be hijacked
|
||||
@@ -111,4 +122,20 @@ export const CustomStarterKit = StarterKit.extend<CustomStarterKitOptions>({
|
||||
|
||||
return extensions;
|
||||
},
|
||||
|
||||
// fixup for tiptap's wrapper over https://github.com/ProseMirror/prosemirror-dropcursor to enable "disableDropCursor" setting
|
||||
extendNodeSchema(extension) {
|
||||
const context = {
|
||||
name: extension.name,
|
||||
options: extension.options,
|
||||
storage: extension.storage,
|
||||
};
|
||||
|
||||
return {
|
||||
disableDropCursor:
|
||||
callOrReturn(
|
||||
getExtensionField(extension, "disableDropCursor", context),
|
||||
) ?? null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
.flexContainer {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--mantine-radius-md);
|
||||
position: relative;
|
||||
max-width: 900px;
|
||||
height: 100%;
|
||||
caret-color: transparent;
|
||||
}
|
||||
|
||||
.flexContainer.selected {
|
||||
outline: 2px solid var(--mb-color-brand);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.flexContent > :global(div[data-node-view-wrapper]) {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
grid-template-columns: var(--mb-card-container-col-widths);
|
||||
}
|
||||
|
||||
.flexContent {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.flexContent :global(.node-cardEmbed) :global(.editor-style-boundary) {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flexContent :global(.node-cardEmbed) {
|
||||
height: 100%;
|
||||
padding-left: 0.5rem;
|
||||
padding-right: 0.5rem;
|
||||
caret-color: auto;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.flexContent :global(.node-cardEmbed):first-child {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.flexContent :global(.node-cardEmbed):last-child {
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
/* Resize handles */
|
||||
.resizeHandle {
|
||||
position: absolute;
|
||||
top: calc(50% - 2rem);
|
||||
width: 0.25rem;
|
||||
height: 4rem;
|
||||
margin-left: -0.125rem;
|
||||
cursor: col-resize;
|
||||
background-color: transparent;
|
||||
border-radius: 100px;
|
||||
opacity: 0;
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
background-color 0.2s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.flexContainer:hover .resizeHandle {
|
||||
opacity: 1;
|
||||
background-color: var(--mb-color-text-secondary-inverse);
|
||||
}
|
||||
|
||||
.flexContainer.resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.flexContainer.resizing .resizeHandle {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { Node, mergeAttributes } from "@tiptap/core";
|
||||
import {
|
||||
NodeViewContent,
|
||||
type NodeViewProps,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from "@tiptap/react";
|
||||
import cx from "classnames";
|
||||
import type React from "react";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Box } from "metabase/ui";
|
||||
import { getCommentSidebarOpen } from "metabase-enterprise/documents/selectors";
|
||||
|
||||
import styles from "./FlexContainer.module.css";
|
||||
|
||||
const COLUMN_MIN_WIDTH = 200;
|
||||
|
||||
export interface FlexContainerAttributes {
|
||||
class?: string;
|
||||
columnWidths?: number[]; // Array of width percentages for each column
|
||||
}
|
||||
|
||||
export const FlexContainer: Node<{
|
||||
HTMLAttributes: FlexContainerAttributes;
|
||||
}> = Node.create({
|
||||
name: "flexContainer",
|
||||
group: "block",
|
||||
content: "cardEmbed{1,3}", // Contains 1-3 CardEmbed nodes only
|
||||
defining: true,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
disableDropCursor: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
columnWidths: {
|
||||
default: null,
|
||||
parseHTML: (element) => {
|
||||
const widths = element.getAttribute("data-column-widths");
|
||||
return widths ? JSON.parse(widths) : null;
|
||||
},
|
||||
renderHTML: (attributes) => {
|
||||
return attributes.columnWidths
|
||||
? {
|
||||
"data-column-widths": JSON.stringify(attributes.columnWidths),
|
||||
}
|
||||
: {};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${FlexContainer.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": FlexContainer.name,
|
||||
class: "flex-container",
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(FlexContainerComponent);
|
||||
},
|
||||
});
|
||||
|
||||
const FlexContainerComponent: React.FC<NodeViewProps> = ({
|
||||
node,
|
||||
updateAttributes,
|
||||
selected,
|
||||
}) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
|
||||
const shouldDisableResizing = useSelector(getCommentSidebarOpen);
|
||||
|
||||
// Get column count and current widths
|
||||
const columnCount = node.content.childCount;
|
||||
const currentWidths = node.attrs.columnWidths;
|
||||
|
||||
// Calculate default equal widths if not set
|
||||
const columnWidths = useMemo(() => {
|
||||
if ((currentWidths || []).length === columnCount) {
|
||||
return currentWidths;
|
||||
}
|
||||
// Default to equal widths
|
||||
return Array(columnCount).fill(100 / columnCount) as number[];
|
||||
}, [currentWidths, columnCount]);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(handleIndex: number, e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setIsResizing(true);
|
||||
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const startX = e.clientX;
|
||||
const startWidths = [...columnWidths];
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
const deltaX = e.clientX - startX;
|
||||
const containerWidth = containerRect.width;
|
||||
const deltaPercent = (deltaX / containerWidth) * 100;
|
||||
|
||||
const newWidths = [...startWidths];
|
||||
|
||||
// Adjust the current column and next column
|
||||
const leftColumnIndex = handleIndex;
|
||||
const rightColumnIndex = handleIndex + 1;
|
||||
|
||||
// Calculate minimum width percentage based on 200px minimum
|
||||
const minWidthPercent = (COLUMN_MIN_WIDTH / containerWidth) * 100;
|
||||
|
||||
// Calculate new widths ensuring minimum column width
|
||||
let leftNewWidth = Math.max(
|
||||
minWidthPercent,
|
||||
startWidths[leftColumnIndex] + deltaPercent,
|
||||
);
|
||||
let rightNewWidth = Math.max(
|
||||
minWidthPercent,
|
||||
startWidths[rightColumnIndex] - deltaPercent,
|
||||
);
|
||||
|
||||
// Ensure the two adjusted columns don't exceed their total original width
|
||||
const originalTotal =
|
||||
startWidths[leftColumnIndex] + startWidths[rightColumnIndex];
|
||||
const newTotal = leftNewWidth + rightNewWidth;
|
||||
|
||||
if (newTotal !== originalTotal) {
|
||||
// Adjust proportionally to maintain the original total
|
||||
const ratio = originalTotal / newTotal;
|
||||
leftNewWidth *= ratio;
|
||||
rightNewWidth *= ratio;
|
||||
|
||||
// Re-check minimum constraints after adjustment
|
||||
if (
|
||||
leftNewWidth < minWidthPercent ||
|
||||
rightNewWidth < minWidthPercent
|
||||
) {
|
||||
return; // Don't update if constraints would be violated
|
||||
}
|
||||
}
|
||||
|
||||
// Only apply if both columns meet minimum requirements
|
||||
if (
|
||||
leftNewWidth >= minWidthPercent &&
|
||||
rightNewWidth >= minWidthPercent
|
||||
) {
|
||||
newWidths[leftColumnIndex] = leftNewWidth;
|
||||
newWidths[rightColumnIndex] = rightNewWidth;
|
||||
|
||||
// Verify total width is still 100%
|
||||
const totalWidth = newWidths.reduce((sum, width) => sum + width, 0);
|
||||
if (Math.abs(totalWidth - 100) > 0.01) {
|
||||
// Normalize to ensure exact 100%
|
||||
const normalizedWidths = newWidths.map(
|
||||
(width) => (width / totalWidth) * 100,
|
||||
);
|
||||
updateAttributes({ columnWidths: normalizedWidths });
|
||||
} else {
|
||||
updateAttributes({ columnWidths: newWidths });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setIsResizing(false);
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
},
|
||||
[columnWidths, updateAttributes],
|
||||
);
|
||||
|
||||
const renderResizeHandles = () => {
|
||||
if (columnCount <= 1 || shouldDisableResizing) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handles = [];
|
||||
for (let i = 0; i < columnCount - 1; i++) {
|
||||
// Calculate the cumulative width of columns before this handle
|
||||
const leftWidth = columnWidths
|
||||
.slice(0, i + 1)
|
||||
.reduce((sum: number, width: number) => sum + width, 0);
|
||||
|
||||
handles.push(
|
||||
<div
|
||||
key={i}
|
||||
className={styles.resizeHandle}
|
||||
style={{
|
||||
left: `${leftWidth}%`,
|
||||
}}
|
||||
contentEditable={false}
|
||||
onMouseDown={(e) => handleMouseDown(i, e)}
|
||||
data-testid="flex-container-drag-handle"
|
||||
/>,
|
||||
);
|
||||
}
|
||||
return handles;
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
className={cx(styles.flexContainer, {
|
||||
[styles.selected]: selected,
|
||||
[styles.resizing]: isResizing,
|
||||
})}
|
||||
data-type="flexContainer"
|
||||
>
|
||||
<Box h="100%" ref={containerRef}>
|
||||
<NodeViewContent
|
||||
className={styles.flexContent}
|
||||
style={
|
||||
{
|
||||
"--mb-card-container-col-widths": columnWidths
|
||||
.map(
|
||||
(width: number) => `minmax(${COLUMN_MIN_WIDTH}px, ${width}%)`,
|
||||
)
|
||||
.join(" "),
|
||||
} as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
{renderResizeHandles()}
|
||||
</Box>
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
FlexContainerComponent.displayName = "FlexContainerComponent";
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { Node } from "@tiptap/pm/model";
|
||||
|
||||
import {
|
||||
RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
RESIZE_NODE_MIN_HEIGHT,
|
||||
} from "../../ResizeNode/ResizeNode";
|
||||
import {
|
||||
type DroppedCardEmbedNodeData,
|
||||
cleanupFlexContainerNodes,
|
||||
getCardEmbedDropSide,
|
||||
} from "../utils";
|
||||
|
||||
export const handleCardDropOnCard = (
|
||||
payload: DroppedCardEmbedNodeData,
|
||||
targetCardEmbed: Node,
|
||||
) => {
|
||||
const {
|
||||
cardEmbedNode,
|
||||
view,
|
||||
event: e,
|
||||
originalParent,
|
||||
originalPos,
|
||||
dropToParent,
|
||||
dropToParentPos,
|
||||
cameFromFlexContainer,
|
||||
} = payload;
|
||||
|
||||
if (targetCardEmbed === cardEmbedNode) {
|
||||
return true; // Prevent dropping on itself
|
||||
}
|
||||
|
||||
const dropSide = getCardEmbedDropSide(e);
|
||||
// Create new FlexContainer when dropping on a standalone CardEmbed
|
||||
const children =
|
||||
dropSide === "left"
|
||||
? [cardEmbedNode.copy(), targetCardEmbed.copy()]
|
||||
: [targetCardEmbed.copy(), cardEmbedNode.copy()];
|
||||
|
||||
const flexContainer = view.state.schema.nodes.flexContainer.create(
|
||||
{},
|
||||
children,
|
||||
);
|
||||
|
||||
// Wrap the flexContainer in a resizeNode
|
||||
const wrapper = view.state.schema.nodes.resizeNode.create(
|
||||
{
|
||||
height: RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
minHeight: RESIZE_NODE_MIN_HEIGHT,
|
||||
},
|
||||
[flexContainer],
|
||||
);
|
||||
|
||||
// Determine what to replace - if target is wrapped in resizeNode, replace the wrapper
|
||||
let replacePos = originalPos;
|
||||
let replaceSize = cardEmbedNode.nodeSize;
|
||||
if (dropToParent.type.name === "resizeNode") {
|
||||
// Target is wrapped, replace the entire resizeNode
|
||||
replacePos = dropToParentPos.before();
|
||||
replaceSize = dropToParent.nodeSize;
|
||||
}
|
||||
|
||||
// Create a single transaction for both operations to avoid position shifting
|
||||
const tr = view.state.tr;
|
||||
|
||||
// First, find and remove the dropped node from its original position
|
||||
const nodeToRemove =
|
||||
originalParent.type.name === "resizeNode" ? originalParent : cardEmbedNode;
|
||||
let removalHandled = false;
|
||||
let removePos: number | null = null;
|
||||
view.state.doc.descendants((node, nodePos) => {
|
||||
if (node === nodeToRemove && !removalHandled) {
|
||||
tr.delete(nodePos, nodePos + node.nodeSize);
|
||||
removalHandled = true;
|
||||
removePos = nodePos;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Then replace the target with the new FlexContainer
|
||||
// Need to recalculate positions after the removal
|
||||
let adjustedReplacePos = replacePos;
|
||||
if (removalHandled && removePos !== null && removePos < replacePos) {
|
||||
// If we removed something before the replace position, adjust it
|
||||
const removedSize = nodeToRemove.nodeSize;
|
||||
adjustedReplacePos = replacePos - removedSize;
|
||||
}
|
||||
|
||||
tr.replaceWith(adjustedReplacePos, adjustedReplacePos + replaceSize, wrapper);
|
||||
|
||||
view.dispatch(tr);
|
||||
|
||||
if (cameFromFlexContainer) {
|
||||
cleanupFlexContainerNodes(view);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
RESIZE_NODE_MIN_HEIGHT,
|
||||
} from "../../ResizeNode/ResizeNode";
|
||||
import { type DroppedCardEmbedNodeData, extractCardEmbed } from "../utils";
|
||||
|
||||
export const handleCardDropOnDocument = (payload: DroppedCardEmbedNodeData) => {
|
||||
const {
|
||||
originalParent,
|
||||
originalPos,
|
||||
view,
|
||||
cameFromFlexContainer,
|
||||
event: e,
|
||||
cardEmbedNode,
|
||||
dropPos,
|
||||
} = payload;
|
||||
if (cameFromFlexContainer) {
|
||||
// Handle moving from FlexContainer to document
|
||||
if (originalPos !== null && originalParent?.type.name === "flexContainer") {
|
||||
const tr = view.state.tr;
|
||||
|
||||
// First, update/remove from FlexContainer (do this BEFORE insertion to avoid position shifting)
|
||||
const flexContainerPos = view.state.doc.resolve(originalPos).before();
|
||||
const flexContainer = originalParent;
|
||||
|
||||
// Remove the card from the FlexContainer
|
||||
const newChildren = [];
|
||||
const sourceIndex = view.state.doc.resolve(originalPos).index();
|
||||
|
||||
for (let i = 0; i < flexContainer.childCount; i++) {
|
||||
if (i !== sourceIndex) {
|
||||
const child = flexContainer.child(i);
|
||||
newChildren.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
if (newChildren.length === 1) {
|
||||
// If only one card left, unwrap it from FlexContainer and wrap in resizeNode
|
||||
const remainingChild = newChildren[0];
|
||||
const remainingCardEmbed = extractCardEmbed(remainingChild);
|
||||
|
||||
if (remainingCardEmbed) {
|
||||
const wrappedRemainingCard =
|
||||
view.state.schema.nodes.resizeNode.create(
|
||||
{
|
||||
height: RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
minHeight: RESIZE_NODE_MIN_HEIGHT,
|
||||
},
|
||||
[remainingCardEmbed],
|
||||
);
|
||||
|
||||
// Replace the entire FlexContainer (and its resizeNode wrapper if it exists)
|
||||
const containerResolvedPos = view.state.doc.resolve(flexContainerPos);
|
||||
const containerParent = containerResolvedPos.parent;
|
||||
|
||||
if (containerParent.type.name === "resizeNode") {
|
||||
// FlexContainer is wrapped in resizeNode, replace the entire wrapper
|
||||
const wrapperPos = containerResolvedPos.before();
|
||||
tr.replaceWith(
|
||||
wrapperPos,
|
||||
wrapperPos + containerParent.nodeSize,
|
||||
wrappedRemainingCard,
|
||||
);
|
||||
} else {
|
||||
// FlexContainer is not wrapped, just replace it
|
||||
tr.replaceWith(
|
||||
flexContainerPos,
|
||||
flexContainerPos + flexContainer.nodeSize,
|
||||
wrappedRemainingCard,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (newChildren.length > 1) {
|
||||
// Multiple cards left, keep as FlexContainer
|
||||
const newFlexContainer = view.state.schema.nodes.flexContainer.create(
|
||||
flexContainer.attrs,
|
||||
newChildren,
|
||||
);
|
||||
tr.replaceWith(
|
||||
flexContainerPos,
|
||||
flexContainerPos + flexContainer.nodeSize,
|
||||
newFlexContainer,
|
||||
);
|
||||
} else {
|
||||
// No cards left, remove the entire FlexContainer and its wrapper
|
||||
const containerResolvedPos = view.state.doc.resolve(flexContainerPos);
|
||||
const containerParent = containerResolvedPos.parent;
|
||||
|
||||
if (containerParent.type.name === "resizeNode") {
|
||||
// Remove the entire resizeNode wrapper
|
||||
const wrapperPos = containerResolvedPos.before();
|
||||
tr.delete(wrapperPos, wrapperPos + containerParent.nodeSize);
|
||||
} else {
|
||||
// Remove just the FlexContainer
|
||||
tr.delete(
|
||||
flexContainerPos,
|
||||
flexContainerPos + flexContainer.nodeSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Now insert the wrapped node at drop position (after FlexContainer operations)
|
||||
const wrappedNode = view.state.schema.nodes.resizeNode.create(
|
||||
{
|
||||
height: RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
minHeight: RESIZE_NODE_MIN_HEIGHT,
|
||||
},
|
||||
[cardEmbedNode],
|
||||
);
|
||||
|
||||
// Calculate adjusted drop position if the FlexContainer operations affected positions
|
||||
let adjustedDropPos = dropPos;
|
||||
if (dropPos > flexContainerPos) {
|
||||
// If dropping after the FlexContainer, positions might have shifted
|
||||
// This is complex to calculate, so let's recalculate
|
||||
const coords = view.posAtCoords({
|
||||
left: e.clientX,
|
||||
top: e.clientY,
|
||||
});
|
||||
if (coords) {
|
||||
adjustedDropPos = tr.doc.resolve(coords.pos).pos;
|
||||
}
|
||||
}
|
||||
|
||||
tr.insert(adjustedDropPos, wrappedNode);
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
// Handle general case: cardEmbed being moved within document or from resizeNode
|
||||
// Always ensure cardEmbed is wrapped in resizeNode when dropped in document
|
||||
const tr = view.state.tr;
|
||||
|
||||
// Wrap the cardEmbed in a resizeNode
|
||||
const wrappedNode = view.state.schema.nodes.resizeNode.create(
|
||||
{
|
||||
height: RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
minHeight: RESIZE_NODE_MIN_HEIGHT,
|
||||
},
|
||||
[cardEmbedNode],
|
||||
);
|
||||
|
||||
// Remove the original node from its position
|
||||
if (originalPos !== null) {
|
||||
// Find the actual node to remove (could be the cardEmbed itself or its resizeNode wrapper)
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (
|
||||
node === originalParent ||
|
||||
extractCardEmbed(node) === cardEmbedNode
|
||||
) {
|
||||
// Remove the node (whether it's a standalone cardEmbed or wrapped in resizeNode)
|
||||
tr.delete(pos, pos + node.nodeSize);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Insert wrapped node at drop position
|
||||
tr.insert(dropPos, wrappedNode);
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,362 @@
|
||||
import type { Node } from "@tiptap/pm/model";
|
||||
|
||||
import {
|
||||
RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
RESIZE_NODE_MIN_HEIGHT,
|
||||
} from "../../ResizeNode/ResizeNode";
|
||||
import {
|
||||
type DroppedCardEmbedNodeData,
|
||||
extractCardEmbed,
|
||||
getCardEmbedDropSide,
|
||||
getTargetCardEmbedNode,
|
||||
} from "../utils";
|
||||
|
||||
export const handleCardDropToFlexContainer = (
|
||||
payload: DroppedCardEmbedNodeData,
|
||||
) => {
|
||||
const {
|
||||
cameFromFlexContainer,
|
||||
originalParent,
|
||||
dropToParent,
|
||||
cardEmbedNode,
|
||||
view,
|
||||
originalPos,
|
||||
event: e,
|
||||
dropToParentPos,
|
||||
} = payload;
|
||||
|
||||
// Handle dropping within the same FlexContainer - columns reordering
|
||||
if (cameFromFlexContainer && originalParent === dropToParent) {
|
||||
const targetCardEmbedNode = getTargetCardEmbedNode(e, view);
|
||||
|
||||
if (!targetCardEmbedNode) {
|
||||
return;
|
||||
}
|
||||
if (targetCardEmbedNode === cardEmbedNode) {
|
||||
return true; // Prevent dropping on itself
|
||||
}
|
||||
|
||||
// Moving within the same FlexContainer - reorder cards
|
||||
const tr = view.state.tr;
|
||||
|
||||
// Find the source index
|
||||
const sourceIndex = view.state.doc.resolve(originalPos).index();
|
||||
|
||||
// Calculate the intended insertion index based on drop position
|
||||
let targetIndex = dropToParent.content.content.indexOf(targetCardEmbedNode);
|
||||
const dropSide = getCardEmbedDropSide(e);
|
||||
|
||||
if (dropSide === "left" && targetIndex > 1) {
|
||||
targetIndex--;
|
||||
} else if (dropSide === "right") {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
// Create array of all children except the dragged one
|
||||
const allChildren = [];
|
||||
for (let i = 0; i < dropToParent.childCount; i++) {
|
||||
if (i !== sourceIndex) {
|
||||
const child = dropToParent.child(i);
|
||||
const childCardEmbed = extractCardEmbed(child);
|
||||
allChildren.push(childCardEmbed ? childCardEmbed : child);
|
||||
}
|
||||
}
|
||||
|
||||
// Adjust targetIndex for the removed element
|
||||
// Since we removed the source element, we need to adjust the target index
|
||||
let adjustedTargetIndex;
|
||||
if (targetIndex > sourceIndex) {
|
||||
// Moving right: the target index shifts down by 1 because we removed an element before it
|
||||
adjustedTargetIndex = targetIndex - 1;
|
||||
} else {
|
||||
// Moving left: target index stays the same
|
||||
adjustedTargetIndex = targetIndex;
|
||||
}
|
||||
|
||||
// But for moving from left (0) to right of right card, we actually want to append
|
||||
// In our case: sourceIndex=0, targetIndex=1, so we want index 1 in the reduced array
|
||||
// which means "after the remaining element"
|
||||
if (sourceIndex === 0 && targetIndex === 1 && allChildren.length === 1) {
|
||||
adjustedTargetIndex = 1; // Insert after the remaining element
|
||||
}
|
||||
|
||||
// Insert the dragged element at the correct position
|
||||
allChildren.splice(adjustedTargetIndex, 0, cardEmbedNode);
|
||||
|
||||
const newContainer = view.state.schema.nodes.flexContainer.create(
|
||||
dropToParent.attrs,
|
||||
allChildren,
|
||||
);
|
||||
|
||||
const containerPos = dropToParentPos.before();
|
||||
|
||||
tr.replaceWith(
|
||||
containerPos,
|
||||
containerPos + dropToParent.nodeSize,
|
||||
newContainer,
|
||||
);
|
||||
|
||||
view.dispatch(tr);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle dropping from one FlexContainer to another FlexContainer
|
||||
if (cameFromFlexContainer && originalParent !== dropToParent) {
|
||||
const targetFlexContainer = dropToParent;
|
||||
|
||||
// Check if target flexContainer already has 3 or more cards
|
||||
if (targetFlexContainer.content.childCount >= 3) {
|
||||
return true; // Don't allow dropping if target already has 3 cards
|
||||
}
|
||||
|
||||
const targetCardEmbedNode = getTargetCardEmbedNode(e, view);
|
||||
if (!targetCardEmbedNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the intended insertion index based on drop position
|
||||
let targetIndex = dropToParent.content.content.indexOf(targetCardEmbedNode);
|
||||
const dropSide = getCardEmbedDropSide(e);
|
||||
|
||||
if (dropSide === "left" && targetIndex > 1) {
|
||||
targetIndex--;
|
||||
} else if (dropSide === "right") {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
// Create transaction
|
||||
const tr = view.state.tr;
|
||||
|
||||
// First, remove the card from the source flexContainer
|
||||
const sourceFlexContainer = originalParent;
|
||||
const sourceIndex = view.state.doc.resolve(originalPos).index();
|
||||
|
||||
const sourceNewChildren = [];
|
||||
for (let i = 0; i < sourceFlexContainer.childCount; i++) {
|
||||
if (i !== sourceIndex) {
|
||||
const child = sourceFlexContainer.child(i);
|
||||
sourceNewChildren.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
// Find source flexContainer position
|
||||
let sourceFlexContainerPos: number | null = null;
|
||||
view.state.doc.descendants((node, nodePos) => {
|
||||
if (node === sourceFlexContainer) {
|
||||
sourceFlexContainerPos = nodePos;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (sourceFlexContainerPos === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update source flexContainer or remove it if empty
|
||||
if (sourceNewChildren.length === 1) {
|
||||
// Only one card left in source - unwrap it from flexContainer
|
||||
const remainingChild = sourceNewChildren[0];
|
||||
const remainingCardEmbed = extractCardEmbed(remainingChild);
|
||||
|
||||
if (remainingCardEmbed) {
|
||||
const wrappedRemainingCard = view.state.schema.nodes.resizeNode.create(
|
||||
{
|
||||
height: RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
minHeight: RESIZE_NODE_MIN_HEIGHT,
|
||||
},
|
||||
[remainingCardEmbed],
|
||||
);
|
||||
|
||||
// Replace the source flexContainer with unwrapped card
|
||||
const sourceContainerResolvedPos = view.state.doc.resolve(
|
||||
sourceFlexContainerPos,
|
||||
);
|
||||
const sourceContainerParent = sourceContainerResolvedPos.parent;
|
||||
|
||||
if (sourceContainerParent.type.name === "resizeNode") {
|
||||
// FlexContainer is wrapped in resizeNode, replace the entire wrapper
|
||||
const sourceWrapperPos = sourceContainerResolvedPos.before();
|
||||
tr.replaceWith(
|
||||
sourceWrapperPos,
|
||||
sourceWrapperPos + sourceContainerParent.nodeSize,
|
||||
wrappedRemainingCard,
|
||||
);
|
||||
} else {
|
||||
// FlexContainer is not wrapped, just replace it
|
||||
tr.replaceWith(
|
||||
sourceFlexContainerPos,
|
||||
sourceFlexContainerPos + sourceFlexContainer.nodeSize,
|
||||
wrappedRemainingCard,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (sourceNewChildren.length > 1) {
|
||||
// Multiple cards left in source, keep as flexContainer
|
||||
const newSourceFlexContainer =
|
||||
view.state.schema.nodes.flexContainer.create(
|
||||
sourceFlexContainer.attrs,
|
||||
sourceNewChildren,
|
||||
);
|
||||
tr.replaceWith(
|
||||
sourceFlexContainerPos,
|
||||
sourceFlexContainerPos + sourceFlexContainer.nodeSize,
|
||||
newSourceFlexContainer,
|
||||
);
|
||||
} else {
|
||||
// No cards left, remove the entire source flexContainer and its wrapper
|
||||
const sourceContainerResolvedPos = view.state.doc.resolve(
|
||||
sourceFlexContainerPos,
|
||||
);
|
||||
const sourceContainerParent = sourceContainerResolvedPos.parent;
|
||||
|
||||
if (sourceContainerParent.type.name === "resizeNode") {
|
||||
// Remove the entire resizeNode wrapper
|
||||
const sourceWrapperPos = sourceContainerResolvedPos.before();
|
||||
tr.delete(
|
||||
sourceWrapperPos,
|
||||
sourceWrapperPos + sourceContainerParent.nodeSize,
|
||||
);
|
||||
} else {
|
||||
// Remove just the flexContainer
|
||||
tr.delete(
|
||||
sourceFlexContainerPos,
|
||||
sourceFlexContainerPos + sourceFlexContainer.nodeSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Now add the card to the target flexContainer
|
||||
const targetChildren: Node[] = [];
|
||||
for (let i = 0; i < targetFlexContainer.content.childCount; i++) {
|
||||
const child = targetFlexContainer.content.child(i);
|
||||
targetChildren.push(child);
|
||||
}
|
||||
|
||||
targetChildren.splice(targetIndex, 0, cardEmbedNode.copy());
|
||||
|
||||
// Find target flexContainer position
|
||||
let targetFlexContainerPos: number | null = null;
|
||||
view.state.doc.descendants((node, nodePos) => {
|
||||
if (node === targetFlexContainer) {
|
||||
targetFlexContainerPos = nodePos;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (targetFlexContainerPos === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replace target flexContainer with updated one
|
||||
const newTargetFlexContainer = view.state.schema.nodes.flexContainer.create(
|
||||
targetFlexContainer.attrs,
|
||||
targetChildren,
|
||||
);
|
||||
|
||||
// Since we may have modified the document above, recalculate the target position
|
||||
const updatedDoc = tr.doc;
|
||||
let updatedTargetPos: number | null = null;
|
||||
updatedDoc.descendants((node, nodePos) => {
|
||||
if (node === targetFlexContainer) {
|
||||
updatedTargetPos = nodePos;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (updatedTargetPos !== null) {
|
||||
tr.replaceWith(
|
||||
updatedTargetPos,
|
||||
updatedTargetPos + targetFlexContainer.nodeSize,
|
||||
newTargetFlexContainer,
|
||||
);
|
||||
}
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Handle dropping on a cardEmbed that is already in a flexContainer
|
||||
if (!cameFromFlexContainer) {
|
||||
const flexContainer = dropToParent;
|
||||
|
||||
if (flexContainer.content.childCount === 3) {
|
||||
return true; // Don't allow more than 3 cards in flexContainer
|
||||
}
|
||||
|
||||
const targetCardEmbedNode = getTargetCardEmbedNode(e, view);
|
||||
if (!targetCardEmbedNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the intended insertion index based on drop position
|
||||
let targetIndex = dropToParent.content.content.indexOf(targetCardEmbedNode);
|
||||
const dropSide = getCardEmbedDropSide(e);
|
||||
|
||||
if (dropSide === "left" && targetIndex > 1) {
|
||||
targetIndex--;
|
||||
} else if (dropSide === "right") {
|
||||
targetIndex++;
|
||||
}
|
||||
|
||||
// Get all current children as cardEmbeds
|
||||
const newChildren: Node[] = [];
|
||||
for (let i = 0; i < flexContainer.content.childCount; i++) {
|
||||
const child = flexContainer.content.child(i);
|
||||
newChildren.push(child);
|
||||
}
|
||||
|
||||
newChildren.splice(targetIndex, 0, cardEmbedNode.copy());
|
||||
|
||||
// Find the position of the flexContainer (it should be wrapped in resizeNode)
|
||||
let flexContainerPos: number | null = null;
|
||||
|
||||
view.state.doc.descendants((node, nodePos) => {
|
||||
if (node === flexContainer) {
|
||||
flexContainerPos = nodePos;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (flexContainerPos === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create transaction
|
||||
const tr = view.state.tr;
|
||||
|
||||
// First, replace the flexContainer with the new one containing the inserted card
|
||||
const newFlexContainer = view.state.schema.nodes.flexContainer.create(
|
||||
flexContainer.attrs,
|
||||
newChildren,
|
||||
);
|
||||
|
||||
tr.replaceWith(
|
||||
flexContainerPos,
|
||||
flexContainerPos + flexContainer.nodeSize,
|
||||
newFlexContainer,
|
||||
);
|
||||
|
||||
// Now remove the dropped node from its original position
|
||||
// We need to find it again in the updated document
|
||||
const nodeToRemove =
|
||||
originalParent.type.name === "resizeNode"
|
||||
? originalParent
|
||||
: cardEmbedNode;
|
||||
|
||||
const updatedDoc = tr.doc;
|
||||
let nodeFound = false;
|
||||
updatedDoc.descendants((node, nodePos) => {
|
||||
if (node === nodeToRemove && !nodeFound) {
|
||||
tr.delete(nodePos, nodePos + node.nodeSize);
|
||||
nodeFound = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from "./cardToCard";
|
||||
export * from "./cardToFlexContainer";
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Extension } from "@tiptap/core";
|
||||
import { Fragment, type NodeType as PMNodeType, Slice } from "@tiptap/pm/model";
|
||||
import { NodeSelection, Plugin, PluginKey } from "@tiptap/pm/state";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
import {
|
||||
handleCardDropOnCard,
|
||||
handleCardDropToFlexContainer,
|
||||
} from "./DropHandlers";
|
||||
import { handleCardDropOnDocument } from "./DropHandlers/cardToDocument";
|
||||
import {
|
||||
type DroppedCardEmbedNodeData,
|
||||
cleanupFlexContainerNodes,
|
||||
extractCardEmbed,
|
||||
getDroppedCardEmbedNodeData,
|
||||
} from "./utils";
|
||||
|
||||
export const HandleEditorDrop = Extension.create({
|
||||
name: "handleEditorDrop",
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("metabaseTiptapDrop"),
|
||||
props: {
|
||||
handleDrop: (view, e, slice, moved) => {
|
||||
const cardEmbedInitialData = getDroppedCardEmbedNodeData(
|
||||
view,
|
||||
e,
|
||||
slice,
|
||||
moved,
|
||||
);
|
||||
|
||||
if (cardEmbedInitialData) {
|
||||
const { dropToParent } = cardEmbedInitialData;
|
||||
|
||||
// Dropping inside a flexContainer
|
||||
if (dropToParent.type.name === "flexContainer") {
|
||||
return handleCardDropToFlexContainer(cardEmbedInitialData);
|
||||
}
|
||||
|
||||
const targetCardEmbed = extractCardEmbed(dropToParent);
|
||||
// Dropping on another cardEmbed
|
||||
if (
|
||||
targetCardEmbed &&
|
||||
targetCardEmbed.type.name === "cardEmbed"
|
||||
) {
|
||||
return handleCardDropOnCard(
|
||||
cardEmbedInitialData,
|
||||
targetCardEmbed,
|
||||
);
|
||||
}
|
||||
|
||||
if (dropToParent.type.name === "paragraph") {
|
||||
return handleCardDropOnParagraph(cardEmbedInitialData);
|
||||
}
|
||||
|
||||
// Check if dropping into document (not into another flexContainer or cardEmbed)
|
||||
if (
|
||||
dropToParent.type.name !== "flexContainer" &&
|
||||
dropToParent.type.name !== "cardEmbed" &&
|
||||
dropToParent.type.name !== "resizeNode"
|
||||
) {
|
||||
handleCardDropOnDocument(cardEmbedInitialData);
|
||||
}
|
||||
}
|
||||
|
||||
// Return false to allow default drop behavior for other cases
|
||||
return false;
|
||||
},
|
||||
|
||||
handleDOMEvents: {
|
||||
dragstart: (view, event) => {
|
||||
const maybePos = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (maybePos) {
|
||||
const { pos } = maybePos;
|
||||
const node = view.state.doc.nodeAt(pos);
|
||||
|
||||
view.draggingNode = node;
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
handleTextInput: (view) => {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
// Check if we have a node selection on a cardEmbed
|
||||
if (selection instanceof NodeSelection) {
|
||||
const selectedNode = selection.node;
|
||||
if (selectedNode.type.name === "cardEmbed") {
|
||||
// Ignore text input when a cardEmbed is selected
|
||||
return true; // Prevent default behavior
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleKeyDown: (view) => {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
// Check if we have a node selection on a cardEmbed
|
||||
if (selection instanceof NodeSelection) {
|
||||
const selectedNode = selection.node;
|
||||
if (selectedNode.type.name === "cardEmbed") {
|
||||
setTimeout(() => {
|
||||
cleanupFlexContainerNodes(view);
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
transformPasted: (slice, view) => {
|
||||
const { content } = slice.content;
|
||||
const isPastingCardEmbed =
|
||||
content.length === 1 && content[0]?.type.name === "cardEmbed";
|
||||
|
||||
if (!isPastingCardEmbed) {
|
||||
return slice;
|
||||
}
|
||||
|
||||
const { state } = view;
|
||||
const resolvedPos = state.doc.resolve(state.selection.from);
|
||||
const isTopLevelParagraph =
|
||||
resolvedPos.parent.type.name === "paragraph" &&
|
||||
resolvedPos.depth === 1;
|
||||
|
||||
if (!isTopLevelParagraph) {
|
||||
return slice;
|
||||
}
|
||||
|
||||
const transformedContent = slice.content.content.map((node) => {
|
||||
return state.schema.nodes.resizeNode.create({}, [node]);
|
||||
});
|
||||
|
||||
return new Slice(
|
||||
Fragment.fromArray(transformedContent),
|
||||
slice.openStart,
|
||||
slice.openEnd,
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
const handleCardDropOnParagraph = ({
|
||||
originalPos,
|
||||
view,
|
||||
dropToParentPos,
|
||||
event,
|
||||
cameFromFlexContainer,
|
||||
}: DroppedCardEmbedNodeData) => {
|
||||
const resolvedPos = dropToParentPos;
|
||||
// Get the DOM element of the paragraph.
|
||||
const paragraphDOM = view.domAtPos(resolvedPos.start()).node as Element;
|
||||
const rect = paragraphDOM.getBoundingClientRect();
|
||||
// If dropping in the upper half of the paragraph, insert before; else, insert after.
|
||||
const insertBefore = event.clientY < rect.top + rect.height / 2;
|
||||
|
||||
const targetPos = insertBefore ? resolvedPos.start() : resolvedPos.end();
|
||||
// Create a transaction that inserts the slice at the computed target position.
|
||||
moveNode(
|
||||
view,
|
||||
originalPos,
|
||||
targetPos,
|
||||
cameFromFlexContainer ? view.state.schema.nodes.resizeNode : undefined,
|
||||
);
|
||||
// If we have moved content out of a flex container, we may need to unwrap an unneeded flex container
|
||||
cleanupFlexContainerNodes(view);
|
||||
return true; // Indicate that we've handled the drop.
|
||||
};
|
||||
|
||||
const moveNode = (
|
||||
view: EditorView,
|
||||
fromPos: number,
|
||||
toPos: number,
|
||||
wrapper?: PMNodeType,
|
||||
) => {
|
||||
const { state } = view;
|
||||
const { tr, doc } = state;
|
||||
|
||||
// Get the node at the fromPos.
|
||||
const node = doc.nodeAt(fromPos);
|
||||
if (!node) {
|
||||
console.error("No node found at position", fromPos);
|
||||
return;
|
||||
}
|
||||
|
||||
const possiblyWrappedNode = wrapper ? wrapper.create({}, [node]) : node;
|
||||
|
||||
// Calculate the range: from the start of the node to its end.
|
||||
const start = fromPos;
|
||||
const end = fromPos + node.nodeSize;
|
||||
|
||||
// Delete the node from its current location.
|
||||
tr.delete(start, end);
|
||||
|
||||
// Insert it at the new desired position.
|
||||
//
|
||||
// Note: Because you've already deleted the node, the toPos might need adjustment.
|
||||
// A simple approach is to calculate the new target position relative to the deletion.
|
||||
// If the node is moved to a location that comes after its original position,
|
||||
// subtract the node size from the target.
|
||||
const adjustedToPos = toPos > start ? toPos - node.nodeSize : toPos;
|
||||
|
||||
tr.insert(adjustedToPos, possiblyWrappedNode);
|
||||
|
||||
view.dispatch(tr);
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Node, ResolvedPos, Slice } from "@tiptap/pm/model";
|
||||
import type { EditorView } from "@tiptap/pm/view";
|
||||
|
||||
// Helper function to extract cardEmbed from resizeNode wrapper
|
||||
export const extractCardEmbed = (node: Node): Node | null => {
|
||||
if (node.type.name === "cardEmbed") {
|
||||
return node;
|
||||
}
|
||||
if (node.type.name === "resizeNode" && node.content.childCount === 1) {
|
||||
const child = node.content.child(0);
|
||||
if (child.type.name === "cardEmbed") {
|
||||
return child;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getCardEmbedDropSide = (e: DragEvent): "left" | "right" | null => {
|
||||
const targetCardEmbedDOM =
|
||||
e.target instanceof Element ? e.target.closest(".node-cardEmbed") : null;
|
||||
if (targetCardEmbedDOM) {
|
||||
const rect = targetCardEmbedDOM.getBoundingClientRect();
|
||||
const xPct = (e.clientX - rect.left) / rect.width;
|
||||
|
||||
return xPct >= 0.5 ? "right" : "left";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export const getTargetCardEmbedNode = (e: DragEvent, view: EditorView) => {
|
||||
const targetCardEmbedDOM =
|
||||
e.target instanceof Element ? e.target.closest(".node-cardEmbed") : null;
|
||||
|
||||
if (targetCardEmbedDOM) {
|
||||
const pos = view.posAtDOM(targetCardEmbedDOM, 0);
|
||||
const targetCardEmbed = view.state.doc.nodeAt(pos);
|
||||
|
||||
if (targetCardEmbed) {
|
||||
return extractCardEmbed(targetCardEmbed);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export interface DroppedCardEmbedNodeData {
|
||||
cardEmbedNode: Node;
|
||||
originalPos: number;
|
||||
originalParent: Node;
|
||||
cameFromFlexContainer: boolean;
|
||||
dropPos: number;
|
||||
dropToParentPos: ResolvedPos;
|
||||
dropToParent: Node;
|
||||
event: DragEvent;
|
||||
slice: Slice;
|
||||
moved: boolean;
|
||||
view: EditorView;
|
||||
}
|
||||
|
||||
export const getDroppedCardEmbedNodeData = (
|
||||
view: EditorView,
|
||||
event: DragEvent,
|
||||
slice: Slice,
|
||||
moved: boolean,
|
||||
): DroppedCardEmbedNodeData | undefined => {
|
||||
// Check if we're moving a cardEmbed node
|
||||
if (slice.content.childCount === 1) {
|
||||
const droppedNode = slice.content.child(0);
|
||||
const cardEmbedNode = extractCardEmbed(droppedNode);
|
||||
|
||||
// Dropping a "cardEmbed" node
|
||||
if (cardEmbedNode && moved) {
|
||||
const nodeParentResult = findNodeParentAndPos(view, cardEmbedNode);
|
||||
|
||||
if (!nodeParentResult) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { parent: originalParent, parentPos: originalPos } =
|
||||
nodeParentResult;
|
||||
|
||||
const cameFromFlexContainer = originalParent
|
||||
? (originalParent as Node).type.name === "flexContainer"
|
||||
: false;
|
||||
|
||||
// Find the position where the card was dropped
|
||||
const coords = view.posAtCoords({
|
||||
left: event.clientX,
|
||||
top: event.clientY,
|
||||
});
|
||||
|
||||
if (coords) {
|
||||
const dropPos = coords.pos;
|
||||
const dropToParentPos = view.state.doc.resolve(dropPos);
|
||||
const dropToParent = dropToParentPos.parent;
|
||||
|
||||
return {
|
||||
cardEmbedNode,
|
||||
originalPos,
|
||||
originalParent,
|
||||
cameFromFlexContainer,
|
||||
dropPos,
|
||||
dropToParentPos,
|
||||
dropToParent,
|
||||
event,
|
||||
slice,
|
||||
view,
|
||||
moved,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export const findNodeParentAndPos = (
|
||||
view: EditorView,
|
||||
nodeToFind: Node,
|
||||
): { parentPos: number; parent: Node } | null => {
|
||||
let parentPos: number | null = null;
|
||||
let parent: Node | null = null;
|
||||
|
||||
view.state.doc.descendants((node, pos, nodeParent) => {
|
||||
if (node === nodeToFind) {
|
||||
parentPos = pos;
|
||||
parent = nodeParent;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (extractCardEmbed(node) === nodeToFind && node !== nodeToFind) {
|
||||
parentPos = pos;
|
||||
parent = node;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (parentPos && parent) {
|
||||
return { parentPos, parent };
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Traverses the document and unwraps any flexContainer nodes that only have 1 child
|
||||
export const cleanupFlexContainerNodes = (view: EditorView) => {
|
||||
view.state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "flexContainer" && node.childCount === 1) {
|
||||
const child = node.firstChild;
|
||||
if (child) {
|
||||
view.dispatch(
|
||||
view.state.tr.replaceWith(pos, pos + node.nodeSize, child),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -26,6 +26,8 @@ import { getCurrentDocument } from "metabase-enterprise/documents/selectors";
|
||||
import MetabotThinkingStyles from "metabase-enterprise/metabot/components/MetabotChat/MetabotThinking.module.css";
|
||||
import type { Card, MetabotGenerateContentRequest } from "metabase-types/api";
|
||||
|
||||
import { wrapCardEmbed } from "../shared/layout";
|
||||
|
||||
import S from "./MetabotEmbed.module.css";
|
||||
|
||||
declare module "@tiptap/core" {
|
||||
@@ -233,12 +235,12 @@ export const MetabotComponent = memo(
|
||||
);
|
||||
|
||||
editor.commands.insertContentAt(nodePosition, [
|
||||
{
|
||||
wrapCardEmbed({
|
||||
type: "cardEmbed",
|
||||
attrs: {
|
||||
id: newCardId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [createTextNode(data.description)],
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
.content {
|
||||
& > [data-node-view-content-react] {
|
||||
height: 100%;
|
||||
transform: translateZ(0);
|
||||
|
||||
& > :global(.node-cardEmbed) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& > :global(.node-flexContainer) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
height: 100%;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dragContainer {
|
||||
position: absolute;
|
||||
bottom: 0.25rem;
|
||||
width: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 150ms ease;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
background-color: var(--mb-color-text-secondary-inverse);
|
||||
height: 0.25rem;
|
||||
width: 4rem;
|
||||
cursor: row-resize;
|
||||
z-index: 10;
|
||||
border-radius: var(--default-border-radius);
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
cursor: default;
|
||||
|
||||
&:hover {
|
||||
.dragContainer {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Node, type NodeViewProps, mergeAttributes } from "@tiptap/core";
|
||||
import {
|
||||
NodeViewContent,
|
||||
NodeViewWrapper,
|
||||
ReactNodeViewRenderer,
|
||||
} from "@tiptap/react";
|
||||
import type React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
|
||||
import { useSelector } from "metabase/lib/redux";
|
||||
import { Box, Flex } from "metabase/ui";
|
||||
import { getCommentSidebarOpen } from "metabase-enterprise/documents/selectors";
|
||||
|
||||
import S from "./ResizeNode.module.css";
|
||||
|
||||
export const RESIZE_NODE_MIN_HEIGHT = 280;
|
||||
export const RESIZE_NODE_DEFAULT_HEIGHT = 442;
|
||||
|
||||
export const ResizeNode: Node<{
|
||||
HTMLAttributes: {
|
||||
height: number;
|
||||
minHeight: number;
|
||||
};
|
||||
}> = Node.create({
|
||||
name: "resizeNode",
|
||||
group: "block",
|
||||
content: "(flexContainer | cardEmbed)",
|
||||
atom: true,
|
||||
draggable: false,
|
||||
selectable: false,
|
||||
disableDropCursor: true,
|
||||
|
||||
addAttributes() {
|
||||
return {
|
||||
height: {
|
||||
default: RESIZE_NODE_DEFAULT_HEIGHT,
|
||||
parseHTML: (element) => {
|
||||
const rawValue = element.getAttribute("data-height");
|
||||
return (rawValue && parseInt(rawValue, 10)) || null;
|
||||
},
|
||||
},
|
||||
minHeight: {
|
||||
default: RESIZE_NODE_MIN_HEIGHT,
|
||||
parseHTML: (element) => {
|
||||
const rawValue = element.getAttribute("data-min-height");
|
||||
return (rawValue && parseInt(rawValue, 10)) || null;
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
parseHTML() {
|
||||
return [
|
||||
{
|
||||
tag: `div[data-type="${ResizeNode.name}"]`,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
renderHTML({ node, HTMLAttributes }) {
|
||||
return [
|
||||
"div",
|
||||
mergeAttributes(
|
||||
HTMLAttributes,
|
||||
{
|
||||
"data-type": ResizeNode.name,
|
||||
"data-height": node.attrs.height,
|
||||
"data-min-height": node.attrs.minHeight,
|
||||
},
|
||||
this.options.HTMLAttributes,
|
||||
),
|
||||
0,
|
||||
];
|
||||
},
|
||||
addNodeView() {
|
||||
return ReactNodeViewRenderer(ResizeNodeView);
|
||||
},
|
||||
});
|
||||
|
||||
const ResizeNodeView = ({
|
||||
node: { attrs },
|
||||
updateAttributes,
|
||||
}: NodeViewProps) => {
|
||||
const { minHeight } = attrs;
|
||||
|
||||
const [height, setHeight] = useState(attrs.height);
|
||||
const _height = useRef(attrs.height);
|
||||
|
||||
const shouldDisableResizing = useSelector(getCommentSidebarOpen);
|
||||
|
||||
const computeHeight = (delta: number) => {
|
||||
const newHeight = _height.current + delta;
|
||||
|
||||
return newHeight < minHeight ? minHeight : newHeight;
|
||||
};
|
||||
|
||||
const handleDrag = (delta: number) => {
|
||||
setHeight(computeHeight(delta));
|
||||
};
|
||||
|
||||
const handleDragEnd = (delta: number) => {
|
||||
const newHeight = computeHeight(delta);
|
||||
setHeight(newHeight);
|
||||
updateAttributes({
|
||||
height: newHeight,
|
||||
});
|
||||
_height.current = newHeight;
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper
|
||||
style={{ height }}
|
||||
className={S.wrapper}
|
||||
data-type="resizeNode"
|
||||
>
|
||||
<NodeViewContent className={S.content} />
|
||||
{!shouldDisableResizing && (
|
||||
<DragHandle onDrag={handleDrag} onDragEnd={handleDragEnd} />
|
||||
)}
|
||||
</NodeViewWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
const DragHandle = ({
|
||||
onDrag,
|
||||
onDragEnd,
|
||||
}: {
|
||||
onDrag: (val: number) => void;
|
||||
onDragEnd: (val: number) => void;
|
||||
}) => {
|
||||
const startY = useRef<number | null>(null);
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
startY.current = e.pageY;
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (startY.current) {
|
||||
const delta = e.pageY - startY.current;
|
||||
onDrag?.(delta);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("mousemove", handleMouseMove);
|
||||
window.addEventListener(
|
||||
"mouseup",
|
||||
(e) => {
|
||||
window.removeEventListener("mousemove", handleMouseMove);
|
||||
if (startY.current) {
|
||||
onDragEnd?.(e.pageY - startY.current);
|
||||
startY.current = null;
|
||||
}
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex justify="center" className={S.dragContainer} contentEditable={false}>
|
||||
<Box
|
||||
className={S.dragHandle}
|
||||
onMouseDown={handleMouseDown}
|
||||
data-testid="resize-node-drag-handle"
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { JSONContent } from "@tiptap/core";
|
||||
|
||||
export const wrapCardEmbed = (cardEmbedNode: JSONContent): JSONContent => ({
|
||||
type: "resizeNode",
|
||||
content: [cardEmbedNode],
|
||||
});
|
||||
@@ -29,6 +29,7 @@ export const useCellMeasure = (
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-element-id="data-grid-measure-root"
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: "-9999px",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12.854 2.009A1.826 1.826 0 0 1 14.5 3.825v7.01a1.829 1.829 0 0 1-1.826 1.828H4.979a.51.51 0 0 0-.362.15L3.33 14.098a1.073 1.073 0 0 1-1.65-.162 1.072 1.072 0 0 1-.167-.438l-.013-.158V3.825A1.826 1.826 0 0 1 3.326 2h9.348l.18.009zM3.326 3.314a.512.512 0 0 0-.51.511v8.93l.872-.872.133-.121a1.826 1.826 0 0 1 1.158-.413h7.695a.513.513 0 0 0 .51-.513v-7.01a.512.512 0 0 0-.41-.5l-.1-.012H3.326zM8 4.921c.363 0 .657.294.657.657v1.096h1.096a.658.658 0 0 1 0 1.314H8.657v1.096a.657.657 0 1 1-1.314 0V7.988H6.247a.657.657 0 0 1 0-1.314h1.096V5.578c0-.363.294-.657.657-.657z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 673 B |
@@ -0,0 +1,3 @@
|
||||
<svg viewBox="0 0 16 16" fill="currentcolor" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M13.185 3.765a.486.486 0 0 0-.15-.35.517.517 0 0 0-.26-.134l-.1-.01h-9.35a.52.52 0 0 0-.36.145.486.486 0 0 0-.15.349v8.632l.872-.843.134-.117c.325-.258.734-.4 1.157-.4h7.696a.52.52 0 0 0 .362-.145.487.487 0 0 0 .15-.35V3.765zm1.315 6.777c0 .469-.193.918-.535 1.249-.342.33-.806.517-1.29.517H4.977a.52.52 0 0 0-.36.145L3.33 13.696a1.107 1.107 0 0 1-1.168.225 1.067 1.067 0 0 1-.481-.382 1.016 1.016 0 0 1-.169-.423l-.012-.153V3.765c0-.468.193-.917.535-1.248.342-.33.806-.517 1.29-.517h9.35l.18.008c.418.04.81.22 1.11.51.342.33.535.779.535 1.247v6.777z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 656 B |
@@ -6,6 +6,8 @@ import add_component from "./add.svg?component";
|
||||
import add_source from "./add.svg?source";
|
||||
import add_column_component from "./add_column.svg?component";
|
||||
import add_column_source from "./add_column.svg?source";
|
||||
import add_comment_component from "./add_comment.svg?component";
|
||||
import add_comment_source from "./add_comment.svg?source";
|
||||
import add_data_component from "./add_data.svg?component";
|
||||
import add_data_source from "./add_data.svg?source";
|
||||
import add_folder_component from "./add_folder.svg?component";
|
||||
@@ -98,6 +100,8 @@ import code_block_component from "./code_block.svg?component";
|
||||
import code_block_source from "./code_block.svg?source";
|
||||
import combine_component from "./combine.svg?component";
|
||||
import combine_source from "./combine.svg?source";
|
||||
import comment_component from "./comment.svg?component";
|
||||
import comment_source from "./comment.svg?source";
|
||||
import company_component from "./company.svg?component";
|
||||
import company_source from "./company.svg?source";
|
||||
import compare_component from "./compare.svg?component";
|
||||
@@ -264,8 +268,6 @@ import mail_component from "./mail.svg?component";
|
||||
import mail_source from "./mail.svg?source";
|
||||
import mail_filled_component from "./mail_filled.svg?component";
|
||||
import mail_filled_source from "./mail_filled.svg?source";
|
||||
import message_component from "./message.svg?component";
|
||||
import message_source from "./message.svg?source";
|
||||
import metabot_component from "./metabot.svg?component";
|
||||
import metabot_source from "./metabot.svg?source";
|
||||
import metabot_sad_component from "./metabot_sad.svg?component";
|
||||
@@ -505,6 +507,10 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
|
||||
component: add_to_dash_component,
|
||||
source: add_to_dash_source,
|
||||
},
|
||||
add_comment: {
|
||||
component: add_comment_component,
|
||||
source: add_comment_source,
|
||||
},
|
||||
ai: {
|
||||
component: ai_component,
|
||||
source: ai_source,
|
||||
@@ -677,6 +683,10 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
|
||||
component: company_component,
|
||||
source: company_source,
|
||||
},
|
||||
comment: {
|
||||
component: comment_component,
|
||||
source: comment_source,
|
||||
},
|
||||
connections: {
|
||||
component: connections_component,
|
||||
source: connections_source,
|
||||
@@ -993,10 +1003,6 @@ export const Icons: Record<IconName, { component: React.VFC; source: string }> =
|
||||
component: mail_filled_component,
|
||||
source: mail_filled_source,
|
||||
},
|
||||
message: {
|
||||
component: message_component,
|
||||
source: message_source,
|
||||
},
|
||||
metabot: {
|
||||
component: metabot_component,
|
||||
source: metabot_source,
|
||||
@@ -1465,6 +1471,7 @@ export type IconName =
|
||||
| "add_folder"
|
||||
| "add_row"
|
||||
| "add_to_dash"
|
||||
| "add_comment"
|
||||
| "ai"
|
||||
| "alert"
|
||||
| "alert_filled"
|
||||
@@ -1508,6 +1515,7 @@ export type IconName =
|
||||
| "compare"
|
||||
| "combine"
|
||||
| "company"
|
||||
| "comment"
|
||||
| "currency"
|
||||
| "connections"
|
||||
| "contract"
|
||||
@@ -1590,7 +1598,6 @@ export type IconName =
|
||||
| "lock_filled"
|
||||
| "mail"
|
||||
| "mail_filled"
|
||||
| "message"
|
||||
| "metabot"
|
||||
| "metabot_sad"
|
||||
| "metric"
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21 15C21 15.5304 20.7893 16.0391 20.4142 16.4142C20.0391 16.7893 19.5304 17 19 17H7L3 21V5C3 4.46957 3.21071 3.96086 3.58579 3.58579C3.96086 3.21071 4.46957 3 5 3H19C19.5304 3 20.0391 3.21071 20.4142 3.58579C20.7893 3.96086 21 4.46957 21 5V15Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 427 B |
@@ -185,7 +185,9 @@ export default class FunnelNormal extends Component {
|
||||
</Head>
|
||||
<FunnelStart isNarrow={isNarrow}>
|
||||
<Title>{formatMetric(sortedRows[0][metricIndex])}</Title>
|
||||
<Subtitle>{cols[metricIndex].display_name}</Subtitle>
|
||||
<Subtitle>
|
||||
<Ellipsified>{cols[metricIndex].display_name}</Ellipsified>
|
||||
</Subtitle>
|
||||
</FunnelStart>
|
||||
{/* This part of code in used only to share height between .Start and .Graph columns. */}
|
||||
<Info isNarrow={isNarrow}>
|
||||
|
||||
8
frontend/src/types/prosemirror-view.d.ts
vendored
Normal file
8
frontend/src/types/prosemirror-view.d.ts
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
import type { NodeViewProps } from "@tiptap/react";
|
||||
|
||||
declare module "prosemirror-view" {
|
||||
// This adds a new configuration option to the NodeConfig
|
||||
interface EditorView {
|
||||
draggingNode?: NodeViewProps["node"] | null;
|
||||
}
|
||||
}
|
||||
13
patches/prosemirror-dropcursor+1.8.2.patch
Normal file
13
patches/prosemirror-dropcursor+1.8.2.patch
Normal file
@@ -0,0 +1,13 @@
|
||||
diff --git a/node_modules/prosemirror-dropcursor/dist/index.js b/node_modules/prosemirror-dropcursor/dist/index.js
|
||||
index c9bc7b5..ae9fcce 100644
|
||||
--- a/node_modules/prosemirror-dropcursor/dist/index.js
|
||||
+++ b/node_modules/prosemirror-dropcursor/dist/index.js
|
||||
@@ -128,7 +128,7 @@ class DropCursorView {
|
||||
target = point;
|
||||
}
|
||||
this.setCursor(target);
|
||||
- this.scheduleRemoval(5000);
|
||||
+ this.scheduleRemoval(1000);
|
||||
}
|
||||
}
|
||||
dragend() {
|
||||
Reference in New Issue
Block a user