Skip to content

Commit

Permalink
#8979 Migrate standalone mod components (#8989)
Browse files Browse the repository at this point in the history
* write the standalone component migration using a placeholder V4 type, write tests for the conversion to mods

* fix deployment updater test

* Migrate form states also

* add to strict null checks

* fix strict null checks

* fix null checks again

* fix another test

* remove debug log

* move migrate up in the tree

---------

Co-authored-by: Ben Loe <[email protected]>
  • Loading branch information
BLoe and Ben Loe authored Aug 9, 2024
1 parent b945017 commit 0133816
Show file tree
Hide file tree
Showing 13 changed files with 372 additions and 69 deletions.
17 changes: 17 additions & 0 deletions src/auth/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,23 @@ import {
import { type Me } from "@/data/model/Me";
import selectAuthUserOrganizations from "@/auth/selectAuthUserOrganizations";
import { UserRole } from "@/types/contract";
import {
readReduxStorage,
validateReduxStorageKey,
} from "@/utils/storageUtils";
import { type Nullishable } from "@/utils/nullishUtils";
import { anonAuth } from "@/auth/authConstants";

const AUTH_SLICE_STORAGE_KEY = validateReduxStorageKey("persist:authOptions");

export async function getUserScope(): Promise<Nullishable<string>> {
const { scope } = await readReduxStorage(
AUTH_SLICE_STORAGE_KEY,
{},
anonAuth,
);
return scope;
}

export function selectUserDataUpdate({
email,
Expand Down
9 changes: 9 additions & 0 deletions src/background/deploymentUpdater.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,15 @@ jest.mock("@/background/installer", () => ({
isUpdateAvailable: jest.fn().mockReturnValue(false),
}));

// This comes up in the extensions slice redux-persist migrations that run when mod component state is loaded
jest.mock("@/auth/authUtils", () => {
const actual = jest.requireActual("@/auth/authUtils");
return {
...actual,
getUserScope: jest.fn(() => "@test-user"),
};
});

const registryFindMock = jest.mocked(registry.find);

const isLinkedMock = jest.mocked(isLinked);
Expand Down
9 changes: 9 additions & 0 deletions src/background/starterMods.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ jest.mock("@/auth/authStorage", () => ({
jest.mock("@/contentScript/messenger/api");
jest.mock("./refreshRegistries");

// This comes up in the extensions slice redux-persist migrations that run when mod component state is loaded
jest.mock("@/auth/authUtils", () => {
const actual = jest.requireActual("@/auth/authUtils");
return {
...actual,
getUserScope: jest.fn(() => "@test-user"),
};
});

const isLinkedMock = jest.mocked(isLinked);
const refreshRegistriesMock = jest.mocked(refreshRegistries);

Expand Down
37 changes: 3 additions & 34 deletions src/contentScript/loadActivationEnhancementsCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import { initSidebarActivation } from "@/contentScript/sidebarActivation";
import { getActivatedModIds } from "@/store/extensionsStorage";
import { getDocument } from "@/starterBricks/starterBrickTestUtils";
import { validateRegistryId } from "@/types/helpers";
import { type ActivatedModComponent } from "@/types/modComponentTypes";
import { waitForEffect } from "@/testUtils/testHelpers";
import { getActivatingMods } from "@/background/messenger/external/_implementation";
import {
Expand Down Expand Up @@ -90,19 +89,7 @@ describe("marketplace enhancements", () => {
test("given user is logged in, when an activate button is clicked, should open the sidebar", async () => {
isLinkedMock.mockResolvedValue(true);
window.location.assign(MARKETPLACE_URL);
// Recipe 1 is installed, recipe 2 is not
const modComponent1 = modComponentFactory({
_recipe: modMetadataFactory({
id: modId1,
}),
}) as ActivatedModComponent;
const modComponent2 = modComponentFactory() as ActivatedModComponent;
getActivatedModIdsMock.mockResolvedValue(
new Set<RegistryId | undefined>([
modComponent1._recipe?.id,
modComponent2._recipe?.id,
]),
);
getActivatedModIdsMock.mockResolvedValue(new Set<RegistryId>([modId1]));

await loadActivationEnhancements();
await initSidebarActivation();
Expand Down Expand Up @@ -195,16 +182,7 @@ describe("marketplace enhancements", () => {
test("given user is not logged in, when loaded, should change button text", async () => {
isLinkedMock.mockResolvedValue(false);
window.location.assign(MARKETPLACE_URL);
// Recipe 1 is installed, recipe 2 is not
const modComponent1 = modComponentFactory({
_recipe: modMetadataFactory({
id: modId1,
}),
}) as ActivatedModComponent;
const modComponent2 = modComponentFactory() as ActivatedModComponent;
getActivatedModIdsMock.mockResolvedValue(
new Set([modComponent1._recipe?.id, modComponent2._recipe?.id]),
);
getActivatedModIdsMock.mockResolvedValue(new Set([modId1]));

await loadActivationEnhancements();
await initSidebarActivation();
Expand All @@ -218,16 +196,7 @@ describe("marketplace enhancements", () => {
test("given user is logged in, when loaded, should change button text for installed recipe", async () => {
isLinkedMock.mockResolvedValue(true);
window.location.assign(MARKETPLACE_URL);
// Recipe 1 is installed, recipe 2 is not
const modComponent1 = modComponentFactory({
_recipe: modMetadataFactory({
id: modId1,
}),
}) as ActivatedModComponent;
const modComponent2 = modComponentFactory() as ActivatedModComponent;
getActivatedModIdsMock.mockResolvedValue(
new Set([modComponent1._recipe?.id, modComponent2._recipe?.id]),
);
getActivatedModIdsMock.mockResolvedValue(new Set([modId1]));

await loadActivationEnhancements();
await initSidebarActivation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { normalizeSemVerString } from "@/types/helpers";
/**
* Map a standalone mod definition from the server to a mod definition
* @see mapModComponentToUnsavedModDefinition
* @see createModMetadataForStandaloneComponent - similar functionality
*/
export function mapStandaloneModDefinitionToModDefinition(
standaloneModDefinition: StandaloneModDefinition,
Expand Down
51 changes: 51 additions & 0 deletions src/pageEditor/hooks/useMigrateStandaloneComponentsToMods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

import { useDispatch, useSelector } from "react-redux";
import { selectModComponentFormStates } from "@/pageEditor/store/editor/editorSelectors";
import { selectActivatedModComponents } from "@/store/extensionsSelectors";
import { useEffect } from "react";
import { actions } from "@/pageEditor/store/editor/editorSlice";

/**
* Note: This must be run underneath the PersistGate component in the React component tree
*/
export default function useMigrateStandaloneComponentsToMods() {
const dispatch = useDispatch();
const formStates = useSelector(selectModComponentFormStates);
const activatedModComponents = useSelector(selectActivatedModComponents);

useEffect(() => {
const standaloneComponentFormStates = formStates.filter(
(formState) => formState.modMetadata == null,
);

for (const formState of standaloneComponentFormStates) {
const modMetadata = activatedModComponents.find(
({ id }) => id === formState.uuid,
)?._recipe;

if (modMetadata == null) {
dispatch(actions.removeModComponentFormState(formState.uuid));
} else {
formState.modMetadata = modMetadata;
dispatch(actions.syncModComponentFormState(formState));
}
}
// eslint-disable-next-line -- Only need to run this migration once
}, []);
}
10 changes: 10 additions & 0 deletions src/pageEditor/layout/EditorLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import { selectIsStaleSession } from "@/store/sessionChanges/sessionChangesSelec
import StaleSessionPane from "@/pageEditor/panes/StaleSessionPane";
import { actions as editorActions } from "@/pageEditor/store/editor/editorSlice";
import { usePreviousValue } from "@/hooks/usePreviousValue";
import useMigrateStandaloneComponentsToMods from "@/pageEditor/hooks/useMigrateStandaloneComponentsToMods";

const EditorLayout: React.FunctionComponent = () => {
const dispatch = useDispatch();
Expand All @@ -45,6 +46,15 @@ const EditorLayout: React.FunctionComponent = () => {

const url = useCurrentInspectedUrl();

/**
* Migrate form states for activated standalone mod components. We are
* running it here because it's the top-most component underneath
* the redux-persist PersistGate.
*
* @since 2.0.8
*/
useMigrateStandaloneComponentsToMods();

const currentPane = useMemo(
() =>
isRestricted ? (
Expand Down
141 changes: 141 additions & 0 deletions src/store/extensionsMigrations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/*
* 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 <http://www.gnu.org/licenses/>.
*/

import {
createModMetadataForStandaloneComponent,
migrateStandaloneComponentsToMods,
} from "@/store/extensionsMigrations";
import {
activatedModComponentFactory,
modMetadataFactory,
} from "@/testUtils/factories/modComponentFactories";
import {
autoUUIDSequence,
timestampFactory,
} from "@/testUtils/factories/stringFactories";
import { toLower } from "lodash";

const testUserScope = "@test-user";

describe("createModMetadataForStandaloneComponent", () => {
it("creates mod metadata for standalone component", () => {
const componentId = autoUUIDSequence();
const componentLabel = "My Test Mod Component";
const componentUpdateTimestamp = timestampFactory();
const component = activatedModComponentFactory({
id: componentId,
label: componentLabel,
updateTimestamp: componentUpdateTimestamp,
});
expect(
createModMetadataForStandaloneComponent(component, testUserScope),
).toEqual({
...component,
_recipe: {
id: `${testUserScope}/converted/${toLower(componentId)}`,
name: componentLabel,
version: "1.0.0",
description: "Page Editor mod automatically converted to a package",
sharing: {
public: false,
organizations: [],
},
updated_at: componentUpdateTimestamp,
},
});
});
});

describe("migrateStandaloneComponentsToMods", () => {
it("does not throw error when extensions is empty", () => {
expect(migrateStandaloneComponentsToMods([], testUserScope)).toEqual([]);
});

it("returns mod components when there are no standalone components", () => {
const modMetadata = modMetadataFactory();
const modComponents = [
activatedModComponentFactory({
_recipe: modMetadata,
}),
activatedModComponentFactory({
_recipe: modMetadata,
}),
activatedModComponentFactory({
_recipe: modMetadata,
}),
];

expect(
migrateStandaloneComponentsToMods(modComponents, testUserScope),
).toEqual(modComponents);
});

it("returns only mod components when userScope is null", () => {
const modMetadata = modMetadataFactory();
const modComponents = [
activatedModComponentFactory({
_recipe: modMetadata,
}),
activatedModComponentFactory({
_recipe: modMetadata,
}),
activatedModComponentFactory({
_recipe: modMetadata,
}),
];
const standaloneComponents = [
activatedModComponentFactory(),
activatedModComponentFactory(),
];

expect(
migrateStandaloneComponentsToMods(
[...modComponents, ...standaloneComponents],
null,
),
).toEqual(modComponents);
});

it("converts standalone components correctly", () => {
const modMetadata = modMetadataFactory();
const modComponents = [
activatedModComponentFactory({
_recipe: modMetadata,
}),
activatedModComponentFactory({
_recipe: modMetadata,
}),
activatedModComponentFactory({
_recipe: modMetadata,
}),
];
const standaloneComponents = [
activatedModComponentFactory(),
activatedModComponentFactory(),
];
const migratedStandaloneComponents = standaloneComponents.map((component) =>
createModMetadataForStandaloneComponent(component, testUserScope),
);

expect(
migrateStandaloneComponentsToMods(
[...modComponents, ...standaloneComponents],
testUserScope,
),
).toEqual([...modComponents, ...migratedStandaloneComponents]);
});
});
Loading

0 comments on commit 0133816

Please sign in to comment.