From 9c490843bb9ddfb06dc7d889b90597453082ea35 Mon Sep 17 00:00:00 2001 From: Todd Schiller Date: Sun, 6 Oct 2024 18:16:26 -0400 Subject: [PATCH] 9244: move/copy mod component Drop invalid page editor page object method Fix CreateModModalBody logic --- .../pageObjects/pageEditor/createModModal.ts | 9 +- .../pageObjects/pageEditor/pageEditorPage.ts | 93 +++++--- .../pageEditor/moveCopyModComponent.spec.ts | 116 +++++++++ .../modListingPanel/ActionMenu.module.scss | 2 +- .../DraftModComponentListItem.tsx | 30 ++- .../ModComponentActionMenu.stories.tsx | 20 +- .../ModComponentActionMenu.tsx | 27 ++- .../modListingPanel/modals/AddToModModal.tsx | 221 ------------------ .../modListingPanel/modals/CreateModModal.tsx | 28 ++- .../modals/MoveCopyToModModal.tsx | 171 ++++++++++++++ .../modals/MoveFromModModal.tsx | 162 ------------- src/pageEditor/modals/Modals.tsx | 6 +- .../store/editor/editorSelectors.ts | 16 +- .../store/editor/editorSlice.test.ts | 5 +- src/pageEditor/store/editor/editorSlice.ts | 64 ++--- .../store/editor/pageEditorTypes.ts | 3 +- 16 files changed, 466 insertions(+), 507 deletions(-) create mode 100644 end-to-end-tests/tests/pageEditor/moveCopyModComponent.spec.ts delete mode 100644 src/pageEditor/modListingPanel/modals/AddToModModal.tsx create mode 100644 src/pageEditor/modListingPanel/modals/MoveCopyToModModal.tsx delete mode 100644 src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx diff --git a/end-to-end-tests/pageObjects/pageEditor/createModModal.ts b/end-to-end-tests/pageObjects/pageEditor/createModModal.ts index e939829107..ff32f52976 100644 --- a/end-to-end-tests/pageObjects/pageEditor/createModModal.ts +++ b/end-to-end-tests/pageObjects/pageEditor/createModModal.ts @@ -18,6 +18,7 @@ import { BasePageObject } from "../basePageObject"; import { type UUID } from "@/types/stringTypes"; import { ModifiesModFormState } from "./utils"; +import { uuidv4 } from "@/types/helpers"; export class CreateModModal extends BasePageObject { modIdInput = this.getByTestId("registryId-id-id"); @@ -27,11 +28,13 @@ export class CreateModModal extends BasePageObject { /** * Creates a mod using the Create Mod modal, with the given modId and modName. * @param modName the modName to use - * @param modUuid the UUID of the mod component from adding the starter brick + * @param modUuid an optional UUID to force the modId to be unique, if not provided a random UUID will be generated */ @ModifiesModFormState - async createMod(modName: string, modUuid: UUID): Promise { - const modId = `${modName.split(" ").join("-").toLowerCase()}-${modUuid}`; + async createMod(modName: string, modUuid?: UUID): Promise { + const modId = `${modName.split(" ").join("-").toLowerCase()}-${ + modUuid ?? uuidv4() + }`; await this.modIdInput.fill(modId); await this.modNameInput.fill(modName); diff --git a/end-to-end-tests/pageObjects/pageEditor/pageEditorPage.ts b/end-to-end-tests/pageObjects/pageEditor/pageEditorPage.ts index 0bb0f45003..5ad9f0de60 100644 --- a/end-to-end-tests/pageObjects/pageEditor/pageEditorPage.ts +++ b/end-to-end-tests/pageObjects/pageEditor/pageEditorPage.ts @@ -33,7 +33,6 @@ import { uuidv4 } from "@/types/helpers"; class EditorPane extends BasePageObject { editTab = this.getByRole("tab", { name: "Edit" }); - logsTab = this.getByRole("tab", { name: "Logs" }); runTriggerButton = this.getByRole("button", { name: "Run Trigger" }); autoRunTrigger = this.getSwitchByLabel("Auto-Run"); @@ -199,16 +198,68 @@ export class PageEditorPage extends BasePageObject { return { modId }; } + /** + * Create a new mod by moving a mod component to a new mod. + * @param sourceModComponentName the name of the mod component to move + * @param destinationModName the root name of the new mod + */ @ModifiesModFormState - async saveStandaloneMod(modName: string, modUuid: UUID) { - const modListItem = this.modListingPanel.getModListItemByName(modName); - await modListItem.select(); - await modListItem.saveButton.click(); + async moveModComponentToNewMod({ + sourceModComponentName, + destinationModName, + }: { + sourceModComponentName: string; + destinationModName: string; + }) { + const modListItem = this.modListingPanel.getModListItemByName( + sourceModComponentName, + ); + await modListItem.menuButton.click(); + await this.getByRole("menuitem", { name: "Move to Mod" }).click(); + + const moveDialog = this.getByRole("dialog"); + + await moveDialog.getByRole("combobox").click(); + await moveDialog.getByRole("option", { name: /Create new mod.../ }).click(); + await moveDialog.getByRole("button", { name: "Move" }).click(); + // Create mod modal is shown const createModModal = new CreateModModal(this.getByRole("dialog")); - const modId = await createModModal.createMod(modName, modUuid); - this.savedPackageModIds.push(modId); + const modId = await createModModal.createMod(destinationModName); + return { modId }; + } + + /** + * Create a new mod by copying a mod component to a new mod. + * @param sourceModComponentName the name of the mod component to move + * @param destinationModName the root name of the new mod + */ + @ModifiesModFormState + async copyModComponentToNewMod({ + sourceModComponentName, + destinationModName, + }: { + sourceModComponentName: string; + destinationModName: string; + }) { + const modListItem = this.modListingPanel.getModListItemByName( + sourceModComponentName, + ); + await modListItem.menuButton.click(); + await this.getByRole("menuitem", { name: "Copy to Mod" }).click(); + + const moveDialog = this.getByRole("dialog"); + + await moveDialog.getByRole("combobox").click(); + await moveDialog.getByRole("option", { name: /Create new mod.../ }).click(); + await moveDialog.getByRole("button", { name: "Copy" }).click(); + + // Create mod modal is shown + const createModModal = new CreateModModal(this.getByRole("dialog")); + + const modId = await createModModal.createMod(destinationModName); + return { modId }; } @ModifiesModFormState @@ -238,34 +289,6 @@ export class PageEditorPage extends BasePageObject { await deactivateModModal.deactivateButton.click(); } - @ModifiesModFormState - async createModFromModComponent({ - modNameRoot, - modComponentName, - modUuid, - }: { - modNameRoot: string; - modComponentName: string; - modUuid: UUID; - }) { - const modName = `${modNameRoot} ${modUuid}`; - - const modListItem = - this.modListingPanel.getModListItemByName(modComponentName); - await modListItem.menuButton.click(); - await this.getByRole("menuitem", { name: "Add to mod" }).click(); - - await this.getByText("Select...Choose a mod").click(); - await this.getByRole("option", { name: /Create new mod.../ }).click(); - await this.getByRole("button", { name: "Move" }).click(); - - // Create mod modal is shown - const createModModal = new CreateModModal(this.getByRole("dialog")); - const modId = await createModModal.createMod(modName, modUuid); - - return { modName, modId }; - } - /** * This method is meant to be called exactly once after the test is done to clean up any saved mods created during the * test. diff --git a/end-to-end-tests/tests/pageEditor/moveCopyModComponent.spec.ts b/end-to-end-tests/tests/pageEditor/moveCopyModComponent.spec.ts new file mode 100644 index 0000000000..b9c1cd7dc8 --- /dev/null +++ b/end-to-end-tests/tests/pageEditor/moveCopyModComponent.spec.ts @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { test, expect } from "../../fixtures/testBase"; +// @ts-expect-error -- https://youtrack.jetbrains.com/issue/AQUA-711/Provide-a-run-configuration-for-Playwright-tests-in-specs-with-fixture-imports-only +import { type Page, test as base } from "@playwright/test"; +import { uuidv4 } from "@/types/helpers"; + +test("Create new mod by moving mod component", async ({ + page, + newPageEditorPage, +}) => { + await page.goto("/"); + const pageEditorPage = await newPageEditorPage(page.url()); + + await test.step("Add new Trigger starter brick", async () => { + const { modComponentNameMatcher } = + await pageEditorPage.modListingPanel.addNewMod({ + starterBrickName: "Trigger", + }); + + await expect( + pageEditorPage.brickConfigurationPanel.getByRole("textbox", { + name: "Name", + }), + ).toHaveValue(modComponentNameMatcher); + }); + + const modComponentName = await pageEditorPage.brickConfigurationPanel + .getByRole("textbox", { + name: "Name", + }) + .inputValue(); + + // Since 2.1.4, new mods are created with the name "New Mod" instead of being a standalone mod component + // Use span locator to distinguish from the New Mod button + await expect( + pageEditorPage.locator("span").filter({ hasText: "New Mod" }), + ).toBeVisible(); + + const modName = `Destination Mod ${uuidv4()}`; + + await pageEditorPage.moveModComponentToNewMod({ + sourceModComponentName: modComponentName, + destinationModName: modName, + }); + + await expect(pageEditorPage.getByText(modName)).toBeVisible(); + await expect(pageEditorPage.getByText(modComponentName)).toBeVisible(); + + // Should not be visible. Because it's only mod component was moved + await expect( + pageEditorPage.locator("span").filter({ hasText: "New Mod" }), + ).toBeHidden(); +}); + +test("Create new mod by copying a mod component", async ({ + page, + newPageEditorPage, +}) => { + await page.goto("/"); + const pageEditorPage = await newPageEditorPage(page.url()); + + await test.step("Add new Trigger starter brick", async () => { + const { modComponentNameMatcher } = + await pageEditorPage.modListingPanel.addNewMod({ + starterBrickName: "Trigger", + }); + + await expect( + pageEditorPage.brickConfigurationPanel.getByRole("textbox", { + name: "Name", + }), + ).toHaveValue(modComponentNameMatcher); + }); + + const modComponentName = await pageEditorPage.brickConfigurationPanel + .getByRole("textbox", { + name: "Name", + }) + .inputValue(); + + // Since 2.1.4, new mods are created with the name "New Mod" instead of being a standalone mod component + // Use span locator to distinguish from the New Mod button + await expect( + pageEditorPage.locator("span").filter({ hasText: "New Mod" }), + ).toBeVisible(); + + const modName = `Destination Mod ${uuidv4()}`; + + await pageEditorPage.copyModComponentToNewMod({ + sourceModComponentName: modComponentName, + destinationModName: modName, + }); + + // Use span locator to distinguish from the New Mod button + await expect( + pageEditorPage.locator("span").filter({ hasText: "New Mod" }), + ).toBeVisible(); + await expect(pageEditorPage.getByText(modName)).toBeVisible(); + await expect(pageEditorPage.getByText(modComponentName)).toHaveCount(2); +}); diff --git a/src/pageEditor/modListingPanel/ActionMenu.module.scss b/src/pageEditor/modListingPanel/ActionMenu.module.scss index cf7345e8f7..03c2431ed8 100644 --- a/src/pageEditor/modListingPanel/ActionMenu.module.scss +++ b/src/pageEditor/modListingPanel/ActionMenu.module.scss @@ -45,7 +45,7 @@ margin-right: 2px; } -.removeIcon { +.moveIcon { margin-left: 3px; margin-right: -3px; } diff --git a/src/pageEditor/modListingPanel/DraftModComponentListItem.tsx b/src/pageEditor/modListingPanel/DraftModComponentListItem.tsx index 9fa2d8b122..495b68c481 100644 --- a/src/pageEditor/modListingPanel/DraftModComponentListItem.tsx +++ b/src/pageEditor/modListingPanel/DraftModComponentListItem.tsx @@ -201,30 +201,40 @@ const DraftModComponentListItem: React.FunctionComponent< { - dispatch(actions.duplicateActiveModComponent()); + dispatch( + actions.duplicateActiveModComponent({ + // Pass undefined to duplicate the mod component in the same mod + destinationModMetadata: undefined, + }), + ); }} onClearChanges={ modComponentFormState.installed ? onClearChanges : undefined } - onAddToMod={ + onMoveToMod={ modComponentFormState.modMetadata - ? undefined - : async () => { - dispatch(actions.showAddToModModal()); + ? async () => { + dispatch( + actions.showMoveCopyToModModal({ moveOrCopy: "move" }), + ); } + : undefined } - onRemoveFromMod={ + onCopyToMod={ modComponentFormState.modMetadata ? async () => { - dispatch(actions.showRemoveFromModModal()); + dispatch( + actions.showMoveCopyToModModal({ moveOrCopy: "copy" }), + ); } : undefined } + // TODO: https://github.com/pixiebrix/pixiebrix-extension/issues/9242, remove standalone mod component actions + onSave={onSave} + onDelete={onDelete} /> ); diff --git a/src/pageEditor/modListingPanel/ModComponentActionMenu.stories.tsx b/src/pageEditor/modListingPanel/ModComponentActionMenu.stories.tsx index 196f888369..0749d30bb8 100644 --- a/src/pageEditor/modListingPanel/ModComponentActionMenu.stories.tsx +++ b/src/pageEditor/modListingPanel/ModComponentActionMenu.stories.tsx @@ -27,10 +27,6 @@ export default { control: "boolean", defaultValue: false, }, - disabled: { - control: "boolean", - defaultValue: false, - }, }, } as ComponentMeta; @@ -43,28 +39,32 @@ const Template: ComponentStory = (args) => ( export const NewModComponent = Template.bind({}); NewModComponent.args = { onClearChanges: undefined, - onRemoveFromMod: undefined, + onCopyToMod: undefined, + onMoveToMod: undefined, isDirty: true, }; export const OldModComponent = Template.bind({}); OldModComponent.args = { - onRemoveFromMod: undefined, + onCopyToMod: undefined, + onMoveToMod: undefined, }; export const Mod = Template.bind({}); Mod.args = { - onAddToMod: undefined, - onRemoveFromMod: undefined, + onCopyToMod: undefined, + onMoveToMod: undefined, }; export const NewModComponentInMod = Template.bind({}); NewModComponentInMod.args = { onClearChanges: undefined, - onAddToMod: undefined, + onCopyToMod: undefined, + onMoveToMod: undefined, }; export const OldModComponentInMod = Template.bind({}); OldModComponentInMod.args = { - onAddToMod: undefined, + onCopyToMod: undefined, + onMoveToMod: undefined, }; diff --git a/src/pageEditor/modListingPanel/ModComponentActionMenu.tsx b/src/pageEditor/modListingPanel/ModComponentActionMenu.tsx index 81815704cd..ad370a483a 100644 --- a/src/pageEditor/modListingPanel/ModComponentActionMenu.tsx +++ b/src/pageEditor/modListingPanel/ModComponentActionMenu.tsx @@ -19,7 +19,6 @@ import React from "react"; import { faClone, faFileExport, - faFileImport, faHistory, faTimes, faTrash, @@ -40,8 +39,8 @@ type ActionMenuProps = { onDelete: OptionalAction; onDuplicate: OptionalAction; onClearChanges: OptionalAction; - onAddToMod: OptionalAction; - onRemoveFromMod: OptionalAction; + onMoveToMod: OptionalAction; + onCopyToMod: OptionalAction; // TODO: https://github.com/pixiebrix/pixiebrix-extension/issues/9242, remove standalone mod component actions onSave: OptionalAction; onDeactivate: OptionalAction; @@ -54,8 +53,8 @@ const ModComponentActionMenu: React.FC = ({ onDelete = null, onDuplicate = null, onClearChanges = null, - onAddToMod = null, - onRemoveFromMod = null, + onMoveToMod = null, + onCopyToMod = null, // Standalone Mod Component Actions onSave = null, onDeactivate = null, @@ -76,28 +75,28 @@ const ModComponentActionMenu: React.FC = ({ hide: !onDuplicate, }, { - title: "Add to mod", + title: "Move to mod", icon: ( ), - action: onAddToMod, - hide: !onAddToMod, + action: onMoveToMod, + hide: !onMoveToMod, }, { - title: "Move from mod", + title: "Copy to mod", icon: ( ), - action: onRemoveFromMod, - hide: !onRemoveFromMod, + action: onCopyToMod, + hide: !onCopyToMod, }, { title: "Delete", diff --git a/src/pageEditor/modListingPanel/modals/AddToModModal.tsx b/src/pageEditor/modListingPanel/modals/AddToModModal.tsx deleted file mode 100644 index 3d77443710..0000000000 --- a/src/pageEditor/modListingPanel/modals/AddToModModal.tsx +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (C) 2024 PixieBrix, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React, { useCallback, useMemo } from "react"; -import { Button, Modal } from "react-bootstrap"; -import SelectWidget from "@/components/form/widgets/SelectWidget"; -import { useDispatch, useSelector } from "react-redux"; -import { actions as editorActions } from "@/pageEditor/store/editor/editorSlice"; -import { - selectActiveModComponentFormState, - selectEditorModalVisibilities, - selectActivatedModMetadatas, -} from "@/pageEditor/store/editor/editorSelectors"; -import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate"; -import notify from "@/utils/notify"; -import Form, { - type OnSubmit, - type RenderBody, - type RenderSubmit, -} from "@/components/form/Form"; -import { object, string } from "yup"; -import RadioItemListWidget from "@/components/form/widgets/radioItemList/RadioItemListWidget"; -import { type RadioItem } from "@/components/form/widgets/radioItemList/radioItemListWidgetTypes"; -import { isSingleObjectBadRequestError } from "@/errors/networkErrorHelpers"; -import { type RegistryId } from "@/types/registryTypes"; -import { type ModComponentBase } from "@/types/modComponentTypes"; -import { useRemoveModComponentFromStorage } from "@/pageEditor/hooks/useRemoveModComponentFromStorage"; -import { assertNotNullish } from "@/utils/nullishUtils"; - -type FormState = { - modId: RegistryId | null; - moveOrCopy: "move" | "copy"; -}; - -const initialFormState: FormState = { - modId: null, - moveOrCopy: "move", -}; - -const NEW_MOD_ID = "@new" as RegistryId; - -const formStateSchema = object({ - modId: string().required(), - moveOrCopy: string().oneOf(["move", "copy"]).required(), -}); - -const AddToModModal: React.FC = () => { - const { isAddToModModalVisible: show } = useSelector( - selectEditorModalVisibilities, - ); - const activatedModMetadatas = useSelector(selectActivatedModMetadatas); - const activeModComponentFormState = useSelector( - selectActiveModComponentFormState, - ); - const removeModComponentFromStorage = useRemoveModComponentFromStorage(); - - const modMetadataById = useMemo(() => { - const result: Record = {}; - for (const metadata of activatedModMetadatas) { - result[metadata.id] = metadata; - } - - return result; - }, [activatedModMetadatas]); - - const dispatch = useDispatch(); - - const hideModal = useCallback(() => { - dispatch(editorActions.hideModal()); - }, [dispatch]); - - const onSubmit: OnSubmit = async ( - { modId, moveOrCopy }, - helpers, - ) => { - assertNotNullish(modId, "Mod id must be defined"); - const keepLocalCopy = moveOrCopy === "copy"; - - if (modId === NEW_MOD_ID) { - dispatch(editorActions.showCreateModModal({ keepLocalCopy })); - return; - } - - // eslint-disable-next-line security/detect-object-injection -- mod id is from select options - const modMetadata = modMetadataById[modId]; - - try { - const modComponentId = activeModComponentFormState?.uuid; - assertNotNullish(modComponentId, "modComponentId must be defined"); - dispatch( - editorActions.addModComponentFormStateToMod({ - modComponentId, - modMetadata, - keepLocalCopy, - }), - ); - if (!keepLocalCopy) { - await removeModComponentFromStorage({ - modComponentId, - }); - } - - // Need to setSubmitting to false before hiding the modal, - // otherwise the form will be unmounted before the state update - helpers.setSubmitting(false); - - hideModal(); - } catch (error) { - if (isSingleObjectBadRequestError(error) && error.response?.data.config) { - helpers.setStatus(error.response.data.config); - return; - } - - notify.error({ - message: "Problem adding to mod", - error, - }); - - helpers.setSubmitting(false); - } - }; - - const selectOptions = useMemo( - () => [ - { label: "➕ Create new mod...", value: NEW_MOD_ID }, - ...activatedModMetadatas.map((metadata) => ({ - label: metadata.name, - value: metadata.id, - })), - ], - [activatedModMetadatas], - ); - - const radioItems: RadioItem[] = useMemo( - () => [ - { - label: "Move into the selected mod", - value: "move", - }, - { - label: "Create a copy in the selected mod", - value: "copy", - }, - ], - [], - ); - - const renderBody: RenderBody = useCallback( - () => ( - - - - - ), - [radioItems, selectOptions], - ); - - const renderSubmit: RenderSubmit = useCallback( - ({ isSubmitting, isValid, values: { moveOrCopy } }) => ( - - - - - ), - [hideModal], - ); - - return ( - - - - Add {activeModComponentFormState?.label} to a mod - - -
- - ); -}; - -export default AddToModModal; diff --git a/src/pageEditor/modListingPanel/modals/CreateModModal.tsx b/src/pageEditor/modListingPanel/modals/CreateModModal.tsx index d74bf188aa..f6459fceca 100644 --- a/src/pageEditor/modListingPanel/modals/CreateModModal.tsx +++ b/src/pageEditor/modListingPanel/modals/CreateModModal.tsx @@ -163,21 +163,19 @@ const CreateModModalBody: React.FC = () => { ); const { createModFromMod } = useCreateModFromMod(); + const { createModFromUnsavedMod } = useCreateModFromUnsavedMod(); const { createModFromComponent } = useCreateModFromModComponent( activeModComponentFormState, ); - // `selectActiveModId` returns the mod id if a mod is selected. Assumption: if the CreateModal - // is open, and a mod is active, then we're performing a "Save as New" on that mod. + // `selectActiveModId` returns the mod id if a mod entry is selected (not a mod component within the mod) const directlyActiveModId = useSelector(selectActiveModId); const activeModId = directlyActiveModId ?? activeModComponentFormState?.modMetadata?.id; - const { data: activeModDefinition, isFetching: isModFetching } = + const { data: activeModDefinition, isFetching: isModDefinitionFetching } = useOptionalModDefinition(activeModId); - const { createModFromUnsavedMod } = useCreateModFromUnsavedMod(); - const formSchema = useFormSchema(); const hideModal = useCallback(() => { @@ -191,21 +189,20 @@ const CreateModModalBody: React.FC = () => { }); const onSubmit: OnSubmit = async (values, helpers) => { - if (isModFetching) { + if (isModDefinitionFetching) { helpers.setSubmitting(false); return; } try { - // If the active mod's saved definition could be loaded from the server, we need to use createModFromMod - if (activeModDefinition) { - await createModFromMod(activeModDefinition, values); - } else if (activeModId && isInnerDefinitionRegistryId(activeModId)) { - // New local mod, definition couldn't be fetched from the server, so we use createModFromUnsavedMod - await createModFromUnsavedMod(activeModId, values); - } else if (activeModComponentFormState) { - // Stand-alone mod component + if (activeModComponentFormState) { + // Move/Copy a mod component to create a new mod await createModFromComponent(activeModComponentFormState, values); + } else if (directlyActiveModId && activeModDefinition) { + await createModFromMod(activeModDefinition, values); + } else if (directlyActiveModId) { + // If the mod is unsaved or there was an error fetching the mod definition from the server + await createModFromUnsavedMod(directlyActiveModId, values); } else { // Should not happen in practice // noinspection ExceptionCaughtLocallyJS @@ -215,6 +212,7 @@ const CreateModModalBody: React.FC = () => { notify.success({ message: "Mod created successfully", }); + hideModal(); } catch (error) { if (isSingleObjectBadRequestError(error) && error.response.data.config) { @@ -280,7 +278,7 @@ const CreateModModalBody: React.FC = () => { return ( <> - {isModFetching ? ( + {isModDefinitionFetching ? ( ) : ( . + */ + +import React, { useCallback, useMemo } from "react"; +import { Button, Modal } from "react-bootstrap"; +import SelectWidget from "@/components/form/widgets/SelectWidget"; +import { useDispatch, useSelector } from "react-redux"; +import { actions as editorActions } from "@/pageEditor/store/editor/editorSlice"; +import { + selectActiveModComponentFormState, + selectEditorModalVisibilities, + selectKeepLocalCopyOnCreateMod, + selectModMetadataMap, +} from "@/pageEditor/store/editor/editorSelectors"; +import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate"; +import Form, { + type OnSubmit, + type RenderBody, + type RenderSubmit, +} from "@/components/form/Form"; +import { object, string } from "yup"; +import { type RegistryId } from "@/types/registryTypes"; +import { assertNotNullish } from "@/utils/nullishUtils"; + +type FormState = { + modId: RegistryId | null; +}; + +const initialFormState: FormState = { + modId: null, +}; + +const NEW_MOD_ID = "@new" as RegistryId; + +const formStateSchema = object({ + modId: string().required(), +}); + +/** + * Modal to move/copy a mod component to an existing or new mod + */ +const MoveCopyToModModal: React.FC = () => { + const { isMoveCopyToModVisible: show } = useSelector( + selectEditorModalVisibilities, + ); + const modMetadataMap = useSelector(selectModMetadataMap); + const isCopyAction = useSelector(selectKeepLocalCopyOnCreateMod); + const activeModComponentFormState = useSelector( + selectActiveModComponentFormState, + ); + + const dispatch = useDispatch(); + + const hideModal = useCallback(() => { + dispatch(editorActions.hideModal()); + }, [dispatch]); + + const onSubmit: OnSubmit = async ({ modId }) => { + assertNotNullish(modId, "Invalid form state: modId is null"); + assertNotNullish(activeModComponentFormState, "No active mod component"); + + if (modId === NEW_MOD_ID) { + dispatch( + editorActions.showCreateModModal({ keepLocalCopy: isCopyAction }), + ); + return; + } + + const modMetadata = modMetadataMap.get(modId); + assertNotNullish( + modMetadata, + "Invalid for state: mod id does not match activate mod metadata entry", + ); + + dispatch( + editorActions.duplicateActiveModComponent({ + destinationModMetadata: modMetadata, + }), + ); + + if (!isCopyAction) { + // Remove the original mod component to complete the move action + dispatch( + editorActions.removeModComponentFormState( + activeModComponentFormState.uuid, + ), + ); + } + + hideModal(); + }; + + const selectOptions = useMemo( + () => [ + { label: "➕ Create new mod...", value: NEW_MOD_ID }, + ...[...modMetadataMap.values()].map((metadata) => ({ + label: metadata.name, + value: metadata.id, + })), + ], + [modMetadataMap], + ); + + const renderBody: RenderBody = useCallback( + () => ( + + + + ), + [selectOptions], + ); + + const renderSubmit: RenderSubmit = useCallback( + ({ isSubmitting, isValid }) => ( + + + + + ), + [isCopyAction, hideModal], + ); + + return ( + + + + {isCopyAction ? "Copy" : "Move"}{" "} + {activeModComponentFormState?.label} + + + + + ); +}; + +export default MoveCopyToModModal; diff --git a/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx b/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx deleted file mode 100644 index 347b3f2010..0000000000 --- a/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx +++ /dev/null @@ -1,162 +0,0 @@ -/* - * Copyright (C) 2024 PixieBrix, Inc. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import React, { useCallback } from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { actions } from "@/pageEditor/store/editor/editorSlice"; -import { - selectActiveModComponentFormState, - selectEditorModalVisibilities, -} from "@/pageEditor/store/editor/editorSelectors"; -import notify from "@/utils/notify"; -import { Alert, Button, Modal } from "react-bootstrap"; -import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate"; -import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import { - faExclamationTriangle, - faHistory, -} from "@fortawesome/free-solid-svg-icons"; -import { object, string } from "yup"; -import Form, { - type OnSubmit, - type RenderBody, - type RenderSubmit, -} from "@/components/form/Form"; -import { type RadioItem } from "@/components/form/widgets/radioItemList/radioItemListWidgetTypes"; -import RadioItemListWidget from "@/components/form/widgets/radioItemList/RadioItemListWidget"; -import { assertNotNullish } from "@/utils/nullishUtils"; - -type FormState = { - moveOrRemove: "move" | "remove"; -}; - -const initialFormState: FormState = { - moveOrRemove: "move", -}; - -const formStateSchema = object({ - moveOrRemove: string().oneOf(["move", "remove"]).required(), -}); - -const MoveFromModModal: React.FC = () => { - const { isRemoveFromModModalVisible: show } = useSelector( - selectEditorModalVisibilities, - ); - const modComponentFormState = useSelector(selectActiveModComponentFormState); - - const dispatch = useDispatch(); - const hideModal = useCallback(() => { - dispatch(actions.hideModal()); - }, [dispatch]); - - const onSubmit = useCallback>( - async ({ moveOrRemove }, helpers) => { - const keepLocalCopy = moveOrRemove === "move"; - - try { - const modComponentId = modComponentFormState?.uuid; - assertNotNullish( - modComponentId, - "mod component id not found for active mod component", - ); - dispatch( - actions.removeModComponentFormStateFromMod({ - modComponentId, - keepLocalCopy, - }), - ); - hideModal(); - } catch (error) { - notify.error({ - message: "Problem removing from mod", - error, - }); - } finally { - helpers.setSubmitting(false); - } - }, - [modComponentFormState?.uuid, dispatch, hideModal], - ); - - const radioItems: RadioItem[] = [ - { - label: "Move the starter brick to stand-alone", - value: "move", - }, - { - label: "Delete starter brick", - value: "remove", - }, - ]; - - const renderBody: RenderBody = ({ values }) => ( - - - {values.moveOrRemove === "remove" && ( - - -  The{" "} - - Reset{" "} - - action located on the mod's three-dot menu can be used to restore - the starter brick before saving the mod. - - )} - - ); - - const renderSubmit: RenderSubmit = ({ isSubmitting, isValid, values }) => ( - - - - - ); - - return ( - - - - Remove {modComponentFormState?.label} from mod{" "} - {modComponentFormState?.modMetadata?.name}? - - - - - ); -}; - -export default MoveFromModModal; diff --git a/src/pageEditor/modals/Modals.tsx b/src/pageEditor/modals/Modals.tsx index 0e698caeba..da33d85fbc 100644 --- a/src/pageEditor/modals/Modals.tsx +++ b/src/pageEditor/modals/Modals.tsx @@ -17,16 +17,14 @@ import AddBrickModal from "@/pageEditor/modals/addBrickModal/AddBrickModal"; import React from "react"; -import AddToModModal from "@/pageEditor/modListingPanel/modals/AddToModModal"; import CreateModModal from "@/pageEditor/modListingPanel/modals/CreateModModal"; -import MoveFromModModal from "@/pageEditor/modListingPanel/modals/MoveFromModModal"; +import MoveCopyToModModal from "@/pageEditor/modListingPanel/modals/MoveCopyToModModal"; import SaveAsNewModModal from "@/pageEditor/modListingPanel/modals/SaveAsNewModModal"; import SaveDataIntegrityErrorModal from "@/pageEditor/panes/save/SaveDataIntegrityErrorModal"; const Modals: React.FunctionComponent = () => ( <> - - + diff --git a/src/pageEditor/store/editor/editorSelectors.ts b/src/pageEditor/store/editor/editorSelectors.ts index 9be373fffe..1ff30bea70 100644 --- a/src/pageEditor/store/editor/editorSelectors.ts +++ b/src/pageEditor/store/editor/editorSelectors.ts @@ -264,9 +264,7 @@ export const selectModIsDirty = Boolean(modId && modIsDirtySelector(state, modId)); export const selectEditorModalVisibilities = ({ editor }: EditorRootState) => ({ - isAddToModModalVisible: editor.visibleModalKey === ModalKey.ADD_TO_MOD, - isRemoveFromModModalVisible: - editor.visibleModalKey === ModalKey.REMOVE_FROM_MOD, + isMoveCopyToModVisible: editor.visibleModalKey === ModalKey.MOVE_COPY_TO_MOD, isSaveAsNewModModalVisible: editor.visibleModalKey === ModalKey.SAVE_AS_NEW_MOD, isCreateModModalVisible: editor.visibleModalKey === ModalKey.CREATE_MOD, @@ -310,6 +308,18 @@ export const selectActivatedModMetadatas = createSelector( }, ); +export const selectModMetadataMap = createSelector( + selectActivatedModMetadatas, + (metadatas) => { + const metadataMap = new Map(); + for (const metadata of metadatas) { + metadataMap.set(metadata.id, metadata); + } + + return metadataMap; + }, +); + export const selectEditorUpdateKey = ({ editor }: EditorRootState) => editor.selectionSeq; diff --git a/src/pageEditor/store/editor/editorSlice.test.ts b/src/pageEditor/store/editor/editorSlice.test.ts index 2a9e74db29..350b387163 100644 --- a/src/pageEditor/store/editor/editorSlice.test.ts +++ b/src/pageEditor/store/editor/editorSlice.test.ts @@ -226,7 +226,10 @@ describe("Add/Remove Bricks", () => { const dispatch = jest.fn(); const getState: () => EditorRootState = () => ({ editor }); - await actions.duplicateActiveModComponent()(dispatch, getState, undefined); + await actions.duplicateActiveModComponent({ + // Pass undefined to duplicate the mod component in the same mod + destinationModMetadata: undefined, + })(dispatch, getState, undefined); // Dispatch call args (actions) should be: // 1. thunk pending diff --git a/src/pageEditor/store/editor/editorSlice.ts b/src/pageEditor/store/editor/editorSlice.ts index 6bd1a6ac41..2e0b559eaa 100644 --- a/src/pageEditor/store/editor/editorSlice.ts +++ b/src/pageEditor/store/editor/editorSlice.ts @@ -119,34 +119,44 @@ export const initialState: EditorState = { /** * Duplicate the active mod component within the containing mod. + * */ const duplicateActiveModComponent = createAsyncThunk< void, - void, + { + /** + * Optional destination mod to create the duplicate in. + */ + destinationModMetadata?: ModComponentBase["_recipe"]; + }, { state: EditorRootState } ->("editor/duplicateActiveModComponent", async (arg, thunkAPI) => { - const state = thunkAPI.getState(); +>( + "editor/duplicateActiveModComponent", + async ({ destinationModMetadata }, thunkAPI) => { + const state = thunkAPI.getState(); + + const originalFormState = selectActiveModComponentFormState(state); + assertNotNullish( + originalFormState, + "Active mod component form state not found", + ); - const originalFormState = selectActiveModComponentFormState(state); - assertNotNullish( - originalFormState, - "Active mod component form state not found", - ); + const newFormState = await produce(originalFormState, async (draft) => { + draft.uuid = uuidv4(); + draft.label += " (Copy)"; + draft.modMetadata = destinationModMetadata ?? draft.modMetadata; + // Re-generate instance IDs for all the bricks in the mod component + draft.modComponent.brickPipeline = await normalizePipelineForEditor( + draft.modComponent.brickPipeline, + ); + }); - const newFormState = await produce(originalFormState, async (draft) => { - draft.uuid = uuidv4(); - draft.label += " (Copy)"; - // Re-generate instance IDs for all the bricks in the mod component - draft.modComponent.brickPipeline = await normalizePipelineForEditor( - draft.modComponent.brickPipeline, + thunkAPI.dispatch( + // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Add the cloned mod component + actions.addModComponentFormState(newFormState), ); - }); - - thunkAPI.dispatch( - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- Add the cloned mod component - actions.addModComponentFormState(newFormState), - ); -}); + }, +); type AvailableActivatedModComponents = { availableActivatedModComponentIds: UUID[]; @@ -607,9 +617,6 @@ export const editorSlice = createSlice({ formState.modMetadata = modMetadata; } }, - showAddToModModal(state) { - state.visibleModalKey = ModalKey.ADD_TO_MOD; - }, addModComponentFormStateToMod( state, action: PayloadAction<{ @@ -655,8 +662,13 @@ export const editorSlice = createSlice({ } } }, - showRemoveFromModModal(state) { - state.visibleModalKey = ModalKey.REMOVE_FROM_MOD; + showMoveCopyToModModal( + state, + action: PayloadAction<{ moveOrCopy: "move" | "copy" }>, + ) { + const { moveOrCopy } = action.payload; + state.visibleModalKey = ModalKey.MOVE_COPY_TO_MOD; + state.keepLocalCopyOnCreateMod = moveOrCopy === "copy"; }, removeModComponentFormStateFromMod( state, diff --git a/src/pageEditor/store/editor/pageEditorTypes.ts b/src/pageEditor/store/editor/pageEditorTypes.ts index 9ad94a68a5..03246d6a38 100644 --- a/src/pageEditor/store/editor/pageEditorTypes.ts +++ b/src/pageEditor/store/editor/pageEditorTypes.ts @@ -61,8 +61,7 @@ export type AddBrickLocation = { }; export enum ModalKey { - ADD_TO_MOD, - REMOVE_FROM_MOD, + MOVE_COPY_TO_MOD, SAVE_AS_NEW_MOD, CREATE_MOD, ADD_BRICK,