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:
Denis Berezin
2025-09-25 21:08:49 +04:00
committed by GitHub
parent f393237eb9
commit 7fb62b782b
38 changed files with 2971 additions and 140 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View 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",
};

View File

@@ -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() {

View File

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

View 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);
}
});
});
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
);

View File

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

View File

@@ -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],
);

View File

@@ -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
}
}
]
}
]
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export * from "./cardToCard";
export * from "./cardToFlexContainer";

View File

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

View File

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

View File

@@ -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)],

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import type { JSONContent } from "@tiptap/core";
export const wrapCardEmbed = (cardEmbedNode: JSONContent): JSONContent => ({
type: "resizeNode",
content: [cardEmbedNode],
});

View File

@@ -29,6 +29,7 @@ export const useCellMeasure = (
return (
<div
ref={rootRef}
data-element-id="data-grid-measure-root"
style={{
position: "absolute",
top: "-9999px",

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;
}
}

View 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() {