diff --git a/src/background/deploymentUpdater.test.ts b/src/background/deploymentUpdater.test.ts index 77563db36c..72dce2285d 100644 --- a/src/background/deploymentUpdater.test.ts +++ b/src/background/deploymentUpdater.test.ts @@ -48,6 +48,7 @@ import { checkDeploymentPermissions } from "@/permissions/deploymentPermissionsH import { emptyPermissionsFactory } from "@/permissions/permissionsUtils"; import { TEST_setContext } from "webext-detect"; import { + activatedModComponentFactory, modComponentFactory, modMetadataFactory, } from "@/testUtils/factories/modComponentFactories"; @@ -376,29 +377,31 @@ describe("syncDeployments", () => { isLinkedMock.mockResolvedValue(true); const starterBrick = starterBrickDefinitionFactory(); - const brick = { + const packageVersion = { ...parsePackage(starterBrick as unknown as RegistryPackage), timestamp: new Date(), }; - registryFindMock.mockResolvedValue(brick); + registryFindMock.mockResolvedValue(packageVersion); - // An extension without a recipe. Exclude _recipe entirely to handle the case where the property is missing - const modComponent = modComponentFactory({ + // A mod without a deployment. Exclude _deployment entirely to handle the case where the property is missing + const manualModComponent = activatedModComponentFactory({ extensionPointId: starterBrick.metadata!.id, - }) as ActivatedModComponent; - delete modComponent._recipe; - delete modComponent._deployment; + _recipe: modMetadataFactory(), + }); + delete manualModComponent._deployment; await saveModComponentState({ - activatedModComponents: [modComponent], + activatedModComponents: [manualModComponent], }); let editorState = initialEditorState; const { fromModComponent } = adapter(starterBrick.definition.type); - const element = (await fromModComponent(modComponent)) as ButtonFormState; + const editorComponentFormState = (await fromModComponent( + manualModComponent, + )) as ButtonFormState; editorState = editorSlice.reducer( editorState, - editorSlice.actions.addModComponentFormState(element), + editorSlice.actions.addModComponentFormState(editorComponentFormState), ); await saveEditorState(editorState); @@ -749,58 +752,58 @@ describe("syncDeployments", () => { }); test("can deactivate all deployed mods", async () => { - const personalStarterBrick = starterBrickDefinitionFactory(); - const personalBrick = { - ...parsePackage(personalStarterBrick as unknown as RegistryPackage), + const manualModStarterBrickDefinition = starterBrickDefinitionFactory(); + const manualModStarterBrickPackageVersion = { + ...parsePackage( + manualModStarterBrickDefinition as unknown as RegistryPackage, + ), timestamp: new Date(), }; - const standaloneModComponent = modComponentFactory({ - extensionPointId: personalStarterBrick.metadata!.id, - }) as ActivatedModComponent; - - const recipeModComponent = modComponentFactory({ + const manuallyActivatedModComponent = activatedModComponentFactory({ _recipe: modMetadataFactory(), - }) as ActivatedModComponent; + }); - const deploymentStarterBrick = starterBrickDefinitionFactory(); - const deploymentsBrick = { - ...parsePackage(deploymentStarterBrick as unknown as RegistryPackage), + const deploymentStarterBrickDefinition = starterBrickDefinitionFactory(); + const deploymentStarterBrickPackageVersion = { + ...parsePackage( + deploymentStarterBrickDefinition as unknown as RegistryPackage, + ), timestamp: new Date(), }; const deploymentModComponent = modComponentFactory({ - extensionPointId: deploymentStarterBrick.metadata!.id, + extensionPointId: deploymentStarterBrickDefinition.metadata!.id, _deployment: { id: uuidv4(), timestamp: "2021-10-07T12:52:16.189Z" }, _recipe: modMetadataFactory(), }) as ActivatedModComponent; registryFindMock.mockImplementation(async (id) => { - if (id === personalBrick.id) { - return personalBrick; + if (id === manualModStarterBrickPackageVersion.id) { + return manualModStarterBrickPackageVersion; } - return deploymentsBrick; + return deploymentStarterBrickPackageVersion; }); let editorState = initialEditorState; - const personalModComponentAdapter = adapter( - personalStarterBrick.definition.type, + const manualModAdapter = adapter( + manualModStarterBrickDefinition.definition.type, ); - const personalModComponentFormState = - (await personalModComponentAdapter.fromModComponent( - standaloneModComponent, + const manualModComponentEditorFormState = + (await manualModAdapter.fromModComponent( + manuallyActivatedModComponent, )) as ButtonFormState; editorState = editorSlice.reducer( editorState, editorSlice.actions.addModComponentFormState( - personalModComponentFormState, + manualModComponentEditorFormState, ), ); const deploymentModComponentAdapter = adapter( - deploymentStarterBrick.definition.type, + deploymentStarterBrickDefinition.definition.type, ); const deploymentElement = (await deploymentModComponentAdapter.fromModComponent( @@ -813,9 +816,8 @@ describe("syncDeployments", () => { await saveModComponentState({ activatedModComponents: [ - standaloneModComponent, deploymentModComponent, - recipeModComponent, + manuallyActivatedModComponent, ], }); await saveEditorState(editorState); @@ -827,15 +829,18 @@ describe("syncDeployments", () => { const { activatedModComponents } = await getModComponentState(); - expect(activatedModComponents).toHaveLength(2); + expect(activatedModComponents).toHaveLength(1); const activatedModComponentIds = activatedModComponents.map((x) => x.id); - expect(activatedModComponentIds).toContain(standaloneModComponent.id); - expect(activatedModComponentIds).toContain(recipeModComponent.id); + expect(activatedModComponentIds).toContain( + manuallyActivatedModComponent.id, + ); const { modComponentFormStates } = (await getEditorState()) ?? {}; expect(modComponentFormStates).toBeArrayOfSize(1); - expect(modComponentFormStates![0]!).toEqual(personalModComponentFormState); + expect(modComponentFormStates![0]!).toEqual( + manualModComponentEditorFormState, + ); }); test("deactivates old mod when deployed mod id is changed", async () => { diff --git a/src/background/deploymentUpdater.ts b/src/background/deploymentUpdater.ts index 9e2816ee72..7d4284efbe 100644 --- a/src/background/deploymentUpdater.ts +++ b/src/background/deploymentUpdater.ts @@ -16,13 +16,12 @@ */ import { type Deployment } from "@/types/contract"; -import { isEmpty } from "lodash"; +import { uniq } from "lodash"; import reportError from "@/telemetry/reportError"; import { getUUID } from "@/telemetry/telemetryHelpers"; import { isLinked, readAuthData, updateUserData } from "@/auth/authStorage"; import reportEvent from "@/telemetry/reportEvent"; import { refreshRegistries } from "@/hooks/useRefreshRegistries"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import { maybeGetLinkedApiClient } from "@/data/service/apiClient"; import { getExtensionVersion } from "@/utils/extensionUtils"; import { parse as parseSemVer, satisfies, type SemVer } from "semver"; @@ -40,7 +39,7 @@ import { findLocalDeploymentConfiguredIntegrationDependencies, makeUpdatedFilter, mergeDeploymentIntegrationDependencies, - selectInstalledDeployments, + selectActivatedDeployments, } from "@/utils/deploymentUtils"; import { selectUpdatePromptState } from "@/store/settings/settingsSelectors"; import settingsSlice from "@/store/settings/settingsSlice"; @@ -66,10 +65,11 @@ import { SessionValue } from "@/mv3/SessionStorage"; import { FeatureFlags } from "@/auth/featureFlags"; import { API_PATHS } from "@/data/service/urlPaths"; import deactivateMod from "@/background/utils/deactivateMod"; -import deactivateModComponentsAndSaveState from "@/background/utils/deactivateModComponentsAndSaveState"; +import deactivateModInstancesAndSaveState from "@/background/utils/deactivateModInstancesAndSaveState"; import saveModComponentStateAndReloadTabs, { type ReloadOptions, } from "@/background/utils/saveModComponentStateAndReloadTabs"; +import { selectModInstances } from "@/store/modComponents/modInstanceSelectors"; // eslint-disable-next-line local-rules/persistBackgroundData -- Static const { reducer: modComponentReducer, actions: modComponentActions } = @@ -107,29 +107,31 @@ export async function deactivateAllDeployedMods(): Promise { getModComponentState(), getEditorState(), ]); - const activatedModComponents = selectActivatedModComponents({ + const modInstances = selectModInstances({ options: modComponentState, }); - const modComponentsToDeactivate = activatedModComponents.filter( - (activatedModComponent) => !isEmpty(activatedModComponent._deployment), + const modsToDeactivate = modInstances.filter( + (x) => x.deploymentMetadata != null, ); - if (modComponentsToDeactivate.length === 0) { + if (modsToDeactivate.length === 0) { // Short-circuit to skip reporting telemetry return; } - await deactivateModComponentsAndSaveState(modComponentsToDeactivate, { + await deactivateModInstancesAndSaveState(modsToDeactivate, { editorState, modComponentState, }); reportEvent(Events.DEPLOYMENT_DEACTIVATE_ALL, { auto: true, - deployments: modComponentsToDeactivate - .map((x) => x._deployment?.id) - .filter((x) => x != null), + deployments: uniq( + modsToDeactivate + .map((x) => x.deploymentMetadata?.id) + .filter((x) => x != null), + ), }); } @@ -140,7 +142,7 @@ async function deactivateUnassignedDeployments( getModComponentState(), getEditorState(), ]); - const activatedModComponents = selectActivatedModComponents({ + const allModInstances = selectModInstances({ options: modComponentState, }); @@ -148,28 +150,29 @@ async function deactivateUnassignedDeployments( assignedDeployments.map((deployment) => deployment.package.package_id), ); - const unassignedModComponents = activatedModComponents.filter( - (activatedModComponent) => - !isEmpty(activatedModComponent._deployment) && - activatedModComponent._recipe?.id && - !deployedModIds.has(activatedModComponent._recipe.id), + const unassignedModInstances = allModInstances.filter( + (modInstance) => + modInstance.deploymentMetadata != null && + !deployedModIds.has(modInstance.definition.metadata.id), ); - if (unassignedModComponents.length === 0) { + if (unassignedModInstances.length === 0) { // Short-circuit to skip reporting telemetry return; } - await deactivateModComponentsAndSaveState(unassignedModComponents, { + await deactivateModInstancesAndSaveState(unassignedModInstances, { editorState, modComponentState, }); reportEvent(Events.DEPLOYMENT_DEACTIVATE_UNASSIGNED, { auto: true, - deployments: unassignedModComponents - .map((x) => x._deployment?.id) - .filter((x) => x != null), + deployments: uniq( + unassignedModInstances + .map((x) => x.deploymentMetadata?.id) + .filter((x) => x != null), + ), }); } @@ -189,12 +192,15 @@ async function activateDeployment({ let _editorState = editorState; const { deployment, modDefinition } = activatableDeployment; - const isAlreadyActivated = optionsState.activatedModComponents.some( - (activatedModComponent) => - activatedModComponent._deployment?.id === deployment.id, + const modInstances = selectModInstances({ + options: _optionsState, + }); + + const isAlreadyActivated = modInstances.some( + (modInstance) => modInstance.deploymentMetadata?.id === deployment.id, ); - // Deactivate existing mod component versions + // Deactivate any existing mod instance corresponding to the deployed package const result = deactivateMod(deployment.package.package_id, { modComponentState: _optionsState, editorState: _editorState, @@ -303,9 +309,11 @@ async function selectUpdatedDeployments( deployments: Deployment[], { restricted }: { restricted: boolean }, ): Promise { - // Always get the freshest options slice from the local storage - const { activatedModComponents } = await getModComponentState(); - const updatePredicate = makeUpdatedFilter(activatedModComponents, { + // Always get the freshest data from the local storage + const modInstances = selectModInstances({ + options: await getModComponentState(), + }); + const updatePredicate = makeUpdatedFilter(modInstances, { restricted, }); return deployments.filter((deployment) => updatePredicate(deployment)); @@ -405,8 +413,10 @@ export async function syncDeployments(): Promise { return; } - // Always get the freshest options slice from the local storage - const { activatedModComponents } = await getModComponentState(); + // Always get the freshest data from the local storage + const modInstances = selectModInstances({ + options: await getModComponentState(), + }); // This is the "heartbeat". The old behavior was to only send if the user had at least one deployment activated. // Now we're always sending in order to help team admins understand any gaps between number of registered users @@ -449,7 +459,7 @@ export async function syncDeployments(): Promise { { uid: await getUUID(), version: getExtensionVersion(), - active: selectInstalledDeployments(activatedModComponents), + active: selectActivatedDeployments(modInstances), campaignIds, }, ); diff --git a/src/background/teamTrialUpdater.ts b/src/background/teamTrialUpdater.ts index 006ce4db60..91e7f3045a 100644 --- a/src/background/teamTrialUpdater.ts +++ b/src/background/teamTrialUpdater.ts @@ -14,31 +14,31 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import deactivateModComponentsAndSaveState from "@/background/utils/deactivateModComponentsAndSaveState"; +import deactivateModInstancesAndSaveState from "@/background/utils/deactivateModInstancesAndSaveState"; import { type Team } from "@/data/model/Team"; import { getTeams } from "@/data/service/backgroundApi"; import { getEditorState } from "@/store/editorStorage"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import { getModComponentState } from "@/store/modComponents/modComponentStorage"; -import { type ActivatedModComponent } from "@/types/modComponentTypes"; import { getScopeAndId } from "@/utils/registryUtils"; +import { selectModInstances } from "@/store/modComponents/modInstanceSelectors"; +import type { ModInstance } from "@/types/modInstanceTypes"; async function getTeamsWithTrials() { const teams = await getTeams(); return teams.filter((x) => x.trialEndTimestamp != null); } -function getManuallyActivatedTeamModComponents( - activatedModComponents: ActivatedModComponent[], +function getManuallyActivatedTeamModInstances( + modInstances: ModInstance[], teamsWithTrials: Team[], ) { const teamScopes = teamsWithTrials.map((x) => x.scope); - return activatedModComponents.filter((x) => { - if (x._deployment != null) { + return modInstances.filter((x) => { + if (x.deploymentMetadata != null) { return false; } - const { scope } = getScopeAndId(x._recipe?.id); + const { scope } = getScopeAndId(x.definition.metadata.id); return Boolean(scope && teamScopes.includes(scope)); }); } @@ -49,11 +49,11 @@ async function syncActivatedModComponents() { getEditorState(), ]); - const activatedModComponents = selectActivatedModComponents({ + const modInstances = selectModInstances({ options: modComponentState, }); - if (activatedModComponents.length === 0) { + if (modInstances.length === 0) { return; } @@ -63,23 +63,17 @@ async function syncActivatedModComponents() { return; } - const manuallyActivatedTeamModComponents = - getManuallyActivatedTeamModComponents( - activatedModComponents, - teamsWithTrials, - ); + const manuallyActivatedTeamModInstances = + getManuallyActivatedTeamModInstances(modInstances, teamsWithTrials); - if (manuallyActivatedTeamModComponents.length === 0) { + if (manuallyActivatedTeamModInstances.length === 0) { return; } - await deactivateModComponentsAndSaveState( - manuallyActivatedTeamModComponents, - { - modComponentState, - editorState, - }, - ); + await deactivateModInstancesAndSaveState(manuallyActivatedTeamModInstances, { + modComponentState, + editorState, + }); } // Update interval for the team trial updater: 5 minutes diff --git a/src/background/utils/deactivateModComponentsAndSaveState.ts b/src/background/utils/deactivateModInstancesAndSaveState.ts similarity index 80% rename from src/background/utils/deactivateModComponentsAndSaveState.ts rename to src/background/utils/deactivateModInstancesAndSaveState.ts index 85c715ff1f..e9100b9202 100644 --- a/src/background/utils/deactivateModComponentsAndSaveState.ts +++ b/src/background/utils/deactivateModInstancesAndSaveState.ts @@ -21,11 +21,11 @@ import saveModComponentStateAndReloadTabs from "@/background/utils/saveModCompon import { type EditorState } from "@/pageEditor/store/editor/pageEditorTypes"; import { saveEditorState } from "@/store/editorStorage"; import { type ModComponentState } from "@/store/modComponents/modComponentTypes"; -import { type SerializedModComponent } from "@/types/modComponentTypes"; import { allSettled } from "@/utils/promiseUtils"; +import { type ModInstance } from "@/types/modInstanceTypes"; -async function deactivateModComponentsAndSaveState( - modComponentsToDeactivate: SerializedModComponent[], +async function deactivateModInstancesAndSaveState( + modInstancesToDeactivate: ModInstance[], { modComponentState, editorState, @@ -37,9 +37,13 @@ async function deactivateModComponentsAndSaveState( let _modComponentState = modComponentState; let _editorState = editorState; + const modComponentIds = modInstancesToDeactivate.flatMap( + (x) => x.modComponentIds, + ); + // Deactivate existing mod components - for (const modComponent of modComponentsToDeactivate) { - const result = deactivateModComponent(modComponent.id, { + for (const modComponentId of modComponentIds) { + const result = deactivateModComponent(modComponentId, { modComponentState: _modComponentState, editorState: _editorState, }); @@ -48,9 +52,7 @@ async function deactivateModComponentsAndSaveState( } await allSettled( - modComponentsToDeactivate.map(async ({ id }) => - removeModComponentForEveryTab(id), - ), + modComponentIds.map(async (id) => removeModComponentForEveryTab(id)), { catch: "ignore" }, ); @@ -61,4 +63,4 @@ async function deactivateModComponentsAndSaveState( await saveEditorState(_editorState); } -export default deactivateModComponentsAndSaveState; +export default deactivateModInstancesAndSaveState; diff --git a/src/data/service/api.ts b/src/data/service/api.ts index 2dbba9cf43..8395cd658e 100644 --- a/src/data/service/api.ts +++ b/src/data/service/api.ts @@ -32,6 +32,7 @@ import { type RetrieveRecipeResponse, type RemoteIntegrationConfig, type DeploymentPayload, + type ActivatedDeployment, } from "@/types/contract"; import { type components } from "@/types/swagger"; import { dumpBrickYaml } from "@/runtime/brickYaml"; @@ -42,7 +43,6 @@ import { type UnsavedModDefinition, } from "@/types/modDefinitionTypes"; import baseQuery from "@/data/service/baseQuery"; -import { type InstalledDeployment } from "@/utils/deploymentUtils"; import { type Me, transformMeResponse } from "@/data/model/Me"; import { type UserMilestone } from "@/data/model/UserMilestone"; import { API_PATHS } from "@/data/service/urlPaths"; @@ -398,7 +398,7 @@ export const appApi = createApi({ getDeployments: builder.query< Deployment[], // Uid is used for the clientId property on events in Mixpanel telemetry - { uid: UUID; version: string; active: InstalledDeployment[] } + { uid: UUID; version: string; active: ActivatedDeployment[] } >({ query: (data) => ({ url: API_PATHS.DEPLOYMENTS, diff --git a/src/extensionConsole/pages/deployments/DeploymentsContext.tsx b/src/extensionConsole/pages/deployments/DeploymentsContext.tsx index 18b9a168b0..56868858ee 100644 --- a/src/extensionConsole/pages/deployments/DeploymentsContext.tsx +++ b/src/extensionConsole/pages/deployments/DeploymentsContext.tsx @@ -23,7 +23,6 @@ import { import { useDispatch, useSelector } from "react-redux"; import reportEvent from "@/telemetry/reportEvent"; import { Events } from "@/telemetry/events"; -import { selectActivatedModComponents } from "@/store/modComponents/modComponentSelectors"; import notify from "@/utils/notify"; import { integrationConfigLocator } from "@/background/messenger/api"; import { refreshRegistries } from "@/hooks/useRefreshRegistries"; @@ -31,9 +30,8 @@ import { type Dispatch } from "@reduxjs/toolkit"; import useFlags, { type FlagHelpers } from "@/hooks/useFlags"; import { checkExtensionUpdateRequired, - type InstalledDeployment, makeUpdatedFilter, - selectInstalledDeployments, + selectActivatedDeployments, } from "@/utils/deploymentUtils"; import settingsSlice from "@/store/settings/settingsSlice"; import { checkDeploymentPermissions } from "@/permissions/deploymentPermissionsHelpers"; @@ -49,14 +47,15 @@ import { fetchDeploymentModDefinitions } from "@/modDefinitions/modDefinitionRaw import { isEqual } from "lodash"; import useMemoCompare from "@/hooks/useMemoCompare"; import useDeriveAsyncState from "@/hooks/useDeriveAsyncState"; -import type { Deployment } from "@/types/contract"; +import type { ActivatedDeployment, Deployment } from "@/types/contract"; import useBrowserIdentifier from "@/hooks/useBrowserIdentifier"; import type { ActivatableDeployment } from "@/types/deploymentTypes"; import type { Permissions } from "webextension-polyfill"; import useDeactivateUnassignedDeploymentsEffect from "@/extensionConsole/pages/deployments/useDeactivateUnassignedDeploymentsEffect"; import { valueToAsyncState } from "@/utils/asyncStateUtils"; -import type { ActivatedModComponent } from "@/types/modComponentTypes"; import { RestrictedFeatures } from "@/auth/featureFlags"; +import { selectModInstances } from "@/store/modComponents/modInstanceSelectors"; +import type { ModInstance } from "@/types/modInstanceTypes"; export type DeploymentsState = { /** @@ -98,10 +97,10 @@ export type DeploymentsState = { function useDeployments(): DeploymentsState { const dispatch = useDispatch(); const { data: browserIdentifier } = useBrowserIdentifier(); - const activatedModComponents = useSelector(selectActivatedModComponents); + const modInstances = useSelector(selectModInstances); const { state: flagsState } = useFlags(); - const activeDeployments = useMemoCompare( - selectInstalledDeployments(activatedModComponents), + const activeDeployments = useMemoCompare( + selectActivatedDeployments(modInstances), isEqual, ); @@ -121,14 +120,14 @@ function useDeployments(): DeploymentsState { const deploymentUpdateState = useDeriveAsyncState( deploymentsState, flagsState, - // Including activatedModComponents in the dependencies to ensure the derived state is recalculated when they change - valueToAsyncState(activatedModComponents), + // Including modInstances in the dependencies to ensure the derived state is recalculated when they change + valueToAsyncState(modInstances), async ( deployments: Deployment[], { restrict }: FlagHelpers, - _activatedModComponents: ActivatedModComponent[], + _modInstances: ModInstance[], ) => { - const isUpdated = makeUpdatedFilter(_activatedModComponents, { + const isUpdated = makeUpdatedFilter(_modInstances, { restricted: restrict(RestrictedFeatures.DEACTIVATE_DEPLOYMENT), }); @@ -136,11 +135,10 @@ function useDeployments(): DeploymentsState { deployments.map((deployment) => deployment.package.package_id), ); - const unassignedModComponents = _activatedModComponents.filter( - (activeModComponent) => - activeModComponent._deployment && - activeModComponent._recipe && - !deployedModIds.has(activeModComponent._recipe.id), + const unassignedModInstances = _modInstances.filter( + (modInstance) => + modInstance.deploymentMetadata && + !deployedModIds.has(modInstance.definition.metadata.id), ); const updatedDeployments = deployments.filter((x) => isUpdated(x)); @@ -173,7 +171,7 @@ function useDeployments(): DeploymentsState { return { activatableDeployments, - unassignedModComponents, + unassignedModInstances, extensionUpdateRequired: checkExtensionUpdateRequired( activatableDeployments, ), @@ -185,24 +183,24 @@ function useDeployments(): DeploymentsState { // Fallback values for loading/error states const { activatableDeployments, - unassignedModComponents, + unassignedModInstances, extensionUpdateRequired, permissions, } = deploymentUpdateState.data ?? { // `useAutoDeploy` expects `null` to represent deployment loading state. It tries to activate once available activatableDeployments: null as ActivatableDeployment[] | null, extensionUpdateRequired: false as boolean, - unassignedModComponents: [], + unassignedModInstances: [], permissions: [] as Permissions.Permissions, }; const { isAutoDeploying } = useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }); - useDeactivateUnassignedDeploymentsEffect(unassignedModComponents); + useDeactivateUnassignedDeploymentsEffect(unassignedModInstances); const handleUpdateFromUserGesture = useCallback(async () => { // IMPORTANT: can't do a fetch or any potentially stalling operation (including IDB calls) because the call to @@ -255,14 +253,14 @@ function useDeployments(): DeploymentsState { await activateDeployments({ dispatch, activatableDeployments, - activatedModComponents, + modInstances, reloadMode: "immediate", }); notify.success("Updated team deployments"); } catch (error) { notify.error({ message: "Error updating team deployments", error }); } - }, [dispatch, activatableDeployments, permissions, activatedModComponents]); + }, [dispatch, activatableDeployments, permissions, modInstances]); const updateExtension = useCallback(async () => { await reloadIfNewVersionIsReady(); diff --git a/src/extensionConsole/pages/deployments/activateDeployments.ts b/src/extensionConsole/pages/deployments/activateDeployments.ts index 743cd9cac8..237ad9bcef 100644 --- a/src/extensionConsole/pages/deployments/activateDeployments.ts +++ b/src/extensionConsole/pages/deployments/activateDeployments.ts @@ -19,7 +19,6 @@ import { integrationConfigLocator } from "@/background/messenger/api"; import modComponentSlice from "@/store/modComponents/modComponentSlice"; import { Events } from "@/telemetry/events"; import reportEvent from "@/telemetry/reportEvent"; -import { type ModComponentBase } from "@/types/modComponentTypes"; import { mergeDeploymentIntegrationDependencies } from "@/utils/deploymentUtils"; import { type Dispatch } from "@reduxjs/toolkit"; import type { ActivatableDeployment } from "@/types/deploymentTypes"; @@ -28,6 +27,7 @@ import { reloadModsEveryTab, } from "@/contentScript/messenger/api"; import { persistor } from "@/extensionConsole/store"; +import type { ModInstance } from "@/types/modInstanceTypes"; const { actions } = modComponentSlice; @@ -44,27 +44,24 @@ async function flushAndPersist(mode: "queue" | "immediate") { async function activateDeployment({ dispatch, activatableDeployment, - activatedModComponents, + modInstances, }: { dispatch: Dispatch; activatableDeployment: ActivatableDeployment; - activatedModComponents: ModComponentBase[]; + modInstances: ModInstance[]; }): Promise { const { deployment, modDefinition } = activatableDeployment; let isReactivate = false; - // Clear existing activated mod deployments - for (const modComponent of activatedModComponents) { + // Clear instances associated activated mod deployments, or packages that would be replaced by a deployment + for (const modInstance of modInstances) { + const activatedModId = modInstance.definition.metadata.id; + if ( - modComponent._deployment?.id === deployment.id || - modComponent._recipe?.id === deployment.package.package_id + modInstance.deploymentMetadata?.id === deployment.id || + activatedModId === deployment.package.package_id ) { - dispatch( - actions.removeModComponent({ - modComponentId: modComponent.id, - }), - ); - + dispatch(actions.removeModById(activatedModId)); isReactivate = true; } } @@ -93,12 +90,12 @@ async function activateDeployment({ export async function activateDeployments({ dispatch, activatableDeployments, - activatedModComponents, + modInstances, reloadMode, }: { dispatch: Dispatch; activatableDeployments: ActivatableDeployment[]; - activatedModComponents: ModComponentBase[]; + modInstances: ModInstance[]; reloadMode: "queue" | "immediate"; }): Promise { // Activate as many as we can @@ -110,7 +107,7 @@ export async function activateDeployments({ await activateDeployment({ dispatch, activatableDeployment, - activatedModComponents, + modInstances, }); } catch (error) { errors.push(error); @@ -130,21 +127,17 @@ export async function activateDeployments({ export function deactivateUnassignedModComponents({ dispatch, - unassignedModComponents, + unassignedModInstances, }: { dispatch: Dispatch; - unassignedModComponents: ModComponentBase[]; + unassignedModInstances: ModInstance[]; }) { - const deactivatedModComponents = []; + const deactivatedModInstances = []; - for (const modComponent of unassignedModComponents) { + for (const modInstance of unassignedModInstances) { try { - dispatch( - actions.removeModComponent({ - modComponentId: modComponent.id, - }), - ); - deactivatedModComponents.push(modComponent); + dispatch(actions.removeModById(modInstance.definition.metadata.id)); + deactivatedModInstances.push(modInstance); } catch (error) { reportError( new Error("Error deactivating unassigned mod component", { @@ -158,8 +151,8 @@ export function deactivateUnassignedModComponents({ reportEvent(Events.DEPLOYMENT_DEACTIVATE_UNASSIGNED, { auto: true, - deployments: deactivatedModComponents.map( - (modComponent) => modComponent._deployment?.id, - ), + deployments: deactivatedModInstances + .map((x) => x.deploymentMetadata?.id) + .filter((x) => x != null), }); } diff --git a/src/extensionConsole/pages/deployments/useAutoDeploy.test.ts b/src/extensionConsole/pages/deployments/useAutoDeploy.test.ts index 1c5fe36f58..2120167c58 100644 --- a/src/extensionConsole/pages/deployments/useAutoDeploy.test.ts +++ b/src/extensionConsole/pages/deployments/useAutoDeploy.test.ts @@ -21,8 +21,8 @@ import { renderHook } from "@/extensionConsole/testHelpers"; import useFlags from "@/hooks/useFlags"; import useModPermissions from "@/mods/hooks/useModPermissions"; import { activatableDeploymentFactory } from "@/testUtils/factories/deploymentFactories"; -import { modComponentFactory } from "@/testUtils/factories/modComponentFactories"; import type { ActivatableDeployment } from "@/types/deploymentTypes"; +import { modInstanceFactory } from "@/testUtils/factories/modInstanceFactories"; jest.mock("@/mods/hooks/useModPermissions"); jest.mock("@/hooks/useFlags"); @@ -59,16 +59,13 @@ describe("useAutoDeploy", () => { const activatableDeployments: ActivatableDeployment[] | undefined = undefined; - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result } = renderHook(() => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), ); @@ -80,16 +77,13 @@ describe("useAutoDeploy", () => { mockHooks(); const activatableDeployments: ActivatableDeployment[] = []; - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result } = renderHook(() => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), ); @@ -109,16 +103,13 @@ describe("useAutoDeploy", () => { const activatableDeployments: ActivatableDeployment[] = [ activatableDeploymentFactory(), ]; - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result, waitForValueToChange } = renderHook(() => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), ); @@ -127,7 +118,7 @@ describe("useAutoDeploy", () => { expect(activateDeployments).toHaveBeenCalledWith({ dispatch: expect.any(Function), activatableDeployments, - activatedModComponents, + modInstances, reloadMode: "queue", }); @@ -141,16 +132,13 @@ describe("useAutoDeploy", () => { const activatableDeployments: ActivatableDeployment[] = [ activatableDeploymentFactory(), ]; - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result, waitForValueToChange } = renderHook(() => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), ); @@ -174,17 +162,14 @@ describe("useAutoDeploy", () => { const activatableDeployment: ActivatableDeployment = activatableDeploymentFactory(); - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result, rerender, waitForValueToChange } = renderHook( ({ activatableDeployments }) => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), { @@ -211,16 +196,13 @@ describe("useAutoDeploy", () => { const activatableDeployments: ActivatableDeployment[] = [ activatableDeploymentFactory(), ]; - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result } = renderHook(() => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), ); @@ -234,16 +216,13 @@ describe("useAutoDeploy", () => { const activatableDeployments: ActivatableDeployment[] = [ activatableDeploymentFactory(), ]; - const activatedModComponents = [ - modComponentFactory(), - modComponentFactory(), - ]; + const modInstances = [modInstanceFactory(), modInstanceFactory()]; const extensionUpdateRequired = false; const { result } = renderHook(() => useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }), ); diff --git a/src/extensionConsole/pages/deployments/useAutoDeploy.ts b/src/extensionConsole/pages/deployments/useAutoDeploy.ts index 92b697428f..4e2b231946 100644 --- a/src/extensionConsole/pages/deployments/useAutoDeploy.ts +++ b/src/extensionConsole/pages/deployments/useAutoDeploy.ts @@ -18,7 +18,6 @@ import { activateDeployments } from "@/extensionConsole/pages/deployments/activateDeployments"; import useFlags from "@/hooks/useFlags"; import useModPermissions from "@/mods/hooks/useModPermissions"; -import { type ModComponentBase } from "@/types/modComponentTypes"; import notify from "@/utils/notify"; import { type Dispatch } from "@reduxjs/toolkit"; import { useState } from "react"; @@ -27,6 +26,7 @@ import useAsyncEffect from "use-async-effect"; import type { ActivatableDeployment } from "@/types/deploymentTypes"; import type { Nullishable } from "@/utils/nullishUtils"; import { RestrictedFeatures } from "@/auth/featureFlags"; +import type { ModInstance } from "@/types/modInstanceTypes"; type UseAutoDeployReturn = { /** @@ -37,12 +37,12 @@ type UseAutoDeployReturn = { function useAutoDeploy({ activatableDeployments, - activatedModComponents, + modInstances, extensionUpdateRequired, }: { // Expects nullish value if activatableDeployments are uninitialized/not loaded yet activatableDeployments: Nullishable; - activatedModComponents: ModComponentBase[]; + modInstances: ModInstance[]; extensionUpdateRequired: boolean; }): UseAutoDeployReturn { const dispatch = useDispatch(); @@ -53,7 +53,7 @@ function useAutoDeploy({ ] = useState(true); // Only `true` while deployments are being activated. Prevents multiple activations from happening at once. const [isActivationInProgress, setIsActivationInProgress] = useState(false); - const { hasPermissions } = useModPermissions(activatedModComponents); + const { hasPermissions } = useModPermissions(modInstances); const { restrict } = useFlags(); /** @@ -91,7 +91,7 @@ function useAutoDeploy({ await activateDeployments({ dispatch, activatableDeployments, - activatedModComponents, + modInstances, reloadMode: "queue", }); notify.success("Updated team deployments"); diff --git a/src/extensionConsole/pages/deployments/useDeactivateUnassignedDeploymentsEffect.ts b/src/extensionConsole/pages/deployments/useDeactivateUnassignedDeploymentsEffect.ts index 262b2524f6..2bdb569914 100644 --- a/src/extensionConsole/pages/deployments/useDeactivateUnassignedDeploymentsEffect.ts +++ b/src/extensionConsole/pages/deployments/useDeactivateUnassignedDeploymentsEffect.ts @@ -19,22 +19,22 @@ import { deactivateUnassignedModComponents } from "@/extensionConsole/pages/depl import { useEffect } from "react"; import { useDispatch } from "react-redux"; import type { Dispatch } from "@reduxjs/toolkit"; -import { type ModComponentBase } from "@/types/modComponentTypes"; +import type { ModInstance } from "@/types/modInstanceTypes"; -const useDeactivateUnassignedDeploymentsEffect = ( - unassignedModComponents: ModComponentBase[], -) => { +function useDeactivateUnassignedDeploymentsEffect( + unassignedModInstances: ModInstance[], +): void { const dispatch = useDispatch(); useEffect(() => { - if (unassignedModComponents.length === 0) { + if (unassignedModInstances.length === 0) { return; } deactivateUnassignedModComponents({ dispatch, - unassignedModComponents, + unassignedModInstances, }); - }, [dispatch, unassignedModComponents]); -}; + }, [dispatch, unassignedModInstances]); +} export default useDeactivateUnassignedDeploymentsEffect; diff --git a/src/extensionConsole/pages/mods/Status.tsx b/src/extensionConsole/pages/mods/Status.tsx index 825041fa79..b9b6595e6a 100644 --- a/src/extensionConsole/pages/mods/Status.tsx +++ b/src/extensionConsole/pages/mods/Status.tsx @@ -17,7 +17,7 @@ import styles from "./Status.module.scss"; -import React from "react"; +import React, { useMemo } from "react"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faCheck, @@ -32,9 +32,10 @@ import useModPermissions from "@/mods/hooks/useModPermissions"; import reportEvent from "@/telemetry/reportEvent"; import { Events } from "@/telemetry/events"; import { useHistory } from "react-router"; -import useActivatedModComponents from "@/mods/hooks/useActivatedModComponents"; import { UI_PATHS } from "@/data/service/urlPaths"; import { TrialAwareButton } from "@/extensionConsole/pages/teamTrials/TrialAwareButton"; +import useFindModInstance from "@/mods/hooks/useFindModInstance"; +import { compact } from "lodash"; const Status: React.VoidFunctionComponent<{ modViewItem: ModViewItem; @@ -51,11 +52,13 @@ const Status: React.VoidFunctionComponent<{ modActions: { showActivate, showReactivate }, } = modViewItem; - const activatedModComponents = useActivatedModComponents(modId); + const modInstance = useFindModInstance(modId); - const { hasPermissions, requestPermissions } = useModPermissions( - activatedModComponents, - ); + // Memoize array reference to avoid infinite re-render loop with useModPermissions + const modInstances = useMemo(() => compact([modInstance]), [modInstance]); + + const { hasPermissions, requestPermissions } = + useModPermissions(modInstances); if (isUnavailable) { return ( diff --git a/src/mods/hooks/useModPermissions.ts b/src/mods/hooks/useModPermissions.ts index 878309c89e..544afed573 100644 --- a/src/mods/hooks/useModPermissions.ts +++ b/src/mods/hooks/useModPermissions.ts @@ -15,7 +15,6 @@ * along with this program. If not, see . */ -import { type ModComponentBase } from "@/types/modComponentTypes"; import { emptyPermissionsFactory } from "@/permissions/permissionsUtils"; import { checkExtensionPermissions } from "@/permissions/modComponentPermissionsHelpers"; import useAsyncState from "@/hooks/useAsyncState"; @@ -24,6 +23,8 @@ import { type PermissionsStatus } from "@/permissions/permissionsTypes"; import useExtensionPermissions from "@/permissions/useExtensionPermissions"; import useRequestPermissionsCallback from "@/permissions/useRequestPermissionsCallback"; import { assertNotNullish } from "@/utils/nullishUtils"; +import type { ModInstance } from "@/types/modInstanceTypes"; +import { mapModInstanceToActivatedModComponents } from "@/store/modComponents/modInstanceUtils"; // By default, assume the extensions have permissions. const fallback: PermissionsStatus = { @@ -36,7 +37,7 @@ const fallback: PermissionsStatus = { * Outside the `ModsPage` you probably want to use useAsyncState with `collectModComponentPermissions` * @see collectModComponentPermissions */ -function useModPermissions(modComponents: ModComponentBase[]): { +function useModPermissions(modInstances: ModInstance[]): { hasPermissions: boolean; requestPermissions: () => Promise; } { @@ -45,11 +46,14 @@ function useModPermissions(modComponents: ModComponentBase[]): { const { data } = fallbackValue( useAsyncState(async () => { if (isSuccess) { + const modComponents = modInstances.flatMap((x) => + mapModInstanceToActivatedModComponents(x), + ); return checkExtensionPermissions(modComponents); } return fallback; - }, [modComponents, browserPermissions, isSuccess]), + }, [modInstances, browserPermissions, isSuccess]), fallback, ); diff --git a/src/store/modComponents/modInstanceSelectors.ts b/src/store/modComponents/modInstanceSelectors.ts index 03364a24ee..850cf92a62 100644 --- a/src/store/modComponents/modInstanceSelectors.ts +++ b/src/store/modComponents/modInstanceSelectors.ts @@ -19,28 +19,27 @@ 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"); - } +// Written using createSelector to memoize because it creates a new object. Not a big deal at the moment because +// that's the only property in the slice. But it's good habit to memoize properly. +export const selectModInstances = createSelector( + (state: ModComponentsRootState) => state.options.activatedModComponents, + (activatedModComponents) => { + if (!Array.isArray(activatedModComponents)) { + throw new TypeError("state migration has not been applied yet"); + } - return Object.values( - groupBy(options.activatedModComponents, (x) => x._recipe?.id), - ).map((modComponents) => - mapActivatedModComponentsToModInstance(modComponents), - ); -} + return Object.values( + groupBy(activatedModComponents, (x) => x._recipe?.id), + ).map((modComponents) => + mapActivatedModComponentsToModInstance(modComponents), + ); + }, +); /** * Returns a Map of activated mod instances keyed by mod id. diff --git a/src/store/modComponents/modInstanceUtils.ts b/src/store/modComponents/modInstanceUtils.ts index 6546f6db96..e2495915bd 100644 --- a/src/store/modComponents/modInstanceUtils.ts +++ b/src/store/modComponents/modInstanceUtils.ts @@ -20,7 +20,7 @@ import type { ActivatedModComponent, ModComponentBase, } from "@/types/modComponentTypes"; -import { omit, pick } from "lodash"; +import { omit, pick, zip } from "lodash"; import { assertNotNullish } from "@/utils/nullishUtils"; import { uuidv4 } from "@/types/helpers"; import { @@ -32,8 +32,23 @@ import { createPrivateSharing } from "@/utils/registryUtils"; import { emptyModOptionsDefinitionFactory } from "@/utils/modUtils"; import { assertModComponentNotHydrated } from "@/runtime/runtimeUtils"; import type { ModComponentDefinition } from "@/types/modDefinitionTypes"; +import type { SetRequired } from "type-fest"; +import { pickModDefinitionMetadata } from "@/modDefinitions/util/pickModDefinitionMetadata"; +import getModDefinitionIntegrationIds from "@/integrations/util/getModDefinitionIntegrationIds"; +import { emptyPermissionsFactory } from "@/permissions/permissionsUtils"; -function generateModInstanceId(): ModInstanceId { +/** + * A version of ActivatedModComponent with stronger nullness guarantees to support type evolution in the future. + */ +type ModInstanceActivatedModComponent = SetRequired< + ActivatedModComponent, + "_recipe" | "definitions" | "integrationDependencies" | "permissions" +>; + +/** + * Generate a tagged UUID for a mod instance. + */ +export function generateModInstanceId(): ModInstanceId { return uuidv4() as ModInstanceId; } @@ -56,6 +71,72 @@ export function mapModComponentBaseToModComponentDefinition( }; } +/** + * Returns the activated mod component for a given mod instance. Is side effect free -- only maps the shape, does + * not persist the mod components or modify the UI. + * + * @see mapModComponentDefinitionToActivatedModComponent + */ +export function mapModInstanceToActivatedModComponents( + modInstance: ModInstance, +): ModInstanceActivatedModComponent[] { + const { deploymentMetadata, integrationsArgs, updatedAt, definition } = + modInstance; + + const modMetadata = pickModDefinitionMetadata(definition); + + return zip(definition.extensionPoints, modInstance.modComponentIds).map( + ([modComponentDefinition, modComponentId]) => { + assertNotNullish( + modComponentDefinition, + "extensionPoints mismatch with modComponentIds", + ); + assertNotNullish( + modComponentId, + "extensionPoints mismatch with modComponentIds", + ); + + const componentIntegrationIds = getModDefinitionIntegrationIds({ + extensionPoints: [modComponentDefinition], + }); + + const base = { + id: modComponentId, + active: true, + label: modComponentDefinition.label, + config: modComponentDefinition.config, + permissions: + modComponentDefinition.permissions ?? emptyPermissionsFactory(), + // Default to `v1` for backward compatability + apiVersion: definition.apiVersion ?? "v1", + _recipe: modMetadata, + // All definitions are pushed down into the mod components. That's OK because `resolveDefinitions` determines + // uniqueness based on the content of the definition. Therefore, bricks will be re-used as necessary + definitions: definition.definitions ?? {}, + optionsArgs: modInstance.optionsArgs, + extensionPointId: modComponentDefinition.id, + createTimestamp: updatedAt, + updateTimestamp: updatedAt, + // XXX: do we have to filter to only the integrations referenced by this particular mod? Historically, was this + // only to simplify moving mod components in the Page Editor? + integrationDependencies: integrationsArgs.filter(({ integrationId }) => + componentIntegrationIds.includes(integrationId), + ), + } as ModInstanceActivatedModComponent; + + if (modComponentDefinition.templateEngine) { + base.templateEngine = modComponentDefinition.templateEngine; + } + + if (deploymentMetadata) { + base._deployment = deploymentMetadata; + } + + return base; + }, + ); +} + /** * Maps activated mod components to a mod instance. * @param modComponents mod components from the mod diff --git a/src/mods/hooks/useActivatedModComponents.ts b/src/testUtils/factories/modInstanceFactories.ts similarity index 52% rename from src/mods/hooks/useActivatedModComponents.ts rename to src/testUtils/factories/modInstanceFactories.ts index c40cbee008..62414fa9f9 100644 --- a/src/mods/hooks/useActivatedModComponents.ts +++ b/src/testUtils/factories/modInstanceFactories.ts @@ -15,15 +15,21 @@ * along with this program. If not, see . */ -import { useSelector } from "react-redux"; -import { selectGetModComponentsForMod } from "@/store/modComponents/modComponentSelectors"; -import { useMemo } from "react"; -import type { RegistryId } from "@/types/registryTypes"; +import { define } from "cooky-cutter"; +import type { ModInstance } from "@/types/modInstanceTypes"; +import { + autoUUIDSequence, + timestampFactory, +} from "@/testUtils/factories/stringFactories"; +import { modDefinitionFactory } from "@/testUtils/factories/modDefinitionFactories"; +import { generateModInstanceId } from "@/store/modComponents/modInstanceUtils"; -export default function useActivatedModComponents(modId: RegistryId) { - const getModComponentsForMod = useSelector(selectGetModComponentsForMod); - return useMemo( - () => getModComponentsForMod(modId), - [modId, getModComponentsForMod], - ); -} +export const modInstanceFactory = define({ + id: generateModInstanceId, + modComponentIds: () => [autoUUIDSequence()], + definition: modDefinitionFactory(), + deploymentMetadata: undefined, + integrationsArgs: [], + optionsArgs: {}, + updatedAt: timestampFactory(), +}); diff --git a/src/types/contract.ts b/src/types/contract.ts index fd43786178..2c53f613c7 100644 --- a/src/types/contract.ts +++ b/src/types/contract.ts @@ -258,3 +258,18 @@ export type PackageVersionUpdates = { export type OrganizationAuthUrlPattern = components["schemas"]["OrganizationAuthUrlPattern"]; + +/** + * Deployment installed on the client. A deployment may be "activated" locally but not paused + * (see DeploymentContext.active) + * + * See https://github.com/pixiebrix/pixiebrix-app/blob/71cdfd8aea1992ae7cac7cb6fd049d38f7135c10/api/serializers/deployments.py#L109-L109 + * + * @see DeploymentTelemetrySerializer + */ +export type ActivatedDeployment = { + deployment: UUID; + // Use legacy names - these are passed to the server + blueprint: RegistryId; + blueprintVersion: string; +}; diff --git a/src/types/modInstanceTypes.ts b/src/types/modInstanceTypes.ts index 078699841f..35fc5dbbda 100644 --- a/src/types/modInstanceTypes.ts +++ b/src/types/modInstanceTypes.ts @@ -24,6 +24,7 @@ import type { Tagged } from "type-fest"; /** * A unique identifier for a mod instance activation. Tagged to prevent mixing with mod component id. + * @see generateModInstanceId */ export type ModInstanceId = Tagged; diff --git a/src/utils/deploymentUtils.test.ts b/src/utils/deploymentUtils.test.ts index 2ce50331b2..60a11e829a 100644 --- a/src/utils/deploymentUtils.test.ts +++ b/src/utils/deploymentUtils.test.ts @@ -29,7 +29,10 @@ import { } from "@/types/helpers"; import { type SanitizedIntegrationConfig } from "@/integrations/integrationTypes"; import { validateOutputKey } from "@/runtime/runtimeTypes"; -import { modComponentFactory } from "@/testUtils/factories/modComponentFactories"; +import { + activatedModComponentFactory, + modComponentFactory, +} from "@/testUtils/factories/modComponentFactories"; import { modComponentDefinitionFactory } from "@/testUtils/factories/modDefinitionFactories"; import { sanitizedIntegrationConfigFactory } from "@/testUtils/factories/integrationFactories"; import { @@ -43,14 +46,16 @@ import { import getModDefinitionIntegrationIds from "@/integrations/util/getModDefinitionIntegrationIds"; import { getExtensionVersion } from "@/utils/extensionUtils"; import { validateTimestamp } from "@/utils/timeUtils"; +import { modInstanceFactory } from "@/testUtils/factories/modInstanceFactories"; +import { mapActivatedModComponentsToModInstance } from "@/store/modComponents/modInstanceUtils"; describe("makeUpdatedFilter", () => { test.each([[{ restricted: true }, { restricted: false }]])( "unassigned deployment", ({ restricted }) => { - const modComponents = [modComponentFactory()]; + const modInstances = [modInstanceFactory()]; - const filter = makeUpdatedFilter(modComponents, { restricted }); + const filter = makeUpdatedFilter(modInstances, { restricted }); expect(filter(deploymentFactory())).toBeTrue(); }, ); @@ -60,17 +65,15 @@ describe("makeUpdatedFilter", () => { ({ restricted }) => { const deployment = deploymentFactory(); - const extensions = [ - modComponentFactory({ - _deployment: { - id: deployment.id, - timestamp: deployment.updated_at!, - active: true, - }, - }), - ]; - - const filter = makeUpdatedFilter(extensions, { restricted }); + const modInstance = modInstanceFactory({ + deploymentMetadata: { + id: deployment.id, + timestamp: deployment.updated_at!, + active: true, + }, + }); + + const filter = makeUpdatedFilter([modInstance], { restricted }); expect(filter(deployment)).toBeFalse(); }, ); @@ -80,26 +83,24 @@ describe("makeUpdatedFilter", () => { ({ restricted }) => { const deployment = deploymentFactory(); - const extensions = [ - modComponentFactory({ - _deployment: { - id: deployment.id, - timestamp: "2020-10-07T12:52:16.189Z", - active: true, - }, - }), - ]; - - const filter = makeUpdatedFilter(extensions, { restricted }); + const modInstance = modInstanceFactory({ + deploymentMetadata: { + id: deployment.id, + timestamp: "2020-10-07T12:52:16.189Z", + active: true, + }, + }); + + const filter = makeUpdatedFilter([modInstance], { restricted }); expect(filter(deployment)).toBeTrue(); }, ); - test("matched blueprint for restricted user", () => { + test("matched mod for restricted user", () => { const { deployment, modDefinition } = activatableDeploymentFactory(); - const extensions = [ - modComponentFactory({ + const modInstance = mapActivatedModComponentsToModInstance([ + activatedModComponentFactory({ _deployment: undefined, _recipe: { ...modDefinition.metadata, @@ -108,17 +109,17 @@ describe("makeUpdatedFilter", () => { sharing: null, }, }), - ]; + ]); - const filter = makeUpdatedFilter(extensions, { restricted: true }); + const filter = makeUpdatedFilter([modInstance], { restricted: true }); expect(filter(deployment)).toBeTrue(); }); test("matched blueprint for unrestricted user / developer", () => { const { deployment, modDefinition } = activatableDeploymentFactory(); - const extensions = [ - modComponentFactory({ + const modInstance = mapActivatedModComponentsToModInstance([ + activatedModComponentFactory({ _deployment: undefined, _recipe: { ...modDefinition.metadata, @@ -129,9 +130,9 @@ describe("makeUpdatedFilter", () => { sharing: null, }, }), - ]; + ]); - const filter = makeUpdatedFilter(extensions, { restricted: false }); + const filter = makeUpdatedFilter([modInstance], { restricted: false }); expect(filter(deployment)).toBeFalse(); }); }); diff --git a/src/utils/deploymentUtils.ts b/src/utils/deploymentUtils.ts index c490fcda3d..0bbbd17a1b 100644 --- a/src/utils/deploymentUtils.ts +++ b/src/utils/deploymentUtils.ts @@ -15,12 +15,11 @@ * along with this program. If not, see . */ -import { type Deployment } from "@/types/contract"; +import { type ActivatedDeployment, type Deployment } from "@/types/contract"; import { gte, satisfies } from "semver"; import { compact, uniqBy } from "lodash"; import { type ModComponentBase } from "@/types/modComponentTypes"; import { type RegistryId } from "@/types/registryTypes"; -import { type UUID } from "@/types/stringTypes"; import { type IntegrationDependency, type SanitizedIntegrationConfig, @@ -31,6 +30,7 @@ import { PIXIEBRIX_INTEGRATION_ID } from "@/integrations/constants"; import getUnconfiguredComponentIntegrations from "@/integrations/util/getUnconfiguredComponentIntegrations"; import type { ActivatableDeployment } from "@/types/deploymentTypes"; import { getExtensionVersion } from "@/utils/extensionUtils"; +import type { ModInstance } from "@/types/modInstanceTypes"; /** * Returns `true` if a managed deployment is active (i.e., has not been remotely paused by an admin) @@ -60,44 +60,41 @@ export function isDeploymentActive(extensionLike: { * - Same as above, but ignore deployments where the user has a newer version of the blueprint installed because that * means they are doing local deployment on the blueprint. * - * @param activatedModComponents the user's currently installed modComponents (including for paused deployments) + * @param modInstances the user's currently activated mod instances (including for paused deployments) * @param restricted `true` if the user is a restricted organization user (i.e., as opposed to a developer) */ export const makeUpdatedFilter = - ( - activatedModComponents: ModComponentBase[], - { restricted }: { restricted: boolean }, - ) => + (modInstances: ModInstance[], { restricted }: { restricted: boolean }) => (deployment: Deployment) => { - const deploymentMatch = activatedModComponents.find( - (modComponent) => modComponent._deployment?.id === deployment.id, + const deploymentMatch = modInstances.find( + (x) => x.deploymentMetadata?.id === deployment.id, ); if (restricted) { return ( - !deploymentMatch?._deployment || + !deploymentMatch?.deploymentMetadata || !deployment.updated_at || - new Date(deploymentMatch._deployment.timestamp) < + new Date(deploymentMatch.deploymentMetadata.timestamp) < new Date(deployment.updated_at) ); } // Local copies an unrestricted user (i.e., a developer role) is working on - const modMatch = activatedModComponents.find( - (modComponent) => - modComponent._deployment == null && - modComponent._recipe?.id === deployment.package.package_id, + const packageMatch = modInstances.find( + (modInstance) => + modInstance.deploymentMetadata == null && + modInstance.definition.metadata.id === deployment.package.package_id, ); - if (!deploymentMatch && !modMatch) { + if (!deploymentMatch && !packageMatch) { return true; } if ( - modMatch && + packageMatch && gte( - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- modMatch is checked above - modMatch._recipe!.version!, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- version is present for persisted mods + packageMatch.definition.metadata.version!, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- deployment package is checked above deployment.package.version!, ) @@ -111,9 +108,9 @@ export const makeUpdatedFilter = } return ( - !deploymentMatch?._deployment || + !deploymentMatch?.deploymentMetadata || !deployment.updated_at || - new Date(deploymentMatch._deployment?.timestamp) < + new Date(deploymentMatch.deploymentMetadata?.timestamp) < new Date(deployment.updated_at) ); }; @@ -147,32 +144,18 @@ export function checkExtensionUpdateRequired( ); } -/** - * Deployment installed on the client. A deployment may be installed but not active (see DeploymentContext.active) - */ -export type InstalledDeployment = { - deployment: UUID; - blueprint: RegistryId; - blueprintVersion: string; -}; - -export function selectInstalledDeployments( - activatedModComponents: Array< - Pick - >, -): InstalledDeployment[] { +export function selectActivatedDeployments( + modInstances: ModInstance[], +): ActivatedDeployment[] { return uniqBy( - activatedModComponents - .filter((x) => x._deployment?.id != null) - .map( - (x) => - ({ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- _deployment is checked above - deployment: x._deployment!.id, - blueprint: x._recipe?.id, - blueprintVersion: x._recipe?.version, - }) as InstalledDeployment, - ), + modInstances + .filter((x) => x.deploymentMetadata != null) + .map((x) => ({ + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Typescript not picking up filter + deployment: x.deploymentMetadata!.id, + blueprint: x.definition.metadata.id, + blueprintVersion: x.definition.metadata.id, + })), (x) => x.deployment, ); }