diff --git a/src/activation/mapModComponentDefinitionToActivatedModComponent.ts b/src/activation/mapModComponentDefinitionToActivatedModComponent.ts index de2dcab198..3cda9d3c1c 100644 --- a/src/activation/mapModComponentDefinitionToActivatedModComponent.ts +++ b/src/activation/mapModComponentDefinitionToActivatedModComponent.ts @@ -53,6 +53,9 @@ export type ActivateModComponentParam = { /** * Transform a given ModComponentDefinition into an ActivatedModComponent. + * + * Assigns a fresh UUID to the mod component. + * * Note: This function has no side effects, it's just a type-transformer. It does * NOT save the activated mod component anywhere. */ diff --git a/src/activation/modOptionsHelpers.ts b/src/activation/modOptionsHelpers.ts index de26ecbc4b..6c7829411f 100644 --- a/src/activation/modOptionsHelpers.ts +++ b/src/activation/modOptionsHelpers.ts @@ -51,7 +51,7 @@ export async function autoCreateDatabaseOptionsArgsInPlace( databaseFactory: (args: { name: string }) => Promise, ): Promise { const optionsProperties = Object.entries( - modDefinition.options?.schema?.properties ?? {}, + modDefinition.options?.schema.properties ?? {}, ) .filter( ([name, fieldSchema]) => diff --git a/src/activation/useActivateMod.test.ts b/src/activation/useActivateMod.test.ts index 124bd36313..549410fe1a 100644 --- a/src/activation/useActivateMod.test.ts +++ b/src/activation/useActivateMod.test.ts @@ -62,7 +62,6 @@ function setupInputs(): { modDefinition: ModDefinition; } { const formValues: WizardValues = { - modComponents: { 0: true }, integrationDependencies: [], optionsArgs: {}, }; diff --git a/src/activation/useActivateMod.ts b/src/activation/useActivateMod.ts index ac145d2389..39831400c2 100644 --- a/src/activation/useActivateMod.ts +++ b/src/activation/useActivateMod.ts @@ -23,7 +23,6 @@ import modComponentSlice from "@/store/modComponents/modComponentSlice"; import reportEvent from "@/telemetry/reportEvent"; import { getErrorMessage } from "@/errors/errorHelpers"; import { deactivateMod } from "@/store/deactivateUtils"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import { ensurePermissionsFromUserGesture } from "@/permissions/permissionsUtils"; import { checkModDefinitionPermissions } from "@/modDefinitions/modDefinitionPermissionsHelpers"; import { @@ -37,6 +36,8 @@ import { type ReportEventData } from "@/telemetry/telemetryTypes"; import { type Deployment, type DeploymentPayload } from "@/types/contract"; import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants"; import notify from "@/utils/notify"; +import { selectModInstanceMap } from "@/store/modComponents/modInstanceSelectors"; +import { getIsPersonalDeployment } from "@/store/modComponents/modInstanceUtils"; export type ActivateResult = { success: boolean; @@ -81,17 +82,15 @@ function useActivateMod( { checkPermissions = true }: { checkPermissions?: boolean } = {}, ): ActivateModFormCallback { const dispatch = useDispatch(); - const activatedModComponents = useSelector(selectActivatedModComponents); + const modInstanceMap = useSelector(selectModInstanceMap); const [createDatabase] = useCreateDatabaseMutation(); const [createUserDeployment] = useCreateUserDeploymentMutation(); return useCallback( async (formValues: WizardValues, modDefinition: ModDefinition) => { - const activeModComponent = activatedModComponents.find( - (x) => x._recipe?.id === modDefinition.metadata.id, - ); - const isReactivate = Boolean(activeModComponent); + const modInstance = modInstanceMap.get(modDefinition.metadata.id); + const isReactivate = Boolean(modInstance); if (source === "extensionConsole") { // Note: The prefix "Marketplace" on the telemetry event name @@ -150,24 +149,20 @@ function useActivateMod( }, ); - const existingModComponents = activatedModComponents.filter( - (x) => x._recipe?.id === modDefinition.metadata.id, - ); - await deactivateMod( modDefinition.metadata.id, - existingModComponents, + modInstance?.modComponentIds ?? [], dispatch, ); - // TODO: handle updating a deployment from a previous version to the new version and + // TODO: handle updating a deployment from a previous version to the new version and // handle deleting a deployment if the user turns off personal deployment // https://github.com/pixiebrix/pixiebrix-extension/issues/9092 let createdUserDeployment: Deployment | undefined; if ( formValues.personalDeployment && - // Avoid creating a personal deployment if the mod already has one - !activeModComponent?._deployment?.isPersonalDeployment + // Avoid creating a personal deployment if the mod is already associated with one + !getIsPersonalDeployment(modInstance) ) { const data: DeploymentPayload = { name: `Personal deployment for ${modDefinition.metadata.name}, version ${modDefinition.metadata.version}`, @@ -202,7 +197,7 @@ function useActivateMod( configuredDependencies: integrationDependencies, optionsArgs, screen: source, - isReactivate: existingModComponents.length > 0, + isReactivate, deployment: createdUserDeployment, }), ); @@ -226,7 +221,7 @@ function useActivateMod( }; }, [ - activatedModComponents, + modInstanceMap, source, checkPermissions, dispatch, diff --git a/src/activation/useActivateModWizard.test.tsx b/src/activation/useActivateModWizard.test.tsx index 6abff4bd31..5e8822f391 100644 --- a/src/activation/useActivateModWizard.test.tsx +++ b/src/activation/useActivateModWizard.test.tsx @@ -235,7 +235,7 @@ describe("useActivateModWizard", () => { ], defaultAuthOptions: {}, databaseOptions: [], - activatedModComponents: [], + modInstance: undefined, optionsValidationSchema: Yup.object(), initialModOptions: {}, }); diff --git a/src/activation/useActivateModWizard.ts b/src/activation/useActivateModWizard.ts index 1bad7e10d4..8b52c5bd5f 100644 --- a/src/activation/useActivateModWizard.ts +++ b/src/activation/useActivateModWizard.ts @@ -16,10 +16,8 @@ */ import { type WizardStep, type WizardValues } from "@/activation/wizardTypes"; -import { useSelector } from "react-redux"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import type React from "react"; -import { isEmpty, mapValues } from "lodash"; +import { mapValues } from "lodash"; import OptionsBody from "@/extensionConsole/pages/activateMod/OptionsBody"; import IntegrationsBody from "@/extensionConsole/pages/activateMod/IntegrationsBody"; import PermissionsBody from "@/extensionConsole/pages/activateMod/PermissionsBody"; @@ -30,19 +28,13 @@ import { type ModDefinition } from "@/types/modDefinitionTypes"; import { type Schema } from "@/types/schemaTypes"; import { type RegistryId } from "@/types/registryTypes"; import { type AuthOption } from "@/auth/authTypes"; -import { - collectConfiguredIntegrationDependencies, - collectModOptions, -} from "@/store/modComponents/modComponentUtils"; import { isDatabaseField } from "@/components/fields/schemaFields/fieldTypeCheckers"; import { type Primitive } from "type-fest"; import useDatabaseOptions from "@/hooks/useDatabaseOptions"; import useMergeAsyncState from "@/hooks/useMergeAsyncState"; import { type Option } from "@/components/form/widgets/SelectWidget"; import { type FetchableAsyncState } from "@/types/sliceTypes"; -import { type ActivatedModComponent } from "@/types/modComponentTypes"; import { isPrimitive } from "@/utils/typeUtils"; -import { inputProperties } from "@/utils/schemaUtils"; import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants"; import getUnconfiguredComponentIntegrations from "@/integrations/util/getUnconfiguredComponentIntegrations"; import { makeDatabasePreviewName } from "@/activation/modOptionsHelpers"; @@ -57,6 +49,13 @@ import type { IntegrationDependency } from "@/integrations/integrationTypes"; import { useAuthOptions } from "@/hooks/useAuthOptions"; import { freeze } from "@/utils/objectUtils"; import { fallbackValue } from "@/utils/asyncStateUtils"; +import type { ModInstance } from "@/types/modInstanceTypes"; +import useFindModInstance from "@/mods/hooks/useFindModInstance"; +import { + hasDefinedModOptions, + normalizeModOptionsDefinition, +} from "@/utils/modUtils"; +import { getIsPersonalDeployment } from "@/store/modComponents/modInstanceUtils"; const STEPS: WizardStep[] = [ { key: "services", label: "Integrations", Component: IntegrationsBody }, @@ -77,73 +76,122 @@ const STEPS: WizardStep[] = [ { key: "activate", label: "Permissions & URLs", Component: PermissionsBody }, ]; -function forcePrimitive(value: unknown): Primitive | undefined { - return isPrimitive(value) ? value : undefined; -} - export type UseActivateModWizardResult = { wizardSteps: WizardStep[]; initialValues: WizardValues; validationSchema: Yup.AnyObjectSchema; }; +function getInitialIntegrationDependencies( + modDefinition: ModDefinition, + modInstance: ModInstance | undefined, + defaultAuthOptions: Record, +) { + const definedIntegrationDependencies = + getUnconfiguredComponentIntegrations(modDefinition); + + const currentIntegrationConfigurationLookup = Object.fromEntries( + // Exclude PixieBrix integration - will be returned as an unconfigured dependency + modInstance?.integrationsArgs + ?.filter((x) => x.integrationId !== PIXIEBRIX_INTEGRATION_ID) + .map((dependency) => [dependency.integrationId, dependency.configId]) ?? + [], + ); + + return definedIntegrationDependencies.map((unconfiguredDependency) => ({ + ...unconfiguredDependency, + configId: + // Prefer the activated dependency for reactivate cases, otherwise use the default + currentIntegrationConfigurationLookup[ + unconfiguredDependency.integrationId + ] ?? defaultAuthOptions[unconfiguredDependency.integrationId]?.value, + })); +} + +function forcePrimitive(value: unknown): Primitive | undefined { + return isPrimitive(value) ? value : undefined; +} + +function getInitialOptionsArgs( + modDefinition: ModDefinition, + modInstance: ModInstance | undefined, + databaseOptions: Option[], + initialModOptions: UnknownObject, +) { + const optionsDefinition = normalizeModOptionsDefinition( + modDefinition.options ?? null, + ); + + return mapValues( + optionsDefinition.schema.properties, + (optionSchema: Schema, name: string) => { + // eslint-disable-next-line security/detect-object-injection -- name from schema + const activatedValue = modInstance?.optionsArgs?.[name]; + if (activatedValue) { + return forcePrimitive(activatedValue); + } + + if (isDatabaseField(optionSchema) && optionSchema.format === "preview") { + const databaseName = makeDatabasePreviewName( + modDefinition, + optionSchema, + name, + ); + const existingDatabaseOption = databaseOptions.find( + (option) => option.label === `${databaseName} - Private`, + ); + return existingDatabaseOption?.value ?? databaseName; + } + + // eslint-disable-next-line security/detect-object-injection -- name from schema + const initialValue = initialModOptions[name]; + + if (initialValue !== undefined) { + return forcePrimitive(initialValue); + } + + return forcePrimitive(optionSchema.default); + }, + ); +} + export function wizardStateFactory({ flagOn, + modInstance, modDefinition, authOptions = [], defaultAuthOptions = {}, databaseOptions, - activatedModComponents, optionsValidationSchema, initialModOptions, }: { flagOn: (flag: FeatureFlag) => boolean; modDefinition: ModDefinition; + modInstance: ModInstance | undefined; authOptions?: AuthOption[]; defaultAuthOptions: Record; databaseOptions: Option[]; - activatedModComponents: ActivatedModComponent[]; optionsValidationSchema: AnyObjectSchema; initialModOptions: UnknownObject; }): UseActivateModWizardResult { - const modComponentDefinitions = modDefinition.extensionPoints ?? []; + const hasPersonalDeployment = getIsPersonalDeployment(modInstance); - const activatedModComponentsForMod = activatedModComponents?.filter( - (x) => x._recipe?.id === modDefinition.metadata.id, - ); - - const activatedOptions = collectModOptions(activatedModComponentsForMod); - const activatedIntegrationConfigs = Object.fromEntries( - collectConfiguredIntegrationDependencies(activatedModComponentsForMod).map( - ({ integrationId, configId }) => [integrationId, configId], - ), - ); - const hasPersonalDeployment = activatedModComponentsForMod?.some( - (x) => x._deployment?.isPersonalDeployment, - ); - - const unconfiguredIntegrationDependencies = - getUnconfiguredComponentIntegrations(modDefinition); - const integrationDependencies = unconfiguredIntegrationDependencies.map( - (unconfiguredDependency) => ({ - ...unconfiguredDependency, - // Prefer the activated dependency for reactivate cases, otherwise use the default - configId: - activatedIntegrationConfigs[unconfiguredDependency.integrationId] ?? - defaultAuthOptions[unconfiguredDependency.integrationId]?.value, - }), + const initialIntegrationDependencies = getInitialIntegrationDependencies( + modDefinition, + modInstance, + defaultAuthOptions, ); const wizardSteps = STEPS.filter((step) => { switch (step.key) { case "services": { - return integrationDependencies.some( + return initialIntegrationDependencies.some( ({ integrationId }) => integrationId !== PIXIEBRIX_INTEGRATION_ID, ); } case "options": { - return !isEmpty(inputProperties(modDefinition.options?.schema ?? {})); + return hasDefinedModOptions(modDefinition); } case "synchronize": { @@ -157,53 +205,17 @@ export function wizardStateFactory({ }); const initialValues: WizardValues = { - modComponents: Object.fromEntries( - // By default, all mod components in the mod should be toggled on - modComponentDefinitions.map((_, index) => [index, true]), - ), - integrationDependencies, - optionsArgs: mapValues( - modDefinition.options?.schema?.properties ?? {}, - (optionSchema: Schema, name: string) => { - const activatedValue = activatedOptions[name]; - if (activatedValue) { - return forcePrimitive(activatedValue); - } - - if ( - isDatabaseField(optionSchema) && - optionSchema.format === "preview" - ) { - const databaseName = makeDatabasePreviewName( - modDefinition, - optionSchema, - name, - ); - const existingDatabaseOption = databaseOptions.find( - (option) => option.label === `${databaseName} - Private`, - ); - return existingDatabaseOption?.value ?? databaseName; - } - - if (initialModOptions[name] !== undefined) { - return forcePrimitive(initialModOptions[name]); - } - - return forcePrimitive(optionSchema.default); - }, + integrationDependencies: initialIntegrationDependencies, + optionsArgs: getInitialOptionsArgs( + modDefinition, + modInstance, + databaseOptions, + initialModOptions, ), personalDeployment: hasPersonalDeployment, }; const validationSchema = Yup.object().shape({ - modComponents: Yup.object().shape( - Object.fromEntries( - modComponentDefinitions.map((_, index) => [ - index, - Yup.boolean().required(), - ]), - ), - ), integrationDependencies: Yup.array().of( Yup.object().test( "integrationConfigsRequired", @@ -248,7 +260,8 @@ function useActivateModWizard( defaultAuthOptions: Record = {}, initialOptions: UnknownObject = {}, ): FetchableAsyncState { - const activatedModComponents = useSelector(selectActivatedModComponents); + const modInstance = useFindModInstance(modDefinition.metadata.id); + const optionsValidationSchemaState = useAsyncModOptionsValidationSchema( modDefinition.options?.schema, ); @@ -286,7 +299,7 @@ function useActivateModWizard( authOptions, defaultAuthOptions, databaseOptions, - activatedModComponents, + modInstance, optionsValidationSchema, initialModOptions: initialOptions, }); diff --git a/src/activation/wizardTypes.ts b/src/activation/wizardTypes.ts index c072162e00..27876b9789 100644 --- a/src/activation/wizardTypes.ts +++ b/src/activation/wizardTypes.ts @@ -15,10 +15,10 @@ * along with this program. If not, see . */ -import { type Primitive } from "type-fest"; import type React from "react"; -import { type ModDefinition } from "@/types/modDefinitionTypes"; -import { type IntegrationDependency } from "@/integrations/integrationTypes"; +import type { ModDefinition } from "@/types/modDefinitionTypes"; +import type { IntegrationDependency } from "@/integrations/integrationTypes"; +import type { OptionsArgs } from "@/types/runtimeTypes"; export type WizardStep = { key: string; @@ -29,21 +29,17 @@ export type WizardStep = { }; export type WizardValues = { - /** - * Mapping from mod component index to whether or not it's toggled. - */ - modComponents: Record; - /** * Integration dependencies for the mod */ integrationDependencies: IntegrationDependency[]; - // XXX: optionsArgs can contain periods, which will throw off formik - optionsArgs: Record; + // XXX: optionsArgs keys (options names) can contain periods, which will throw off formik + optionsArgs: OptionsArgs; /** * Whether to set up a personal deployment with the mod + * @since 2.1.2 */ personalDeployment?: boolean; }; diff --git a/src/components/fields/optionsRegistry.ts b/src/components/fields/optionsRegistry.ts index 305dcea7da..58a38cbc3d 100644 --- a/src/components/fields/optionsRegistry.ts +++ b/src/components/fields/optionsRegistry.ts @@ -20,7 +20,7 @@ import { type BrickOptionProps } from "@/components/fields/schemaFields/genericO import { type RegistryId } from "@/types/registryTypes"; /** - * Mapping from block id to block editor. + * Mapping from brick id to brick editor. * @see genericOptionsFactory */ const optionsRegistry = new Map< diff --git a/src/contentScript/lifecycle.ts b/src/contentScript/lifecycle.ts index cf0ee73ea1..6e1ce1da6f 100644 --- a/src/contentScript/lifecycle.ts +++ b/src/contentScript/lifecycle.ts @@ -438,7 +438,7 @@ async function loadActivatedModComponents(): Promise { // Exclude the following: // - disabled deployments: the organization admin might have disabled the deployment because via Admin Console - // - draft mod components: these are already installed on the page via the Page Editor + // - draft mod components: these are already registered on the page via the Page Editor const modComponentsToActivate = options.activatedModComponents.filter( (modComponent) => { if (_draftModComponentStarterBrickMap.has(modComponent.id)) { diff --git a/src/extensionConsole/pages/activateMod/ActivateModPage.test.tsx b/src/extensionConsole/pages/activateMod/ActivateModPage.test.tsx index 0288016c7d..c3317af7a4 100644 --- a/src/extensionConsole/pages/activateMod/ActivateModPage.test.tsx +++ b/src/extensionConsole/pages/activateMod/ActivateModPage.test.tsx @@ -20,7 +20,6 @@ import { render } from "@/extensionConsole/testHelpers"; import { waitForEffect } from "@/testUtils/testHelpers"; import { screen, waitFor } from "@testing-library/react"; import registerDefaultWidgets from "@/components/fields/schemaFields/widgets/registerDefaultWidgets"; -import { type RegistryId } from "@/types/registryTypes"; import userEvent from "@testing-library/user-event"; import { MemoryRouter } from "react-router"; import { type ModDefinition } from "@/types/modDefinitionTypes"; @@ -28,8 +27,8 @@ import { appApiMock, mockAllApiEndpoints } from "@/testUtils/appApiMock"; import { validateRegistryId } from "@/types/helpers"; import { type RetrieveRecipeResponse } from "@/types/contract"; import { - modComponentDefinitionFactory, defaultModDefinitionFactory, + modComponentDefinitionFactory, } from "@/testUtils/factories/modDefinitionFactories"; import { metadataFactory } from "@/testUtils/factories/metadataFactory"; import useActivateMod, { @@ -144,7 +143,7 @@ describe("ActivateModDefinitionPage", () => { test("activate mod definition permissions", async () => { const modDefinition = defaultModDefinitionFactory({ metadata: metadataFactory({ - id: "test/blueprint-with-required-options" as RegistryId, + id: validateRegistryId("test/blueprint-with-required-options"), name: "A Mod", }), extensionPoints: [ @@ -163,7 +162,6 @@ describe("ActivateModDefinitionPage", () => { await waitFor(() => { expect(activateModCallbackMock).toHaveBeenCalledWith( { - modComponents: { "0": true }, optionsArgs: {}, integrationDependencies: [], personalDeployment: false, @@ -206,7 +204,6 @@ describe("ActivateModDefinitionPage", () => { await waitFor(() => { expect(activateModCallbackMock).toHaveBeenCalledWith( { - modComponents: { "0": true }, optionsArgs: {}, integrationDependencies: [], personalDeployment: true, diff --git a/src/extensionConsole/pages/activateMod/ActivateModPage.tsx b/src/extensionConsole/pages/activateMod/ActivateModPage.tsx index da25c1e517..d8d4987424 100644 --- a/src/extensionConsole/pages/activateMod/ActivateModPage.tsx +++ b/src/extensionConsole/pages/activateMod/ActivateModPage.tsx @@ -60,8 +60,6 @@ function useModNotFoundRedirectEffect(error: unknown): void { /** * Common page for activating a mod definition - * - * @param modDefinitionQuery The mod definition to activate */ const ActivateModPage: React.FC = () => { const modId = useRegistryIdParam(); diff --git a/src/extensionConsole/pages/mods/ModsPageActions.tsx b/src/extensionConsole/pages/mods/ModsPageActions.tsx index d679a5d854..c3b07eb0ca 100644 --- a/src/extensionConsole/pages/mods/ModsPageActions.tsx +++ b/src/extensionConsole/pages/mods/ModsPageActions.tsx @@ -42,8 +42,8 @@ import { useDeletePackageMutation } from "@/data/service/api"; import { useModals } from "@/components/ConfirmationModal"; import { CancelError } from "@/errors/businessErrors"; import { assertNotNullish } from "@/utils/nullishUtils"; -import useActivatedModComponents from "@/mods/hooks/useActivatedModComponents"; import { UI_PATHS } from "@/data/service/urlPaths"; +import useFindModInstance from "@/mods/hooks/useFindModInstance"; const ModsPageActions: React.FunctionComponent<{ modViewItem: ModViewItem; @@ -71,12 +71,14 @@ const ModsPageActions: React.FunctionComponent<{ }, } = modViewItem; - const modComponents = useActivatedModComponents(modId); + const modInstance = useFindModInstance(modId); const deactivateModAction = useUserAction( async () => { + assertNotNullish(modInstance, "Expected mod instance"); + reportEvent(Events.MOD_REMOVE, { modId }); - await deactivateMod(modId, modComponents, dispatch); + await deactivateMod(modId, modInstance.modComponentIds, dispatch); }, { successMessage: `Deactivated mod: ${name}`, diff --git a/src/extensionConsole/pages/mods/utils/useReactivateMod.test.ts b/src/extensionConsole/pages/mods/utils/useReactivateMod.test.ts index ffde0b2148..d7268ec8b5 100644 --- a/src/extensionConsole/pages/mods/utils/useReactivateMod.test.ts +++ b/src/extensionConsole/pages/mods/utils/useReactivateMod.test.ts @@ -53,7 +53,7 @@ test("deactivates mod components", async () => { expect(deactivateMod).toHaveBeenCalledWith( modDefinition.metadata.id, - [expectedExtension], + [expectedExtension!.id], expect.any(Function), ); }); diff --git a/src/extensionConsole/pages/mods/utils/useReactivateMod.ts b/src/extensionConsole/pages/mods/utils/useReactivateMod.ts index 28367988fd..522e258053 100644 --- a/src/extensionConsole/pages/mods/utils/useReactivateMod.ts +++ b/src/extensionConsole/pages/mods/utils/useReactivateMod.ts @@ -17,51 +17,38 @@ import { type ModDefinition } from "@/types/modDefinitionTypes"; import { useDispatch, useSelector } from "react-redux"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import { useCallback } from "react"; import { actions as modComponentActions } from "@/store/modComponents/modComponentSlice"; -import { collectModOptions } from "@/store/modComponents/modComponentUtils"; import { deactivateMod } from "@/store/deactivateUtils"; -import collectExistingConfiguredDependenciesForMod from "@/integrations/util/collectExistingConfiguredDependenciesForMod"; +import { selectModInstanceMap } from "@/store/modComponents/modInstanceSelectors"; +import { assertNotNullish } from "@/utils/nullishUtils"; type ReactivateMod = (modDefinition: ModDefinition) => Promise; function useReactivateMod(): ReactivateMod { const dispatch = useDispatch(); - const allComponents = useSelector(selectActivatedModComponents); + const modInstanceMap = useSelector(selectModInstanceMap); return useCallback( async (modDefinition: ModDefinition) => { const modId = modDefinition.metadata.id; - const activatedModComponents = allComponents.filter( - (x) => x._recipe?.id === modId, - ); - - if (activatedModComponents.length === 0) { - throw new Error(`No mod components to re-activate for ${modId}`); - } + const modInstance = modInstanceMap.get(modId); - const currentOptions = collectModOptions(activatedModComponents); - - const configuredDependencies = - collectExistingConfiguredDependenciesForMod( - modDefinition, - activatedModComponents, - ); + assertNotNullish(modInstance, `Mod is not active: ${modId}`); - await deactivateMod(modId, activatedModComponents, dispatch); + await deactivateMod(modId, modInstance.modComponentIds, dispatch); dispatch( modComponentActions.activateMod({ modDefinition, - configuredDependencies, - optionsArgs: currentOptions, + configuredDependencies: modInstance.integrationsArgs, + optionsArgs: modInstance.optionsArgs, screen: "extensionConsole", isReactivate: true, }), ); }, - [allComponents, dispatch], + [modInstanceMap, dispatch], ); } diff --git a/src/mods/hooks/useFindModInstance.ts b/src/mods/hooks/useFindModInstance.ts new file mode 100644 index 0000000000..a84c6e51c2 --- /dev/null +++ b/src/mods/hooks/useFindModInstance.ts @@ -0,0 +1,32 @@ +/* + * 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 { useSelector } from "react-redux"; +import type { RegistryId } from "@/types/registryTypes"; +import type { ModInstance } from "@/types/modInstanceTypes"; +import { selectModInstanceMap } from "@/store/modComponents/modInstanceSelectors"; + +/** + * Hook to the activated mod instance for a given mod, or undefined if the mod is not activated on the device. + * @param modId the mod id to find + */ +export default function useFindModInstance( + modId: RegistryId, +): ModInstance | undefined { + const modInstanceMap = useSelector(selectModInstanceMap); + return modInstanceMap.get(modId); +} diff --git a/src/pageEditor/panes/save/saveHelpers.test.ts b/src/pageEditor/panes/save/saveHelpers.test.ts index 5e914b77dd..59ac14c7d8 100644 --- a/src/pageEditor/panes/save/saveHelpers.test.ts +++ b/src/pageEditor/panes/save/saveHelpers.test.ts @@ -17,10 +17,8 @@ import { buildNewMod, - findMaxIntegrationDependencyApiVersion, generateScopeBrickId, replaceModComponent, - selectModComponentIntegrations, } from "@/pageEditor/panes/save/saveHelpers"; import { normalizeSemVerString, validateRegistryId } from "@/types/helpers"; import brickModComponentAdapter from "@/pageEditor/starterBricks/button"; @@ -41,17 +39,14 @@ import { import { type ModComponentFormState } from "@/pageEditor/starterBricks/formStateTypes"; import { validateOutputKey } from "@/runtime/runtimeTypes"; import { - type InnerDefinitionRef, DefinitionKinds, + type InnerDefinitionRef, } from "@/types/registryTypes"; import { type ModOptionsDefinition, type UnsavedModDefinition, } from "@/types/modDefinitionTypes"; -import { - type ModComponentBase, - type SerializedModComponent, -} from "@/types/modComponentTypes"; +import { type SerializedModComponent } from "@/types/modComponentTypes"; import { modComponentFactory } from "@/testUtils/factories/modComponentFactories"; import { defaultModDefinitionFactory, @@ -62,14 +57,12 @@ import { starterBrickInnerDefinitionFactory, versionedModDefinitionWithHydratedModComponents, } from "@/testUtils/factories/modDefinitionFactories"; -import { type IntegrationDependency } from "@/integrations/integrationTypes"; import { integrationDependencyFactory } from "@/testUtils/factories/integrationFactories"; import { minimalUiSchemaFactory } from "@/utils/schemaUtils"; import { emptyModOptionsDefinitionFactory, normalizeModDefinition, } from "@/utils/modUtils"; -import { INTEGRATIONS_BASE_SCHEMA_URL } from "@/integrations/constants"; import { registryIdFactory } from "@/testUtils/factories/stringFactories"; import { adapter } from "@/pageEditor/starterBricks/adapter"; @@ -890,115 +883,3 @@ describe("buildNewMod", () => { }, ); }); - -describe("findMaxIntegrationDependencyApiVersion", () => { - it("returns v1 for v1 dependencies", () => { - const dependencies: Array> = [ - { - apiVersion: "v1", - }, - { - apiVersion: "v1", - }, - ]; - expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v1"); - }); - - it("returns v2 for v2 dependencies", () => { - const dependencies: Array> = [ - { - apiVersion: "v2", - }, - { - apiVersion: "v2", - }, - { - apiVersion: "v2", - }, - ]; - expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v2"); - }); - - it("works with undefined version", () => { - const dependencies: Array> = [ - {}, - {}, - ]; - expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v1"); - }); - - it("works with mixed dependencies", () => { - const dependencies: Array> = [ - { - apiVersion: "v1", - }, - { - apiVersion: "v2", - }, - { - apiVersion: "v1", - }, - {}, - ]; - expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v2"); - }); -}); - -describe("selectModComponentIntegrations", () => { - it("works for v1 integrations", () => { - const modComponent: Pick = { - integrationDependencies: [ - integrationDependencyFactory(), - integrationDependencyFactory({ - isOptional: undefined, - apiVersion: undefined, - }), - ], - }; - expect(selectModComponentIntegrations(modComponent)).toStrictEqual({ - [modComponent.integrationDependencies![0]!.outputKey]: - modComponent.integrationDependencies![0]!.integrationId, - [modComponent.integrationDependencies![1]!.outputKey]: - modComponent.integrationDependencies![1]!.integrationId, - }); - }); - - it("works for v2 integrations", () => { - const modComponent: Pick = { - integrationDependencies: [ - integrationDependencyFactory({ - apiVersion: "v2", - isOptional: true, - }), - integrationDependencyFactory({ - apiVersion: "v2", - isOptional: false, - }), - integrationDependencyFactory({ - apiVersion: "v2", - isOptional: true, - }), - ], - }; - expect(selectModComponentIntegrations(modComponent)).toStrictEqual({ - properties: { - [modComponent.integrationDependencies![0]!.outputKey]: { - $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${ - modComponent.integrationDependencies![0]!.integrationId - }`, - }, - [modComponent.integrationDependencies![1]!.outputKey]: { - $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${ - modComponent.integrationDependencies![1]!.integrationId - }`, - }, - [modComponent.integrationDependencies![2]!.outputKey]: { - $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${ - modComponent.integrationDependencies![2]!.integrationId - }`, - }, - }, - required: [modComponent.integrationDependencies![1]!.outputKey], - }); - }); -}); diff --git a/src/pageEditor/panes/save/saveHelpers.ts b/src/pageEditor/panes/save/saveHelpers.ts index 54627fb90a..698d2432eb 100644 --- a/src/pageEditor/panes/save/saveHelpers.ts +++ b/src/pageEditor/panes/save/saveHelpers.ts @@ -16,10 +16,10 @@ */ import { + DefinitionKinds, type InnerDefinitionRef, type InnerDefinitions, type Metadata, - DefinitionKinds, type RegistryId, } from "@/types/registryTypes"; import { @@ -48,13 +48,7 @@ import { import { type SafeString } from "@/types/stringTypes"; import { type ModMetadataFormState } from "@/pageEditor/store/editor/pageEditorTypes"; import { freshIdentifier } from "@/utils/variableUtils"; -import { - type IntegrationDependency, - type ModDependencyAPIVersion, -} from "@/integrations/integrationTypes"; -import { type Schema } from "@/types/schemaTypes"; import { normalizeModOptionsDefinition } from "@/utils/modUtils"; -import { INTEGRATIONS_BASE_SCHEMA_URL } from "@/integrations/constants"; import { isStarterBrickDefinitionLike, type StarterBrickDefinitionLike, @@ -65,6 +59,8 @@ import { } from "@/starterBricks/starterBrickUtils"; import { assertNotNullish } from "@/utils/nullishUtils"; import { adapterForComponent } from "@/pageEditor/starterBricks/adapter"; +import { selectModComponentIntegrations } from "@/store/modComponents/modComponentUtils"; +import { mapModComponentBaseToModComponentDefinition } from "@/store/modComponents/modInstanceUtils"; /** * Generate a new registry id from an existing registry id by adding/replacing the scope. @@ -128,68 +124,6 @@ function findModComponentIndex( ); } -/** - * Return the highest API Version used by any of the integrations in the mod. Only exported for testing. - * @param integrationDependencies mod integration dependencies - * @since 1.7.37 - * @note This function is just for safety, there's currently no way for a mod to end up with "mixed" integration api versions. - */ -export function findMaxIntegrationDependencyApiVersion( - integrationDependencies: Array>, -): ModDependencyAPIVersion { - let maxApiVersion: ModDependencyAPIVersion = "v1"; - for (const integrationDependency of integrationDependencies) { - const { apiVersion } = integrationDependency; - if (apiVersion && apiVersion > maxApiVersion) { - maxApiVersion = apiVersion; - } - } - - return maxApiVersion; -} - -export function selectModComponentIntegrations({ - integrationDependencies, -}: Pick< - ModComponentBase, - "integrationDependencies" ->): ModComponentDefinition["services"] { - const _integrationDependencies = compact(integrationDependencies); - const apiVersion = findMaxIntegrationDependencyApiVersion( - _integrationDependencies, - ); - if (apiVersion === "v1") { - return Object.fromEntries( - _integrationDependencies.map((x) => [x.outputKey, x.integrationId]), - ); - } - - if (apiVersion === "v2") { - const properties: Record = {}; - const required: string[] = []; - for (const { - outputKey, - integrationId, - isOptional, - } of _integrationDependencies) { - properties[outputKey] = { - $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${integrationId}`, - }; - if (!isOptional) { - required.push(outputKey); - } - } - - return { - properties, - required, - } as Schema; - } - - const exhaustiveCheck: never = apiVersion; - throw new Error(`Unknown ModDependencyApiVersion: ${exhaustiveCheck}`); -} - /** * Deleted unreferenced inner starter brick definitions. Stopgap for logic errors causing unmatched inner definitions * in `buildNewMod` and `replaceModComponent` @@ -355,16 +289,6 @@ export function replaceModComponent( }); } -function selectModComponentDefinition( - modComponent: ModComponentBase, -): ModComponentDefinition { - return { - ...pick(modComponent, ["label", "config", "permissions", "templateEngine"]), - id: modComponent.extensionPointId, - services: selectModComponentIntegrations(modComponent), - }; -} - export type ModParts = { sourceMod?: ModDefinition; cleanModComponents: SerializedModComponent[]; @@ -582,7 +506,8 @@ export function buildModComponents( } // Construct the modComponent point config from the modComponent - const modComponentDefinition = selectModComponentDefinition(modComponent); + const modComponentDefinition = + mapModComponentBaseToModComponentDefinition(modComponent); // Add the starter brick, replacing the id with our updated // starter brick id, if we've tracked a change in newStarterBrickId diff --git a/src/sidebar/activateMod/ActivateMultipleModsPanel.tsx b/src/sidebar/activateMod/ActivateMultipleModsPanel.tsx index 014e0937e3..90fae2474c 100644 --- a/src/sidebar/activateMod/ActivateMultipleModsPanel.tsx +++ b/src/sidebar/activateMod/ActivateMultipleModsPanel.tsx @@ -23,7 +23,6 @@ import AsyncStateGate from "@/components/AsyncStateGate"; import { getOptionsValidationSchema } from "@/hooks/useAsyncModOptionsValidationSchema"; import useDatabaseOptions from "@/hooks/useDatabaseOptions"; import { useDispatch, useSelector } from "react-redux"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import useDeriveAsyncState from "@/hooks/useDeriveAsyncState"; import { type Option } from "@/components/form/widgets/SelectWidget"; import { wizardStateFactory } from "@/activation/useActivateModWizard"; @@ -34,6 +33,7 @@ import { SuccessPanel } from "@/sidebar/activateMod/ActivateModPanel"; import sidebarSlice from "@/store/sidebar/sidebarSlice"; import type { ModActivationConfig } from "@/types/modTypes"; import useFlags from "@/hooks/useFlags"; +import { selectModInstances } from "@/store/modComponents/modInstanceSelectors"; type ModResultPair = { mod: RequiredModDefinition; @@ -76,7 +76,7 @@ const AutoActivatePanel: React.FC<{ mods: RequiredModDefinition[] }> = ({ // Only activate new mods that the user does not already have activated. If there are updates available, the // user will be prompted to update according to marketplace mod updater rules. const newMods = useMemo(() => mods.filter((x) => !x.isActive), [mods]); - const activatedModComponents = useSelector(selectActivatedModComponents); + const modInstances = useSelector(selectModInstances); const databaseOptionsState = useDatabaseOptions({ refetchOnMount: true }); const { flagOn } = useFlags(); @@ -103,7 +103,7 @@ const AutoActivatePanel: React.FC<{ mods: RequiredModDefinition[] }> = ({ databaseOptions, optionsValidationSchema, initialModOptions: mod.initialOptions, - activatedModComponents, + modInstance: modInstances.find((x) => x.definition.metadata.id), }); const result = await activate( diff --git a/src/store/deactivateUtils.ts b/src/store/deactivateUtils.ts index 3c1470625a..6c2d1b2f0c 100644 --- a/src/store/deactivateUtils.ts +++ b/src/store/deactivateUtils.ts @@ -20,7 +20,6 @@ import { removeDraftModComponentsForMod } from "@/store/editorStorage"; import { actions as modComponentActions } from "@/store/modComponents/modComponentSlice"; import { removeModComponentForEveryTab } from "@/background/messenger/api"; import { uniq } from "lodash"; -import { type SerializedModComponent } from "@/types/modComponentTypes"; import { type RegistryId } from "@/types/registryTypes"; import { type UUID } from "@/types/stringTypes"; @@ -40,7 +39,7 @@ import { type UUID } from "@/types/stringTypes"; */ export async function deactivateMod( modId: RegistryId, - modComponents: SerializedModComponent[], + modComponentIds: UUID[], dispatch: Dispatch, ): Promise { const removedDraftModComponentIds = @@ -49,10 +48,7 @@ export async function deactivateMod( dispatch(modComponentActions.removeModById(modId)); removeModComponentsFromAllTabs( - uniq([ - ...modComponents.map(({ id }) => id), - ...removedDraftModComponentIds, - ]), + uniq([...modComponentIds, ...removedDraftModComponentIds]), ); } diff --git a/src/store/modComponents/modComponentUtils.test.ts b/src/store/modComponents/modComponentUtils.test.ts index 351c9fb7e2..71f750ad8c 100644 --- a/src/store/modComponents/modComponentUtils.test.ts +++ b/src/store/modComponents/modComponentUtils.test.ts @@ -18,12 +18,19 @@ import { collectConfiguredIntegrationDependencies, collectModOptions, + findMaxIntegrationDependencyApiVersion, + selectModComponentIntegrations, } from "@/store/modComponents/modComponentUtils"; import { uuidv4, validateRegistryId } from "@/types/helpers"; import { validateOutputKey } from "@/runtime/runtimeTypes"; import { integrationDependencyFactory } from "@/testUtils/factories/integrationFactories"; -import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants"; +import { + INTEGRATIONS_BASE_SCHEMA_URL, + PIXIEBRIX_INTEGRATION_ID, +} from "@/integrations/constants"; +import type { IntegrationDependency } from "@/integrations/integrationTypes"; +import type { ModComponentBase } from "@/types/modComponentTypes"; describe("collectModOptions", () => { it("returns first option", () => { @@ -104,3 +111,115 @@ describe("collectConfiguredIntegrationDependencies", () => { ).toStrictEqual([pixiebrix, configured]); }); }); + +describe("findMaxIntegrationDependencyApiVersion", () => { + it("returns v1 for v1 dependencies", () => { + const dependencies: Array> = [ + { + apiVersion: "v1", + }, + { + apiVersion: "v1", + }, + ]; + expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v1"); + }); + + it("returns v2 for v2 dependencies", () => { + const dependencies: Array> = [ + { + apiVersion: "v2", + }, + { + apiVersion: "v2", + }, + { + apiVersion: "v2", + }, + ]; + expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v2"); + }); + + it("works with undefined version", () => { + const dependencies: Array> = [ + {}, + {}, + ]; + expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v1"); + }); + + it("works with mixed dependencies", () => { + const dependencies: Array> = [ + { + apiVersion: "v1", + }, + { + apiVersion: "v2", + }, + { + apiVersion: "v1", + }, + {}, + ]; + expect(findMaxIntegrationDependencyApiVersion(dependencies)).toBe("v2"); + }); +}); + +describe("selectModComponentIntegrations", () => { + it("works for v1 integrations", () => { + const modComponent: Pick = { + integrationDependencies: [ + integrationDependencyFactory(), + integrationDependencyFactory({ + isOptional: undefined, + apiVersion: undefined, + }), + ], + }; + expect(selectModComponentIntegrations(modComponent)).toStrictEqual({ + [modComponent.integrationDependencies![0]!.outputKey]: + modComponent.integrationDependencies![0]!.integrationId, + [modComponent.integrationDependencies![1]!.outputKey]: + modComponent.integrationDependencies![1]!.integrationId, + }); + }); + + it("works for v2 integrations", () => { + const modComponent: Pick = { + integrationDependencies: [ + integrationDependencyFactory({ + apiVersion: "v2", + isOptional: true, + }), + integrationDependencyFactory({ + apiVersion: "v2", + isOptional: false, + }), + integrationDependencyFactory({ + apiVersion: "v2", + isOptional: true, + }), + ], + }; + expect(selectModComponentIntegrations(modComponent)).toStrictEqual({ + properties: { + [modComponent.integrationDependencies![0]!.outputKey]: { + $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${ + modComponent.integrationDependencies![0]!.integrationId + }`, + }, + [modComponent.integrationDependencies![1]!.outputKey]: { + $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${ + modComponent.integrationDependencies![1]!.integrationId + }`, + }, + [modComponent.integrationDependencies![2]!.outputKey]: { + $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${ + modComponent.integrationDependencies![2]!.integrationId + }`, + }, + }, + required: [modComponent.integrationDependencies![1]!.outputKey], + }); + }); +}); diff --git a/src/store/modComponents/modComponentUtils.ts b/src/store/modComponents/modComponentUtils.ts index d8a370f37e..58458e856a 100644 --- a/src/store/modComponents/modComponentUtils.ts +++ b/src/store/modComponents/modComponentUtils.ts @@ -15,15 +15,23 @@ * along with this program. If not, see . */ -import { uniqBy } from "lodash"; +import { compact, uniqBy } from "lodash"; import { type ModComponentBase } from "@/types/modComponentTypes"; import { type OptionsArgs } from "@/types/runtimeTypes"; -import { type IntegrationDependency } from "@/integrations/integrationTypes"; -import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants"; +import { + type IntegrationDependency, + type ModDependencyAPIVersion, +} from "@/integrations/integrationTypes"; +import { + INTEGRATIONS_BASE_SCHEMA_URL, + PIXIEBRIX_INTEGRATION_ID, +} from "@/integrations/constants"; +import type { ModComponentDefinition } from "@/types/modDefinitionTypes"; +import type { Schema } from "@/types/schemaTypes"; /** - * Infer options from existing mod-component-like instances for reinstalling a mod - * @see installRecipe + * Infer options from existing mod-component-like instances for reactivating a mod + * @see activateMod */ export function collectModOptions( modComponents: Array>, @@ -34,13 +42,31 @@ export function collectModOptions( return modComponents[0]?.optionsArgs ?? {}; } +/** + * Collect integration dependencies from existing mod-component-like instances. + * + * Includes unconfigured integrations and the PixieBrix integration. + * + * @see collectConfiguredIntegrationDependencies + */ +export function collectIntegrationDependencies( + modComponents: Array>, +): IntegrationDependency[] { + return uniqBy( + modComponents.flatMap( + ({ integrationDependencies }) => integrationDependencies ?? [], + ), + ({ integrationId }) => integrationId, + ); +} + /** * Collect configured integration dependencies from existing mod-component-like - * instances for reinstalling a mod. Filters out any optional integrations that + * instances for re-activating a mod. Filters out any optional integrations that * don't have a config set. * @param modComponents mod components from which to extract integration dependencies * @returns IntegrationDependency[] the configured integration dependencies for the mod components - * @see installMod + * @see activateMod */ export function collectConfiguredIntegrationDependencies( modComponents: Array>, @@ -55,3 +81,65 @@ export function collectConfiguredIntegrationDependencies( ({ integrationId }) => integrationId, ); } + +/** + * Return the highest API Version used by any of the integrations in the mod. Only exported for testing. + * @param integrationDependencies mod integration dependencies + * @since 1.7.37 + * @note This function is just for safety, there's currently no way for a mod to end up with "mixed" integration api versions. + */ +export function findMaxIntegrationDependencyApiVersion( + integrationDependencies: Array>, +): ModDependencyAPIVersion { + let maxApiVersion: ModDependencyAPIVersion = "v1"; + for (const integrationDependency of integrationDependencies) { + const { apiVersion } = integrationDependency; + if (apiVersion && apiVersion > maxApiVersion) { + maxApiVersion = apiVersion; + } + } + + return maxApiVersion; +} + +export function selectModComponentIntegrations({ + integrationDependencies, +}: Pick< + ModComponentBase, + "integrationDependencies" +>): ModComponentDefinition["services"] { + const _integrationDependencies = compact(integrationDependencies); + const apiVersion = findMaxIntegrationDependencyApiVersion( + _integrationDependencies, + ); + if (apiVersion === "v1") { + return Object.fromEntries( + _integrationDependencies.map((x) => [x.outputKey, x.integrationId]), + ); + } + + if (apiVersion === "v2") { + const properties: Record = {}; + const required: string[] = []; + for (const { + outputKey, + integrationId, + isOptional, + } of _integrationDependencies) { + properties[outputKey] = { + $ref: `${INTEGRATIONS_BASE_SCHEMA_URL}${integrationId}`, + }; + if (!isOptional) { + required.push(outputKey); + } + } + + return { + properties, + required, + } as Schema; + } + + const exhaustiveCheck: never = apiVersion; + throw new Error(`Unknown ModDependencyApiVersion: ${exhaustiveCheck}`); +} diff --git a/src/store/modComponents/modInstanceSelectors.ts b/src/store/modComponents/modInstanceSelectors.ts new file mode 100644 index 0000000000..03364a24ee --- /dev/null +++ b/src/store/modComponents/modInstanceSelectors.ts @@ -0,0 +1,58 @@ +/* + * 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 { createSelector } from "@reduxjs/toolkit"; +import { groupBy } from "lodash"; +import { mapActivatedModComponentsToModInstance } from "@/store/modComponents/modInstanceUtils"; +import type { ModComponentsRootState } from "@/store/modComponents/modComponentTypes"; +import type { ModInstance } from "@/types/modInstanceTypes"; + +/** + * Returns all activated mod instances. + * @throws {TypeError} if required state migrations have not been applied yet + */ +export function selectModInstances({ + options, +}: ModComponentsRootState): ModInstance[] { + if (!Array.isArray(options.activatedModComponents)) { + console.warn("state migration has not been applied yet", { + options, + }); + throw new TypeError("state migration has not been applied yet"); + } + + return Object.values( + groupBy(options.activatedModComponents, (x) => x._recipe?.id), + ).map((modComponents) => + mapActivatedModComponentsToModInstance(modComponents), + ); +} + +/** + * Returns a Map of activated mod instances keyed by mod id. + * @see useFindModInstance + */ +export const selectModInstanceMap = createSelector( + selectModInstances, + (modInstances) => + new Map( + modInstances.map((modInstance) => [ + modInstance.definition.metadata.id, + modInstance, + ]), + ), +); diff --git a/src/store/modComponents/modInstanceUtils.ts b/src/store/modComponents/modInstanceUtils.ts new file mode 100644 index 0000000000..f5641755d8 --- /dev/null +++ b/src/store/modComponents/modInstanceUtils.ts @@ -0,0 +1,99 @@ +/* + * 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 type { ModInstance } from "@/types/modInstanceTypes"; +import type { + ActivatedModComponent, + ModComponentBase, +} from "@/types/modComponentTypes"; +import { omit, pick } from "lodash"; +import { assertNotNullish } from "@/utils/nullishUtils"; +import { uuidv4 } from "@/types/helpers"; +import { + collectIntegrationDependencies, + collectModOptions, + selectModComponentIntegrations, +} from "@/store/modComponents/modComponentUtils"; +import { createPrivateSharing } from "@/utils/registryUtils"; +import { emptyModOptionsDefinitionFactory } from "@/utils/modUtils"; +import { assertModComponentNotHydrated } from "@/runtime/runtimeUtils"; +import type { ModComponentDefinition } from "@/types/modDefinitionTypes"; + +/** + * Returns true if mod instance is defined and is associated with a personal deployment. + */ +export function getIsPersonalDeployment( + modInstance: ModInstance | undefined, +): boolean { + return Boolean(modInstance?.deploymentMetadata?.isPersonalDeployment); +} + +export function mapModComponentBaseToModComponentDefinition( + modComponent: ModComponentBase, +): ModComponentDefinition { + return { + ...pick(modComponent, ["label", "config", "permissions", "templateEngine"]), + id: modComponent.extensionPointId, + services: selectModComponentIntegrations(modComponent), + }; +} + +/** + * Maps activated mod components to a mod instance. + * @param modComponents mod components from the mod + */ +export function mapActivatedModComponentsToModInstance( + modComponents: ActivatedModComponent[], +): ModInstance { + const firstComponent = modComponents[0]; + assertNotNullish(firstComponent, "activatedModComponents is empty"); + + const modMetadata = firstComponent._recipe; + assertNotNullish(modMetadata, "Mod metadata is required"); + + return { + id: uuidv4(), + modComponentIds: modComponents.map(({ id }) => id), + deploymentMetadata: firstComponent._deployment, + optionsArgs: collectModOptions(modComponents), + integrationsArgs: collectIntegrationDependencies(modComponents), + updatedAt: firstComponent.updateTimestamp, + definition: { + kind: "recipe", + apiVersion: firstComponent.apiVersion, + definitions: firstComponent.definitions, + metadata: omit(modMetadata, ["sharing", "updated_at"]), + // Don't bother inferring mod options schema from the components. It's not necessary because activation screens + // always use the definition from the server. It's also not possible to reliably infer the schema because multiple + // types can have the same value. E.g., a UUID might be a PixieBrix database ID or just a UUID. + // XXX: we might need to revisit the defaulting behavior for the Page Editor if we want to use the activation-time + // definition vs. looking up the definition + options: emptyModOptionsDefinitionFactory(), + sharing: modMetadata.sharing ?? createPrivateSharing(), + updated_at: modMetadata.updated_at ?? firstComponent.updateTimestamp, + extensionPoints: modComponents.map((modComponent) => { + assertModComponentNotHydrated(modComponent); + + if (modComponent._recipe?.id !== modMetadata.id) { + throw new Error("Mod component does not match mod metadata"); + } + + return mapModComponentBaseToModComponentDefinition(modComponent); + }), + }, + }; +} diff --git a/src/types/deploymentTypes.ts b/src/types/deploymentTypes.ts index 8fe42a0425..d4b5391e17 100644 --- a/src/types/deploymentTypes.ts +++ b/src/types/deploymentTypes.ts @@ -17,6 +17,7 @@ import type { ModDefinition } from "@/types/modDefinitionTypes"; import type { Deployment } from "@/types/contract"; +import type { UUID } from "@/types/stringTypes"; /** * A deployment and its associated mod definition (for exact package and version). @@ -30,3 +31,59 @@ export type ActivatableDeployment = { deployment: Deployment; modDefinition: ModDefinition; }; + +type BaseDeploymentMetadata = { + /** + * Unique id of the deployment + */ + id: UUID; + + /** + * `updated_at` timestamp of the deployment object from the server (in ISO format). Used to determine whether the + * client has the latest deployment setting applied + */ + timestamp: string; + + /** + * True iff the deployment is temporarily disabled. + * + * If undefined, is considered active for backward compatability + * + * @since 1.4.0 + */ + active?: boolean; +}; + +/** + * Metadata about an automatically activated deployment. + * + * Where possible, reference as ModComponent["_deployment"] or ModInstance["deploymentMetadata"] for clarity. + */ +export type DeploymentMetadata = + | (BaseDeploymentMetadata & { + /** + * Indicates if the deployment is a personal deployment. + * If true, the organization property should be undefined. + * @since 2.1.2 + */ + isPersonalDeployment: true; + organization?: undefined; + }) + | (BaseDeploymentMetadata & { + isPersonalDeployment?: false; + /** + * Context about the organization that the deployment is associated with. + * @since 2.1.2 + */ + organization?: { + /** + * UUID of the organization + */ + id: UUID; + + /** + * Name of the organization + */ + name: string; + }; + }); diff --git a/src/types/modComponentTypes.ts b/src/types/modComponentTypes.ts index 6522fe8d09..e1ef476f20 100644 --- a/src/types/modComponentTypes.ts +++ b/src/types/modComponentTypes.ts @@ -35,6 +35,7 @@ import { type IntegrationDependencyV2, } from "@/integrations/integrationTypes"; import { isRegistryId, isUUID } from "@/types/helpers"; +import { type DeploymentMetadata } from "@/types/deploymentTypes"; /** * ModMetadata that includes sharing information. @@ -61,61 +62,6 @@ export type ModMetadata = Metadata & { updated_at: Timestamp | null; }; -type BaseDeploymentMetadata = { - /** - * Unique id of the deployment - */ - id: UUID; - - /** - * `updated_at` timestamp of the deployment object from the server (in ISO format). Used to determine whether the - * client has latest deployment settings installed. - */ - timestamp: string; - - /** - * True iff the deployment is temporarily disabled. - * - * If undefined, is considered active for backward compatability - * - * @since 1.4.0 - */ - active?: boolean; -}; - -/** - * Context about an automatically activated organization Deployment. - * Don't export -- context is clearer if it's always written as ModComponentBase[_deployment] property - */ -type DeploymentMetadata = - | (BaseDeploymentMetadata & { - /** - * Indicates if the deployment is a personal deployment. - * If true, the organization property should be undefined. - * @since 2.1.2 - */ - isPersonalDeployment: true; - organization?: undefined; - }) - | (BaseDeploymentMetadata & { - isPersonalDeployment?: false; - /** - * Context about the organization that the deployment is associated with. - * @since 2.1.2 - */ - organization?: { - /** - * UUID of the organization - */ - id: UUID; - - /** - * Name of the organization - */ - name: string; - }; - }); - /** * @deprecated - Do not use versioned state types directly */ diff --git a/src/types/modInstanceTypes.ts b/src/types/modInstanceTypes.ts new file mode 100644 index 0000000000..75eaf08f5e --- /dev/null +++ b/src/types/modInstanceTypes.ts @@ -0,0 +1,80 @@ +/* + * 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 type { ModDefinition } from "@/types/modDefinitionTypes"; +import type { OptionsArgs } from "@/types/runtimeTypes"; +import type { IntegrationDependency } from "@/integrations/integrationTypes"; +import type { Timestamp, UUID } from "@/types/stringTypes"; +import type { DeploymentMetadata } from "@/types/deploymentTypes"; + +/** + * An activated mod instance. + * @since 2.1.3 + */ +export type ModInstance = { + /** + * A unique identifier for the mod instance. Used to differentiate instances across activations. + * + * NOTE: at this time, a device can only have one instance of a mod active at a time. + */ + id: UUID; + + /** + * Mod component instance ids. Array order corresponds to the order of the ModDefinition.extensionPoints. + * + * Required to be able to track and correlate mod components across contexts (e.g., content script, page editor, + * extension console), e.g., for logging, error handling. + * + * In the future, we might consider eliminating by using a predictable id based on the mod instance id and position + * in the mod definition. But that's not possible today because the ids use a UUID format. + */ + modComponentIds: UUID[]; + + /** + * The deployment metadata for the mod instance, or undefined if the mod instance is not managed via a + * team or personal deployment + */ + deploymentMetadata: DeploymentMetadata | undefined; + + /** + * The mod definition. + */ + definition: ModDefinition; + + /** + * Validated options args for the mod instance. + * + * Validated at activation time, but might no longer be valid, e.g., if a referenced database or Google Sheet + * no longer exists. + */ + optionsArgs: OptionsArgs; + + /** + * Integration configurations for the mod instance. + * + * Validated at activation time, but might no longer be valid, e.g., if a configuration no longer exists. + * + * NOTE: in the future, integration configuration will likely be moved into mod options to allow multiple integrations + * of the same type to use different configurations (e.g., for data transfer use cases). + */ + integrationsArgs: IntegrationDependency[]; + + /** + * The timestamp when the mod instance was created or last reconfigured locally. + */ + updatedAt: Timestamp; +}; diff --git a/src/utils/modUtils.ts b/src/utils/modUtils.ts index 04594b1738..bde2237114 100644 --- a/src/utils/modUtils.ts +++ b/src/utils/modUtils.ts @@ -38,7 +38,7 @@ import { minimalUiSchemaFactory, propertiesToSchema, } from "@/utils/schemaUtils"; -import { cloneDeep, mapValues, sortBy } from "lodash"; +import { cloneDeep, isEmpty, mapValues, sortBy } from "lodash"; import { isNullOrBlank } from "@/utils/stringUtils"; import { type Schema, @@ -186,7 +186,7 @@ export function emptyModOptionsDefinitionFactory(): Required { +): NonNullable> { if (!optionsDefinition) { return emptyModOptionsDefinitionFactory(); } @@ -217,6 +217,15 @@ export function normalizeModOptionsDefinition( }; } +/** + * Returns true if the mod definition any defined options. + */ +export function hasDefinedModOptions(modDefinition: ModDefinition): boolean { + return !isEmpty( + normalizeModOptionsDefinition(modDefinition.options).schema.properties, + ); +} + /** * Return the activation instructions for a mod as markdown, or null if there are none. */ diff --git a/src/utils/registryUtils.ts b/src/utils/registryUtils.ts index 6e3783b5a0..d2d17abd82 100644 --- a/src/utils/registryUtils.ts +++ b/src/utils/registryUtils.ts @@ -15,7 +15,11 @@ * along with this program. If not, see . */ -import { INNER_SCOPE, type RegistryId } from "@/types/registryTypes"; +import { + INNER_SCOPE, + type RegistryId, + type Sharing, +} from "@/types/registryTypes"; import { validateRegistryId } from "@/types/helpers"; import slugify from "slugify"; import { split } from "lodash"; @@ -72,3 +76,13 @@ export function getScopeAndId(value: Nullishable): { const [scope, ...idParts] = split(value, "/"); return { scope, id: idParts.join("/") }; } + +/** + * Returns a sharing object private package defined in the user's scope. + */ +export function createPrivateSharing(): Sharing { + return { + public: false, + organizations: [], + }; +}