excalidraw/src/tests/contextmenu.test.tsx

561 lines
16 KiB
TypeScript
Raw Normal View History

2021-04-05 17:26:37 +02:00
import ReactDOM from "react-dom";
import {
render,
fireEvent,
mockBoundingClientRect,
restoreOriginalGetBoundingClientRect,
GlobalTestState,
screen,
queryByText,
queryAllByText,
waitFor,
} from "./test-utils";
import ExcalidrawApp from "../excalidraw-app";
import * as Renderer from "../renderer/renderScene";
import { reseed } from "../random";
import { UI, Pointer, Keyboard } from "./helpers/ui";
import { CODES } from "../keys";
import { ShortcutName } from "../actions/shortcuts";
import { copiedStyles } from "../actions/actionStyles";
import { API } from "./helpers/api";
import { setDateTimeForTests } from "../utils";
import { t } from "../i18n";
feat: Allow publishing libraries from UI (#4115) * feat: Allow publishing libraries from UI * Add status for each library item and show publish only for unpublished libs * Add publish library dialog * Pass the data to publish the library * pass lib blob * Handle old and new libraries when importing * Better error handling * Show publish success when library submitted for review * don't close library when publish success dialog open * Support multiple libs deletion and publish * Set status to published once library submitted for review * Save to LS after library published * unique key for publish and delete * fix layout shift when hover and also highlight selected library items * design improvements * migrate old library to the new one * fix * fix tests * use i18n * Support submit type in toolbutton * Use html5 form validation, add asteriks for required fields, add twitter handle, mark github handle optional * Add twitter handle in form state * revert html5 validation as fetch is giving some issues :/ * clarify types around LibraryItems * Add website optional field * event.preventDefault to make htm5 form validationw work * improve png generation by drawing a bounding box rect and aligining pngs to support multiple libs png * remove ts-ignore * add placeholders for fields * decrease clickable area for checkbox by 0.5em * add checkbox background color * rename `items` to `elements` * improve checkbox hit area * show selected library items in publish dialog * decrease dimensions by 3px to improve jerky experience when opening/closing library menu * Don't close publish dialog when clicked outside * Show selected library actions only when any library item selected and use icons instead of button * rename library to libraryItems in excalidrawLib and added migration * change icon and swap bg/color * use blue brand color for hover/selected states * prompt for confirmation when deleting library items * separate unpublished items from published * factor `LibraryMenu` into own file * i18n and minor fixes for unpublished items * fix not rendering empty cells when library empty * don't render published section if empty and unpublished is not * Add edit name functionality for library items * fix * edit lib name with onchange/blur * bump library version * prefer response error message * add library urls to ENV vars * mark lib item name as required * Use input only for lib item name * better error validation for lib items * fix label styling for lib items * design and i18n fixes * Save publish dialog data to local storage and clear once published * Add a note about MIT License * Add note for guidelines * Add tooltip for publish button * Show spinner in submit button when submission is in progress * assign id for older lib items when installed and set status as published for all lib when installed * update export icon and support export library for selected items * move LibraryMenuItems into its own component as its best to keep one comp per file * fix spec * Refactoring the library actions for reusablility * show only load when items not present * close on click outside in publish dialog * ad dialog description and tweak copy * vertically center input labels * align input styles * move author name input to other usernames * rename param * inline to simplify * fix to not inline `undefined` class names * fix version & include only latest lib schema in library export type * await response callback * refactor types * refactor * i18n * align casing & tweaks * move ls logic to publishLibrary * support removal of item inside publish dialog * fix labels for trash icon when items selected * replace window.confirm for removal libs with confirm dialog * fix input/textarea styling * move library item menu scss to its own file * use blue for load and cyan for publish * reduce margin for submit and make submit => Submit * Make library items header sticky * move publish icon to left so there is no jerkiness when unpublish items selected * update url * fix grid gap between lib items * Mark older items imported from initial data as unpublished * add text to publish button on non-mobile * add items counter * fix test * show personal and excal libs sections and personal goes first * show toast on adding to library via contextMenu * Animate plus icon and not the pending item * fix snap * use i18n when no item in publish dialog * tweak style of new lib item * show empty cells for both sections and set status as published for installed libs * fix * push selected item first in unpublished section * set status as published for imported from webiste but unpublished for json * Add items to the begining of library * add `created` library item attr * fix test * use `defaultValue` instead of `value` * fix dark theme styles * fix toggle button not closing library * close library menu on Escape * tweak publish dialog item remove style * fix remove icon in publish dialog Co-authored-by: dwelle <luzar.david@gmail.com>
2021-11-17 23:53:43 +05:30
import { LibraryItem } from "../types";
2021-04-05 17:26:37 +02:00
const checkpoint = (name: string) => {
expect(renderScene.mock.calls.length).toMatchSnapshot(
`[${name}] number of renders`,
);
expect(h.state).toMatchSnapshot(`[${name}] appState`);
expect(h.history.getSnapshotForTest()).toMatchSnapshot(`[${name}] history`);
expect(h.elements.length).toMatchSnapshot(`[${name}] number of elements`);
h.elements.forEach((element, i) =>
expect(element).toMatchSnapshot(`[${name}] element ${i}`),
);
};
const mouse = new Pointer("mouse");
// Unmount ReactDOM from root
ReactDOM.unmountComponentAtNode(document.getElementById("root")!);
const renderScene = jest.spyOn(Renderer, "renderScene");
beforeEach(() => {
localStorage.clear();
renderScene.mockClear();
reseed(7);
});
const { h } = window;
describe("contextMenu element", () => {
beforeEach(async () => {
localStorage.clear();
renderScene.mockClear();
reseed(7);
setDateTimeForTests("201933152653");
await render(<ExcalidrawApp />);
});
beforeAll(() => {
mockBoundingClientRect();
});
afterAll(() => {
restoreOriginalGetBoundingClientRect();
});
afterEach(() => {
checkpoint("end of test");
mouse.reset();
mouse.down(0, 0);
});
it("shows context menu for canvas", () => {
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
2021-04-05 17:26:37 +02:00
const expectedShortcutNames: ShortcutName[] = [
"selectAll",
"gridMode",
"zenMode",
"viewMode",
"stats",
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows context menu for element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
2021-04-05 17:26:37 +02:00
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"addToLibrary",
"flipHorizontal",
"flipVertical",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
"hyperlink",
"toggleLock",
2021-04-05 17:26:37 +02:00
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows context menu for element", () => {
const rect1 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
height: 200,
width: 200,
backgroundColor: "red",
});
const rect2 = API.createElement({
type: "rectangle",
x: 0,
y: 0,
height: 200,
width: 200,
backgroundColor: "red",
});
h.elements = [rect1, rect2];
API.setSelectedElements([rect1]);
// lower z-index
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 100,
clientY: 100,
});
expect(UI.queryContextMenu()).not.toBeNull();
expect(API.getSelectedElement().id).toBe(rect1.id);
// higher z-index
API.setSelectedElements([rect2]);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 100,
clientY: 100,
});
expect(UI.queryContextMenu()).not.toBeNull();
expect(API.getSelectedElement().id).toBe(rect2.id);
});
2021-04-05 17:26:37 +02:00
it("shows 'Group selection' in context menu for multiple selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
2021-04-05 17:26:37 +02:00
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"group",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
"toggleLock",
2021-04-05 17:26:37 +02:00
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("shows 'Ungroup selection' in context menu for group inside selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(10, 10);
UI.clickTool("rectangle");
mouse.down(10, -10);
mouse.up(10, 10);
mouse.reset();
mouse.click(10, 10);
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(20, 0);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
const contextMenuOptions =
contextMenu?.querySelectorAll(".context-menu li");
2021-04-05 17:26:37 +02:00
const expectedShortcutNames: ShortcutName[] = [
"copyStyles",
"pasteStyles",
"deleteSelectedElements",
"ungroup",
"addToLibrary",
"sendBackward",
"bringForward",
"sendToBack",
"bringToFront",
"duplicateSelection",
"toggleLock",
2021-04-05 17:26:37 +02:00
];
expect(contextMenu).not.toBeNull();
expect(contextMenuOptions?.length).toBe(expectedShortcutNames.length);
expectedShortcutNames.forEach((shortcutName) => {
expect(
contextMenu?.querySelector(`li[data-testid="${shortcutName}"]`),
).not.toBeNull();
});
});
it("selecting 'Copy styles' in context menu copies styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
expect(copiedStyles).toBe("{}");
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
expect(copiedStyles).not.toBe("{}");
const element = JSON.parse(copiedStyles);
expect(element).toEqual(API.getSelectedElement());
});
it("selecting 'Paste styles' in context menu pastes styles", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
// Change some styles of second rectangle
UI.clickLabeledElement("Stroke");
UI.clickLabeledElement(t("colors.c92a2a"));
UI.clickLabeledElement("Background");
UI.clickLabeledElement(t("colors.e64980"));
2021-04-05 17:26:37 +02:00
// Fill style
fireEvent.click(screen.getByTitle("Cross-hatch"));
// Stroke width
fireEvent.click(screen.getByTitle("Bold"));
// Stroke style
fireEvent.click(screen.getByTitle("Dotted"));
// Roughness
fireEvent.click(screen.getByTitle("Cartoonist"));
// Opacity
fireEvent.change(screen.getByLabelText("Opacity"), {
target: { value: "60" },
});
mouse.reset();
// Copy styles of second rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
let contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
fireEvent.click(queryByText(contextMenu as HTMLElement, "Copy styles")!);
const secondRect = JSON.parse(copiedStyles);
expect(secondRect.id).toBe(h.elements[1].id);
mouse.reset();
// Paste styles to first rectangle
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
fireEvent.click(queryByText(contextMenu as HTMLElement, "Paste styles")!);
const firstRect = API.getSelectedElement();
expect(firstRect.id).toBe(h.elements[0].id);
expect(firstRect.strokeColor).toBe("#c92a2a");
expect(firstRect.backgroundColor).toBe("#e64980");
expect(firstRect.fillStyle).toBe("cross-hatch");
expect(firstRect.strokeWidth).toBe(2); // Bold: 2
expect(firstRect.strokeStyle).toBe("dotted");
expect(firstRect.roughness).toBe(2); // Cartoonist: 2
expect(firstRect.opacity).toBe(60);
});
it("selecting 'Delete' in context menu deletes element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
fireEvent.click(queryAllByText(contextMenu as HTMLElement, "Delete")[0]);
expect(API.getSelectedElements()).toHaveLength(0);
expect(h.elements[0].isDeleted).toBe(true);
});
it("selecting 'Add to library' in context menu adds element to library", async () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
fireEvent.click(queryByText(contextMenu as HTMLElement, "Add to library")!);
await waitFor(() => {
const library = localStorage.getItem("excalidraw-library");
expect(library).not.toBeNull();
feat: Allow publishing libraries from UI (#4115) * feat: Allow publishing libraries from UI * Add status for each library item and show publish only for unpublished libs * Add publish library dialog * Pass the data to publish the library * pass lib blob * Handle old and new libraries when importing * Better error handling * Show publish success when library submitted for review * don't close library when publish success dialog open * Support multiple libs deletion and publish * Set status to published once library submitted for review * Save to LS after library published * unique key for publish and delete * fix layout shift when hover and also highlight selected library items * design improvements * migrate old library to the new one * fix * fix tests * use i18n * Support submit type in toolbutton * Use html5 form validation, add asteriks for required fields, add twitter handle, mark github handle optional * Add twitter handle in form state * revert html5 validation as fetch is giving some issues :/ * clarify types around LibraryItems * Add website optional field * event.preventDefault to make htm5 form validationw work * improve png generation by drawing a bounding box rect and aligining pngs to support multiple libs png * remove ts-ignore * add placeholders for fields * decrease clickable area for checkbox by 0.5em * add checkbox background color * rename `items` to `elements` * improve checkbox hit area * show selected library items in publish dialog * decrease dimensions by 3px to improve jerky experience when opening/closing library menu * Don't close publish dialog when clicked outside * Show selected library actions only when any library item selected and use icons instead of button * rename library to libraryItems in excalidrawLib and added migration * change icon and swap bg/color * use blue brand color for hover/selected states * prompt for confirmation when deleting library items * separate unpublished items from published * factor `LibraryMenu` into own file * i18n and minor fixes for unpublished items * fix not rendering empty cells when library empty * don't render published section if empty and unpublished is not * Add edit name functionality for library items * fix * edit lib name with onchange/blur * bump library version * prefer response error message * add library urls to ENV vars * mark lib item name as required * Use input only for lib item name * better error validation for lib items * fix label styling for lib items * design and i18n fixes * Save publish dialog data to local storage and clear once published * Add a note about MIT License * Add note for guidelines * Add tooltip for publish button * Show spinner in submit button when submission is in progress * assign id for older lib items when installed and set status as published for all lib when installed * update export icon and support export library for selected items * move LibraryMenuItems into its own component as its best to keep one comp per file * fix spec * Refactoring the library actions for reusablility * show only load when items not present * close on click outside in publish dialog * ad dialog description and tweak copy * vertically center input labels * align input styles * move author name input to other usernames * rename param * inline to simplify * fix to not inline `undefined` class names * fix version & include only latest lib schema in library export type * await response callback * refactor types * refactor * i18n * align casing & tweaks * move ls logic to publishLibrary * support removal of item inside publish dialog * fix labels for trash icon when items selected * replace window.confirm for removal libs with confirm dialog * fix input/textarea styling * move library item menu scss to its own file * use blue for load and cyan for publish * reduce margin for submit and make submit => Submit * Make library items header sticky * move publish icon to left so there is no jerkiness when unpublish items selected * update url * fix grid gap between lib items * Mark older items imported from initial data as unpublished * add text to publish button on non-mobile * add items counter * fix test * show personal and excal libs sections and personal goes first * show toast on adding to library via contextMenu * Animate plus icon and not the pending item * fix snap * use i18n when no item in publish dialog * tweak style of new lib item * show empty cells for both sections and set status as published for installed libs * fix * push selected item first in unpublished section * set status as published for imported from webiste but unpublished for json * Add items to the begining of library * add `created` library item attr * fix test * use `defaultValue` instead of `value` * fix dark theme styles * fix toggle button not closing library * close library menu on Escape * tweak publish dialog item remove style * fix remove icon in publish dialog Co-authored-by: dwelle <luzar.david@gmail.com>
2021-11-17 23:53:43 +05:30
const addedElement = JSON.parse(library!)[0] as LibraryItem;
expect(addedElement.elements[0]).toEqual(h.elements[0]);
2021-04-05 17:26:37 +02:00
});
});
it("selecting 'Duplicate' in context menu duplicates element", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
fireEvent.click(queryByText(contextMenu as HTMLElement, "Duplicate")!);
expect(h.elements).toHaveLength(2);
const { id: _id0, seed: _seed0, x: _x0, y: _y0, ...rect1 } = h.elements[0];
const { id: _id1, seed: _seed1, x: _x1, y: _y1, ...rect2 } = h.elements[1];
expect(rect1).toEqual(rect2);
});
it("selecting 'Send backward' in context menu sends element backward", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Send backward")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Bring forward' in context menu brings element forward", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring forward")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Send to back' in context menu sends element to back", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 40,
clientY: 40,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Send to back")!);
expect(elementsBefore[1].id).toEqual(h.elements[0].id);
});
it("selecting 'Bring to front' in context menu brings element to front", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 10,
clientY: 10,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
const elementsBefore = h.elements;
fireEvent.click(queryByText(contextMenu as HTMLElement, "Bring to front")!);
expect(elementsBefore[0].id).toEqual(h.elements[1].id);
});
it("selecting 'Group selection' in context menu groups selected elements", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(10, 10);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Group selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(h.elements[0].groupIds).toEqual(selectedGroupIds);
expect(h.elements[1].groupIds).toEqual(selectedGroupIds);
});
it("selecting 'Ungroup selection' in context menu ungroups selected group", () => {
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
UI.clickTool("rectangle");
mouse.down(10, 10);
mouse.up(20, 20);
mouse.reset();
Keyboard.withModifierKeys({ shift: true }, () => {
mouse.click(10, 10);
});
Keyboard.withModifierKeys({ ctrl: true }, () => {
Keyboard.codePress(CODES.G);
});
fireEvent.contextMenu(GlobalTestState.canvas, {
button: 2,
clientX: 1,
clientY: 1,
});
const contextMenu = UI.queryContextMenu();
2021-04-05 17:26:37 +02:00
expect(contextMenu).not.toBeNull();
fireEvent.click(
queryByText(contextMenu as HTMLElement, "Ungroup selection")!,
);
const selectedGroupIds = Object.keys(h.state.selectedGroupIds);
expect(selectedGroupIds).toHaveLength(0);
expect(h.elements[0].groupIds).toHaveLength(0);
expect(h.elements[1].groupIds).toHaveLength(0);
});
});