diff --git a/src/pageEditor/sidebar/ActivatedModComponentListItem.test.tsx b/src/pageEditor/sidebar/ActivatedModComponentListItem.test.tsx index a1a678c82d..7c0c4188cd 100644 --- a/src/pageEditor/sidebar/ActivatedModComponentListItem.test.tsx +++ b/src/pageEditor/sidebar/ActivatedModComponentListItem.test.tsx @@ -16,14 +16,14 @@ */ import React from "react"; -import { waitForEffect } from "@/testUtils/testHelpers"; import { render } from "@/pageEditor/testHelpers"; import { actions as editorActions } from "@/pageEditor/slices/editorSlice"; -import { authActions } from "@/auth/authSlice"; import ActivatedModComponentListItem from "@/pageEditor/sidebar/ActivatedModComponentListItem"; import { modComponentFactory } from "@/testUtils/factories/modComponentFactories"; import { formStateFactory } from "@/testUtils/factories/pageEditorFactories"; -import { authStateFactory } from "@/testUtils/factories/authFactories"; +import { screen, waitFor } from "@testing-library/react"; +import { disableOverlay, enableOverlay } from "@/contentScript/messenger/api"; +import userEvent from "@testing-library/user-event"; jest.mock("@/pageEditor/starterBricks/adapter", () => { const actual = jest.requireActual("@/pageEditor/starterBricks/adapter"); @@ -33,6 +33,14 @@ jest.mock("@/pageEditor/starterBricks/adapter", () => { }; }); +jest.mock("@/contentScript/messenger/api", () => ({ + enableOverlay: jest.fn(), + disableOverlay: jest.fn(), +})); + +const enableOverlayMock = jest.mocked(enableOverlay); +const disableOverlayMock = jest.mocked(disableOverlay); + beforeAll(() => { // When a FontAwesomeIcon gets a title, it generates a random id, which breaks the snapshot. jest.spyOn(global.Math, "random").mockImplementation(() => 0); @@ -43,55 +51,76 @@ afterAll(() => { }); describe("ActivatedModComponentListItem", () => { - test("it renders not active element", async () => { + it("renders not active element", async () => { const modComponent = modComponentFactory(); - const formState = formStateFactory(); - const { asFragment } = render( - , + render( + , + ); + + const button = await screen.findByRole("button", { + name: modComponent.label, + }); + expect(button).toBeVisible(); + expect(button).not.toHaveClass("active"); + }); + + it("renders active element", async () => { + // Note: This is a contrived situation, because in the real app, a + // form state element will always be created when a mod component + // is selected (active) in the sidebar + const modComponent = modComponentFactory(); + const formState = formStateFactory({ + uuid: modComponent.id, + }); + render( + , { - initialValues: formState, setupRedux(dispatch) { - dispatch(authActions.setAuth(authStateFactory())); - // The addElement also sets the active element - dispatch(editorActions.addElement(formStateFactory())); - - // Add new element to deactivate the previous one dispatch(editorActions.addElement(formState)); - // Remove the active element and stay with one inactive item - dispatch(editorActions.removeElement(formState.uuid)); }, }, ); - await waitForEffect(); - expect(asFragment()).toMatchSnapshot(); + const button = await screen.findByRole("button", { + name: modComponent.label, + }); + expect(button).toBeVisible(); + // Wait for Redux side effects + await waitFor(() => { + expect(button).toHaveClass("active"); + }); }); - test("it renders active element", async () => { + it("shows not-available icon properly", async () => { const modComponent = modComponentFactory(); - const formState = formStateFactory(); - const { asFragment } = render( + render( , - { - initialValues: formState, - setupRedux(dispatch) { - dispatch(authActions.setAuth(authStateFactory())); - // The addElement also sets the active element - dispatch(editorActions.addElement(formState)); - }, - }, ); - await waitForEffect(); - expect(asFragment()).toMatchSnapshot(); + expect( + await screen.findByRole("img", { name: "Not available on page" }), + ).toBeVisible(); + }); + + it("handles mouseover action properly for button mod components", async () => { + const modComponent = modComponentFactory(); + render( + , + ); + + const button = await screen.findByRole("button", { + name: modComponent.label, + }); + await userEvent.hover(button); + + expect(enableOverlayMock).toHaveBeenCalledTimes(1); + + await userEvent.unhover(button); + + expect(disableOverlayMock).toHaveBeenCalledTimes(1); }); }); diff --git a/src/pageEditor/sidebar/ActivatedModComponentListItem.tsx b/src/pageEditor/sidebar/ActivatedModComponentListItem.tsx index 1a2a16ecf0..5e5f4b5e4b 100644 --- a/src/pageEditor/sidebar/ActivatedModComponentListItem.tsx +++ b/src/pageEditor/sidebar/ActivatedModComponentListItem.tsx @@ -28,11 +28,9 @@ import { actions } from "@/pageEditor/slices/editorSlice"; import reportError from "@/telemetry/reportError"; import { ListGroup } from "react-bootstrap"; import { - NotAvailableIcon, ExtensionIcon, + NotAvailableIcon, } from "@/pageEditor/sidebar/ExtensionIcons"; -import { type ModDefinition } from "@/types/modDefinitionTypes"; -import { initRecipeOptionsIfNeeded } from "@/pageEditor/starterBricks/base"; import { disableOverlay, enableOverlay, @@ -49,6 +47,9 @@ import { } from "@/pageEditor/slices/editorSelectors"; import { type UUID } from "@/types/stringTypes"; import { type ModComponentBase } from "@/types/modComponentTypes"; +import { appApi } from "@/services/api"; +import { emptyModOptionsDefinitionFactory } from "@/utils/modUtils"; +import { type Schema } from "@/types/schemaTypes"; /** * A sidebar menu entry corresponding to an untouched mod component @@ -56,10 +57,9 @@ import { type ModComponentBase } from "@/types/modComponentTypes"; */ const ActivatedModComponentListItem: React.FunctionComponent<{ modComponent: ModComponentBase; - mods: ModDefinition[]; isAvailable: boolean; isNested?: boolean; -}> = ({ modComponent, mods, isAvailable, isNested = false }) => { +}> = ({ modComponent, isAvailable, isNested = false }) => { const sessionId = useSelector(selectSessionId); const dispatch = useDispatch(); const [type] = useAsyncState( @@ -67,14 +67,16 @@ const ActivatedModComponentListItem: React.FunctionComponent<{ [modComponent.extensionPointId], ); - const activeRecipeId = useSelector(selectActiveRecipeId); + const [getModDefinition] = appApi.endpoints.getRecipe.useLazyQuery(); + + const activeModId = useSelector(selectActiveRecipeId); const activeElement = useSelector(selectActiveElement); const isActive = activeElement?.uuid === modComponent.id; - // Get the selected recipe id, or the recipe id of the selected item - const recipeId = activeRecipeId ?? activeElement?.recipe?.id; + // Get the selected mod id, or the mod id of the selected mod component + const modId = activeModId ?? activeElement?.recipe?.id; // Set the alternate background if this item isn't active, but either its recipe or another item in its recipe is active - const hasRecipeBackground = - !isActive && recipeId && modComponent._recipe?.id === recipeId; + const hasActiveModBackground = + !isActive && modId && modComponent._recipe?.id === modId; const selectHandler = useCallback( async (modComponent: ModComponentBase) => { @@ -84,10 +86,31 @@ const ActivatedModComponentListItem: React.FunctionComponent<{ extensionId: modComponent.id, }); - const state = await extensionToFormState(modComponent); - initRecipeOptionsIfNeeded(state, mods); + const modComponentFormState = await extensionToFormState(modComponent); + + // Initialize mod options schema if needed + if (modComponent._recipe) { + const { data: modDefinition } = await getModDefinition( + { recipeId: modComponent._recipe.id }, + true, + ); + if (modDefinition) { + modComponentFormState.optionsDefinition = + modDefinition.options == null + ? emptyModOptionsDefinitionFactory() + : { + schema: modDefinition.options.schema.properties + ? modDefinition.options.schema + : ({ + type: "object", + properties: modDefinition.options.schema, + } as Schema), + uiSchema: modDefinition.options.uiSchema, + }; + } + } - dispatch(actions.selectInstalled(state)); + dispatch(actions.selectInstalled(modComponentFormState)); dispatch(actions.checkActiveElementAvailability()); if (type === "actionPanel") { @@ -104,7 +127,7 @@ const ActivatedModComponentListItem: React.FunctionComponent<{ dispatch(actions.adapterError({ uuid: modComponent.id, error })); } }, - [dispatch, sessionId, mods, type], + [sessionId, dispatch, type, getModDefinition], ); const isButton = type === "menuItem"; @@ -120,7 +143,7 @@ const ActivatedModComponentListItem: React.FunctionComponent<{ return ( . */ -import { type ModDefinition } from "@/types/modDefinitionTypes"; import React from "react"; -import { type ModComponentFormState } from "@/pageEditor/starterBricks/formStateTypes"; -import { isModComponentBase } from "./common"; +import { isModComponentBase, type ModComponentSidebarItem } from "./common"; import DynamicModComponentListItem from "./DynamicModComponentListItem"; import ActivatedModComponentListItem from "./ActivatedModComponentListItem"; -import { type ModComponentBase } from "@/types/modComponentTypes"; import { type UUID } from "@/types/stringTypes"; type ModComponentListItemProps = { - modComponent: ModComponentBase | ModComponentFormState; - mods: ModDefinition[]; + modComponentSidebarItem: ModComponentSidebarItem; availableInstalledIds: UUID[]; availableDynamicIds: UUID[]; isNested?: boolean; @@ -35,29 +31,26 @@ type ModComponentListItemProps = { const ModComponentListItem: React.FunctionComponent< ModComponentListItemProps > = ({ - modComponent, - mods, + modComponentSidebarItem, availableInstalledIds, availableDynamicIds, isNested = false, }) => - isModComponentBase(modComponent) ? ( + isModComponentBase(modComponentSidebarItem) ? ( ) : ( diff --git a/src/pageEditor/sidebar/ModListItem.test.tsx b/src/pageEditor/sidebar/ModListItem.test.tsx index eb4d03da2e..499fffd054 100644 --- a/src/pageEditor/sidebar/ModListItem.test.tsx +++ b/src/pageEditor/sidebar/ModListItem.test.tsx @@ -16,102 +16,121 @@ */ import React from "react"; -import extensionsSlice from "@/store/extensionsSlice"; -import { - createRenderFunctionWithRedux, - type RenderFunctionWithRedux, -} from "@/testUtils/testHelpers"; -import { - editorSlice, - initialState as editorInitialState, -} from "@/pageEditor/slices/editorSlice"; -import ModListItem, { type ModListItemProps } from "./ModListItem"; -import { type EditorState } from "@/pageEditor/pageEditorTypes"; -import { type ModComponentState } from "@/store/extensionsTypes"; -import { validateSemVerString } from "@/types/helpers"; -import { defaultModDefinitionFactory } from "@/testUtils/factories/modDefinitionFactories"; -import { metadataFactory } from "@/testUtils/factories/metadataFactory"; +import ModListItem from "./ModListItem"; import { screen } from "@testing-library/react"; +import { modMetadataFactory } from "@/testUtils/factories/modComponentFactories"; +import { render } from "@/pageEditor/testHelpers"; +import { Accordion, ListGroup } from "react-bootstrap"; +import { appApiMock } from "@/testUtils/appApiMock"; +import { modDefinitionFactory } from "@/testUtils/factories/modDefinitionFactories"; +import { validateSemVerString } from "@/types/helpers"; -let renderModListItem: RenderFunctionWithRedux< - { - editor: EditorState; - options: ModComponentState; - }, - ModListItemProps ->; +describe("ModListItem", () => { + it("renders expanded", async () => { + const modMetadata = modMetadataFactory(); + appApiMock.onGet(`/api/recipes/${modMetadata.id}/`).reply( + 200, + modDefinitionFactory({ + metadata: modMetadata, + }), + ); + render( + + + +
test children
+
+
+
, + ); -beforeEach(() => { - const recipe = defaultModDefinitionFactory(); - const recipeId = recipe.metadata.id; - // eslint-disable-next-line testing-library/no-render-in-lifecycle -- higher order function, not the actual render - renderModListItem = createRenderFunctionWithRedux({ - reducer: { - editor: editorSlice.reducer, - options: extensionsSlice.reducer, - }, - preloadedState: { - editor: { - ...editorInitialState, - expandedRecipeId: recipeId, - }, - }, - ComponentUnderTest: ModListItem, - defaultProps: { - recipe, - children:
test children
, - installedVersion: validateSemVerString("1.0.0"), - onSave: jest.fn(), - isSaving: false, - onReset: jest.fn(), - onDeactivate: jest.fn(), - onClone: jest.fn(), - }, + expect(await screen.findByText(modMetadata.name)).toBeVisible(); + // eslint-disable-next-line testing-library/no-node-access -- Accordion collapse state + expect(screen.getByText("test children").parentElement).toHaveClass( + "collapse show", + ); }); -}); -test("it renders", () => { - const { asFragment } = renderModListItem(); + it("renders not expanded", async () => { + const modMetadata = modMetadataFactory(); + appApiMock.onGet(`/api/recipes/${modMetadata.id}/`).reply( + 200, + modDefinitionFactory({ + metadata: modMetadata, + }), + ); + render( + + + +
test children
+
+
+
, + ); - expect(asFragment()).toMatchSnapshot(); -}); - -test("renders with empty recipe", () => { - const { asFragment } = renderModListItem({ - propsOverride: { - recipe: undefined, - }, + expect(await screen.findByText(modMetadata.name)).toBeVisible(); + // eslint-disable-next-line testing-library/no-node-access -- Accordion collapse state + expect(screen.getByText("test children").parentElement).toHaveClass( + "collapse", + ); + // eslint-disable-next-line testing-library/no-node-access -- Accordion collapse state + expect(screen.getByText("test children").parentElement).not.toHaveClass( + "show", + ); }); - expect(asFragment()).toMatchSnapshot(); -}); - -test("renders with empty metadata", () => { - const recipe = defaultModDefinitionFactory({ metadata: null }); - const { asFragment } = renderModListItem({ - propsOverride: { - recipe, - }, - }); + it("renders has-update icon properly", async () => { + const modMetadata = modMetadataFactory(); + const modDefinition = modDefinitionFactory({ + metadata: { + ...modMetadata, + version: validateSemVerString("1.0.1"), + }, + }); + appApiMock + .onGet(`/api/recipes/${encodeURIComponent(modMetadata.id)}/`) + .reply(200, { + config: modDefinition, + sharing: modDefinition.sharing, + updated_at: modDefinition.updated_at, + }); + render( + + + +
test children
+
+
+
, + ); - expect(asFragment()).toMatchSnapshot(); -}); + const expectedMessage = + "You are editing version 1.0.0 of this mod, the latest version is 1.0.1."; -test("renders the warning icon when has update", () => { - const recipe = defaultModDefinitionFactory({ - metadata: metadataFactory({ - version: validateSemVerString("2.0.0"), - }), + expect( + await screen.findByRole("img", { name: expectedMessage }), + ).toBeVisible(); }); - renderModListItem({ - propsOverride: { - recipe, - }, - }); - - const warningIcon = screen.getByTitle( - "You are editing version 1.0.0 of this mod, the latest version is 2.0.0.", - ); - - expect(warningIcon).toBeInTheDocument(); }); diff --git a/src/pageEditor/sidebar/ModListItem.tsx b/src/pageEditor/sidebar/ModListItem.tsx index 03c1f91a77..ef6e45e084 100644 --- a/src/pageEditor/sidebar/ModListItem.tsx +++ b/src/pageEditor/sidebar/ModListItem.tsx @@ -16,7 +16,7 @@ */ import React, { type PropsWithChildren } from "react"; -import { type SemVerString } from "@/types/registryTypes"; +import { type Metadata } from "@/types/registryTypes"; import styles from "./Entry.module.scss"; import { RecipeHasUpdateIcon, @@ -34,18 +34,17 @@ import { useDispatch, useSelector } from "react-redux"; import cx from "classnames"; import { selectActiveElement, + selectActiveRecipeId, selectDirtyMetadataForRecipeId, selectExpandedRecipeId, selectRecipeIsDirty, } from "@/pageEditor/slices/editorSelectors"; -import { type ModDefinition } from "@/types/modDefinitionTypes"; import * as semver from "semver"; import ActionMenu from "@/pageEditor/sidebar/ActionMenu"; +import { useGetRecipeQuery } from "@/services/api"; export type ModListItemProps = PropsWithChildren<{ - recipe: ModDefinition | undefined; - isActive?: boolean; - installedVersion: SemVerString; + modMetadata: Metadata; onSave: () => Promise; isSaving: boolean; onReset: () => Promise; @@ -54,10 +53,8 @@ export type ModListItemProps = PropsWithChildren<{ }>; const ModListItem: React.FC = ({ - recipe, - isActive, + modMetadata, children, - installedVersion, onSave, isSaving, onReset, @@ -65,43 +62,43 @@ const ModListItem: React.FC = ({ onClone, }) => { const dispatch = useDispatch(); - - const expandedRecipeId = useSelector(selectExpandedRecipeId); + const activeModId = useSelector(selectActiveRecipeId); + const expandedModId = useSelector(selectExpandedRecipeId); const activeElement = useSelector(selectActiveElement); - const { - id: recipeId, - name: savedName, - version: latestRecipeVersion, - } = recipe?.metadata ?? {}; + const { id: modId, name: savedName, version: installedVersion } = modMetadata; + const isActive = activeModId === modId; + + // TODO: Fix this so it pulls from registry, after registry single-item-api-fetch is implemented + // (See: https://github.com/pixiebrix/pixiebrix-extension/issues/7184) + const { data: modDefinition } = useGetRecipeQuery({ recipeId: modId }); + const latestRecipeVersion = modDefinition?.metadata?.version; // Set the alternate background if an extension in this recipe is active - const hasRecipeBackground = activeElement?.recipe?.id === recipeId; + const hasRecipeBackground = activeElement?.recipe?.id === modId; - const dirtyName = useSelector(selectDirtyMetadataForRecipeId(recipeId))?.name; + const dirtyName = useSelector(selectDirtyMetadataForRecipeId(modId))?.name; const name = dirtyName ?? savedName ?? "Loading..."; - const isDirty = useSelector(selectRecipeIsDirty(recipeId)); + const isDirty = useSelector(selectRecipeIsDirty(modId)); const hasUpdate = latestRecipeVersion != null && installedVersion != null && semver.gt(latestRecipeVersion, installedVersion); - const caretIcon = expandedRecipeId === recipeId ? faCaretDown : faCaretRight; + const caretIcon = expandedModId === modId ? faCaretDown : faCaretRight; return ( <> - recipeId != null && dispatch(actions.selectRecipeId(recipeId)) - } + key={`recipe-${modId}`} + onClick={() => modId != null && dispatch(actions.selectRecipeId(modId))} > @@ -130,7 +127,7 @@ const ModListItem: React.FC = ({ /> )} - + <>{children} diff --git a/src/pageEditor/sidebar/SidebarExpanded.tsx b/src/pageEditor/sidebar/SidebarExpanded.tsx index 1ab060f8cc..4bad996e85 100644 --- a/src/pageEditor/sidebar/SidebarExpanded.tsx +++ b/src/pageEditor/sidebar/SidebarExpanded.tsx @@ -16,24 +16,25 @@ */ import styles from "./Sidebar.module.scss"; -import React, { useEffect, useMemo, useState } from "react"; -import { sortBy } from "lodash"; +import React, { useMemo, useState } from "react"; import { Accordion, Button, - // eslint-disable-next-line no-restricted-imports -- TODO: Fix over time - Form, + FormControl, InputGroup, ListGroup, } from "react-bootstrap"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import hash from "object-hash"; -import { isModComponentBase } from "@/pageEditor/sidebar/common"; +import { + getModComponentItemId, + isModSidebarItem, + type SidebarItem, +} from "@/pageEditor/sidebar/common"; import { faAngleDoubleLeft } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import ModListItem from "@/pageEditor/sidebar/ModListItem"; import useFlags from "@/hooks/useFlags"; -import arrangeElements from "@/pageEditor/sidebar/arrangeElements"; +import arrangeSidebarItems from "@/pageEditor/sidebar/arrangeSidebarItems"; import { selectActiveElementId, selectActiveRecipeId, @@ -43,11 +44,6 @@ import { selectNotDeletedExtensions, } from "@/pageEditor/slices/editorSelectors"; import { useDispatch, useSelector } from "react-redux"; -import { - getRecipeById, - getIdForElement, - getModIdForElement, -} from "@/pageEditor/utils"; import useSaveRecipe from "@/pageEditor/hooks/useSaveRecipe"; import useResetRecipe from "@/pageEditor/hooks/useResetRecipe"; import useDeactivateMod from "@/pageEditor/hooks/useDeactivateMod"; @@ -56,86 +52,56 @@ import ReloadButton from "./ReloadButton"; import AddStarterBrickButton from "./AddStarterBrickButton"; import ModComponentListItem from "./ModComponentListItem"; import { actions } from "@/pageEditor/slices/editorSlice"; -import { measureDurationFromAppStart } from "@/utils/performance"; -import { useAllModDefinitions } from "@/modDefinitions/modDefinitionHooks"; import { useDebounce } from "use-debounce"; +import { lowerCase } from "lodash"; +import filterSidebarItems from "@/pageEditor/sidebar/filterSidebarItems"; const SidebarExpanded: React.FunctionComponent<{ collapseSidebar: () => void; }> = ({ collapseSidebar }) => { - const { data: allModDefinitions, isLoading: isAllModDefinitionsLoading } = - useAllModDefinitions(); - - useEffect(() => { - if (!isAllModDefinitionsLoading) { - measureDurationFromAppStart("sidebarExpanded:allRecipesLoaded"); - } - }, [isAllModDefinitionsLoading]); - const dispatch = useDispatch(); - const activeElementId = useSelector(selectActiveElementId); - const activeRecipeId = useSelector(selectActiveRecipeId); - const expandedRecipeId = useSelector(selectExpandedRecipeId); - const activated = useSelector(selectNotDeletedExtensions); - const elements = useSelector(selectNotDeletedElements); + const activeModComponentId = useSelector(selectActiveElementId); + const activeModId = useSelector(selectActiveRecipeId); + const expandedModId = useSelector(selectExpandedRecipeId); + const cleanModComponents = useSelector(selectNotDeletedExtensions); + const modComponentFormStates = useSelector(selectNotDeletedElements); const { availableInstalledIds, availableDynamicIds } = useSelector( selectExtensionAvailability, ); - const mods = useMemo(() => { - const activatedAndElements = [...activated, ...elements]; - return ( - allModDefinitions?.filter((recipe) => - activatedAndElements.some( - (element) => getModIdForElement(element) === recipe.metadata.id, - ), - ) ?? [] - ); - }, [allModDefinitions, elements, activated]); - const { flagOn } = useFlags(); const showDeveloperUI = process.env.ENVIRONMENT === "development" || flagOn("page-editor-developer"); - const [query, setQuery] = useState(""); - - const [debouncedQuery] = useDebounce(query, 250, { + const [filterQuery, setFilterQuery] = useState(""); + const [debouncedFilterQuery] = useDebounce(lowerCase(filterQuery), 250, { trailing: true, leading: false, }); - const elementHash = hash( - sortBy( - elements.map( - (formState) => - `${formState.uuid}-${formState.label}-${formState.recipe?.id ?? ""}`, - ), - ), - ); - const recipeHash = hash( - mods - ? mods.map((recipe) => `${recipe.metadata.id}-${recipe.metadata.name}`) - : "", + const sortedSidebarItems = useMemo( + () => + arrangeSidebarItems({ + modComponentFormStates, + cleanModComponents, + }), + [modComponentFormStates, cleanModComponents], ); - const sortedElements = useMemo( + + const filteredSidebarItems = useMemo( () => - arrangeElements({ - elements, - installed: activated, - recipes: mods, - activeElementId, - activeRecipeId, - query: debouncedQuery, + filterSidebarItems({ + sidebarItems: sortedSidebarItems, + filterText: debouncedFilterQuery, + activeModId, + activeModComponentId, }), - // eslint-disable-next-line react-hooks/exhaustive-deps -- using elementHash and recipeHash to track changes [ - activated, - elementHash, - recipeHash, - activeElementId, - activeRecipeId, - debouncedQuery, + activeModComponentId, + activeModId, + debouncedFilterQuery, + sortedSidebarItems, ], ); @@ -143,44 +109,31 @@ const SidebarExpanded: React.FunctionComponent<{ const resetRecipe = useResetRecipe(); const deactivateMod = useDeactivateMod(); - const listItems = sortedElements.map((item) => { - if (Array.isArray(item)) { - const [recipeId, elements] = item; - const recipe = getRecipeById(mods, recipeId); - const firstElement = elements[0]; - const installedVersion = - firstElement == null - ? // If there's no extensions in the Blueprint (empty Blueprint?), use the Blueprint's version - recipe?.metadata?.version - : isModComponentBase(firstElement) - ? firstElement._recipe.version - : firstElement.recipe.version; - + const listItems = filteredSidebarItems.map((sidebarItem) => { + if (isModSidebarItem(sidebarItem)) { + const { modMetadata, modComponents } = sidebarItem; return ( { - await saveRecipe(activeRecipeId); + await saveRecipe(activeModId); }} isSaving={isSavingRecipe} onReset={async () => { - await resetRecipe(activeRecipeId); + await resetRecipe(activeModId); }} onDeactivate={async () => { - await deactivateMod({ modId: activeRecipeId }); + await deactivateMod({ modId: activeModId }); }} onClone={async () => { dispatch(actions.showCreateRecipeModal({ keepLocalCopy: true })); }} > - {elements.map((element) => ( + {modComponents.map((modComponentSidebarItem) => ( @@ -227,20 +179,20 @@ const SidebarExpanded: React.FunctionComponent<{
- { - setQuery(target.value); + setFilterQuery(target.value); }} /> - {query.length > 0 ? ( + {filterQuery.length > 0 ? ( - - - -`; - -exports[`ActivatedModComponentListItem it renders not active element 1`] = ` - -
- - -
-
-`; diff --git a/src/pageEditor/sidebar/__snapshots__/ModListItem.test.tsx.snap b/src/pageEditor/sidebar/__snapshots__/ModListItem.test.tsx.snap deleted file mode 100644 index baeb349ba9..0000000000 --- a/src/pageEditor/sidebar/__snapshots__/ModListItem.test.tsx.snap +++ /dev/null @@ -1,172 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`it renders 1`] = ` - -
- - - - - - - Mod 1 - -
-
-
- test children -
-
-
-`; - -exports[`renders with empty metadata 1`] = ` - -
- - - - - - - Loading... - -
-
-
- test children -
-
-
-`; - -exports[`renders with empty recipe 1`] = ` - -
- - - - - - - Loading... - -
-
-
- test children -
-
-
-`; diff --git a/src/pageEditor/sidebar/arrangeElements.test.ts b/src/pageEditor/sidebar/arrangeElements.test.ts deleted file mode 100644 index 2d09bf4bba..0000000000 --- a/src/pageEditor/sidebar/arrangeElements.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -/* - * Copyright (C) 2023 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 { type ModDefinition } from "@/types/modDefinitionTypes"; -import { uuidv4, validateRegistryId } from "@/types/helpers"; -import { type ModComponentBase } from "@/types/modComponentTypes"; -import arrangeElements from "@/pageEditor/sidebar/arrangeElements"; -import { type ActionFormState } from "@/pageEditor/starterBricks/formStateTypes"; -import { - modComponentFactory, - modMetadataFactory, -} from "@/testUtils/factories/modComponentFactories"; -import { defaultModDefinitionFactory } from "@/testUtils/factories/modDefinitionFactories"; -import { metadataFactory } from "@/testUtils/factories/metadataFactory"; -import { menuItemFormStateFactory } from "@/testUtils/factories/pageEditorFactories"; - -// Recipes -const ID_FOO = validateRegistryId("test/recipe-foo"); -const recipeFoo: ModDefinition = defaultModDefinitionFactory({ - metadata: metadataFactory({ - id: ID_FOO, - name: "Foo Recipe", - }), -}); - -const ID_BAR = validateRegistryId("test/recipe-bar"); -const recipeBar: ModDefinition = defaultModDefinitionFactory({ - metadata: metadataFactory({ - id: ID_BAR, - name: "Bar Recipe", - }), -}); - -// Extensions -const ID_FOO_A = uuidv4(); -const installedFooA: ModComponentBase = modComponentFactory({ - id: ID_FOO_A, - label: "A", - _recipe: modMetadataFactory({ - id: ID_FOO, - }), -}); - -const ID_FOO_B = uuidv4(); -const dynamicFooB: ActionFormState = menuItemFormStateFactory({ - uuid: ID_FOO_B, - label: "B", - recipe: modMetadataFactory({ - id: ID_FOO, - }), -}); - -const ID_ORPHAN_C = uuidv4(); -const dynamicOrphanC: ActionFormState = menuItemFormStateFactory({ - uuid: ID_ORPHAN_C, - label: "C", -}); - -const ID_BAR_D = uuidv4(); -const installedBarD: ModComponentBase = modComponentFactory({ - id: ID_BAR_D, - label: "D", - _recipe: modMetadataFactory({ - id: ID_BAR, - }), -}); - -const ID_BAR_E = uuidv4(); -const dynamicBarE: ActionFormState = menuItemFormStateFactory({ - uuid: ID_BAR_E, - label: "E", - recipe: modMetadataFactory({ - id: ID_BAR, - }), -}); - -const ID_BAR_F = uuidv4(); -const installedBarF: ModComponentBase = modComponentFactory({ - id: ID_BAR_F, - label: "F", - _recipe: modMetadataFactory({ - id: ID_BAR, - }), -}); - -const ID_ORPHAN_G = uuidv4(); -const installedOrphanG: ModComponentBase = modComponentFactory({ - id: ID_ORPHAN_G, - label: "G", -}); - -const ID_ORPHAN_H = uuidv4(); -const installedOrphanH: ModComponentBase = modComponentFactory({ - id: ID_ORPHAN_H, - label: "H", -}); - -const dynamicOrphanH: ActionFormState = menuItemFormStateFactory({ - uuid: ID_ORPHAN_H, - label: "H", -}); - -describe("arrangeElements()", () => { - test("sort orphaned recipes by metadata.name", () => { - const elements = arrangeElements({ - elements: [dynamicOrphanC], - installed: [installedOrphanH, installedOrphanG], - recipes: [], - activeElementId: dynamicOrphanC.uuid, - activeRecipeId: null, - query: "", - }); - - expect(elements).toStrictEqual([ - dynamicOrphanC, - installedOrphanG, - installedOrphanH, - ]); - }); - - test("group recipes and sort properly", () => { - const elements = arrangeElements({ - elements: [dynamicBarE, dynamicFooB], - installed: [installedFooA, installedBarF, installedBarD], - recipes: [recipeFoo, recipeBar], - activeElementId: dynamicBarE.uuid, - activeRecipeId: null, - query: "", - }); - - expect(elements).toEqual([ - [recipeBar.metadata.id, [installedBarD, dynamicBarE, installedBarF]], - [recipeFoo.metadata.id, [installedFooA, dynamicFooB]], - ]); - }); - - test("do not duplicate extension/element pairs in the results", () => { - const elements = arrangeElements({ - elements: [dynamicOrphanH], - installed: [installedOrphanH], - recipes: [], - activeElementId: ID_ORPHAN_H, - activeRecipeId: null, - query: "", - }); - - expect(elements).toStrictEqual([dynamicOrphanH]); - }); - - test("search query filters correctly", () => { - const elements = arrangeElements({ - elements: [dynamicOrphanC, dynamicOrphanH], - installed: [installedOrphanH, installedOrphanG], - recipes: [], - activeElementId: dynamicOrphanC.uuid, - activeRecipeId: null, - query: "c", - }); - - expect(elements).toStrictEqual([dynamicOrphanC]); - }); - - test("search query keeps active items", () => { - const elements = arrangeElements({ - elements: [dynamicOrphanC, dynamicOrphanH], - installed: [installedOrphanH, installedOrphanG], - recipes: [], - activeElementId: dynamicOrphanC.uuid, - activeRecipeId: null, - query: "g", - }); - - expect(elements).toStrictEqual([dynamicOrphanC, installedOrphanG]); - }); -}); diff --git a/src/pageEditor/sidebar/arrangeElements.ts b/src/pageEditor/sidebar/arrangeElements.ts deleted file mode 100644 index 08bf69b4b2..0000000000 --- a/src/pageEditor/sidebar/arrangeElements.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* - * Copyright (C) 2023 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 { groupBy, lowerCase, sortBy } from "lodash"; -import { type ModDefinition } from "@/types/modDefinitionTypes"; -import { - type ModComponentFormState, - isModComponentFormState, -} from "@/pageEditor/starterBricks/formStateTypes"; -import { getRecipeById } from "@/pageEditor/utils"; -import { isModComponentBase } from "@/pageEditor/sidebar/common"; -import { type UUID } from "@/types/stringTypes"; -import { type ModComponentBase } from "@/types/modComponentTypes"; -import { type RegistryId } from "@/types/registryTypes"; - -type ArrangeElementsArgs = { - elements: ModComponentFormState[]; - installed: ModComponentBase[]; - recipes: ModDefinition[]; - activeElementId: UUID | null; - activeRecipeId: RegistryId | null; - query: string; -}; - -type Element = ModComponentBase | ModComponentFormState; - -function arrangeElements({ - elements, - installed, - recipes, - activeElementId, - activeRecipeId, - query, -}: ArrangeElementsArgs): Array { - const elementIds = new Set(elements.map((formState) => formState.uuid)); - - const queryFilter = (item: ModComponentBase | ModComponentFormState) => { - const recipe = isModComponentFormState(item) ? item.recipe : item._recipe; - const queryName = recipe?.name ?? item.label; - - return ( - activeRecipeId === recipe?.id || - query.length === 0 || - (isModComponentFormState(item) && activeElementId === item.uuid) || - (query.length > 0 && lowerCase(queryName).includes(lowerCase(query))) - ); - }; - - const filteredExtensions: ModComponentBase[] = installed - // Note: we can take out this elementIds filter if and when we persist the editor - // slice and remove installed extensions when they become dynamic elements - .filter((extension) => !elementIds.has(extension.id)) - .filter((extension) => queryFilter(extension)); - - const filteredDynamicElements: ModComponentFormState[] = elements.filter( - (element) => queryFilter(element), - ); - - const grouped = groupBy( - [...filteredExtensions, ...filteredDynamicElements], - (extension) => - isModComponentBase(extension) - ? extension._recipe?.id - : extension.recipe?.id, - ); - - const _elementsByRecipeId = new Map( - Object.entries(grouped), - ); - for (const elements of _elementsByRecipeId.values()) { - elements.sort((a, b) => - lowerCase(a.label).localeCompare(lowerCase(b.label)), - ); - } - - const orphanedElements = _elementsByRecipeId.get("undefined") ?? []; - _elementsByRecipeId.delete("undefined"); - const unsortedElements = [ - ...(_elementsByRecipeId as Map), - ...orphanedElements, - ]; - - const sortedElements = sortBy(unsortedElements, (item) => { - if (!Array.isArray(item)) { - return lowerCase(item.label); - } - - const [recipeId, elements] = item; - const recipe = getRecipeById(recipes, recipeId); - if (recipe) { - return lowerCase(recipe?.metadata?.name ?? ""); - } - - // Look for a recipe name in the elements/extensions in case recipes are still loading - for (const element of elements) { - const name = isModComponentBase(element) - ? element._recipe?.name - : element.recipe?.name; - if (name) { - return lowerCase(name); - } - } - - return ""; - }); - - return sortedElements; -} - -export default arrangeElements; diff --git a/src/pageEditor/sidebar/arrangeSidebarItems.test.ts b/src/pageEditor/sidebar/arrangeSidebarItems.test.ts new file mode 100644 index 0000000000..059720b6a1 --- /dev/null +++ b/src/pageEditor/sidebar/arrangeSidebarItems.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright (C) 2023 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 { uuidv4, validateRegistryId } from "@/types/helpers"; +import { type ModComponentBase } from "@/types/modComponentTypes"; +import arrangeSidebarItems from "@/pageEditor/sidebar/arrangeSidebarItems"; +import { type ActionFormState } from "@/pageEditor/starterBricks/formStateTypes"; +import { + modComponentFactory, + modMetadataFactory, +} from "@/testUtils/factories/modComponentFactories"; +import { menuItemFormStateFactory } from "@/testUtils/factories/pageEditorFactories"; + +// Mods +const ID_FOO = validateRegistryId("test/recipe-foo"); +const modMetadataFoo = modMetadataFactory({ + id: ID_FOO, + name: "Foo Mod", +}); + +const ID_BAR = validateRegistryId("test/recipe-bar"); +const modMetadataBar = modMetadataFactory({ + id: ID_BAR, + name: "Bar Recipe", +}); + +// Mod Components +const ID_FOO_A = uuidv4(); +const cleanModComponentFooA: ModComponentBase = modComponentFactory({ + id: ID_FOO_A, + label: "A", + _recipe: modMetadataFoo, +}); + +const ID_FOO_B = uuidv4(); +const formStateModComponentFooB: ActionFormState = menuItemFormStateFactory({ + uuid: ID_FOO_B, + label: "B", + recipe: modMetadataFoo, +}); + +const ID_ORPHAN_C = uuidv4(); +const formStateModComponentOrphanC: ActionFormState = menuItemFormStateFactory({ + uuid: ID_ORPHAN_C, + label: "C", +}); + +const ID_BAR_D = uuidv4(); +const cleanModComponentBarD: ModComponentBase = modComponentFactory({ + id: ID_BAR_D, + label: "D", + _recipe: modMetadataBar, +}); + +const ID_BAR_E = uuidv4(); +const formStateModComponentBarE: ActionFormState = menuItemFormStateFactory({ + uuid: ID_BAR_E, + label: "E", + recipe: modMetadataBar, +}); + +const ID_BAR_F = uuidv4(); +const cleanModComponentBarF: ModComponentBase = modComponentFactory({ + id: ID_BAR_F, + label: "F", + _recipe: modMetadataBar, +}); + +const ID_ORPHAN_G = uuidv4(); +const cleanModComponentOrphanG: ModComponentBase = modComponentFactory({ + id: ID_ORPHAN_G, + label: "G", +}); + +const ID_ORPHAN_H = uuidv4(); +const cleanModComponentOrphanH: ModComponentBase = modComponentFactory({ + id: ID_ORPHAN_H, + label: "H", +}); + +const formStateModComponentOrphanH: ActionFormState = menuItemFormStateFactory({ + uuid: ID_ORPHAN_H, + label: "H", +}); + +describe("arrangeSidebarItems()", () => { + test("sort orphaned recipes by metadata.name", () => { + const elements = arrangeSidebarItems({ + modComponentFormStates: [formStateModComponentOrphanC], + cleanModComponents: [cleanModComponentOrphanH, cleanModComponentOrphanG], + }); + + expect(elements).toStrictEqual([ + formStateModComponentOrphanC, + cleanModComponentOrphanG, + cleanModComponentOrphanH, + ]); + }); + + test("groups recipes and sorts mod components by label", () => { + const sidebarItems = arrangeSidebarItems({ + modComponentFormStates: [ + formStateModComponentBarE, + formStateModComponentFooB, + ], + cleanModComponents: [ + cleanModComponentFooA, + cleanModComponentBarF, + cleanModComponentBarD, + ], + }); + + expect(sidebarItems).toEqual([ + { + modMetadata: modMetadataBar, + modComponents: [ + cleanModComponentBarD, + formStateModComponentBarE, + cleanModComponentBarF, + ], + }, + { + modMetadata: modMetadataFoo, + modComponents: [cleanModComponentFooA, formStateModComponentFooB], + }, + ]); + }); + + test("do not duplicate extension/element pairs in the results", () => { + const elements = arrangeSidebarItems({ + modComponentFormStates: [formStateModComponentOrphanH], + cleanModComponents: [cleanModComponentOrphanH], + }); + + expect(elements).toStrictEqual([formStateModComponentOrphanH]); + }); +}); diff --git a/src/pageEditor/sidebar/arrangeSidebarItems.ts b/src/pageEditor/sidebar/arrangeSidebarItems.ts new file mode 100644 index 0000000000..384fbec3cd --- /dev/null +++ b/src/pageEditor/sidebar/arrangeSidebarItems.ts @@ -0,0 +1,91 @@ +/* + * Copyright (C) 2023 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 { lowerCase, sortBy } from "lodash"; +import { type ModComponentFormState } from "@/pageEditor/starterBricks/formStateTypes"; +import { type UUID } from "@/types/stringTypes"; +import { type ModComponentBase } from "@/types/modComponentTypes"; +import { type RegistryId } from "@/types/registryTypes"; +import { + isModSidebarItem, + type ModComponentSidebarItem, + type ModSidebarItem, + type SidebarItem, +} from "@/pageEditor/sidebar/common"; + +type ArrangeSidebarItemsArgs = { + modComponentFormStates: ModComponentFormState[]; + cleanModComponents: ModComponentBase[]; +}; + +function arrangeSidebarItems({ + modComponentFormStates, + cleanModComponents, +}: ArrangeSidebarItemsArgs): SidebarItem[] { + const modSidebarItems: Record = {}; + const orphanSidebarItems: ModComponentSidebarItem[] = []; + + const formStateModComponentIds = new Set(); + + for (const formState of modComponentFormStates) { + formStateModComponentIds.add(formState.uuid); + + if (formState.recipe == null) { + orphanSidebarItems.push(formState); + } else { + const modSidebarItem = modSidebarItems[formState.recipe.id] ?? { + modMetadata: formState.recipe, + modComponents: [], + }; + modSidebarItem.modComponents.push(formState); + modSidebarItems[formState.recipe.id] = modSidebarItem; + } + } + + const cleanModComponentsWithoutFormStates = cleanModComponents.filter( + (modComponent) => !formStateModComponentIds.has(modComponent.id), + ); + + for (const cleanModComponent of cleanModComponentsWithoutFormStates) { + if (cleanModComponent._recipe == null) { + orphanSidebarItems.push(cleanModComponent); + } else { + const modSidebarItem = modSidebarItems[cleanModComponent._recipe.id] ?? { + modMetadata: cleanModComponent._recipe, + modComponents: [], + }; + modSidebarItem.modComponents.push(cleanModComponent); + modSidebarItems[cleanModComponent._recipe.id] = modSidebarItem; + } + } + + for (const modSidebarItem of Object.values(modSidebarItems)) { + modSidebarItem.modComponents.sort((a, b) => + lowerCase(a.label).localeCompare(lowerCase(b.label)), + ); + } + + return sortBy( + [...Object.values(modSidebarItems), ...orphanSidebarItems], + (item) => + isModSidebarItem(item) + ? lowerCase(item.modMetadata.name) + : lowerCase(item.label), + ); +} + +export default arrangeSidebarItems; diff --git a/src/pageEditor/sidebar/common.ts b/src/pageEditor/sidebar/common.ts index 178d3babd2..b3b3867cce 100644 --- a/src/pageEditor/sidebar/common.ts +++ b/src/pageEditor/sidebar/common.ts @@ -17,15 +17,32 @@ import { type ModComponentBase } from "@/types/modComponentTypes"; import { type ModComponentFormState } from "@/pageEditor/starterBricks/formStateTypes"; +import type { Metadata } from "@/types/registryTypes"; +import type { UUID } from "@/types/stringTypes"; -type SidebarItem = ModComponentBase | ModComponentFormState; +export type ModComponentSidebarItem = ModComponentBase | ModComponentFormState; + +export type ModSidebarItem = { + modMetadata: Metadata; + modComponents: ModComponentSidebarItem[]; +}; + +export type SidebarItem = ModSidebarItem | ModComponentSidebarItem; export function getLabel(extension: ModComponentFormState): string { return extension.label ?? extension.extensionPoint.metadata.name; } export function isModComponentBase( - value: SidebarItem, + value: ModComponentSidebarItem, ): value is ModComponentBase { return "extensionPointId" in value; } + +export function getModComponentItemId(item: ModComponentSidebarItem): UUID { + return isModComponentBase(item) ? item.id : item.uuid; +} + +export function isModSidebarItem(item: SidebarItem): item is ModSidebarItem { + return "modMetadata" in item; +} diff --git a/src/pageEditor/sidebar/filterSidebarItems.test.ts b/src/pageEditor/sidebar/filterSidebarItems.test.ts new file mode 100644 index 0000000000..a6a532e528 --- /dev/null +++ b/src/pageEditor/sidebar/filterSidebarItems.test.ts @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2023 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 { define } from "cooky-cutter"; +import { type SidebarItem } from "@/pageEditor/sidebar/common"; +import { + modComponentFactory, + modMetadataFactory, +} from "@/testUtils/factories/modComponentFactories"; +import { formStateFactory } from "@/testUtils/factories/pageEditorFactories"; +import filterSidebarItems from "@/pageEditor/sidebar/filterSidebarItems"; +import { validateRegistryId } from "@/types/helpers"; +import { uuidSequence } from "@/testUtils/factories/stringFactories"; + +const modSidebarItemFactory = define({ + modMetadata: modMetadataFactory, + modComponents() { + return [modComponentFactory(), formStateFactory()]; + }, +}); + +describe("filterSidebarItems", () => { + it("returns empty array when sidebar items is empty", () => { + expect( + filterSidebarItems({ + sidebarItems: [], + filterText: "", + activeModId: null, + activeModComponentId: null, + }), + ).toEqual([]); + }); + + it("returns sidebar items when filter text is empty", () => { + const sidebarItems = [ + modSidebarItemFactory(), + modSidebarItemFactory(), + formStateFactory(), + modComponentFactory(), + ]; + expect( + filterSidebarItems({ + sidebarItems, + filterText: "", + activeModId: null, + activeModComponentId: null, + }), + ).toEqual(sidebarItems); + }); + + it("returns sidebar items when filter text matches mod name", () => { + const sidebarItems = [ + modSidebarItemFactory({ + modMetadata: modMetadataFactory({ name: "Foo" }), + }), + modSidebarItemFactory({ + modMetadata: modMetadataFactory({ name: "Bar" }), + }), + ]; + expect( + filterSidebarItems({ + sidebarItems, + filterText: "foo", + activeModId: null, + activeModComponentId: null, + }), + ).toEqual([sidebarItems[0]]); + }); + + it("returns sidebar items when filter text matches mod component label", () => { + const sidebarItems = [ + modSidebarItemFactory({ + modComponents: [ + modComponentFactory({ label: "Foo" }), + modComponentFactory({ label: "Bar" }), + formStateFactory({ label: "foo" }), + ], + }), + ]; + expect( + filterSidebarItems({ + sidebarItems, + filterText: "foo", + activeModId: null, + activeModComponentId: null, + }), + ).toEqual([sidebarItems[0], sidebarItems[2]]); + }); + + it("does not filter out active mod", () => { + const testModId = validateRegistryId("test/foo"); + const sidebarItems = [ + modSidebarItemFactory({ + modMetadata: modMetadataFactory({ id: testModId }), + }), + ]; + expect( + filterSidebarItems({ + sidebarItems, + filterText: "abc", + activeModId: testModId, + activeModComponentId: null, + }), + ).toEqual(sidebarItems); + }); + + it("does not filter out active mod component", () => { + const testModComponentId = uuidSequence(1); + const sidebarItems = [ + modSidebarItemFactory({ + modComponents: [modComponentFactory({ id: testModComponentId })], + }), + ]; + expect( + filterSidebarItems({ + sidebarItems, + filterText: "abc", + activeModId: null, + activeModComponentId: testModComponentId, + }), + ).toEqual(sidebarItems); + }); +}); diff --git a/src/pageEditor/sidebar/filterSidebarItems.ts b/src/pageEditor/sidebar/filterSidebarItems.ts new file mode 100644 index 0000000000..b3e2ebb1b6 --- /dev/null +++ b/src/pageEditor/sidebar/filterSidebarItems.ts @@ -0,0 +1,73 @@ +/* + * Copyright (C) 2023 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 { + getModComponentItemId, + isModSidebarItem, + type SidebarItem, +} from "@/pageEditor/sidebar/common"; +import { type RegistryId } from "@/types/registryTypes"; +import { type UUID } from "@/types/stringTypes"; +import { lowerCase } from "lodash"; + +type FilterSidebarItemsArgs = { + sidebarItems: SidebarItem[]; + filterText: string; + activeModId: RegistryId | null; + activeModComponentId: UUID | null; +}; + +export default function filterSidebarItems({ + sidebarItems, + filterText, + activeModId, + activeModComponentId, +}: FilterSidebarItemsArgs): SidebarItem[] { + if (filterText.length === 0) { + return sidebarItems; + } + + return sidebarItems.filter((sidebarItem) => { + if (isModSidebarItem(sidebarItem)) { + // Don't filter out mod item if the mod is active, or the name matches the query + if ( + sidebarItem.modMetadata.id === activeModId || + lowerCase(sidebarItem.modMetadata.name).includes(filterText) + ) { + return true; + } + + // Don't filter out mod item if any mod component is active, or any mod component label matches the query + for (const modComponentItem of sidebarItem.modComponents) { + if ( + getModComponentItemId(modComponentItem) === activeModComponentId || + lowerCase(modComponentItem.label).includes(filterText) + ) { + return true; + } + } + + return false; + } + + // Don't filter out mod component item if the mod component is active, or the label matches the query + return ( + getModComponentItemId(sidebarItem) === activeModComponentId || + lowerCase(sidebarItem.label).includes(filterText) + ); + }); +}