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 b3689ba157..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 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/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 ? ( ) : (