diff --git a/src/visualBuilder/components/__test__/addInstanceButton.test.tsx b/src/visualBuilder/components/__test__/addInstanceButton.test.tsx index 92fc7364..066d33b7 100644 --- a/src/visualBuilder/components/__test__/addInstanceButton.test.tsx +++ b/src/visualBuilder/components/__test__/addInstanceButton.test.tsx @@ -1,3 +1,4 @@ +import React from "preact/compat"; import { act, cleanup, @@ -7,6 +8,21 @@ import { } from "@testing-library/preact"; import { singleLineFieldSchema } from "../../../__test__/data/fields"; import AddInstanceButtonComponent from "../addInstanceButton"; +import visualBuilderPostMessageActual from "../../utils/visualBuilderPostMessage"; +import { getDiscussionIdByFieldMetaData } from "../../utils/getDiscussionIdByFieldMetaData"; + +const visualBuilderPostMessage = vi.mocked(visualBuilderPostMessageActual); + +vi.mock("../../utils/visualBuilderPostMessage", async () => { + return { + default: { + send: vi.fn().mockImplementation((_eventName: string) => { + return Promise.resolve({}); + }), + on: vi.fn(), + }, + }; +}); describe("AddInstanceButtonComponent", () => { afterEach(cleanup); @@ -18,10 +34,16 @@ describe("AddInstanceButtonComponent", () => { ); - }) + }); const buttonElement = getByTestId( document.body, "visual-builder-add-instance-button" @@ -33,22 +55,63 @@ describe("AddInstanceButtonComponent", () => { expect(buttonElement.querySelector("path")).toBeTruthy(); }); - test("calls onClickCallback when button is clicked", async () => { + test("sends add-instance message when clicked", async () => { const onClickCallback = vi.fn(); await act(() => { render( ); - }) + }); const buttonElement = getByTestId( document.body, "visual-builder-add-instance-button" ); - fireEvent.click(buttonElement); + await act(() => { + fireEvent.click(buttonElement); + }); + expect(visualBuilderPostMessage?.send).toHaveBeenCalledWith( + "add-instance", + { + fieldMetadata: {}, + index: 0, + } + ); + }); + + test("calls onClick callback when clicked", async () => { + const onClickCallback = vi.fn(); + await act(() => { + render( + + ); + }); + const buttonElement = getByTestId( + document.body, + "visual-builder-add-instance-button" + ); + await act(() => { + fireEvent.click(buttonElement); + }); expect(onClickCallback).toHaveBeenCalled(); }); }); diff --git a/src/visualBuilder/components/addInstanceButton.tsx b/src/visualBuilder/components/addInstanceButton.tsx index 036644fe..650341a3 100644 --- a/src/visualBuilder/components/addInstanceButton.tsx +++ b/src/visualBuilder/components/addInstanceButton.tsx @@ -3,44 +3,82 @@ import classNames from "classnames"; import { visualBuilderStyles } from "../visualBuilder.style"; import { PlusIcon } from "./icons"; import { ISchemaFieldMap } from "../utils/types/index.types"; +import { CslpData } from "../../cslp/types/cslp.types"; +import visualBuilderPostMessage from "../utils/visualBuilderPostMessage"; +import { VisualBuilderPostMessageEvents } from "../utils/types/postMessage.types"; +import { Signal } from "@preact/signals"; interface AddInstanceButtonProps { value: any; onClick: (event: MouseEvent) => void; label?: string | undefined; fieldSchema: ISchemaFieldMap | undefined; + fieldMetadata: CslpData; + index: number; + loading: Signal; } function AddInstanceButtonComponent( props: AddInstanceButtonProps ): JSX.Element { const fieldSchema = props.fieldSchema; - const disabled = - fieldSchema && "max_instance" in fieldSchema && fieldSchema.max_instance - ? props.value.length >= fieldSchema.max_instance - : false; + const fieldMetadata = props.fieldMetadata; + const index = props.index; + const loading = props.loading; + + const onClick = async (event: MouseEvent) => { + loading.value = true; + try { + await visualBuilderPostMessage?.send( + VisualBuilderPostMessageEvents.ADD_INSTANCE, + { + fieldMetadata, + index, + } + ); + } catch (error) { + console.error("Visual Builder: Failed to add instance", error); + } + loading.value = false; + props.onClick(event); + }; + + const buttonClassName = classNames( + "visual-builder__add-button", + visualBuilderStyles()["visual-builder__add-button"], + { + "visual-builder__add-button--with-label": props.label, + }, + { + [visualBuilderStyles()["visual-builder__add-button--loading"]]: + loading.value, + }, + visualBuilderStyles()["visual-builder__tooltip"] + ); + + const maxInstances = + fieldSchema && fieldSchema.data_type !== "block" + ? fieldSchema.max_instance + : undefined; + const isMaxInstances = maxInstances + ? props.value.length >= maxInstances + : false; + const disabled = loading.value || isMaxInstances; return ( + ); + }), + }; +}); describe("generateAddInstanceButton", () => { - test("should generate a button", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + test("should generate and return a button", () => { const button = generateAddInstanceButton({ fieldSchema: singleLineFieldSchema, value: "", - onClick: () => {}, + // @ts-expect-error mock field metadata + fieldMetadata: { hello: "world" }, + onClick: vi.fn(), + // @ts-expect-error mocking preact signal + loading: { value: false }, + index: 0, + label: "Add Instance", }); expect(button).toBeInstanceOf(HTMLButtonElement); }); - test("should call the callback when clicked", () => { - const callback = vi.fn(); - const button = generateAddInstanceButton({ + test("should call the AddInstanceButtonComponent with the correct props", () => { + generateAddInstanceButton({ + fieldSchema: singleLineFieldSchema, + value: "", + // @ts-expect-error mock field metadata + fieldMetadata: { hello: "world" }, + onClick: vi.fn(), + // @ts-expect-error mocking preact signal + loading: { value: false }, + index: 0, + label: "Add Instance", + }); + const args = AddInstanceButtonComponent.mock.calls[0][0]; + expect(args).toStrictEqual({ fieldSchema: singleLineFieldSchema, value: "", - onClick: callback, + fieldMetadata: { hello: "world" }, + onClick: expect.any(Function), + loading: { value: false }, + index: 0, + label: "Add Instance", }); - button.click(); - expect(callback).toHaveBeenCalledTimes(1); }); }); diff --git a/src/visualBuilder/generators/generateAddInstanceButtons.tsx b/src/visualBuilder/generators/generateAddInstanceButtons.tsx index 5d552e84..d39272c2 100644 --- a/src/visualBuilder/generators/generateAddInstanceButtons.tsx +++ b/src/visualBuilder/generators/generateAddInstanceButtons.tsx @@ -1,31 +1,44 @@ +import React from "preact/compat"; import { render } from "preact"; import AddInstanceButtonComponent from "../components/addInstanceButton"; import { ISchemaFieldMap } from "../utils/types/index.types"; +import { CslpData } from "../../cslp/types/cslp.types"; +import { Signal } from "@preact/signals"; /** - * Generates a button element, when clicked, triggers the provided callback function. + * Generates a button element, when clicked, sends the add instance message and + * then calls the provided callback function. * @param onClickCallback - The function to be called when the button is clicked. * @returns The generated button element. */ export function generateAddInstanceButton({ - fieldSchema, value, + fieldSchema, + fieldMetadata, + index, + loading, onClick, label, }: { + fieldSchema: ISchemaFieldMap | undefined; value: any; + fieldMetadata: CslpData; + index: number; + loading: Signal; onClick: (event: MouseEvent) => void; label?: string | undefined; - fieldSchema: ISchemaFieldMap | undefined; }): HTMLButtonElement { const wrapper = document.createDocumentFragment(); render( , wrapper ); diff --git a/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts b/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts index 82043d23..fd3e44ae 100644 --- a/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts +++ b/src/visualBuilder/utils/__test__/multipleElementAddButton.test.ts @@ -13,6 +13,7 @@ import getChildrenDirection from "../getChildrenDirection"; import visualBuilderPostMessage from "../visualBuilderPostMessage"; import { VisualBuilderPostMessageEvents } from "../types/postMessage.types"; import { singleLineFieldSchema } from "../../../__test__/data/fields"; +import { signal } from "@preact/signals"; Object.defineProperty(globalThis, "crypto", { value: { @@ -46,27 +47,16 @@ vi.mock("../visualBuilderPostMessage", async () => { }; }); -describe("generateAddInstanceButton", () => { - test("should generate a button", () => { - const button = generateAddInstanceButton({ - fieldSchema: singleLineFieldSchema, - value: "", - onClick: () => {}, - }); - expect(button.tagName).toBe("BUTTON"); - }); - - test("should run the callback when the button is clicked", () => { - const mockCallback = vi.fn(); - const button = generateAddInstanceButton({ - fieldSchema: singleLineFieldSchema, - value: "", - onClick: mockCallback, - }); - - button.click(); - expect(mockCallback).toHaveBeenCalled(); - }); +vi.mock("@preact/signals", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...(actual as unknown as Record), + signal: (initialValue: any) => { + return { + value: initialValue, + }; + }, + }; }); // TODO: rewrite this @@ -577,13 +567,19 @@ describe("removeAddInstanceButtons", () => { previousButton = generateAddInstanceButton({ fieldSchema: singleLineFieldSchema, + // @ts-expect-error mock field metadata + fieldMetadata: { hello: "world" }, value: "", onClick: vi.fn(), + loading: signal(false), }); nextButton = generateAddInstanceButton({ fieldSchema: singleLineFieldSchema, value: "", + // @ts-expect-error mock field metadata + fieldMetadata: { hello: "world" }, onClick: vi.fn(), + loading: signal(false), }); overlayWrapper = document.createElement("div"); eventTarget = document.createElement("div"); @@ -672,7 +668,10 @@ describe("removeAddInstanceButtons", () => { const button = generateAddInstanceButton({ fieldSchema: singleLineFieldSchema, value: "", - onClick: () => {}, + // @ts-expect-error mock field metadata + fieldMetadata: { hello: "world" }, + onClick: vi.fn(), + loading: signal(false), }); visualBuilderContainer.appendChild(button); } @@ -704,7 +703,10 @@ describe("removeAddInstanceButtons", () => { const button = generateAddInstanceButton({ fieldSchema: singleLineFieldSchema, value: "", - onClick: () => {}, + // @ts-expect-error mock field metadata + fieldMetadata: { hello: "world" }, + onClick: vi.fn(), + loading: signal(false), }); visualBuilderContainer.appendChild(button); } diff --git a/src/visualBuilder/utils/multipleElementAddButton.ts b/src/visualBuilder/utils/multipleElementAddButton.ts index adea673d..97fbe82a 100644 --- a/src/visualBuilder/utils/multipleElementAddButton.ts +++ b/src/visualBuilder/utils/multipleElementAddButton.ts @@ -3,12 +3,11 @@ import { generateAddInstanceButton, getAddInstanceButtons, } from "../generators/generateAddInstanceButtons"; -import visualBuilderPostMessage from "./visualBuilderPostMessage"; -import { VisualBuilderPostMessageEvents } from "./types/postMessage.types"; import getChildrenDirection from "./getChildrenDirection"; import { hideOverlay } from "../generators/generateOverlay"; import { hideHoverOutline } from "../listeners/mouseHover"; import { ISchemaFieldMap } from "./types/index.types"; +import { signal } from "@preact/signals"; const WAIT_FOR_NEW_INSTANCE_TIMEOUT = 4000; @@ -106,32 +105,30 @@ export function handleAddButtonsForMultiple( }); }; + // this is a shared loading state between the + // next and previous button for the duration + // between the add-instance post message being + // sent and receiving a response for it. + const loading = signal(false); + const previousButton = generateAddInstanceButton({ - onClick: () => { - visualBuilderPostMessage - ?.send(VisualBuilderPostMessageEvents.ADD_INSTANCE, { - fieldMetadata: eventDetails.fieldMetadata, - index: prevIndex, - }) - .then(onMessageSent.bind(null, prevIndex)); - }, - label, fieldSchema, value: expectedFieldData, + fieldMetadata: eventDetails.fieldMetadata, + index: prevIndex, + onClick: onMessageSent.bind(null, prevIndex), + loading, + label, }); const nextButton = generateAddInstanceButton({ - onClick: () => { - visualBuilderPostMessage - ?.send(VisualBuilderPostMessageEvents.ADD_INSTANCE, { - fieldMetadata: eventDetails.fieldMetadata, - index: nextIndex, - }) - .then(onMessageSent.bind(null, nextIndex)); - }, - label, fieldSchema, value: expectedFieldData, + fieldMetadata: eventDetails.fieldMetadata, + index: nextIndex, + onClick: onMessageSent.bind(null, nextIndex), + loading, + label, }); if (!visualBuilderContainer.contains(previousButton)) { @@ -216,7 +213,7 @@ export function removeAddInstanceButtons( } /** - * This function that observes the parent element and focuses the newly added instance. + * This function observes the parent element and focuses the newly added instance. * * @param parentCslp The parent cslp value. * @param index The index of the new instance. diff --git a/src/visualBuilder/visualBuilder.style.ts b/src/visualBuilder/visualBuilder.style.ts index 64327c4e..ee2cd279 100644 --- a/src/visualBuilder/visualBuilder.style.ts +++ b/src/visualBuilder/visualBuilder.style.ts @@ -148,6 +148,13 @@ export function visualBuilderStyles() { overflow: hidden; text-overflow: ellipsis; `, + "visual-builder__add-button--loading": css` + cursor: wait; + /* we have not-allowed on disabled, so we need this */ + &:disabled { + cursor: wait; + } + `, "visual-builder__start-editing-btn": css` z-index: 1000; text-decoration: none; @@ -666,7 +673,7 @@ export const VisualBuilderGlobalStyles = ` [data-cslp] [contenteditable="true"] { outline: none; } - + @keyframes visual-builder__spinner { 0% { transform: rotate(0deg);