From d87f5a53a34b0c40a4ddfc2cbdf5d4527cd4b496 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 6 Sep 2024 20:23:59 -0400 Subject: [PATCH 01/50] when saving mod, if inner id, show create mod modal, and remove save from components --- src/pageEditor/hooks/useSaveMod.ts | 6 ++++++ src/pageEditor/modListingPanel/ActionMenu.tsx | 14 ++++++++------ .../modListingPanel/DraftModComponentListItem.tsx | 14 -------------- 3 files changed, 14 insertions(+), 20 deletions(-) diff --git a/src/pageEditor/hooks/useSaveMod.ts b/src/pageEditor/hooks/useSaveMod.ts index 775738794c..589d05a093 100644 --- a/src/pageEditor/hooks/useSaveMod.ts +++ b/src/pageEditor/hooks/useSaveMod.ts @@ -49,6 +49,7 @@ import { reloadModsEveryTab } from "@/contentScript/messenger/api"; import type { ModComponentBase } from "@/types/modComponentTypes"; import { pick } from "lodash"; import { assertNotNullish } from "@/utils/nullishUtils"; +import { isInnerDefinitionRegistryId } from "@/types/helpers"; const { actions: modComponentActions } = modComponentSlice; @@ -107,6 +108,11 @@ function useSaveMod(): ModSaver { * @returns boolean indicating successful save */ async function save(modId: RegistryId): Promise { + if (isInnerDefinitionRegistryId(modId)) { + dispatch(editorActions.showCreateModModal({ keepLocalCopy: false })); + return; + } + if (!editablePackages) { return; } diff --git a/src/pageEditor/modListingPanel/ActionMenu.tsx b/src/pageEditor/modListingPanel/ActionMenu.tsx index 400bbe1bf4..4b9e3011b7 100644 --- a/src/pageEditor/modListingPanel/ActionMenu.tsx +++ b/src/pageEditor/modListingPanel/ActionMenu.tsx @@ -36,7 +36,7 @@ import { useAvailableFormStateAdapters } from "@/pageEditor/starterBricks/adapte type ActionMenuProps = { labelRoot?: string; - onSave: () => Promise; + onSave?: () => Promise; // Make onSave optional onDelete?: () => Promise; onDeactivate?: () => Promise; onClone: () => Promise; @@ -135,11 +135,13 @@ const ActionMenu: React.FC = ({ return (
- + {onSave && ( // Only render SaveButton if onSave is provided + + )} removeModComponentFromStorage({ @@ -121,16 +119,6 @@ const DraftModComponentListItem: React.FunctionComponent< showConfirmationModal: DEACTIVATE_MOD_MODAL_PROPS, }); - const onSave = async () => { - if (modComponentFormState.modMetadata) { - await saveMod(modComponentFormState.modMetadata?.id); - } else { - dispatch(actions.showCreateModModal({ keepLocalCopy: false })); - } - }; - - const isSaving = modComponentFormState.modMetadata ? isSavingMod : false; - const onReset = async () => resetModComponent({ modComponentId: modComponentFormState.uuid }); @@ -202,7 +190,6 @@ const DraftModComponentListItem: React.FunctionComponent< {isActive && ( )} From 8675fa30b34eb6f035cff6f33e1d5b2426208301 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 6 Sep 2024 20:25:54 -0400 Subject: [PATCH 02/50] rename reset to clear changes --- src/pageEditor/modListingPanel/ActionMenu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pageEditor/modListingPanel/ActionMenu.tsx b/src/pageEditor/modListingPanel/ActionMenu.tsx index 4b9e3011b7..f57bc7c6d7 100644 --- a/src/pageEditor/modListingPanel/ActionMenu.tsx +++ b/src/pageEditor/modListingPanel/ActionMenu.tsx @@ -65,7 +65,7 @@ const ActionMenu: React.FC = ({ const menuItems: EllipsisMenuItem[] = [ { - title: "Reset", + title: "Clear Changes", icon: , action: onReset, disabled: !isDirty || disabled, From a1277bb67d29f51bf9e5f5f3ce8d7376b18445ee Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 6 Sep 2024 21:13:02 -0400 Subject: [PATCH 03/50] add test --- src/pageEditor/hooks/useSaveMod.test.ts | 47 ++++++++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/src/pageEditor/hooks/useSaveMod.test.ts b/src/pageEditor/hooks/useSaveMod.test.ts index f6eae014fe..3cc4e4d47a 100644 --- a/src/pageEditor/hooks/useSaveMod.test.ts +++ b/src/pageEditor/hooks/useSaveMod.test.ts @@ -26,11 +26,17 @@ import modDefinitionRegistry from "@/modDefinitions/registry"; import { loadBrickYaml } from "@/runtime/brickYaml"; import { type ModDefinition } from "@/types/modDefinitionTypes"; import type { components } from "@/types/swagger"; -import { editorSlice } from "@/pageEditor/store/editor/editorSlice"; +import { + actions as editorActions, + editorSlice, +} from "@/pageEditor/store/editor/editorSlice"; import type { EditablePackageMetadata } from "@/types/contract"; import modComponentSlice from "@/store/modComponents/modComponentSlice"; import { type UUID } from "@/types/stringTypes"; import { API_PATHS } from "@/data/service/urlPaths"; +import { getStandaloneModComponentRuntimeModId } from "@/utils/modUtils"; +import { autoUUIDSequence } from "@/testUtils/factories/stringFactories"; +import { modMetadataFactory } from "@/testUtils/factories/modComponentFactories"; const modId = validateRegistryId("@test/mod"); @@ -251,6 +257,45 @@ describe("useSaveMod", () => { }, }); }); + + it("opens the create mod modal if save is called with a temporary, internal mod", async () => { + const temporaryModId = + getStandaloneModComponentRuntimeModId(autoUUIDSequence()); + + const { result, waitForEffect, getReduxStore } = renderHook( + () => useSaveMod(), + { + setupRedux(dispatch, { store }) { + jest.spyOn(store, "dispatch"); + dispatch( + modComponentSlice.actions.activateMod({ + modDefinition: defaultModDefinitionFactory({ + metadata: modMetadataFactory({ + id: temporaryModId, + }), + }), + screen: "pageEditor", + isReactivate: false, + }), + ); + }, + }, + ); + + await waitForEffect(); + + const { dispatch } = getReduxStore(); + + await hookAct(async () => { + await result.current.save(temporaryModId); + }); + + expect(dispatch).toHaveBeenCalledWith( + editorActions.showCreateModModal({ keepLocalCopy: false }), + ); + expect(notify.success).not.toHaveBeenCalled(); + expect(notify.error).not.toHaveBeenCalled(); + }); }); describe("isModEditable", () => { From 543fe0f4456872c29bdcb46743d2720c2bf37ef8 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 6 Sep 2024 21:17:04 -0400 Subject: [PATCH 04/50] rename component --- src/pageEditor/modListingPanel/ModListingPanel.tsx | 4 ++-- .../{AddStarterBrickButton.tsx => NewModButton.tsx} | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) rename src/pageEditor/modListingPanel/{AddStarterBrickButton.tsx => NewModButton.tsx} (97%) diff --git a/src/pageEditor/modListingPanel/ModListingPanel.tsx b/src/pageEditor/modListingPanel/ModListingPanel.tsx index 2d40d60098..2b00b96c34 100644 --- a/src/pageEditor/modListingPanel/ModListingPanel.tsx +++ b/src/pageEditor/modListingPanel/ModListingPanel.tsx @@ -30,7 +30,7 @@ import useFlags from "@/hooks/useFlags"; import { selectIsEditorSidebarExpanded } from "@/pageEditor/store/editor/editorSelectors"; import HomeButton from "./HomeButton"; import ReloadButton from "./ReloadButton"; -import AddStarterBrickButton from "./AddStarterBrickButton"; +import NewModButton from "./NewModButton"; import ModComponents from "./ModComponents"; import { FeatureFlags } from "@/auth/featureFlags"; @@ -81,7 +81,7 @@ const ModListingPanel: React.VFC = () => { in={isExpanded} className={styles.horizontalActions} > - + {showDeveloperUI && } ); -const AddStarterBrickButton: React.FunctionComponent = () => { +const NewModButton: React.FunctionComponent = () => { const tabHasPermissions = useSelector(selectTabHasPermissions); const sessionId = useSelector(selectSessionId); const modComponentFormStateAdapters = useAvailableFormStateAdapters(); @@ -96,4 +96,4 @@ const AddStarterBrickButton: React.FunctionComponent = () => { ); }; -export default AddStarterBrickButton; +export default NewModButton; From 2dbbbf7b3d99b5a2c974fe5546e6ebdce8b84a4c Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 6 Sep 2024 21:17:26 -0400 Subject: [PATCH 05/50] change label --- src/pageEditor/modListingPanel/NewModButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pageEditor/modListingPanel/NewModButton.tsx b/src/pageEditor/modListingPanel/NewModButton.tsx index cb179c2dcd..3769d39d8a 100644 --- a/src/pageEditor/modListingPanel/NewModButton.tsx +++ b/src/pageEditor/modListingPanel/NewModButton.tsx @@ -62,7 +62,7 @@ const NewModButton: React.FunctionComponent = () => { disabled={!tabHasPermissions} variant="info" size="sm" - title="Add" + title="New Mod" id="add-starter-brick" > {modComponentFormStateAdapters.map((adapter) => ( From dd7a025d9e6105898d47a101e3759ece3946f2b9 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Sat, 7 Sep 2024 14:35:34 -0400 Subject: [PATCH 06/50] implement save logic for unsaved, temp mods --- src/pageEditor/hooks/useAddNewModComponent.ts | 17 ++ .../hooks/useBuildAndValidateMod.ts | 56 ++--- src/pageEditor/hooks/useCreateModFromMod.ts | 2 +- .../hooks/useCreateModFromUnsavedMod.ts | 199 ++++++++++++++++++ src/pageEditor/hooks/useSaveMod.ts | 2 +- .../modListingPanel/NewModButton.tsx | 2 +- .../modListingPanel/modals/CreateModModal.tsx | 41 +++- src/pageEditor/panes/save/saveHelpers.ts | 8 +- .../store/editor/editorSelectors.ts | 7 + src/pageEditor/store/editor/editorSlice.ts | 10 +- .../modMetadata/ModMetadataEditor.module.scss | 8 + .../tabs/modMetadata/ModMetadataEditor.tsx | 38 +++- 12 files changed, 335 insertions(+), 55 deletions(-) create mode 100644 src/pageEditor/hooks/useCreateModFromUnsavedMod.ts diff --git a/src/pageEditor/hooks/useAddNewModComponent.ts b/src/pageEditor/hooks/useAddNewModComponent.ts index 31f82ffafd..22fce01105 100644 --- a/src/pageEditor/hooks/useAddNewModComponent.ts +++ b/src/pageEditor/hooks/useAddNewModComponent.ts @@ -39,6 +39,10 @@ import { StarterBrickTypes } from "@/types/starterBrickTypes"; import { openSidePanel } from "@/utils/sidePanelUtils"; import { useInsertPane } from "@/pageEditor/panes/insert/InsertPane"; import { type ModMetadata } from "@/types/modComponentTypes"; +import { nowTimestamp } from "@/utils/timeUtils"; +import { type UUID } from "@/types/stringTypes"; +import { normalizeSemVerString } from "@/types/helpers"; +import { getStandaloneModComponentRuntimeModId } from "@/utils/modUtils"; export type AddNewModComponent = ( adapter: ModComponentFormStateAdapter, @@ -83,6 +87,19 @@ function useAddNewModComponent(modMetadata?: ModMetadata): AddNewModComponent { if (modMetadata) { initialFormState.modMetadata = modMetadata; + } else { + // Create new mod metadata for standalone components + initialFormState.modMetadata = { + id: getStandaloneModComponentRuntimeModId(initialFormState.uuid), + name: initialFormState.label, // Changed from modComponent.name to label + description: "Created with the PixieBrix Page Editor", + version: normalizeSemVerString("1.0.0"), + sharing: { + public: false, + organizations: [] as UUID[], + }, + updated_at: nowTimestamp(), + }; } return initialFormState as ModComponentFormState; diff --git a/src/pageEditor/hooks/useBuildAndValidateMod.ts b/src/pageEditor/hooks/useBuildAndValidateMod.ts index 92197df933..cbc7f82ad6 100644 --- a/src/pageEditor/hooks/useBuildAndValidateMod.ts +++ b/src/pageEditor/hooks/useBuildAndValidateMod.ts @@ -54,7 +54,7 @@ function useBuildAndValidateMod(): UseBuildAndValidateModReturn { newModComponentFormState, cleanModComponents = [], dirtyModComponentFormStates: existingDirtyModComponentFormStates = [], - dirtyModOptions, + dirtyModOptionsDefinition, dirtyModMetadata, }: BuildAndValidateModParts) => { if ( @@ -76,37 +76,41 @@ function useBuildAndValidateMod(): UseBuildAndValidateModReturn { sourceMod, cleanModComponents, dirtyModComponentFormStates, - dirtyModOptions, + dirtyModOptionsDefinition, dirtyModMetadata, }); - const modComponentDefinitionCountsMatch = - compareModComponentCountsToModDefinition(newMod, { - sourceModDefinition: sourceMod, - newModComponentFormState, - }); + if (sourceMod != null) { + const modComponentDefinitionCountsMatch = + compareModComponentCountsToModDefinition(newMod, { + sourceModDefinition: sourceMod, + newModComponentFormState, + }); - const modComponentStarterBricksMatch = - await checkModStarterBrickInvariants(newMod, { - sourceModDefinition: sourceMod, - newModComponentFormState, - }); + const modComponentStarterBricksMatch = + await checkModStarterBrickInvariants(newMod, { + sourceModDefinition: sourceMod, + newModComponentFormState, + }); - if ( - !modComponentDefinitionCountsMatch || - !modComponentStarterBricksMatch - ) { - // Not including modDefinition because it can be 1.5MB+ in some rare cases - // See discussion: https://github.com/pixiebrix/pixiebrix-extension/pull/7629/files#r1492864349 - reportEvent(Events.PAGE_EDITOR_MOD_SAVE_ERROR, { - // Metadata is an object, but doesn't extend JsonObject so typescript doesn't like it - modMetadata: newMod.metadata as unknown as JsonObject, - modComponentDefinitionCountsMatch, - modComponentStarterBricksMatch, - }); - dispatch(editorActions.showSaveDataIntegrityErrorModal()); + if ( + !modComponentDefinitionCountsMatch || + !modComponentStarterBricksMatch + ) { + // Not including modDefinition because it can be 1.5MB+ in some rare cases + // See discussion: https://github.com/pixiebrix/pixiebrix-extension/pull/7629/files#r1492864349 + reportEvent(Events.PAGE_EDITOR_MOD_SAVE_ERROR, { + // Metadata is an object, but doesn't extend JsonObject so typescript doesn't like it + modMetadata: newMod.metadata as unknown as JsonObject, + modComponentDefinitionCountsMatch, + modComponentStarterBricksMatch, + }); + dispatch(editorActions.showSaveDataIntegrityErrorModal()); - throw new BusinessError("Mod save failed due to data integrity error"); + throw new BusinessError( + "Mod save failed due to data integrity error", + ); + } } return newMod; diff --git a/src/pageEditor/hooks/useCreateModFromMod.ts b/src/pageEditor/hooks/useCreateModFromMod.ts index fb2dcc24f0..4a6f5fc7b8 100644 --- a/src/pageEditor/hooks/useCreateModFromMod.ts +++ b/src/pageEditor/hooks/useCreateModFromMod.ts @@ -67,7 +67,7 @@ function useCreateModFromMod(): UseCreateModFromModReturn { sourceMod: modDefinition, cleanModComponents, dirtyModComponentFormStates, - dirtyModOptions: modOptions, + dirtyModOptionsDefinition: modOptions, dirtyModMetadata: metadata, }); diff --git a/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts b/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts new file mode 100644 index 0000000000..0c8c6d661e --- /dev/null +++ b/src/pageEditor/hooks/useCreateModFromUnsavedMod.ts @@ -0,0 +1,199 @@ +/* + * 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 { ensureModComponentFormStatePermissionsFromUserGesture } from "@/pageEditor/editorPermissionsHelpers"; +import { type ModMetadataFormState } from "@/pageEditor/store/editor/pageEditorTypes"; +import reportEvent from "@/telemetry/reportEvent"; +import produce from "immer"; +import { useCallback, useMemo } from "react"; +import { Events } from "@/telemetry/events"; +import { + useCreateModDefinitionMutation, + useGetEditablePackagesQuery, +} from "@/data/service/api"; +import { useDispatch, useSelector } from "react-redux"; +import { actions as editorActions } from "@/pageEditor/store/editor/editorSlice"; +import { mapModDefinitionUpsertResponseToModMetadata } from "@/pageEditor/utils"; +import useBuildAndValidateMod from "@/pageEditor/hooks/useBuildAndValidateMod"; +import { BusinessError } from "@/errors/businessErrors"; +import { type Nullishable } from "@/utils/nullishUtils"; +import { type RegistryId } from "@/types/registryTypes"; +import { selectGetCleanComponentsAndDirtyFormStatesForMod } from "@/pageEditor/store/editor/selectGetCleanComponentsAndDirtyFormStatesForMod"; +import { adapterForComponent } from "@/pageEditor/starterBricks/adapter"; +import { actions as modComponentActions } from "@/store/modComponents/modComponentSlice"; +import { isInnerDefinitionRegistryId } from "@/types/helpers"; +import { type UUID } from "@/types/stringTypes"; +import { getLinkedApiClient } from "@/data/service/apiClient"; +import { objToYaml } from "@/utils/objToYaml"; +import { API_PATHS } from "@/data/service/urlPaths"; +import { type StarterBrickDefinitionLike } from "@/starterBricks/types"; +import { modComponentWithInnerDefinitions } from "@/pageEditor/starterBricks/base"; +import { selectDirtyModOptionsDefinitions } from "@/pageEditor/store/editor/editorSelectors"; + +async function saveStarterBrickConfig( + packageUuid: UUID, + config: StarterBrickDefinitionLike, +): Promise { + const client = await getLinkedApiClient(); + const data = { config: objToYaml(config), kind: "extensionPoint" }; + await client.put(API_PATHS.BRICK(packageUuid), data); +} + +type UseCreateModFromUnsavedModReturn = { + createModFromUnsavedMod: (modMetadata: ModMetadataFormState) => Promise; +}; + +function useCreateModFromUnsavedMod( + unsavedModId: Nullishable, +): UseCreateModFromUnsavedModReturn { + const dispatch = useDispatch(); + const [createMod] = useCreateModDefinitionMutation(); + const { data: editablePackages } = useGetEditablePackagesQuery(); + const { buildAndValidateMod } = useBuildAndValidateMod(); + const getCleanComponentsAndDirtyFormStatesForMod = useSelector( + selectGetCleanComponentsAndDirtyFormStatesForMod, + ); + const { cleanModComponents, dirtyModComponentFormStates } = useMemo( + () => getCleanComponentsAndDirtyFormStatesForMod(unsavedModId ?? null), + [getCleanComponentsAndDirtyFormStatesForMod, unsavedModId], + ); + const dirtyModOptionsById = useSelector(selectDirtyModOptionsDefinitions); + const dirtyModOptionsDefinition = useMemo( + // eslint-disable-next-line security/detect-object-injection -- Registry id + () => (unsavedModId ? dirtyModOptionsById[unsavedModId] : undefined), + [dirtyModOptionsById, unsavedModId], + ); + + const createModFromUnsavedMod = useCallback( + ( + modMetadata: ModMetadataFormState, + // eslint-disable-next-line @typescript-eslint/promise-function-async -- permissions check must be called in the user gesture context, `async-await` can break the call chain + ) => + ensureModComponentFormStatePermissionsFromUserGesture( + dirtyModComponentFormStates, + // eslint-disable-next-line promise/prefer-await-to-then -- permissions check must be called in the user gesture context, `async-await` can break the call chain + ).then(async (hasPermissions) => { + if (!hasPermissions || !unsavedModId || !editablePackages) { + return; + } + + try { + const newModDefinition = await buildAndValidateMod({ + dirtyModComponentFormStates, + cleanModComponents, + dirtyModMetadata: modMetadata, + dirtyModOptionsDefinition, + }); + + const upsertResponse = await createMod({ + modDefinition: newModDefinition, + organizations: [], + public: false, + }).unwrap(); + + const newComponentFormStates = dirtyModComponentFormStates.map( + (dirtyModComponentFormState) => + produce(dirtyModComponentFormState, (draft) => { + draft.modMetadata = mapModDefinitionUpsertResponseToModMetadata( + newModDefinition, + upsertResponse, + ); + }), + ); + + for (const newComponentFormState of newComponentFormStates) { + const { selectModComponent, selectStarterBrickDefinition } = + adapterForComponent(newComponentFormState); + const starterBrickId = + newComponentFormState.starterBrick.metadata.id; + const hasInnerStarterBrick = + isInnerDefinitionRegistryId(starterBrickId); + let newModComponent = selectModComponent(newComponentFormState); + + // Starter brick exists as a registry item + if (hasInnerStarterBrick) { + const { definition } = selectStarterBrickDefinition( + newComponentFormState, + ); + newModComponent = modComponentWithInnerDefinitions( + newModComponent, + definition, + ); + } else { + const editablePackage = editablePackages.find( + ({ name }) => name === starterBrickId, + ); + if (editablePackage?.id != null) { + const starterBrickConfig = selectStarterBrickDefinition( + newComponentFormState, + ); + // eslint-disable-next-line no-await-in-loop -- There aren't that many of these registry starter bricks for now + await saveStarterBrickConfig( + editablePackage.id, + starterBrickConfig, + ); + } + } + + dispatch( + editorActions.syncModComponentFormState(newComponentFormState), + ); + dispatch( + modComponentActions.saveModComponent({ + modComponent: { + ...newModComponent, + updateTimestamp: upsertResponse.updated_at, + }, + }), + ); + dispatch(editorActions.markClean(newComponentFormState.uuid)); + } + + const newModId = newModDefinition.metadata.id; + dispatch(editorActions.resetMetadataAndOptionsForMod(newModId)); + dispatch( + editorActions.clearDeletedModComponentFormStatesForMod(newModId), + ); + dispatch(editorActions.setActiveModId(newModId)); + + reportEvent(Events.PAGE_EDITOR_MOD_CREATE, { + modId: newModDefinition.metadata.id, + }); + } catch (error) { + if (error instanceof BusinessError) { + // Error is already handled by buildAndValidateMod. + } else { + throw error; + } // Other errors can be thrown during mod activation + } + }), + [ + dirtyModComponentFormStates, + unsavedModId, + buildAndValidateMod, + cleanModComponents, + createMod, + dispatch, + ], + ); + + return { + createModFromUnsavedMod, + }; +} + +export default useCreateModFromUnsavedMod; diff --git a/src/pageEditor/hooks/useSaveMod.ts b/src/pageEditor/hooks/useSaveMod.ts index 589d05a093..947fc7c4ae 100644 --- a/src/pageEditor/hooks/useSaveMod.ts +++ b/src/pageEditor/hooks/useSaveMod.ts @@ -151,7 +151,7 @@ function useSaveMod(): ModSaver { sourceMod: modDefinition, cleanModComponents, dirtyModComponentFormStates, - dirtyModOptions, + dirtyModOptionsDefinition: dirtyModOptions, dirtyModMetadata, }); diff --git a/src/pageEditor/modListingPanel/NewModButton.tsx b/src/pageEditor/modListingPanel/NewModButton.tsx index 3769d39d8a..206d231bca 100644 --- a/src/pageEditor/modListingPanel/NewModButton.tsx +++ b/src/pageEditor/modListingPanel/NewModButton.tsx @@ -63,7 +63,7 @@ const NewModButton: React.FunctionComponent = () => { variant="info" size="sm" title="New Mod" - id="add-starter-brick" + id="new-mod" > {modComponentFormStateAdapters.map((adapter) => ( ; activeMod: ModDefinition | null; + activeModId?: RegistryId; }): ModMetadataFormState | UnknownObject { const scope = useSelector(selectScope); assertNotNullish(scope, "Expected scope to create new mod"); - const activeModId = - activeModComponentFormState?.modMetadata?.id ?? activeMod?.metadata?.id; + // For unsaved mods, if the mod metadata has not been modified, it will only exist on the components + const firstComponentFormStateForActiveMod = useSelector( + selectFirstModComponentFormStateForActiveMod, + ); const dirtyModMetadata = useSelector( selectDirtyMetadataForModId(activeModId), ); - const modMetadata = dirtyModMetadata ?? activeMod?.metadata; + const modMetadata = + dirtyModMetadata ?? + activeMod?.metadata ?? + firstComponentFormStateForActiveMod?.modMetadata; - // Handle the "Save As New" case, where an existing mod, or an + // Handle the "Save As New" case, where an existing mod, or a // mod component within an existing mod, is selected if (modMetadata) { - let newModId = generateScopeBrickId(scope, modMetadata.id); + const isUnsavedMod = isInnerDefinitionRegistryId(modMetadata.id); + let newModId = isUnsavedMod + ? generatePackageId(scope, modMetadata.name) + : generateScopeBrickId(scope, modMetadata.id); if (newModId === modMetadata.id) { newModId = validateRegistryId(newModId + "-copy"); } return { id: newModId, - name: `${modMetadata.name} (Copy)`, - version: normalizeSemVerString("1.0.0"), + name: isUnsavedMod ? modMetadata.name : `${modMetadata.name} (Copy)`, + version: isUnsavedMod + ? modMetadata.version + : normalizeSemVerString("1.0.0"), description: modMetadata.description, }; } @@ -137,6 +153,7 @@ function useFormSchema() { const CreateModModalBody: React.FC = () => { const dispatch = useDispatch(); + const isMounted = useIsMounted(); const activeModComponentFormState = useSelector( selectActiveModComponentFormState, ); @@ -155,6 +172,8 @@ const CreateModModalBody: React.FC = () => { const { data: activeMod = null, isFetching: isModFetching } = useOptionalModDefinition(activeModId); + const { createModFromUnsavedMod } = useCreateModFromUnsavedMod(activeModId); + const formSchema = useFormSchema(); const hideModal = useCallback(() => { @@ -164,6 +183,7 @@ const CreateModModalBody: React.FC = () => { const initialModMetadataFormState = useInitialFormState({ activeModComponentFormState, activeMod, + activeModId, }); const onSubmit: OnSubmit = async (values, helpers) => { @@ -174,6 +194,9 @@ const CreateModModalBody: React.FC = () => { await createModFromMod(activeMod, values); } else if (activeModComponentFormState) { await createModFromComponent(activeModComponentFormState, values); + } else if (activeModId) { + // New local mod, definition couldn't be fetched from the server + await createModFromUnsavedMod(values); } else { // Should not happen in practice // noinspection ExceptionCaughtLocallyJS @@ -192,7 +215,9 @@ const CreateModModalBody: React.FC = () => { error, }); } finally { - helpers.setSubmitting(false); + if (isMounted()) { + helpers.setSubmitting(false); + } } }; diff --git a/src/pageEditor/panes/save/saveHelpers.ts b/src/pageEditor/panes/save/saveHelpers.ts index 54627fb90a..1d932a10dc 100644 --- a/src/pageEditor/panes/save/saveHelpers.ts +++ b/src/pageEditor/panes/save/saveHelpers.ts @@ -372,7 +372,7 @@ export type ModParts = { /** * Dirty/new options to save. Undefined if there are no changes. */ - dirtyModOptions?: ModOptionsDefinition; + dirtyModOptionsDefinition?: ModOptionsDefinition; /** * Dirty/new metadata to save. Undefined if there are no changes. */ @@ -407,7 +407,7 @@ export function buildNewMod({ sourceMod, cleanModComponents, dirtyModComponentFormStates, - dirtyModOptions, + dirtyModOptionsDefinition, dirtyModMetadata, }: ModParts): UnsavedModDefinition { // If there's no source mod, then we're creating a new one, so we @@ -416,8 +416,8 @@ export function buildNewMod({ sourceMod ?? emptyModDefinition; return produce(unsavedModDefinition, (draft: UnsavedModDefinition): void => { - if (dirtyModOptions) { - draft.options = normalizeModOptionsDefinition(dirtyModOptions); + if (dirtyModOptionsDefinition) { + draft.options = normalizeModOptionsDefinition(dirtyModOptionsDefinition); } if (dirtyModMetadata) { diff --git a/src/pageEditor/store/editor/editorSelectors.ts b/src/pageEditor/store/editor/editorSelectors.ts index 6770f23a8a..af501ea2ce 100644 --- a/src/pageEditor/store/editor/editorSelectors.ts +++ b/src/pageEditor/store/editor/editorSelectors.ts @@ -549,3 +549,10 @@ export const selectActiveNodeEventData = createSelector( } satisfies ReportEventData; }, ); + +export const selectFirstModComponentFormStateForActiveMod = createSelector( + selectModComponentFormStates, + selectActiveModId, + (formState, activeModId) => + formState.find((x) => x?.modMetadata?.id === activeModId), +); diff --git a/src/pageEditor/store/editor/editorSlice.ts b/src/pageEditor/store/editor/editorSlice.ts index c8239828f1..34245566b1 100644 --- a/src/pageEditor/store/editor/editorSlice.ts +++ b/src/pageEditor/store/editor/editorSlice.ts @@ -36,11 +36,11 @@ import { DataPanelTabKey } from "@/pageEditor/tabs/editTab/dataPanel/dataPanelTy import { type TreeExpandedState } from "@/components/jsonTree/JsonTree"; import { getInvalidPath } from "@/utils/debugUtils"; import { - selectActiveModComponentFormState, - selectActiveBrickPipelineUIState, selectActiveBrickConfigurationUIState, - selectNotDeletedModComponentFormStates, + selectActiveBrickPipelineUIState, + selectActiveModComponentFormState, selectNotDeletedActivatedModComponents, + selectNotDeletedModComponentFormStates, } from "./editorSelectors"; import { isQuickBarStarterBrick, @@ -48,12 +48,12 @@ import { } from "@/pageEditor/starterBricks/formStateTypes"; import reportError from "@/telemetry/reportError"; import { - setActiveModComponentId, editModMetadata, editModOptionsDefinitions, ensureBrickPipelineUIState, removeModComponentFormState, removeModData, + setActiveModComponentId, setActiveModId, setActiveNodeId, syncBrickConfigurationUIStates, @@ -62,8 +62,8 @@ import { type Draft, produce } from "immer"; import { normalizePipelineForEditor } from "@/pageEditor/starterBricks/pipelineMapping"; import { type ModComponentsRootState } from "@/store/modComponents/modComponentTypes"; import { - getRunningStarterBricks, checkAvailable, + getRunningStarterBricks, } from "@/contentScript/messenger/api"; import { hydrateModComponentInnerDefinitions } from "@/registry/hydrateInnerDefinitions"; import { QuickBarStarterBrickABC } from "@/starterBricks/quickBar/quickBarStarterBrick"; diff --git a/src/pageEditor/tabs/modMetadata/ModMetadataEditor.module.scss b/src/pageEditor/tabs/modMetadata/ModMetadataEditor.module.scss index 5d1d3b40a9..8c9b4edc91 100644 --- a/src/pageEditor/tabs/modMetadata/ModMetadataEditor.module.scss +++ b/src/pageEditor/tabs/modMetadata/ModMetadataEditor.module.scss @@ -19,3 +19,11 @@ padding-top: 1rem; align-items: center; } + +.modIdField { + margin-bottom: 1rem; +} + +.modIdAlert { + margin-bottom: 0; +} diff --git a/src/pageEditor/tabs/modMetadata/ModMetadataEditor.tsx b/src/pageEditor/tabs/modMetadata/ModMetadataEditor.tsx index 8dd7256d51..5935277446 100644 --- a/src/pageEditor/tabs/modMetadata/ModMetadataEditor.tsx +++ b/src/pageEditor/tabs/modMetadata/ModMetadataEditor.tsx @@ -20,6 +20,7 @@ import { useDispatch, useSelector } from "react-redux"; import { selectActiveModId, selectDirtyMetadataForModId, + selectFirstModComponentFormStateForActiveMod, } from "@/pageEditor/store/editor/editorSelectors"; import { Card, Container } from "react-bootstrap"; import { actions } from "@/pageEditor/store/editor/editorSlice"; @@ -28,7 +29,10 @@ import Effect from "@/components/Effect"; import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate"; import styles from "./ModMetadataEditor.module.scss"; import { object, string } from "yup"; -import { testIsSemVerString } from "@/types/helpers"; +import { + isInnerDefinitionRegistryId, + testIsSemVerString, +} from "@/types/helpers"; import Form, { type RenderBody } from "@/components/form/Form"; import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import Alert from "@/components/Alert"; @@ -105,6 +109,10 @@ const ModMetadataEditor: React.VoidFunctionComponent = () => { // Select a single mod component for the mod to check the activated version. // We rely on the assumption that every component in the mod has the same version. const modDefinitionComponent = useSelector(selectFirstModComponent); + // Mod metadata for new mods will only exist on the component form state + const firstComponentFormStateForMod = useSelector( + selectFirstModComponentFormStateForActiveMod, + ); const activatedModVersion = modDefinitionComponent?._recipe?.version; const latestModVersion = modDefinition?.metadata?.version; @@ -116,7 +124,10 @@ const ModMetadataEditor: React.VoidFunctionComponent = () => { const dirtyMetadata = useSelector(selectDirtyMetadataForModId(modId)); // Prefer the metadata from the activated mod component const currentMetadata = - dirtyMetadata ?? modDefinitionComponent?._recipe ?? modDefinition?.metadata; + dirtyMetadata ?? + modDefinitionComponent?._recipe ?? + modDefinition?.metadata ?? + firstComponentFormStateForMod?.modMetadata; const initialFormState: Partial = pick( currentMetadata, @@ -145,13 +156,22 @@ const ModMetadataEditor: React.VoidFunctionComponent = () => { latestModVersion={latestModVersion} /> )} - +
+ {isInnerDefinitionRegistryId( + (values as ModMetadataFormState).id, + ) ? ( + + Save the mod to assign an id + + ) : ( + + )} +
Date: Sun, 8 Sep 2024 19:59:44 -0400 Subject: [PATCH 07/50] fix save mod test --- src/pageEditor/hooks/useSaveMod.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/pageEditor/hooks/useSaveMod.test.ts b/src/pageEditor/hooks/useSaveMod.test.ts index 3cc4e4d47a..afce53baae 100644 --- a/src/pageEditor/hooks/useSaveMod.test.ts +++ b/src/pageEditor/hooks/useSaveMod.test.ts @@ -44,6 +44,10 @@ jest.mock("@/utils/notify"); jest.mock("@/contentScript/messenger/api"); describe("useSaveMod", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + it("saves with no dirty changes", async () => { appApiMock.reset(); @@ -69,7 +73,6 @@ describe("useSaveMod", () => { appApiMock.onGet(API_PATHS.BRICKS).reply(200, [editablePackage]); - appApiMock.onPut(API_PATHS.BRICK(editablePackage.id)).reply(200, {}); appApiMock.onPut(API_PATHS.BRICK(editablePackage.id)).reply(200, {}); const { result, waitForEffect } = renderHook(() => useSaveMod(), { @@ -259,6 +262,8 @@ describe("useSaveMod", () => { }); it("opens the create mod modal if save is called with a temporary, internal mod", async () => { + appApiMock.reset(); + const temporaryModId = getStandaloneModComponentRuntimeModId(autoUUIDSequence()); From fb9e705d008b0d231e8303ecfb4e09e3c777f2a4 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Sun, 8 Sep 2024 20:28:47 -0400 Subject: [PATCH 08/50] fix tests --- src/__snapshots__/Storyshots.test.js.snap | 140 +++--------------- .../useCreateModFromModComponent.test.ts | 59 -------- .../hooks/useCreateModFromModComponent.ts | 109 +++++++------- .../DraftModComponentListItem.test.tsx.snap | 28 +--- 4 files changed, 78 insertions(+), 258 deletions(-) diff --git a/src/__snapshots__/Storyshots.test.js.snap b/src/__snapshots__/Storyshots.test.js.snap index c4dcf2a254..00d5c7d15a 100644 --- a/src/__snapshots__/Storyshots.test.js.snap +++ b/src/__snapshots__/Storyshots.test.js.snap @@ -9613,30 +9613,10 @@ exports[`Storyshots Sidebar/ActionMenu Mod 1`] = ` className="root" > -
-
-
-
-
-
- - - - ), - [hideModal], - ); - - return ( - - - - Add {activeModComponentFormState?.label} to a mod - - -
- - ); -}; - -export default AddToModModal; diff --git a/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx b/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx index 347b3f2010..57574e070a 100644 --- a/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx +++ b/src/pageEditor/modListingPanel/modals/MoveFromModModal.tsx @@ -15,113 +15,128 @@ * along with this program. If not, see . */ -import React, { useCallback } from "react"; +import React, { useCallback, useMemo } from "react"; import { useDispatch, useSelector } from "react-redux"; -import { actions } from "@/pageEditor/store/editor/editorSlice"; import { + actions as editorActions, + actions, +} from "@/pageEditor/store/editor/editorSlice"; +import { + selectActivatedModMetadatas, selectActiveModComponentFormState, selectEditorModalVisibilities, + selectKeepLocalCopyOnCreateMod, } from "@/pageEditor/store/editor/editorSelectors"; import notify from "@/utils/notify"; -import { Alert, Button, Modal } from "react-bootstrap"; +import { 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"; +import SelectWidget from "@/components/form/widgets/SelectWidget"; +import type { RegistryId } from "@/types/registryTypes"; +import { useRemoveModComponentFromStorage } from "@/pageEditor/hooks/useRemoveModComponentFromStorage"; +import { getUnsavedModMetadataForFormState } from "@/pageEditor/utils"; type FormState = { - moveOrRemove: "move" | "remove"; + modId: RegistryId | null; }; const initialFormState: FormState = { - moveOrRemove: "move", + modId: null, }; const formStateSchema = object({ - moveOrRemove: string().oneOf(["move", "remove"]).required(), + modId: string().required(), }); +const NEW_MOD_ID = "@new" as RegistryId; + const MoveFromModModal: React.FC = () => { - const { isRemoveFromModModalVisible: show } = useSelector( + const dispatch = useDispatch(); + + const { isMoveFromModModalVisible: 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"; + const modComponentFormState = useSelector(selectActiveModComponentFormState); + const activatedModMetadatas = useSelector(selectActivatedModMetadatas); + const keepLocalCopy = useSelector(selectKeepLocalCopyOnCreateMod); + const removeModComponentFromStorage = useRemoveModComponentFromStorage(); + const onSubmit = useCallback>>( + async ({ modId }, helpers) => { + assertNotNullish( + modComponentFormState, + "active mod component form state not found", + ); + const modMetadata = + modId === NEW_MOD_ID + ? getUnsavedModMetadataForFormState(modComponentFormState) + : // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Field options created from the same set + activatedModMetadatas.find((metadata) => metadata.id === modId)!; try { const modComponentId = modComponentFormState?.uuid; assertNotNullish( modComponentId, "mod component id not found for active mod component", ); - dispatch( - actions.removeModComponentFormStateFromMod({ - modComponentId, - keepLocalCopy, - }), - ); + + dispatch(editorActions.duplicateActiveModComponent({ modMetadata })); + + if (!keepLocalCopy) { + await removeModComponentFromStorage({ modComponentId }); + } + hideModal(); } catch (error) { notify.error({ - message: "Problem removing from mod", + message: `Problem ${ + keepLocalCopy ? "copying to" : "moving from" + } mod`, error, }); } finally { helpers.setSubmitting(false); } }, - [modComponentFormState?.uuid, dispatch, hideModal], + [ + modComponentFormState, + activatedModMetadatas, + dispatch, + keepLocalCopy, + hideModal, + removeModComponentFromStorage, + ], ); - const radioItems: RadioItem[] = [ - { - label: "Move the starter brick to stand-alone", - value: "move", - }, - { - label: "Delete starter brick", - value: "remove", - }, - ]; + const selectOptions = useMemo( + () => [ + { label: "âž• Create new mod...", value: NEW_MOD_ID }, + ...activatedModMetadatas.map((metadata) => ({ + label: metadata.name, + value: metadata.id, + })), + ], + [activatedModMetadatas], + ); 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. - - )} ); @@ -131,11 +146,11 @@ const MoveFromModModal: React.FC = () => { Cancel ); @@ -144,7 +159,7 @@ const MoveFromModModal: React.FC = () => { - Remove {modComponentFormState?.label} from mod{" "} + Move {modComponentFormState?.label} from mod{" "} {modComponentFormState?.modMetadata?.name}? diff --git a/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx b/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx deleted file mode 100644 index a68527226f..0000000000 --- a/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx +++ /dev/null @@ -1,69 +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 from "react"; -import { useDispatch, useSelector } from "react-redux"; -import { actions } from "@/pageEditor/store/editor/editorSlice"; -import { Button, Modal } from "react-bootstrap"; -import { - selectActiveModId, - selectEditorModalVisibilities, -} from "@/pageEditor/store/editor/editorSelectors"; -import { useOptionalModDefinition } from "@/modDefinitions/modDefinitionHooks"; - -const SaveAsNewModModal: React.FC = () => { - const { isSaveAsNewModModalVisible: show } = useSelector( - selectEditorModalVisibilities, - ); - - const modId = useSelector(selectActiveModId); - const { data: mod, isFetching } = useOptionalModDefinition(modId); - const modName = mod?.metadata?.name ?? "this mod"; - - const dispatch = useDispatch(); - - const hideModal = () => { - dispatch(actions.hideModal()); - }; - - const onConfirm = () => { - // Don't keep the old mod active - dispatch(actions.showCreateModModal({ keepLocalCopy: false })); - }; - - return ( - - - Save as new mod? - - - You do not have permissions to edit {modName}. Save as a new - mod? - - - - - - - ); -}; - -export default SaveAsNewModModal; diff --git a/src/pageEditor/store/editor/editorSelectors.ts b/src/pageEditor/store/editor/editorSelectors.ts index af501ea2ce..d396df5ef5 100644 --- a/src/pageEditor/store/editor/editorSelectors.ts +++ b/src/pageEditor/store/editor/editorSelectors.ts @@ -267,11 +267,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, - isSaveAsNewModModalVisible: - editor.visibleModalKey === ModalKey.SAVE_AS_NEW_MOD, + isMoveFromModModalVisible: editor.visibleModalKey === ModalKey.MOVE_FROM_MOD, isCreateModModalVisible: editor.visibleModalKey === ModalKey.CREATE_MOD, isAddBlockModalVisible: editor.visibleModalKey === ModalKey.ADD_BRICK, isSaveDataIntegrityErrorModalVisible: @@ -515,6 +511,28 @@ export const selectModComponentAvailability = ({ isPendingDraftModComponents, }); +const activatedModComponentIsAvailableSelector = createSelector( + ({ editor }: EditorRootState) => editor.availableActivatedModComponentIds, + (_state: EditorRootState, modComponentId: UUID) => modComponentId, + (availableActivatedModComponentIds, modComponentId) => + availableActivatedModComponentIds.includes(modComponentId), +); + +export const selectActivatedModComponentIsAvailable = + (modComponentId: UUID) => (state: EditorRootState) => + activatedModComponentIsAvailableSelector(state, modComponentId); + +const draftModComponentIsAvailableSelector = createSelector( + ({ editor }: EditorRootState) => editor.availableDraftModComponentIds, + (_state: EditorRootState, modComponentId: UUID) => modComponentId, + (availableDraftModComponentIds, modComponentId) => + availableDraftModComponentIds.includes(modComponentId), +); + +export const selectDraftModComponentIsAvailable = + (modComponentId: UUID) => (state: EditorRootState) => + draftModComponentIsAvailableSelector(state, modComponentId); + export const selectKnownEventNamesForActiveModComponent = createSelector( selectActiveModComponentId, selectKnownEventNames, diff --git a/src/pageEditor/store/editor/editorSlice.ts b/src/pageEditor/store/editor/editorSlice.ts index 34245566b1..726f44d78f 100644 --- a/src/pageEditor/store/editor/editorSlice.ts +++ b/src/pageEditor/store/editor/editorSlice.ts @@ -58,7 +58,7 @@ import { setActiveNodeId, syncBrickConfigurationUIStates, } from "@/pageEditor/store/editor/editorSliceHelpers"; -import { type Draft, produce } from "immer"; +import { castDraft, type Draft, produce } from "immer"; import { normalizePipelineForEditor } from "@/pageEditor/starterBricks/pipelineMapping"; import { type ModComponentsRootState } from "@/store/modComponents/modComponentTypes"; import { @@ -76,7 +76,10 @@ import { removeUnusedDependencies } from "@/components/fields/schemaFields/integ import { type UUID } from "@/types/stringTypes"; import { type RegistryId } from "@/types/registryTypes"; import { type ModOptionsDefinition } from "@/types/modDefinitionTypes"; -import { type ModComponentBase } from "@/types/modComponentTypes"; +import { + type ModComponentBase, + type ModMetadata, +} from "@/types/modComponentTypes"; import { type OptionsArgs } from "@/types/runtimeTypes"; import { createMigrate } from "redux-persist"; import { migrations } from "@/store/editorMigrations"; @@ -117,11 +120,12 @@ export const initialState: EditorState = { /* eslint-disable security/detect-object-injection -- lots of immer-style code here dealing with Records */ -const cloneActiveModComponent = createAsyncThunk< - void, +const duplicateActiveModComponent = createAsyncThunk< void, - { state: EditorRootState } ->("editor/cloneActiveModComponent", async (arg, thunkAPI) => { + { modMetadata?: ModMetadata } | void, + { state: EditorRootState & ModComponentsRootState } +>("editor/cloneActiveModComponent", async (args, thunkAPI) => { + const { modMetadata } = args ?? {}; const state = thunkAPI.getState(); const newActiveModComponentFormState = await produce( selectActiveModComponentFormState(state), @@ -129,12 +133,26 @@ const cloneActiveModComponent = createAsyncThunk< assertNotNullish(draft, "Active mod component form state not found"); draft.uuid = uuidv4(); draft.label += " (Copy)"; - // Remove from its mod, if any (the user can add it to any mod after creation) - delete draft.modMetadata; // Re-generate instance IDs for all the bricks in the mod component draft.modComponent.brickPipeline = await normalizePipelineForEditor( draft.modComponent.brickPipeline, ); + + if (modMetadata != null) { + draft.modMetadata = modMetadata; + const componentFormStateOfDestinationMod = + state.editor.modComponentFormStates.find( + (formState) => formState.modMetadata?.id === modMetadata.id, + ); + if (componentFormStateOfDestinationMod == null) { + return; + } + + draft.optionsDefinition = castDraft( + componentFormStateOfDestinationMod.optionsDefinition, + ); + draft.optionsArgs = componentFormStateOfDestinationMod.optionsArgs; + } }, ); assertNotNullish( @@ -598,106 +616,12 @@ export const editorSlice = createSlice({ formState.modMetadata = modMetadata; } }, - showAddToModModal(state) { - state.visibleModalKey = ModalKey.ADD_TO_MOD; - }, - addModComponentFormStateToMod( + showMoveFromModModal( state, - action: PayloadAction<{ - modComponentId: UUID; - modMetadata: ModComponentBase["_recipe"]; - keepLocalCopy: boolean; - }>, - ) { - const { - payload: { modComponentId, modMetadata, keepLocalCopy }, - } = action; - const modComponentFormStateIndex = state.modComponentFormStates.findIndex( - (x) => x.uuid === modComponentId, - ); - if (modComponentFormStateIndex < 0) { - throw new Error( - "Unable to add mod component to mod, mod component form state not found", - ); - } - - const modComponentFormState = - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length check above - state.modComponentFormStates[modComponentFormStateIndex]!; - - const newId = uuidv4(); - state.modComponentFormStates.push({ - ...modComponentFormState, - uuid: newId, - modMetadata, - installed: false, // Can't "reset" this, only remove or save - }); - state.dirty[newId] = true; - - state.expandedModId = modMetadata?.id ?? null; - - if (!keepLocalCopy) { - ensureBrickPipelineUIState(state, newId); - state.activeModComponentId = newId; - state.modComponentFormStates.splice(modComponentFormStateIndex, 1); - if (modComponentFormState?.uuid) { - delete state.dirty[modComponentFormState.uuid]; - delete state.brickPipelineUIStateById[modComponentFormState.uuid]; - } - } - }, - showRemoveFromModModal(state) { - state.visibleModalKey = ModalKey.REMOVE_FROM_MOD; - }, - removeModComponentFormStateFromMod( - state, - action: PayloadAction<{ - modComponentId: UUID; - keepLocalCopy: boolean; - }>, + action: PayloadAction<{ keepLocalCopy: boolean }>, ) { - const { modComponentId, keepLocalCopy } = action.payload; - const modComponentFormStateIndex = state.modComponentFormStates.findIndex( - (x) => x.uuid === modComponentId, - ); - if (modComponentFormStateIndex < 0) { - throw new Error( - "Unable to remove mod component from mod, mod component form state not found", - ); - } - - const modComponentFormState = - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- length check above - state.modComponentFormStates[modComponentFormStateIndex]!; - assertNotNullish( - modComponentFormState.modMetadata, - "Mod component form state has no mod definition", - ); - const modId = modComponentFormState.modMetadata.id; - state.deletedModComponentFormStatesByModId[modId] ??= []; - - state.deletedModComponentFormStatesByModId[modId].push( - modComponentFormState, - ); - state.modComponentFormStates.splice(modComponentFormStateIndex, 1); - delete state.dirty[modComponentId]; - delete state.brickPipelineUIStateById[modComponentId]; - state.activeModComponentId = null; - - if (keepLocalCopy) { - const newId = uuidv4(); - state.modComponentFormStates.push({ - ...modComponentFormState, - uuid: newId, - modMetadata: undefined, - }); - state.dirty[newId] = true; - ensureBrickPipelineUIState(state, newId); - state.activeModComponentId = newId; - } - }, - showSaveAsNewModModal(state) { - state.visibleModalKey = ModalKey.SAVE_AS_NEW_MOD; + state.keepLocalCopyOnCreateMod = action.payload.keepLocalCopy; + state.visibleModalKey = ModalKey.MOVE_FROM_MOD; }, clearDeletedModComponentFormStatesForMod( state, @@ -731,10 +655,12 @@ export const editorSlice = createSlice({ }, showCreateModModal( state, - action: PayloadAction<{ keepLocalCopy: boolean }>, + action: PayloadAction<{ keepLocalCopy?: boolean }>, ) { state.visibleModalKey = ModalKey.CREATE_MOD; - state.keepLocalCopyOnCreateMod = action.payload.keepLocalCopy; + if (action.payload.keepLocalCopy != null) { + state.keepLocalCopyOnCreateMod = action.payload.keepLocalCopy; + } }, addNode( state, @@ -1028,7 +954,7 @@ export const editorSlice = createSlice({ export const actions = { ...editorSlice.actions, - cloneActiveModComponent, + duplicateActiveModComponent, checkAvailableActivatedModComponents, checkAvailableDraftModComponents, checkActiveModComponentAvailability, diff --git a/src/pageEditor/store/editor/pageEditorTypes.ts b/src/pageEditor/store/editor/pageEditorTypes.ts index 09ddcb4488..82f305b505 100644 --- a/src/pageEditor/store/editor/pageEditorTypes.ts +++ b/src/pageEditor/store/editor/pageEditorTypes.ts @@ -60,9 +60,7 @@ export type AddBrickLocation = { }; export enum ModalKey { - ADD_TO_MOD, - REMOVE_FROM_MOD, - SAVE_AS_NEW_MOD, + MOVE_FROM_MOD, CREATE_MOD, ADD_BRICK, SAVE_DATA_INTEGRITY_ERROR, diff --git a/src/pageEditor/utils.ts b/src/pageEditor/utils.ts index 0c2b9650ce..72b989fe18 100644 --- a/src/pageEditor/utils.ts +++ b/src/pageEditor/utils.ts @@ -30,7 +30,10 @@ import ForEachElement from "@/bricks/transformers/controlFlow/ForEachElement"; import { castArray, pick, pickBy } from "lodash"; import { type AnalysisAnnotation } from "@/analysis/analysisTypes"; import { PIPELINE_BRICKS_FIELD_NAME } from "./consts"; -import { type ModComponentBase } from "@/types/modComponentTypes"; +import { + type ModComponentBase, + type ModMetadata, +} from "@/types/modComponentTypes"; import { type UUID } from "@/types/stringTypes"; import { type RegistryId } from "@/types/registryTypes"; import { type Brick } from "@/types/brickTypes"; @@ -43,6 +46,10 @@ import MapValues from "@/bricks/transformers/controlFlow/MapValues"; import AddDynamicTextSnippet from "@/bricks/effects/AddDynamicTextSnippet"; import { type PackageUpsertResponse } from "@/types/contract"; import { type UnsavedModDefinition } from "@/types/modDefinitionTypes"; +import { getStandaloneModComponentRuntimeModId } from "@/utils/modUtils"; +import { normalizeSemVerString } from "@/types/helpers"; +import { nowTimestamp } from "@/utils/timeUtils"; +import { type BaseFormState } from "@/pageEditor/store/editor/baseFormStateTypes"; export function mapModDefinitionUpsertResponseToModMetadata( unsavedModDefinition: UnsavedModDefinition, @@ -265,3 +272,19 @@ export function selectPageEditorDimensions() { window.innerWidth > window.innerHeight ? "landscape" : "portrait", }; } + +export function getUnsavedModMetadataForFormState( + formState: BaseFormState, +): ModMetadata { + return { + id: getStandaloneModComponentRuntimeModId(formState.uuid), + name: formState.label, + description: "Created with the PixieBrix Page Editor", + version: normalizeSemVerString("1.0.0"), + sharing: { + public: false, + organizations: [] as UUID[], + }, + updated_at: nowTimestamp(), + }; +} diff --git a/src/store/sessionChanges/sessionChangesListenerMiddleware.ts b/src/store/sessionChanges/sessionChangesListenerMiddleware.ts index 8865ea3cb6..1b0c29614d 100644 --- a/src/store/sessionChanges/sessionChangesListenerMiddleware.ts +++ b/src/store/sessionChanges/sessionChangesListenerMiddleware.ts @@ -34,9 +34,7 @@ sessionChangesListenerMiddleware.startListening({ actions.editModOptionsDefinitions, actions.editModOptionsValues, actions.resetMetadataAndOptionsForMod, - actions.addModComponentFormStateToMod, actions.addModComponentFormState, - actions.removeModComponentFormStateFromMod, actions.removeModData, modComponentSlice.actions.removeModComponent, From 5598ecac3f30bae9884b680f9be7b4b407aa924f Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 13 Sep 2024 02:29:07 -0400 Subject: [PATCH 12/50] cleanup --- src/pageEditor/hooks/useAddNewModComponent.ts | 29 +++++++++---------- .../modListingPanel/ModListItem.test.tsx | 27 ++--------------- .../actionMenus/ModActionMenu.tsx | 4 +-- src/pageEditor/modals/Modals.tsx | 4 --- .../store/editor/editorSlice.test.ts | 12 +++++--- 5 files changed, 26 insertions(+), 50 deletions(-) diff --git a/src/pageEditor/hooks/useAddNewModComponent.ts b/src/pageEditor/hooks/useAddNewModComponent.ts index d9151d5496..bcee911b2b 100644 --- a/src/pageEditor/hooks/useAddNewModComponent.ts +++ b/src/pageEditor/hooks/useAddNewModComponent.ts @@ -34,17 +34,16 @@ import { inspectedTab, } from "@/pageEditor/context/connection"; import { getExampleBrickPipeline } from "@/pageEditor/panes/insert/exampleStarterBrickConfigs"; -import { - type StarterBrickType, - StarterBrickTypes, -} from "@/types/starterBrickTypes"; +import { StarterBrickTypes } from "@/types/starterBrickTypes"; import { openSidePanel } from "@/utils/sidePanelUtils"; import { useInsertPane } from "@/pageEditor/panes/insert/InsertPane"; import { type ModMetadata } from "@/types/modComponentTypes"; -import { adapter } from "@/pageEditor/starterBricks/adapter"; import { getUnsavedModMetadataForFormState } from "@/pageEditor/utils"; +import { type ModComponentFormStateAdapter } from "@/pageEditor/starterBricks/modComponentFormStateAdapter"; -export type AddNewModComponent = (starterBrickType: StarterBrickType) => void; +export type AddNewModComponent = ( + adapter: ModComponentFormStateAdapter, +) => void; function useAddNewModComponent(modMetadata?: ModMetadata): AddNewModComponent { const dispatch = useDispatch(); @@ -58,12 +57,11 @@ function useAddNewModComponent(modMetadata?: ModMetadata): AddNewModComponent { ); const getInitialModComponentFormState = useCallback( - async ( - starterBrickType: StarterBrickType, - ): Promise => { - const { selectNativeElement, fromNativeElement } = - adapter(starterBrickType); - + async ({ + starterBrickType, + selectNativeElement, + fromNativeElement, + }: ModComponentFormStateAdapter): Promise => { let element = null; if (selectNativeElement) { setInsertingStarterBrickType(starterBrickType); @@ -91,8 +89,8 @@ function useAddNewModComponent(modMetadata?: ModMetadata): AddNewModComponent { ); return useCallback( - async (starterBrickType: StarterBrickType) => { - const { label, flag, asDraftModComponent } = adapter(starterBrickType); + async (adapter: ModComponentFormStateAdapter) => { + const { starterBrickType, label, flag, asDraftModComponent } = adapter; if (flag && flagOff(flag)) { dispatch(actions.betaError()); @@ -100,8 +98,7 @@ function useAddNewModComponent(modMetadata?: ModMetadata): AddNewModComponent { } try { - const initialFormState = - await getInitialModComponentFormState(starterBrickType); + const initialFormState = await getInitialModComponentFormState(adapter); dispatch(actions.addModComponentFormState(initialFormState)); dispatch(actions.checkActiveModComponentAvailability()); diff --git a/src/pageEditor/modListingPanel/ModListItem.test.tsx b/src/pageEditor/modListingPanel/ModListItem.test.tsx index 658655c2c1..d6126013d8 100644 --- a/src/pageEditor/modListingPanel/ModListItem.test.tsx +++ b/src/pageEditor/modListingPanel/ModListItem.test.tsx @@ -38,14 +38,7 @@ describe("ModListItem", () => { render( - +
test children
@@ -70,14 +63,7 @@ describe("ModListItem", () => { render( - +
test children
@@ -111,14 +97,7 @@ describe("ModListItem", () => { render( - +
test children
diff --git a/src/pageEditor/modListingPanel/actionMenus/ModActionMenu.tsx b/src/pageEditor/modListingPanel/actionMenus/ModActionMenu.tsx index 849e66190c..b05f5b0f0d 100644 --- a/src/pageEditor/modListingPanel/actionMenus/ModActionMenu.tsx +++ b/src/pageEditor/modListingPanel/actionMenus/ModActionMenu.tsx @@ -70,9 +70,9 @@ const ModActionMenu: React.FC<{ modMetadata: ModMetadata }> = ({ const addStarterBrickSubMenu = useMemo( () => modComponentFormStateAdapters.map((adapter) => ({ - title: adapter.label, + title: adapter.flag ? `${adapter.label} (Beta)` : adapter.label, action() { - addNewModComponent(adapter.starterBrickType); + addNewModComponent(adapter); }, icon: , })), diff --git a/src/pageEditor/modals/Modals.tsx b/src/pageEditor/modals/Modals.tsx index 0e698caeba..a0d41746dc 100644 --- a/src/pageEditor/modals/Modals.tsx +++ b/src/pageEditor/modals/Modals.tsx @@ -17,17 +17,13 @@ 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 SaveAsNewModModal from "@/pageEditor/modListingPanel/modals/SaveAsNewModModal"; import SaveDataIntegrityErrorModal from "@/pageEditor/panes/save/SaveDataIntegrityErrorModal"; const Modals: React.FunctionComponent = () => ( <> - - diff --git a/src/pageEditor/store/editor/editorSlice.test.ts b/src/pageEditor/store/editor/editorSlice.test.ts index 7f1975069b..1a1aad050a 100644 --- a/src/pageEditor/store/editor/editorSlice.test.ts +++ b/src/pageEditor/store/editor/editorSlice.test.ts @@ -16,8 +16,8 @@ */ import { - editorSlice, actions, + editorSlice, initialState, persistEditorConfig, } from "@/pageEditor/store/editor/editorSlice"; @@ -49,6 +49,7 @@ import { migrations } from "@/store/editorMigrations"; import { modMetadataFactory } from "@/testUtils/factories/modComponentFactories"; import { setActiveModId } from "./editorSliceHelpers"; import { castDraft } from "immer"; +import { type ModComponentsRootState } from "@/store/modComponents/modComponentTypes"; function getTabState( state: EditorState, @@ -222,11 +223,14 @@ describe("Add/Remove Bricks", () => { ).toBeArrayOfSize(initialIntegrationDependencies.length); }); - test("Can clone a mod compoenent", async () => { + test("Can clone a mod component", async () => { const dispatch = jest.fn(); - const getState: () => EditorRootState = () => ({ editor }); + const getState: () => EditorRootState & ModComponentsRootState = () => ({ + editor, + options: { activatedModComponents: [] }, + }); - await actions.cloneActiveModComponent()(dispatch, getState, undefined); + await actions.duplicateActiveModComponent()(dispatch, getState, undefined); // Dispatch call args (actions) should be: // 1. thunk pending From aecc803244ba13f32f673eef9536beec37f81cd7 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 13 Sep 2024 13:49:10 -0400 Subject: [PATCH 13/50] fix tests and restore saveasnewmodmodal --- .../ActivatedModComponentListItem.test.tsx | 28 +++----- .../DraftModComponentListItem.test.tsx | 50 +++++--------- .../modals/SaveAsNewModModal.tsx | 69 +++++++++++++++++++ .../store/editor/editorSelectors.ts | 2 + src/pageEditor/store/editor/editorSlice.ts | 3 + .../store/editor/pageEditorTypes.ts | 1 + 6 files changed, 101 insertions(+), 52 deletions(-) create mode 100644 src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx diff --git a/src/pageEditor/modListingPanel/ActivatedModComponentListItem.test.tsx b/src/pageEditor/modListingPanel/ActivatedModComponentListItem.test.tsx index 1035aedd39..fcf0c9f11f 100644 --- a/src/pageEditor/modListingPanel/ActivatedModComponentListItem.test.tsx +++ b/src/pageEditor/modListingPanel/ActivatedModComponentListItem.test.tsx @@ -51,9 +51,7 @@ afterAll(() => { describe("ActivatedModComponentListItem", () => { it("renders not active element", async () => { const modComponent = modComponentFactory(); - render( - , - ); + render(); const button = await screen.findByRole("button", { name: modComponent.label, @@ -72,15 +70,12 @@ describe("ActivatedModComponentListItem", () => { uuid: modComponent.id, }, }); - render( - , - { - setupRedux(dispatch) { - // The addElement also sets the active element - dispatch(editorActions.addModComponentFormState(formState)); - }, + render(, { + setupRedux(dispatch) { + // The addElement also sets the active element + dispatch(editorActions.addModComponentFormState(formState)); }, - ); + }); const button = await screen.findByRole("button", { name: modComponent.label, @@ -94,12 +89,7 @@ describe("ActivatedModComponentListItem", () => { it("shows not-available icon properly", async () => { const modComponent = modComponentFactory(); - render( - , - ); + render(); await expect( screen.findByRole("img", { name: "Not available on page" }), @@ -108,9 +98,7 @@ describe("ActivatedModComponentListItem", () => { it("handles mouseover action properly for button mod components", async () => { const modComponent = modComponentFactory(); - render( - , - ); + render(); const button = await screen.findByRole("button", { name: modComponent.label, diff --git a/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx b/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx index c8658a3862..e2cf08b179 100644 --- a/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx +++ b/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx @@ -47,47 +47,33 @@ describe("DraftModComponentListItem", () => { it("renders not active element", () => { const formState = formStateFactory(); expect( - render( - , - { - initialValues: formState, - setupRedux(dispatch) { - dispatch(authActions.setAuth(authStateFactory())); - // The addElement also sets the active element - dispatch( - editorActions.addModComponentFormState(formStateFactory()), - ); + render(, { + initialValues: formState, + setupRedux(dispatch) { + dispatch(authActions.setAuth(authStateFactory())); + // The addElement also sets the active element + dispatch(editorActions.addModComponentFormState(formStateFactory())); - // Add new element to deactivate the previous one - dispatch(editorActions.addModComponentFormState(formState)); - // Remove the active element and stay with one inactive item - dispatch(editorActions.removeModComponentFormState(formState.uuid)); - }, + // Add new element to deactivate the previous one + dispatch(editorActions.addModComponentFormState(formState)); + // Remove the active element and stay with one inactive item + dispatch(editorActions.removeModComponentFormState(formState.uuid)); }, - ).asFragment(), + }).asFragment(), ).toMatchSnapshot(); }); it("renders active element", () => { const formState = formStateFactory(); expect( - render( - , - { - initialValues: formState, - setupRedux(dispatch) { - dispatch(authActions.setAuth(authStateFactory())); - // The addElement also sets the active element - dispatch(editorActions.addModComponentFormState(formState)); - }, + render(, { + initialValues: formState, + setupRedux(dispatch) { + dispatch(authActions.setAuth(authStateFactory())); + // The addElement also sets the active element + dispatch(editorActions.addModComponentFormState(formState)); }, - ).asFragment(), + }).asFragment(), ).toMatchSnapshot(); }); }); diff --git a/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx b/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx new file mode 100644 index 0000000000..a68527226f --- /dev/null +++ b/src/pageEditor/modListingPanel/modals/SaveAsNewModModal.tsx @@ -0,0 +1,69 @@ +/* + * 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 from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { actions } from "@/pageEditor/store/editor/editorSlice"; +import { Button, Modal } from "react-bootstrap"; +import { + selectActiveModId, + selectEditorModalVisibilities, +} from "@/pageEditor/store/editor/editorSelectors"; +import { useOptionalModDefinition } from "@/modDefinitions/modDefinitionHooks"; + +const SaveAsNewModModal: React.FC = () => { + const { isSaveAsNewModModalVisible: show } = useSelector( + selectEditorModalVisibilities, + ); + + const modId = useSelector(selectActiveModId); + const { data: mod, isFetching } = useOptionalModDefinition(modId); + const modName = mod?.metadata?.name ?? "this mod"; + + const dispatch = useDispatch(); + + const hideModal = () => { + dispatch(actions.hideModal()); + }; + + const onConfirm = () => { + // Don't keep the old mod active + dispatch(actions.showCreateModModal({ keepLocalCopy: false })); + }; + + return ( + + + Save as new mod? + + + You do not have permissions to edit {modName}. Save as a new + mod? + + + + + + + ); +}; + +export default SaveAsNewModModal; diff --git a/src/pageEditor/store/editor/editorSelectors.ts b/src/pageEditor/store/editor/editorSelectors.ts index d396df5ef5..a5c6f80865 100644 --- a/src/pageEditor/store/editor/editorSelectors.ts +++ b/src/pageEditor/store/editor/editorSelectors.ts @@ -269,6 +269,8 @@ export const selectModIsDirty = export const selectEditorModalVisibilities = ({ editor }: EditorRootState) => ({ isMoveFromModModalVisible: editor.visibleModalKey === ModalKey.MOVE_FROM_MOD, isCreateModModalVisible: editor.visibleModalKey === ModalKey.CREATE_MOD, + isSaveAsNewModModalVisible: + editor.visibleModalKey === ModalKey.SAVE_AS_NEW_MOD, isAddBlockModalVisible: editor.visibleModalKey === ModalKey.ADD_BRICK, isSaveDataIntegrityErrorModalVisible: editor.visibleModalKey === ModalKey.SAVE_DATA_INTEGRITY_ERROR, diff --git a/src/pageEditor/store/editor/editorSlice.ts b/src/pageEditor/store/editor/editorSlice.ts index 726f44d78f..6d6a5a4bf1 100644 --- a/src/pageEditor/store/editor/editorSlice.ts +++ b/src/pageEditor/store/editor/editorSlice.ts @@ -623,6 +623,9 @@ export const editorSlice = createSlice({ state.keepLocalCopyOnCreateMod = action.payload.keepLocalCopy; state.visibleModalKey = ModalKey.MOVE_FROM_MOD; }, + showSaveAsNewModModal(state) { + state.visibleModalKey = ModalKey.SAVE_AS_NEW_MOD; + }, clearDeletedModComponentFormStatesForMod( state, action: PayloadAction, diff --git a/src/pageEditor/store/editor/pageEditorTypes.ts b/src/pageEditor/store/editor/pageEditorTypes.ts index 82f305b505..6b23a04216 100644 --- a/src/pageEditor/store/editor/pageEditorTypes.ts +++ b/src/pageEditor/store/editor/pageEditorTypes.ts @@ -61,6 +61,7 @@ export type AddBrickLocation = { export enum ModalKey { MOVE_FROM_MOD, + SAVE_AS_NEW_MOD, CREATE_MOD, ADD_BRICK, SAVE_DATA_INTEGRITY_ERROR, From 6ac0195c5c3104794e1e5a59466daa1ff9f413b1 Mon Sep 17 00:00:00 2001 From: Ben Loe Date: Fri, 13 Sep 2024 14:39:05 -0400 Subject: [PATCH 14/50] fix remaining tests --- src/__snapshots__/Storyshots.test.js.snap | 235 ------------------ .../ActivatedModComponentListItem.test.tsx | 26 ++ .../DraftModComponentListItem.test.tsx | 22 ++ .../modListingPanel/ModComponentListItem.tsx | 10 +- .../modListingPanel/ModComponents.tsx | 9 - .../DraftModComponentListItem.test.tsx.snap | 27 +- 6 files changed, 56 insertions(+), 273 deletions(-) diff --git a/src/__snapshots__/Storyshots.test.js.snap b/src/__snapshots__/Storyshots.test.js.snap index 00d5c7d15a..e513884840 100644 --- a/src/__snapshots__/Storyshots.test.js.snap +++ b/src/__snapshots__/Storyshots.test.js.snap @@ -9605,241 +9605,6 @@ exports[`Storyshots Panels/RootErrorPanel No Renderer Error Example 1`] = `
`; -exports[`Storyshots Sidebar/ActionMenu Mod 1`] = ` -
-
- -
-
-
-`; - -exports[`Storyshots Sidebar/ActionMenu New Mod Component 1`] = ` -
-
- -
-
-
-`; - -exports[`Storyshots Sidebar/ActionMenu New Mod Component In Mod 1`] = ` -
-
- -
-
-
-`; - -exports[`Storyshots Sidebar/ActionMenu Old Mod Component 1`] = ` -
-
- -
-
-
-`; - -exports[`Storyshots Sidebar/ActionMenu Old Mod Component In Mod 1`] = ` -
-
- -
-
-
-`; - exports[`Storyshots Sidebar/LoginPanel Default 1`] = `
{ const actual = jest.requireActual("@/pageEditor/starterBricks/adapter"); @@ -39,11 +43,29 @@ jest.mock("@/contentScript/messenger/api"); const enableOverlayMock = jest.mocked(enableOverlay); const disableOverlayMock = jest.mocked(disableOverlay); +jest.mock("@/pageEditor/store/editor/editorSelectors", () => { + const actual = jest.requireActual( + "@/pageEditor/store/editor/editorSelectors", + ); + return { + ...actual, + selectActivatedModComponentIsAvailable: jest.fn(), + selectDraftModComponentIsAvailable: jest.fn(), + }; +}); + beforeAll(() => { // When a FontAwesomeIcon gets a title, it generates a random id, which breaks the snapshot. jest.spyOn(global.Math, "random").mockImplementation(() => 0); }); +beforeEach(() => { + jest + .mocked(selectActivatedModComponentIsAvailable) + .mockReturnValue(() => true); + jest.mocked(selectDraftModComponentIsAvailable).mockReturnValue(() => true); +}); + afterAll(() => { jest.clearAllMocks(); }); @@ -88,6 +110,10 @@ describe("ActivatedModComponentListItem", () => { }); it("shows not-available icon properly", async () => { + jest + .mocked(selectActivatedModComponentIsAvailable) + .mockReturnValue(() => false); + const modComponent = modComponentFactory(); render(); diff --git a/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx b/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx index e2cf08b179..579d7b7521 100644 --- a/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx +++ b/src/pageEditor/modListingPanel/DraftModComponentListItem.test.tsx @@ -22,6 +22,10 @@ import { actions as editorActions } from "@/pageEditor/store/editor/editorSlice" import { authActions } from "@/auth/authSlice"; import { formStateFactory } from "@/testUtils/factories/pageEditorFactories"; import { authStateFactory } from "@/testUtils/factories/authFactories"; +import { + selectActivatedModComponentIsAvailable, + selectDraftModComponentIsAvailable, +} from "@/pageEditor/store/editor/editorSelectors"; jest.mock("@/modDefinitions/modDefinitionHooks", () => ({ useAllModDefinitions: jest @@ -29,11 +33,29 @@ jest.mock("@/modDefinitions/modDefinitionHooks", () => ({ .mockReturnValue({ data: [], isLoading: false }), })); +jest.mock("@/pageEditor/store/editor/editorSelectors", () => { + const actual = jest.requireActual( + "@/pageEditor/store/editor/editorSelectors", + ); + return { + ...actual, + selectActivatedModComponentIsAvailable: jest.fn(), + selectDraftModComponentIsAvailable: jest.fn(), + }; +}); + beforeAll(() => { // When a FontAwesomeIcon gets a title, it generates a random id, which breaks the snapshot. jest.spyOn(global.Math, "random").mockImplementation(() => 0); }); +beforeEach(() => { + jest + .mocked(selectActivatedModComponentIsAvailable) + .mockReturnValue(() => true); + jest.mocked(selectDraftModComponentIsAvailable).mockReturnValue(() => true); +}); + afterAll(() => { jest.clearAllMocks(); }); diff --git a/src/pageEditor/modListingPanel/ModComponentListItem.tsx b/src/pageEditor/modListingPanel/ModComponentListItem.tsx index 00544ac1c0..b622551c84 100644 --- a/src/pageEditor/modListingPanel/ModComponentListItem.tsx +++ b/src/pageEditor/modListingPanel/ModComponentListItem.tsx @@ -19,23 +19,15 @@ import React from "react"; import { isModComponentBase, type ModComponentSidebarItem } from "./common"; import DraftModComponentListItem from "./DraftModComponentListItem"; import ActivatedModComponentListItem from "./ActivatedModComponentListItem"; -import { type UUID } from "@/types/stringTypes"; type ModComponentListItemProps = { modComponentSidebarItem: ModComponentSidebarItem; - availableActivatedModComponentIds: UUID[]; - availableDraftModComponentIds: UUID[]; isNested?: boolean; }; const ModComponentListItem: React.FunctionComponent< ModComponentListItemProps -> = ({ - modComponentSidebarItem, - availableActivatedModComponentIds, - availableDraftModComponentIds, - isNested = false, -}) => +> = ({ modComponentSidebarItem, isNested = false }) => isModComponentBase(modComponentSidebarItem) ? ( { const modComponentFormStates = useSelector( selectNotDeletedModComponentFormStates, ); - const { availableActivatedModComponentIds, availableDraftModComponentIds } = - useSelector(selectModComponentAvailability); const [filterQuery, setFilterQuery] = useState(""); const [debouncedFilterQuery] = useDebounce(filterQuery.toLowerCase(), 250, { @@ -91,10 +88,6 @@ const ModComponents: React.FunctionComponent = () => { ))} @@ -106,8 +99,6 @@ const ModComponents: React.FunctionComponent = () => { ); }); diff --git a/src/pageEditor/modListingPanel/__snapshots__/DraftModComponentListItem.test.tsx.snap b/src/pageEditor/modListingPanel/__snapshots__/DraftModComponentListItem.test.tsx.snap index ed32152908..cc5595a74c 100644 --- a/src/pageEditor/modListingPanel/__snapshots__/DraftModComponentListItem.test.tsx.snap +++ b/src/pageEditor/modListingPanel/__snapshots__/DraftModComponentListItem.test.tsx.snap @@ -32,9 +32,7 @@ exports[`DraftModComponentListItem renders active element 1`] = ` > Element 1 -
+
-
+ + +
-
+
-
-
+
`; exports[`Storyshots Components/EllipsisMenu Single Menu 1`] = ` -
-
+ `; exports[`Storyshots Components/ImagePlaceholder Square Image 1`] = ` diff --git a/src/components/ellipsisMenu/EllipsisMenu.tsx b/src/components/ellipsisMenu/EllipsisMenu.tsx index ef52a4356d..0f03699bd9 100644 --- a/src/components/ellipsisMenu/EllipsisMenu.tsx +++ b/src/components/ellipsisMenu/EllipsisMenu.tsx @@ -132,8 +132,8 @@ const EllipsisMenu: React.FunctionComponent = ({ portal, classNames, }) => ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions,no-restricted-syntax -- Just stopping propagation, don't need accessibility -
{ event.stopPropagation(); }} @@ -160,7 +160,7 @@ const EllipsisMenu: React.FunctionComponent = ({ > {items.filter((x) => !x.hide).map((item) => getMenuItemComponent(item))} -
+ ); export default EllipsisMenu; diff --git a/src/pageEditor/documentBuilder/preview/__snapshots__/ElementPreview.test.tsx.snap b/src/pageEditor/documentBuilder/preview/__snapshots__/ElementPreview.test.tsx.snap index b83deb154e..a9e30f5229 100644 --- a/src/pageEditor/documentBuilder/preview/__snapshots__/ElementPreview.test.tsx.snap +++ b/src/pageEditor/documentBuilder/preview/__snapshots__/ElementPreview.test.tsx.snap @@ -48,7 +48,7 @@ exports[`can preview default card 1`] = ` class="container cardBody overflow-auto card-body" data-testid="card-body" > -
+ -
+ @@ -98,7 +98,7 @@ exports[`can preview default column 1`] = ` > Column -
+ -
+ - + -
+ -
+ -
+ -
+ - + -
+ -
+ +<<<<<<< HEAD +======= + +>>>>>>> a7908684c (fix snapshots) -<<<<<<< HEAD - -======= ->>>>>>> a7908684c (fix snapshots)