diff --git a/src/bricks/transformers/ephemeralForm/formTransformer.ts b/src/bricks/transformers/ephemeralForm/formTransformer.ts index 894b3cfa42..ee56a0686d 100644 --- a/src/bricks/transformers/ephemeralForm/formTransformer.ts +++ b/src/bricks/transformers/ephemeralForm/formTransformer.ts @@ -23,6 +23,7 @@ import { type BrickConfig } from "@/bricks/types"; import { type FormDefinition } from "@/platform/forms/formTypes"; import { isExpression } from "@/utils/expressionUtils"; import type { PlatformCapability } from "@/platform/capabilities"; +import { mapMessageContextToModComponentRef } from "@/utils/modUtils"; export const TEMPORARY_FORM_SCHEMA: Schema = { type: "object", @@ -135,15 +136,13 @@ export class FormTransformer extends TransformerABC { controller.abort(); }); - if (logger.context.extensionId == null) { - throw new Error(`${this.name} must be run in a mod context`); - } - try { - return await platform.form(formDefinition, controller, { - componentId: logger.context.extensionId, - modId: logger.context.blueprintId, - }); + // `mapMessageContextToModComponentRef` throws if there's no mod component or starter brick in the context + return await platform.form( + formDefinition, + controller, + mapMessageContextToModComponentRef(logger.context), + ); } finally { controller.abort(); } diff --git a/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.test.ts b/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.test.ts index 7b1a7880b5..ad20e78513 100644 --- a/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.test.ts +++ b/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.test.ts @@ -48,7 +48,6 @@ import { uuidv4 } from "@/types/helpers"; import ConsoleLogger from "@/utils/ConsoleLogger"; import { tick } from "@/starterBricks/starterBrickTestUtils"; import pDefer from "p-defer"; -import { registryIdFactory } from "@/testUtils/factories/stringFactories"; import { type RendererErrorPayload } from "@/types/rendererTypes"; import { MergeStrategies, @@ -60,16 +59,23 @@ import { unary } from "lodash"; import { toExpression } from "@/utils/expressionUtils"; import { showModal } from "@/contentScript/modalDom"; import { isLoadedInIframe } from "@/utils/iframeUtils"; +import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories"; jest.mock("@/contentScript/modalDom"); jest.mock("@/contentScript/sidebarController"); jest.mock("@/platform/panels/panelController"); - jest.mock("@/utils/iframeUtils"); const displayTemporaryInfoBlock = new DisplayTemporaryInfo(); const renderer = new DocumentRenderer(); +function reduceOptionsFactory() { + return { + ...testOptions("v3"), + logger: new ConsoleLogger(modComponentRefFactory()), + }; +} + describe("DisplayTemporaryInfo", () => { beforeEach(() => { jest.mocked(isLoadedInIframe).mockReturnValue(false); @@ -92,8 +98,7 @@ describe("DisplayTemporaryInfo", () => { }); test("it returns run payload for sidebar panel", async () => { - const extensionId = uuidv4(); - const blueprintId = registryIdFactory(); + const modComponentRef = modComponentRefFactory(); const config = getExampleBrickConfig(renderer.id); const pipeline = { @@ -106,13 +111,12 @@ describe("DisplayTemporaryInfo", () => { await reducePipeline(pipeline, simpleInput({}), { ...testOptions("v3"), - logger: new ConsoleLogger({ extensionId, blueprintId }), + logger: new ConsoleLogger(modComponentRef), }); // Show function will be called with a "loading" payload expect(showTemporarySidebarPanel).toHaveBeenCalledExactlyOnceWith({ - blueprintId, - extensionId, + modComponentRef, nonce: expect.toBeString(), heading: expect.toBeString(), payload: expect.objectContaining({ @@ -122,8 +126,7 @@ describe("DisplayTemporaryInfo", () => { // Panel will be updated when the real payload is ready expect(updatePanelDefinition).toHaveBeenCalledExactlyOnceWith({ - blueprintId, - extensionId, + modComponentRef, nonce: expect.toBeString(), heading: expect.toBeString(), payload: expect.objectContaining({ @@ -160,7 +163,7 @@ describe("DisplayTemporaryInfo", () => { payload = entry.payload; }); - await reducePipeline(pipeline, simpleInput({}), testOptions("v3")); + await reducePipeline(pipeline, simpleInput({}), reduceOptionsFactory()); expect(isRendererErrorPayload(payload)).toBe(true); const error = payload as RendererErrorPayload; @@ -179,13 +182,11 @@ describe("DisplayTemporaryInfo", () => { }, }; - const extensionId = uuidv4(); + const modComponentRef = modComponentRefFactory(); const options = { ...testOptions("v3"), - logger: new ConsoleLogger({ - extensionId, - }), + logger: new ConsoleLogger(modComponentRef), }; await reducePipeline(pipeline, simpleInput({}), options); @@ -195,10 +196,10 @@ describe("DisplayTemporaryInfo", () => { expect(waitForTemporaryPanel).toHaveBeenCalledWith({ nonce: expect.toBeString(), - extensionId, + extensionId: modComponentRef.extensionId, location: "modal", entry: expect.objectContaining({ - extensionId, + modComponentRef, heading: "Test Temp Panel", nonce: expect.toBeString(), payload: expect.toBeObject(), @@ -207,6 +208,7 @@ describe("DisplayTemporaryInfo", () => { }); test("it errors from frame", async () => { + const modComponentRef = modComponentRefFactory(); jest.mocked(isLoadedInIframe).mockReturnValue(true); const config = getExampleBrickConfig(renderer.id); @@ -220,13 +222,9 @@ describe("DisplayTemporaryInfo", () => { }, }; - const extensionId = uuidv4(); - const options = { ...testOptions("v3"), - logger: new ConsoleLogger({ - extensionId, - }), + logger: new ConsoleLogger(modComponentRef), }; await expect( @@ -246,17 +244,8 @@ describe("DisplayTemporaryInfo", () => { }, }; - const extensionId = uuidv4(); - - const options = { - ...testOptions("v3"), - logger: new ConsoleLogger({ - extensionId, - }), - }; - await expect( - reducePipeline(pipeline, simpleInput({}), options), + reducePipeline(pipeline, simpleInput({}), reduceOptionsFactory()), ).rejects.toThrow("Target must be an element for popover"); }); @@ -274,17 +263,13 @@ describe("DisplayTemporaryInfo", () => { }, }; - const extensionId = uuidv4(); const root = document.querySelector("#target"); - const options = { - ...testOptions("v3"), - logger: new ConsoleLogger({ - extensionId, - }), - }; - - await reducePipeline(pipeline, { ...simpleInput({}), root }, options); + await reducePipeline( + pipeline, + { ...simpleInput({}), root }, + reduceOptionsFactory(), + ); expect(showModal).not.toHaveBeenCalled(); expect(showTemporarySidebarPanel).not.toHaveBeenCalled(); @@ -314,16 +299,7 @@ describe("DisplayTemporaryInfo", () => { }, }; - const extensionId = uuidv4(); - - const options = { - ...testOptions("v3"), - logger: new ConsoleLogger({ - extensionId, - }), - }; - - void reducePipeline(pipeline, simpleInput({}), options); + void reducePipeline(pipeline, simpleInput({}), reduceOptionsFactory()); await tick(); @@ -338,7 +314,7 @@ describe("DisplayTemporaryInfo", () => { deferredPromise.resolve(); }); - test("body receives updated mod variable on re-render", async () => { + test("body receives updated public mod variable on re-render", async () => { document.body.innerHTML = '
'; const deferredPromise = pDefer(); @@ -365,9 +341,12 @@ describe("DisplayTemporaryInfo", () => { const options = { ...testOptions("v3"), - logger: new ConsoleLogger({ - extensionId, - }), + logger: new ConsoleLogger( + modComponentRefFactory({ + extensionId, + blueprintId: null, + }), + ), }; void reducePipeline(pipeline, simpleInput({}), options); diff --git a/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.ts b/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.ts index d0015a87b4..0655cac227 100644 --- a/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.ts +++ b/src/bricks/transformers/temporaryInfo/DisplayTemporaryInfo.ts @@ -27,11 +27,11 @@ import { type JsonObject } from "type-fest"; import { TransformerABC } from "@/types/bricks/transformerTypes"; import { type Schema } from "@/types/schemaTypes"; import { type Location } from "@/types/starterBrickTypes"; -import { assumeNotNullish_UNSAFE } from "@/utils/nullishUtils"; import type { RefreshTrigger, TemporaryPanelEntryMetadata, } from "@/platform/panels/panelTypes"; +import { mapMessageContextToModComponentRef } from "@/utils/modUtils"; class DisplayTemporaryInfo extends TransformerABC { static BRICK_ID = validateRegistryId("@pixiebrix/display"); @@ -101,9 +101,7 @@ class DisplayTemporaryInfo extends TransformerABC { isRootAware: boolean; }>, { - logger: { - context: { extensionId, blueprintId }, - }, + logger: { context }, root = document, platform, runRendererPipeline, @@ -113,18 +111,14 @@ class DisplayTemporaryInfo extends TransformerABC { expectContext("contentScript"); const target = isRootAware ? root : document; - assumeNotNullish_UNSAFE(extensionId); - // XXX: blueprintId can actually be nullish if not running on the context of a mod. But assume it's non-nullish - // for passing to the panel for now. The panel can gracefully handle nullish blueprintId. - assumeNotNullish_UNSAFE(blueprintId); // Counter for tracking branch execution let counter = 0; const panelEntryMetadata: TemporaryPanelEntryMetadata = { heading: title, - extensionId, - blueprintId, + // Throws if there's no mod component or starter brick in the context + modComponentRef: mapMessageContextToModComponentRef(context), }; const getPayload = async () => { diff --git a/src/bricks/transformers/temporaryInfo/EphemeralPanel.tsx b/src/bricks/transformers/temporaryInfo/EphemeralPanel.tsx index 113d051dfe..c8b26f8d40 100644 --- a/src/bricks/transformers/temporaryInfo/EphemeralPanel.tsx +++ b/src/bricks/transformers/temporaryInfo/EphemeralPanel.tsx @@ -174,10 +174,7 @@ const EphemeralPanel: React.FC = () => { { resolveTemporaryPanel(target, panelNonce, action); }} @@ -219,10 +216,7 @@ const EphemeralPanel: React.FC = () => { { resolveTemporaryPanel(target, panelNonce, action); }} diff --git a/src/contentScript/contentScriptPlatform.ts b/src/contentScript/contentScriptPlatform.ts index 0aad9e539d..f7535dd145 100644 --- a/src/contentScript/contentScriptPlatform.ts +++ b/src/contentScript/contentScriptPlatform.ts @@ -286,8 +286,8 @@ class ContentScriptPlatform extends PlatformBase { override get panels(): PlatformProtocol["panels"] { return { isContainerVisible: async () => sidebarController.isSidePanelOpen(), - unregisterExtensionPoint: sidebarController.removeExtensionPoint, - removeComponents: sidebarController.removeExtensions, + unregisterExtensionPoint: sidebarController.removeStarterBrick, + removeComponents: sidebarController.removeModComponents, reservePanels: sidebarController.reservePanels, updateHeading: sidebarController.updateHeading, upsertPanel: sidebarController.upsertPanel, diff --git a/src/contentScript/ephemeralForm.ts b/src/contentScript/ephemeralForm.ts index 8a65e48c4c..1c73b5dc7d 100644 --- a/src/contentScript/ephemeralForm.ts +++ b/src/contentScript/ephemeralForm.ts @@ -26,12 +26,11 @@ import { import { uuidv4 } from "@/types/helpers"; import { isLoadedInIframe } from "@/utils/iframeUtils"; import { BusinessError } from "@/errors/businessErrors"; -import type { UUID } from "@/types/stringTypes"; -import type { RegistryId } from "@/types/registryTypes"; import { getThisFrame } from "webext-messenger"; import { expectContext } from "@/utils/expectContext"; import { showModal } from "@/contentScript/modalDom"; import type { Target } from "@/types/messengerTypes"; +import type { ModComponentRef } from "@/types/modComponentTypes"; // The modes for createFrameSource are different from the location argument for FormTransformer. The mode for the frame // just determines the layout container of the form @@ -55,10 +54,7 @@ export async function createFrameSource( export async function ephemeralForm( definition: FormDefinition, controller: AbortController, - { - componentId: extensionId, - modId: blueprintId, - }: { componentId: UUID; modId: RegistryId }, + modComponentRef: ModComponentRef, ): Promise { expectContext("contentScript"); @@ -75,10 +71,9 @@ export async function ephemeralForm( // Pre-registering the form also allows the sidebar to know a form will be shown in computing the default // tab to show during sidebar initialization. const formPromise = registerForm({ - extensionId, nonce: formNonce, + modComponentRef, definition, - blueprintId, }); if (definition.location === "sidebar") { @@ -86,10 +81,9 @@ export async function ephemeralForm( await showSidebar(); await showSidebarForm({ - extensionId, - blueprintId, nonce: formNonce, form: definition, + modComponentRef, }); // Two-way binding between sidebar and form. Listen for the user (or an action) closing the sidebar diff --git a/src/contentScript/ephemeralPanel.ts b/src/contentScript/ephemeralPanel.ts index e3825e9af1..d9947b4539 100644 --- a/src/contentScript/ephemeralPanel.ts +++ b/src/contentScript/ephemeralPanel.ts @@ -114,7 +114,7 @@ export async function ephemeralPanel({ registerEmptyTemporaryPanel({ nonce, location, - extensionId: panelEntryMetadata.extensionId, + extensionId: panelEntryMetadata.modComponentRef.extensionId, }); await showSidebar(); @@ -125,7 +125,7 @@ export async function ephemeralPanel({ nonce, payload: { key: uuidv4(), - extensionId: panelEntryMetadata.extensionId, + extensionId: panelEntryMetadata.modComponentRef.extensionId, loadingMessage: "Loading", }, }); @@ -140,13 +140,15 @@ export async function ephemeralPanel({ } else { // Popover/modal location // Clear existing to remove stale modals/popovers - await cancelTemporaryPanelsForExtension(panelEntryMetadata.extensionId); + await cancelTemporaryPanelsForExtension( + panelEntryMetadata.modComponentRef.extensionId, + ); // Register empty panel for "loading" state registerEmptyTemporaryPanel({ nonce, location, - extensionId: panelEntryMetadata.extensionId, + extensionId: panelEntryMetadata.modComponentRef.extensionId, }); // Create a source URL for content that will be loaded in the panel iframe @@ -233,7 +235,7 @@ export async function ephemeralPanel({ nonce, location, entry, - extensionId: entry.extensionId, + extensionId: entry.modComponentRef.extensionId, onRegister: onReady, }); return panelAction ?? {}; diff --git a/src/contentScript/lifecycle.ts b/src/contentScript/lifecycle.ts index 5a3158c995..6f274739b7 100644 --- a/src/contentScript/lifecycle.ts +++ b/src/contentScript/lifecycle.ts @@ -318,7 +318,7 @@ export function removeDraftModComponents( _runningStarterBricks.delete(starterBrick); _draftModComponentStarterBrickMap.delete(modComponentId); - sidebar.removeExtensions([modComponentId]); + sidebar.removeModComponents([modComponentId]); } else { console.debug( `No draft mod component exists for uuid: ${modComponentId}`, @@ -333,7 +333,7 @@ export function removeDraftModComponents( try { starterBrick.uninstall({ global: true }); _runningStarterBricks.delete(starterBrick); - sidebar.removeExtensionPoint(starterBrick.id); + sidebar.removeStarterBrick(starterBrick.id); } catch (error) { reportError(error); } diff --git a/src/contentScript/messenger/registration.ts b/src/contentScript/messenger/registration.ts index d3bd387b95..31a74126fa 100644 --- a/src/contentScript/messenger/registration.ts +++ b/src/contentScript/messenger/registration.ts @@ -21,7 +21,7 @@ import { showSidebarInTopFrame, sidebarWasLoaded, updateSidebar, - removeExtensions as removeSidebars, + removeModComponents as removeSidebars, getReservedPanelEntries, } from "@/contentScript/sidebarController"; import { handleMenuAction } from "@/contentScript/contextMenus"; diff --git a/src/contentScript/pageEditor/runRendererBrick.ts b/src/contentScript/pageEditor/runRendererBrick.ts index 946f3ceb6f..13522a59ac 100644 --- a/src/contentScript/pageEditor/runRendererBrick.ts +++ b/src/contentScript/pageEditor/runRendererBrick.ts @@ -24,11 +24,11 @@ import { HeadlessModeError } from "@/bricks/errors"; import { showTemporarySidebarPanel } from "@/contentScript/sidebarController"; import { waitForTemporaryPanel } from "@/platform/panels/panelController"; import { type UUID } from "@/types/stringTypes"; -import { type RegistryId } from "@/types/registryTypes"; import { createFrameSource } from "@/contentScript/ephemeralPanel"; import { showModal } from "@/contentScript/modalDom"; import { runBrickPreview } from "@/contentScript/pageEditor/runBrickPreview"; import { type RunBrickArgs } from "@/contentScript/pageEditor/types"; +import { type ModComponentRef } from "@/types/modComponentTypes"; type Location = "modal" | "panel"; @@ -47,15 +47,13 @@ type Location = "modal" | "panel"; * @see useDocumentPreviewRunBlock */ export async function runRendererBrick({ - modComponentId, - modId, + modComponentRef, runId, title, args, location, }: { - modComponentId: UUID; - modId: RegistryId | null; + modComponentRef: ModComponentRef; runId: UUID; title: string; args: RunBrickArgs; @@ -65,7 +63,7 @@ export async function runRendererBrick({ let payload: PanelPayload; try { - await runBrickPreview({ ...args, modId }); + await runBrickPreview({ ...args, modId: modComponentRef.blueprintId }); // We're expecting a HeadlessModeError (or other error) to be thrown in the line above // noinspection ExceptionCaughtLocallyJS throw new NoRendererError(); @@ -76,23 +74,22 @@ export async function runRendererBrick({ blockId: error.blockId, args: error.args, ctxt: error.ctxt, - extensionId: modComponentId, + extensionId: modComponentRef.extensionId, runId, }; } else { payload = { key: nonce, error: serializeError(error), - extensionId: modComponentId, + extensionId: modComponentRef.extensionId, runId, }; } if (location === "panel") { await showTemporarySidebarPanel({ - // Pass extension id so previous run is cancelled - extensionId: modComponentId, - blueprintId: modId, + // Pass component ref id so previous run is cancelled + modComponentRef, nonce, heading: title, payload, @@ -107,10 +104,9 @@ export async function runRendererBrick({ await waitForTemporaryPanel({ nonce, location, - extensionId: modComponentId, + extensionId: modComponentRef.extensionId, entry: { - extensionId: modComponentId, - blueprintId: modId, + modComponentRef, nonce, heading: title, payload, diff --git a/src/contentScript/sidebarController.tsx b/src/contentScript/sidebarController.tsx index 08d5adcf49..e05d3ab8a4 100644 --- a/src/contentScript/sidebarController.tsx +++ b/src/contentScript/sidebarController.tsx @@ -354,41 +354,47 @@ export async function hideTemporarySidebarPanel(nonce: UUID): Promise { } /** - * Remove all panels associated with given extensionIds. - * @param extensionIds the extension UUIDs to remove + * Remove all panels associated with given modComponentIds. + * @param modComponentIds the mod component UUIDs to remove */ -export function removeExtensions(extensionIds: UUID[]): void { +export function removeModComponents(modComponentIds: UUID[]): void { expectContext("contentScript"); - console.debug("sidebarController:removeExtensions", { extensionIds }); + console.debug("sidebarController:removeExtensions", { modComponentIds }); // Avoid unnecessary messaging. More importantly, renderPanelsIfVisible should not be called from iframes. Iframes - // might call removeExtensions as part of cleanup - if (extensionIds.length === 0) { + // might call removeModComponents as part of cleanup + if (modComponentIds.length === 0) { return; } // `panels` is const, so replace the contents const current = panels.splice(0); - panels.push(...current.filter((x) => !extensionIds.includes(x.extensionId))); + panels.push( + ...current.filter( + (x) => !modComponentIds.includes(x.modComponentRef.extensionId), + ), + ); void renderPanelsIfVisible(); } /** * Remove all panels associated with the given extensionPointId. - * @param extensionPointId the extension point id (internal or external) + * @param starterBrickId the extension point id (internal or external) * @param preserveExtensionIds array of extension ids to keep in the panel. Used to avoid flickering if updating * the extensionPoint for a sidebar extension from the Page Editor */ -export function removeExtensionPoint( - extensionPointId: RegistryId, +export function removeStarterBrick( + starterBrickId: RegistryId, { preserveExtensionIds = [] }: { preserveExtensionIds?: UUID[] } = {}, ): void { expectContext("contentScript"); - console.debug("sidebarController:removeExtensionPoint %s", extensionPointId, { + console.debug("sidebarController:removeStarterBrick %s", starterBrickId, { preserveExtensionIds, - panels: panels.filter((x) => x.extensionPointId === extensionPointId), + panels: panels.filter( + (x) => x.modComponentRef.extensionPointId === starterBrickId, + ), }); // `panels` is const, so replace the contents @@ -396,8 +402,8 @@ export function removeExtensionPoint( panels.push( ...current.filter( (x) => - x.extensionPointId !== extensionPointId || - preserveExtensionIds.includes(x.extensionId), + x.modComponentRef.extensionPointId !== starterBrickId || + preserveExtensionIds.includes(x.modComponentRef.extensionId), ), ); @@ -412,14 +418,16 @@ export function reservePanels(refs: ModComponentRef[]): void { return; } - const current = new Set(panels.map((x) => x.extensionId)); + const current = new Set(panels.map((x) => x.modComponentRef.extensionId)); for (const { extensionId, extensionPointId, blueprintId } of refs) { if (!current.has(extensionId)) { const entry: PanelEntry = { type: "panel", - extensionId, - extensionPointId, - blueprintId, + modComponentRef: { + extensionId, + extensionPointId, + blueprintId, + }, heading: "", payload: null, }; @@ -440,14 +448,16 @@ export function reservePanels(refs: ModComponentRef[]): void { } export function updateHeading(extensionId: UUID, heading: string): void { - const entry = panels.find((x) => x.extensionId === extensionId); + const entry = panels.find( + (x) => x.modComponentRef.extensionId === extensionId, + ); if (entry) { entry.heading = heading; console.debug( "updateHeading: update heading for panel %s for %s", extensionId, - entry.extensionPointId, + entry.modComponentRef.extensionPointId, { ...entry }, ); void renderPanelsIfVisible(); @@ -460,11 +470,15 @@ export function updateHeading(extensionId: UUID, heading: string): void { } export function upsertPanel( - { extensionId, extensionPointId, blueprintId }: ModComponentRef, + modComponentRef: ModComponentRef, heading: string, payload: PanelPayload, ): void { - const entry = panels.find((panel) => panel.extensionId === extensionId); + const { extensionId, extensionPointId, blueprintId } = modComponentRef; + + const entry = panels.find( + (panel) => panel.modComponentRef.extensionId === extensionId, + ); if (entry) { entry.payload = payload; entry.heading = heading; @@ -490,9 +504,7 @@ export function upsertPanel( ); panels.push({ type: "panel", - extensionId, - extensionPointId, - blueprintId, + modComponentRef, heading, payload, }); diff --git a/src/pageEditor/starterBricks/modComponentFormStateAdapter.ts b/src/pageEditor/starterBricks/modComponentFormStateAdapter.ts index 5ff23991b6..ae80516690 100644 --- a/src/pageEditor/starterBricks/modComponentFormStateAdapter.ts +++ b/src/pageEditor/starterBricks/modComponentFormStateAdapter.ts @@ -20,7 +20,7 @@ import { type IconProp } from "@fortawesome/fontawesome-svg-core"; import { type Metadata } from "@/types/registryTypes"; import { type StarterBrickDefinitionLike } from "@/starterBricks/types"; import { type StarterBrickType } from "@/types/starterBrickTypes"; -import type { DraftModComponent } from "@/contentScript/pageEditor/types"; +import { type DraftModComponent } from "@/contentScript/pageEditor/types"; import { type ModComponentBase } from "@/types/modComponentTypes"; import { type Target } from "@/types/messengerTypes"; import { type BaseFormState } from "@/pageEditor/store/editor/baseFormStateTypes"; @@ -120,5 +120,6 @@ export interface ModComponentFormStateAdapter< */ readonly selectModComponent: ( modComponentFormState: TState, - ) => ModComponentBase; + ) => // XXX: refine type to enforce starter brick reference is the registry id and not an inner definition reference + ModComponentBase; } diff --git a/src/pageEditor/tabs/effect/useDocumentPreviewRunBlock.ts b/src/pageEditor/tabs/effect/useDocumentPreviewRunBlock.ts index 40d5f5aba4..103b772556 100644 --- a/src/pageEditor/tabs/effect/useDocumentPreviewRunBlock.ts +++ b/src/pageEditor/tabs/effect/useDocumentPreviewRunBlock.ts @@ -37,6 +37,9 @@ import { isExpression } from "@/utils/expressionUtils"; import makeIntegrationsContextFromDependencies from "@/integrations/util/makeIntegrationsContextFromDependencies"; import useAsyncState from "@/hooks/useAsyncState"; import { inspectedTab } from "@/pageEditor/context/connection"; +import { ADAPTERS } from "@/pageEditor/starterBricks/adapter"; +import { validateRegistryId } from "@/types/helpers"; +import { assertNotNullish } from "@/utils/nullishUtils"; type Location = "modal" | "panel"; @@ -103,13 +106,16 @@ export default function useDocumentPreviewRunBlock( ): BlockPreviewRunBlock { const [state, dispatch] = useReducer(previewSlice.reducer, initialState); + const formState = useSelector(selectActiveModComponentFormState); + const { + type, uuid: modComponentId, - modMetadata: mod, + modMetadata, apiVersion, integrationDependencies, starterBrick, - } = useSelector(selectActiveModComponentFormState); + } = formState; const { blockConfig: brickConfig } = useSelector( selectActiveModComponentNodeInfo(brickInstanceId), @@ -173,6 +179,12 @@ export default function useDocumentPreviewRunBlock( dispatch(previewSlice.actions.startPreview()); + const adapter = ADAPTERS.get(type); + const starterBrickId = validateRegistryId( + adapter.selectModComponent(formState).extensionPointId, + ); + assertNotNullish(starterBrickId, "Expected starter brick id"); + // If the block is configured to inherit the root element, and the // starter brick is a trigger, try to get the root element from the // starter brick. @@ -191,10 +203,13 @@ export default function useDocumentPreviewRunBlock( try { await runRendererBrick(inspectedTab, { - modComponentId, - modId: mod?.id, runId: traceRecord.runId, title, + modComponentRef: { + extensionId: modComponentId, + blueprintId: modMetadata?.id, + extensionPointId: starterBrickId, + }, args: { apiVersion, blockConfig: { diff --git a/src/platform/forms/formController.ts b/src/platform/forms/formController.ts index a0f7ee7970..18ac85b25a 100644 --- a/src/platform/forms/formController.ts +++ b/src/platform/forms/formController.ts @@ -20,17 +20,15 @@ import { type UUID } from "@/types/stringTypes"; import pDefer, { type DeferredPromise } from "p-defer"; import { CancelError } from "@/errors/businessErrors"; import { type FormPanelEntry } from "@/types/sidebarTypes"; -import { type RegistryId } from "@/types/registryTypes"; -import { type Nullishable } from "@/utils/nullishUtils"; +import { type ModComponentRef } from "@/types/modComponentTypes"; export type RegisteredForm = { /** * The Mod Component that created the form. Only 1 form can be registered per Mod Component. */ - extensionId: UUID; + modComponentRef: ModComponentRef; definition: FormDefinition; registration: DeferredPromise; - blueprintId: Nullishable; }; /** @@ -47,29 +45,25 @@ export function getFormPanelSidebarEntries(): FormPanelEntry[] { .map(([nonce, form]) => ({ type: "form", nonce, - extensionId: form.extensionId, - blueprintId: form.blueprintId ?? undefined, + modComponentRef: form.modComponentRef, form: form.definition, })); } /** * Register a form with the content script that resolves the form is either submitted or cancelled - * @param extensionId the id of the extension that created the form + * @param componentRef the mod component that created the form * @param nonce the form nonce * @param definition the form definition - * @param blueprintId the blueprint that contains the form */ export async function registerForm({ - extensionId, nonce, definition, - blueprintId, + modComponentRef, }: { - extensionId: UUID; + modComponentRef: ModComponentRef; nonce: UUID; definition: FormDefinition; - blueprintId: Nullishable; }): Promise { const registration = pDefer(); @@ -79,7 +73,9 @@ export async function registerForm({ } const preexistingForms = [...forms.entries()].filter( - ([_, registeredForm]) => registeredForm.extensionId === extensionId, + ([_, registeredForm]) => + registeredForm.modComponentRef.extensionId === + modComponentRef.extensionId, ); if (preexistingForms.length > 0) { @@ -88,10 +84,9 @@ export async function registerForm({ } forms.set(nonce, { - extensionId, + modComponentRef, definition, registration, - blueprintId, }); return registration.promise; diff --git a/src/platform/panels/panelController.ts b/src/platform/panels/panelController.ts index d328261a93..e4f03df7e6 100644 --- a/src/platform/panels/panelController.ts +++ b/src/platform/panels/panelController.ts @@ -105,7 +105,11 @@ export function updatePanelDefinition( } // Panel entry may be undefined if the panel was registered with registerEmptyTemporaryPanel - if (panel.entry && panel.entry.extensionId !== panelDefinition.extensionId) { + if ( + panel.entry && + panel.entry.modComponentRef.extensionId !== + panelDefinition.modComponentRef.extensionId + ) { throw new Error("extensionId mismatch"); } @@ -200,7 +204,9 @@ export async function waitForTemporaryPanel({ function removePanelEntry(panelNonce: UUID): void { const panel = panels.get(panelNonce); if (panel?.entry) { - extensionNonces.get(panel.entry.extensionId)?.delete(panelNonce); + extensionNonces + .get(panel.entry.modComponentRef.extensionId) + ?.delete(panelNonce); } panels.delete(panelNonce); diff --git a/src/platform/platformBase.ts b/src/platform/platformBase.ts index 0ee64b802c..4dc3f6c6d2 100644 --- a/src/platform/platformBase.ts +++ b/src/platform/platformBase.ts @@ -19,9 +19,8 @@ import { type PlatformCapability, PlatformCapabilityNotAvailableError, } from "@/platform/capabilities"; -import type { RegistryId, SemVerString } from "@/types/registryTypes"; +import type { SemVerString } from "@/types/registryTypes"; import type { FormDefinition } from "@/platform/forms/formTypes"; -import type { UUID } from "@/types/stringTypes"; import type { Nullishable } from "@/utils/nullishUtils"; import type { SanitizedIntegrationConfig } from "@/integrations/integrationTypes"; import type { NetworkRequestConfig } from "@/types/networkTypes"; @@ -42,6 +41,7 @@ import type { ClipboardProtocol } from "@/platform/platformTypes/clipboardProtoc import type { PlatformProtocol } from "@/platform/platformProtocol"; import type { PanelProtocol } from "@/platform/platformTypes/panelProtocol"; import type { QuickBarProtocol } from "@/platform/platformTypes/quickBarProtocol"; +import type { ModComponentRef } from "@/types/modComponentTypes"; /** * Base protocol with no capabilities implemented. @@ -72,10 +72,7 @@ export class PlatformBase implements PlatformProtocol { async form( _definition: FormDefinition, _controller: AbortController, - _context: { - componentId: UUID; - modId?: RegistryId; - }, + _context: ModComponentRef, ): Promise { throw new PlatformCapabilityNotAvailableError(this.platformName, "form"); } diff --git a/src/platform/platformProtocol.ts b/src/platform/platformProtocol.ts index 174b08626e..75240dc813 100644 --- a/src/platform/platformProtocol.ts +++ b/src/platform/platformProtocol.ts @@ -15,15 +15,14 @@ * along with this program. If not, see . */ -import { type PlatformCapability } from "@/platform/capabilities"; +import type { PlatformCapability } from "@/platform/capabilities"; import type { ElementReference } from "@/types/runtimeTypes"; import type { SanitizedIntegrationConfig } from "@/integrations/integrationTypes"; import type { NetworkRequestConfig } from "@/types/networkTypes"; import type { RemoteResponse } from "@/types/contract"; import type { Nullishable } from "@/utils/nullishUtils"; import type { FormDefinition } from "@/platform/forms/formTypes"; -import type { UUID } from "@/types/stringTypes"; -import type { RegistryId, SemVerString } from "@/types/registryTypes"; +import type { SemVerString } from "@/types/registryTypes"; import type { JavaScriptPayload } from "@/sandbox/messenger/api"; import type { Logger } from "@/types/loggerTypes"; import type { AudioProtocol } from "@/platform/platformTypes/audioProtocol"; @@ -38,6 +37,7 @@ import type { SnippetShortcutMenuProtocol } from "@/platform/platformTypes/snipp import type { TextSelectionMenuProtocol } from "@/platform/platformTypes/textSelectionMenuProtocol"; import type { PanelProtocol } from "@/platform/platformTypes/panelProtocol"; import type { QuickBarProtocol } from "@/platform/platformTypes/quickBarProtocol"; +import type { ModComponentRef } from "@/types/modComponentTypes"; /** * A protocol for the platform/environment running the mods. @@ -90,7 +90,7 @@ export interface PlatformProtocol { form: ( definition: FormDefinition, controller: AbortController, - context: { componentId: UUID; modId?: RegistryId }, + modComponentRef: ModComponentRef, ) => Promise; /** diff --git a/src/sidebar/Tabs.tsx b/src/sidebar/Tabs.tsx index 0f8a8cdcac..86283ab61f 100644 --- a/src/sidebar/Tabs.tsx +++ b/src/sidebar/Tabs.tsx @@ -64,6 +64,7 @@ import useOnMountOnly from "@/hooks/useOnMountOnly"; import UnavailableOverlay from "@/sidebar/UnavailableOverlay"; import removeFormPanel from "@/store/sidebar/thunks/removeFormPanel"; import ConnectingOverlay from "@/sidebar/ConnectingOverlay"; +import { mapModComponentRefToEventData } from "@/telemetry/telemetryHelpers"; const ActivateModPanel = lazy( async () => @@ -209,7 +210,7 @@ const Tabs: React.FC = () => { > {panels.map((panel) => ( @@ -224,7 +225,7 @@ const Tabs: React.FC = () => { {forms.map((form) => ( @@ -309,15 +310,14 @@ const Tabs: React.FC = () => { // un-submitted form state/scroll position unmountOnExit={false} className={cx("full-height flex-grow", styles.paneOverrides)} - key={panel.extensionId} + key={panel.modComponentRef.extensionId} eventKey={eventKeyForEntry(panel)} > { reportEvent(Events.VIEW_ERROR, { + ...mapModComponentRefToEventData(panel.modComponentRef), panelType: panel.type, - extensionId: panel.extensionId, - blueprintId: panel.blueprintId, }); }} > @@ -335,11 +335,7 @@ const Tabs: React.FC = () => { isRootPanel payload={panel.payload} onAction={permanentSidebarPanelAction} - context={{ - extensionId: panel.extensionId, - extensionPointId: panel.extensionPointId, - blueprintId: panel.blueprintId, - }} + context={panel.modComponentRef} /> @@ -354,9 +350,8 @@ const Tabs: React.FC = () => { { reportEvent(Events.VIEW_ERROR, { + ...mapModComponentRefToEventData(form.modComponentRef), panelType: form.type, - extensionId: form.extensionId, - blueprintId: form.blueprintId, }); }} > diff --git a/src/sidebar/TemporaryPanelTabPane.tsx b/src/sidebar/TemporaryPanelTabPane.tsx index f04f64d7a7..7055bd135b 100644 --- a/src/sidebar/TemporaryPanelTabPane.tsx +++ b/src/sidebar/TemporaryPanelTabPane.tsx @@ -31,6 +31,7 @@ import resolveTemporaryPanel from "@/store/sidebar/thunks/resolveTemporaryPanel" import { type AsyncDispatch } from "@/sidebar/store"; import UnavailableOverlay from "@/sidebar/UnavailableOverlay"; import removeTemporaryPanel from "@/store/sidebar/thunks/removeTemporaryPanel"; +import { mapModComponentRefToEventData } from "@/telemetry/telemetryHelpers"; // Need to memoize this to make sure it doesn't rerender unless its entry actually changes // This was part of the fix for issue: https://github.com/pixiebrix/pixiebrix-extension/issues/5646 @@ -50,7 +51,7 @@ export const TemporaryPanelTabPane: React.FC<{ }, [dispatch, panel.nonce], ); - const { type, extensionId, blueprintId, payload } = panel; + const { type, modComponentRef, payload } = panel; return ( { reportEvent(Events.VIEW_ERROR, { + ...mapModComponentRefToEventData(modComponentRef), panelType: type, - extensionId, - blueprintId, }); }} > @@ -74,10 +74,7 @@ export const TemporaryPanelTabPane: React.FC<{ diff --git a/src/sidebar/activateMod/ActivateModPanel.test.tsx b/src/sidebar/activateMod/ActivateModPanel.test.tsx index 792388d412..32e79609c1 100644 --- a/src/sidebar/activateMod/ActivateModPanel.test.tsx +++ b/src/sidebar/activateMod/ActivateModPanel.test.tsx @@ -162,7 +162,7 @@ function setupMocksAndRender( appApiMock.onGet().reply(200, []); const entry = sidebarEntryFactory("activateMods", { - modIds: [modDefinition.metadata.id], + mods: [{ modId: modDefinition.metadata.id, initialOptions: {} }], heading: "Activate Mod", }); diff --git a/src/sidebar/modLauncher/ActiveSidebarModsList.tsx b/src/sidebar/modLauncher/ActiveSidebarModsList.tsx index 51674a3add..86c1b792a6 100644 --- a/src/sidebar/modLauncher/ActiveSidebarModsList.tsx +++ b/src/sidebar/modLauncher/ActiveSidebarModsList.tsx @@ -124,7 +124,7 @@ export const ActiveSidebarModsList: React.FunctionComponent = () => { tableInstance.prepareRow(row); return ( ); diff --git a/src/sidebar/sidebarSelectors.ts b/src/sidebar/sidebarSelectors.ts index e48f3afb65..4def35c70b 100644 --- a/src/sidebar/sidebarSelectors.ts +++ b/src/sidebar/sidebarSelectors.ts @@ -75,7 +75,8 @@ const extensionForEventKeySelector = createSelector( } return extensions.find( - (extension) => extension.id === sidebarEntry.extensionId, + (modComponent) => + modComponent.id === sidebarEntry.modComponentRef.extensionId, ); }, ); @@ -100,7 +101,8 @@ export const selectExtensionFromEventKey = } return extensions.find( - (extension) => extension.id === sidebarEntry.extensionId, + (modComponent) => + modComponent.id === sidebarEntry.modComponentRef.extensionId, ); }; diff --git a/src/starterBricks/sidebar/sidebarStarterBrick.test.ts b/src/starterBricks/sidebar/sidebarStarterBrick.test.ts index 3b391bac71..b438e25be1 100644 --- a/src/starterBricks/sidebar/sidebarStarterBrick.test.ts +++ b/src/starterBricks/sidebar/sidebarStarterBrick.test.ts @@ -112,7 +112,9 @@ describe("sidebarExtension", () => { forms: [], panels: [ expect.objectContaining({ - extensionPointId: starterBrick.id, + modComponentRef: expect.objectContaining({ + extensionPointId: starterBrick.id, + }), }), ], temporaryPanels: [], diff --git a/src/store/sidebar/eventKeyUtils.test.ts b/src/store/sidebar/eventKeyUtils.test.ts index 7ffdf1ac46..2141492539 100644 --- a/src/store/sidebar/eventKeyUtils.test.ts +++ b/src/store/sidebar/eventKeyUtils.test.ts @@ -20,15 +20,10 @@ import { eventKeyForEntry, } from "@/store/sidebar/eventKeyUtils"; import { uuidv4, validateRegistryId } from "@/types/helpers"; -import { - type SidebarState, - type SidebarEntries, - type PanelEntry, - type TemporaryPanelEntry, -} from "@/types/sidebarTypes"; - +import { type SidebarEntries, type SidebarState } from "@/types/sidebarTypes"; import { sidebarEntryFactory } from "@/testUtils/factories/sidebarEntryFactories"; import { MOD_LAUNCHER } from "@/store/sidebar/constants"; +import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories"; describe("defaultEventKey", () => { it("returns null no content", () => { @@ -48,9 +43,9 @@ describe("defaultEventKey", () => { it("prefers latest form", () => { const args = { - forms: [{ nonce: uuidv4() }, { nonce: uuidv4() }], - temporaryPanels: [{ nonce: uuidv4() }], - panels: [{ extensionId: uuidv4() }], + forms: [sidebarEntryFactory("form"), sidebarEntryFactory("form")], + temporaryPanels: [sidebarEntryFactory("temporaryPanel")], + panels: [sidebarEntryFactory("panel")], } as SidebarEntries; expect(defaultEventKey(args, {})).toBe(eventKeyForEntry(args.forms[1])); @@ -61,10 +56,10 @@ describe("defaultEventKey", () => { const args: SidebarEntries = { forms: [], temporaryPanels: [ - { nonce: uuidv4() }, - { nonce: uuidv4() }, - ] as TemporaryPanelEntry[], - panels: [{ extensionId: uuidv4() }] as PanelEntry[], + sidebarEntryFactory("temporaryPanel"), + sidebarEntryFactory("temporaryPanel"), + ], + panels: [sidebarEntryFactory("panel")], staticPanels: [], modActivationPanel: null, } as SidebarEntries; @@ -80,10 +75,7 @@ describe("defaultEventKey", () => { const entries = { forms: [], temporaryPanels: [], - panels: [ - { extensionId: uuidv4() }, - { extensionId: uuidv4() }, - ] as PanelEntry[], + panels: [sidebarEntryFactory("panel"), sidebarEntryFactory("panel")], staticPanels: [], modActivationPanel: null, } as SidebarEntries; @@ -156,10 +148,12 @@ describe("eventKeyForEntry", () => { expect(eventKeyForEntry(value)).toBeNull(); }); - it("uses recipeId for activateRecipe", () => { - const recipeId = validateRegistryId("@test/test-recipe"); - const entry = sidebarEntryFactory("activateMods", { recipeId }); - // Main part is a an object hash of the mod ids + it("uses modId for activateMods", () => { + const modId = validateRegistryId("@test/test-recipe"); + const entry = sidebarEntryFactory("activateMods", { + mods: [{ modId, initialOptions: {} }], + }); + // Main part is an object hash of the mod ids expect(eventKeyForEntry(entry)).toStartWith("activate-"); }); @@ -167,8 +161,10 @@ describe("eventKeyForEntry", () => { const extensionId = uuidv4(); const extensionPointId = validateRegistryId("@test/test-starter-brick"); const entry = sidebarEntryFactory("panel", { - extensionId, - extensionPointId, + modComponentRef: modComponentRefFactory({ + extensionId, + extensionPointId, + }), }); expect(eventKeyForEntry(entry)).toBe(`panel-${extensionId}`); }); @@ -177,12 +173,17 @@ describe("eventKeyForEntry", () => { const extensionId = uuidv4(); const nonce = uuidv4(); - const formEntry = sidebarEntryFactory("form", { extensionId, nonce }); + const formEntry = sidebarEntryFactory("form", { + nonce, + modComponentRef: modComponentRefFactory({ + extensionId, + }), + }); expect(eventKeyForEntry(formEntry)).toBe(`form-${nonce}`); const temporaryPanelEntry = sidebarEntryFactory("temporaryPanel", { - extensionId, nonce, + modComponentRef: modComponentRefFactory({ extensionId }), }); expect(eventKeyForEntry(temporaryPanelEntry)).toBe( `temporaryPanel-${nonce}`, diff --git a/src/store/sidebar/eventKeyUtils.tsx b/src/store/sidebar/eventKeyUtils.tsx index d6579546ad..93f5f9c976 100644 --- a/src/store/sidebar/eventKeyUtils.tsx +++ b/src/store/sidebar/eventKeyUtils.tsx @@ -44,7 +44,7 @@ function eventKeyForEntry(entry: Nullishable): string | null { } if (isPanelEntry(entry)) { - return getEventKeyForPanel(entry.extensionId); + return getEventKeyForPanel(entry.modComponentRef.extensionId); } if (isStaticPanelEntry(entry)) { diff --git a/src/store/sidebar/sidebarSlice.test.ts b/src/store/sidebar/sidebarSlice.test.ts index 07cf7adafc..820e35382d 100644 --- a/src/store/sidebar/sidebarSlice.test.ts +++ b/src/store/sidebar/sidebarSlice.test.ts @@ -16,7 +16,7 @@ */ import sidebarSlice, { - fixActiveTabOnRemove, + fixActiveTabOnRemoveInPlace, } from "@/store/sidebar/sidebarSlice"; import { eventKeyForEntry } from "@/store/sidebar/eventKeyUtils"; import { @@ -34,6 +34,7 @@ import { configureStore } from "@reduxjs/toolkit"; import addFormPanel from "@/store/sidebar/thunks/addFormPanel"; import addTemporaryPanel from "@/store/sidebar/thunks/addTemporaryPanel"; import removeTemporaryPanel from "@/store/sidebar/thunks/removeTemporaryPanel"; +import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories"; jest.mock("@/sidebar/messenger/api"); jest.mock("@/contentScript/messenger/api"); @@ -101,7 +102,9 @@ describe("sidebarSlice.addTemporaryPanel", () => { const existingPanel = sidebarEntryFactory("temporaryPanel"); const otherExistingPanel = sidebarEntryFactory("temporaryPanel"); const newPanel = sidebarEntryFactory("temporaryPanel", { - extensionId: existingPanel.extensionId, + modComponentRef: modComponentRefFactory({ + extensionId: existingPanel.modComponentRef.extensionId, + }), }); const initialState: SidebarState = { @@ -190,15 +193,13 @@ describe("removeTemporaryPanel", () => { ); }); - it("sets activeKey to a panel with the same extensionId if it exists", async () => { - const originalPanel = sidebarEntryFactory("panel", { - extensionId: uuidv4(), - }); - const otherExistingPanel = sidebarEntryFactory("form", { - extensionId: uuidv4(), - }); + it("sets activeKey to a panel with the same mod component id if it exists", async () => { + const originalPanel = sidebarEntryFactory("panel"); + const otherExistingPanel = sidebarEntryFactory("form"); const newPanel = sidebarEntryFactory("temporaryPanel", { - extensionId: originalPanel.extensionId, + modComponentRef: modComponentRefFactory({ + extensionId: originalPanel.modComponentRef.extensionId, + }), }); const initialState: SidebarState = { @@ -210,7 +211,6 @@ describe("removeTemporaryPanel", () => { const store = configureStore({ reducer: { sidebar: sidebarSlice.reducer }, - preloadedState: { sidebar: initialState }, }); @@ -532,16 +532,20 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { it("sets activeKey to the active key of any panel with the same extensionId as the removedEntry if it exists", () => { const modId = validateRegistryId("test/123"); const originalPanel = sidebarEntryFactory("panel", { - extensionId: uuidv4(), - blueprintId: modId, + modComponentRef: modComponentRefFactory({ + blueprintId: modId, + }), }); const otherExistingPanel = sidebarEntryFactory("form", { - extensionId: uuidv4(), - blueprintId: modId, + modComponentRef: modComponentRefFactory({ + blueprintId: modId, + }), }); const newPanel = sidebarEntryFactory("temporaryPanel", { - extensionId: originalPanel.extensionId, - blueprintId: modId, + modComponentRef: modComponentRefFactory({ + extensionId: originalPanel.modComponentRef.extensionId, + blueprintId: modId, + }), }); const state = { @@ -554,7 +558,7 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { // eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error -- Flaky error // @ts-ignore-error "Type instantiation is excessively deep and possibly infinite" - fixActiveTabOnRemove(state, newPanel); + fixActiveTabOnRemoveInPlace(state, newPanel); expect(state).toStrictEqual({ ...state, @@ -562,19 +566,19 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { }); }); - it("sets activeKey to the active key of any panel with the same modId as the removedEntry if it exists and there is no matching extensionId", () => { + it("sets activeKey to the active key of any panel with the same modId as the removedEntry if it exists and there is no matching component id", () => { const modId = validateRegistryId("test/123"); - const firstPanel = sidebarEntryFactory("panel", { - extensionId: uuidv4(), - }); + const firstPanel = sidebarEntryFactory("panel"); const matchingPanel = sidebarEntryFactory("panel", { - extensionId: uuidv4(), - blueprintId: modId, + modComponentRef: modComponentRefFactory({ + blueprintId: modId, + }), }); const newPanel = sidebarEntryFactory("temporaryPanel", { - extensionId: uuidv4(), - blueprintId: modId, + modComponentRef: modComponentRefFactory({ + blueprintId: modId, + }), }); const state = { @@ -584,7 +588,7 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { temporaryPanels: [], } as SidebarState; - fixActiveTabOnRemove(state, newPanel); + fixActiveTabOnRemoveInPlace(state, newPanel); expect(state).toStrictEqual({ ...state, @@ -596,18 +600,26 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { const extensionId = uuidv4(); const originalPanel = sidebarEntryFactory("panel", { - extensionId, + modComponentRef: modComponentRefFactory({ + extensionId, + }), }); const firstFormPanel = sidebarEntryFactory("form", { - extensionId, + modComponentRef: modComponentRefFactory({ + extensionId, + }), }); const nullModId = sidebarEntryFactory("form", { - extensionId: uuidv4(), - blueprintId: null, + modComponentRef: modComponentRefFactory({ + extensionId, + blueprintId: null, + }), }); const newPanel = sidebarEntryFactory("temporaryPanel", { - extensionId, - blueprintId: null, + modComponentRef: modComponentRefFactory({ + extensionId, + blueprintId: null, + }), }); const state = { @@ -618,7 +630,7 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { temporaryPanels: [], } as SidebarState; - fixActiveTabOnRemove(state, newPanel); + fixActiveTabOnRemoveInPlace(state, newPanel); expect(state).toStrictEqual({ ...state, @@ -627,15 +639,9 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { }); it("sets activeKey to the defaultEventKey if no panel with the same extensionId as the removedEntry exists", () => { - const originalPanel = sidebarEntryFactory("panel", { - extensionId: uuidv4(), - }); - const otherExistingPanel = sidebarEntryFactory("form", { - extensionId: uuidv4(), - }); - const newPanel = sidebarEntryFactory("temporaryPanel", { - extensionId: uuidv4(), - }); + const originalPanel = sidebarEntryFactory("panel"); + const otherExistingPanel = sidebarEntryFactory("form"); + const newPanel = sidebarEntryFactory("temporaryPanel"); const state = { ...sidebarSlice.getInitialState(), @@ -645,7 +651,7 @@ describe("sidebarSlice.fixActiveTabOnRemove", () => { temporaryPanels: [], } as SidebarState; - fixActiveTabOnRemove(state, newPanel); + fixActiveTabOnRemoveInPlace(state, newPanel); expect(state).toStrictEqual({ ...state, diff --git a/src/store/sidebar/sidebarSlice.ts b/src/store/sidebar/sidebarSlice.ts index 268547ae7f..f0d97c4879 100644 --- a/src/store/sidebar/sidebarSlice.ts +++ b/src/store/sidebar/sidebarSlice.ts @@ -72,21 +72,21 @@ function findNextActiveKey( if (extensionId) { // Prefer form to panel -- however, it would be unusual to target an ephemeral form when reshowing the sidebar const extensionForm = state.forms.find( - (x) => x.extensionId === extensionId, + (x) => x.modComponentRef.extensionId === extensionId, ); if (extensionForm) { return eventKeyForEntry(extensionForm); } const extensionTemporaryPanel = state.temporaryPanels.find( - (x) => x.extensionId === extensionId, + (x) => x.modComponentRef.extensionId === extensionId, ); if (extensionTemporaryPanel) { return eventKeyForEntry(extensionTemporaryPanel); } const extensionPanel = state.panels.find( - (x) => x.extensionId === extensionId, + (x) => x.modComponentRef.extensionId === extensionId, ); if (extensionPanel) { return eventKeyForEntry(extensionPanel); @@ -96,7 +96,10 @@ function findNextActiveKey( // Try matching on panel heading if (panelHeading) { const extensionPanel = state.panels - .filter((x) => blueprintId == null || x.blueprintId === blueprintId) + .filter( + (x) => + blueprintId == null || x.modComponentRef.blueprintId === blueprintId, + ) .find((x) => x.heading === panelHeading); if (extensionPanel) { return eventKeyForEntry(extensionPanel); @@ -106,7 +109,7 @@ function findNextActiveKey( // Try matching on blueprint if (blueprintId) { const blueprintPanel = state.panels.find( - (x) => x.blueprintId === blueprintId, + (x) => x.modComponentRef.blueprintId === blueprintId, ); if (blueprintPanel) { return eventKeyForEntry(blueprintPanel); @@ -121,28 +124,33 @@ function findNextActiveKey( return null; } -export function fixActiveTabOnRemove( +/** + * Updates activeKey in place based on a removed entry. Mutates the state object. + */ +export function fixActiveTabOnRemoveInPlace( state: SidebarState, removedEntry: Nullishable, -) { +): void { // Only update the active panel if the panel needs to change if (removedEntry && state.activeKey === eventKeyForEntry(removedEntry)) { const panels = [...state.forms, ...state.panels, ...state.temporaryPanels]; const matchingExtension = panels.find( - ({ extensionId }) => - "extensionId" in removedEntry && - extensionId === removedEntry.extensionId, + ({ modComponentRef: { extensionId } }) => + "modComponentRef" in removedEntry && + extensionId === removedEntry.modComponentRef.extensionId, ); if (matchingExtension) { state.activeKey = eventKeyForEntry(matchingExtension); } else { + // No mod component match, try finding another panel for the mod + const matchingMod = panels.find( - ({ blueprintId }) => - "blueprintId" in removedEntry && - // Need to check for removedEntry.blueprintId to avoid switching between ModComponentBases that don't have blueprint ids - blueprintId === removedEntry.blueprintId && + ({ modComponentRef: { blueprintId } }) => + "modComponentRef" in removedEntry && + blueprintId === removedEntry.modComponentRef.blueprintId && + // Require blueprintId to avoid switching between panels of standalone mod components blueprintId, ); @@ -216,7 +224,7 @@ const sidebarSlice = createSlice({ const entry = remove(state.forms, (form) => form.nonce === nonce)[0]; - fixActiveTabOnRemove(state, entry); + fixActiveTabOnRemoveInPlace(state, entry); }, invalidatePanels(state) { for (const panel of state.panels) { @@ -297,14 +305,16 @@ const sidebarSlice = createSlice({ (oldPanel) => (oldPanel.isUnavailable || oldPanel.isConnecting) && !action.payload.panels.some( - (newPanel) => newPanel.extensionId === oldPanel.extensionId, + (newPanel) => + newPanel.modComponentRef.extensionId === + oldPanel.modComponentRef.extensionId, ), ); // For now, pick an arbitrary order that's stable. There's no guarantees on which order panels are registered state.panels = sortBy( [...oldPanels, ...castDraft(action.payload.panels)], - (panel) => panel.extensionId, + (panel) => panel.modComponentRef.extensionId, ); // Try fulfilling the pendingActivePanel request @@ -340,7 +350,7 @@ const sidebarSlice = createSlice({ closedTabs[eventKeyForEntry(MOD_LAUNCHER)] = false; } - fixActiveTabOnRemove(state, entry); + fixActiveTabOnRemoveInPlace(state, entry); }, closeTab(state, action: PayloadAction) { state.closedTabs[action.payload] = true; @@ -376,7 +386,7 @@ const sidebarSlice = createSlice({ const { removedEntry, forms } = action.payload; state.forms = castDraft(forms); - fixActiveTabOnRemove(state, removedEntry); + fixActiveTabOnRemoveInPlace(state, removedEntry); } }) .addCase(addTemporaryPanel.fulfilled, (state, action) => { @@ -391,7 +401,7 @@ const sidebarSlice = createSlice({ const { removedEntry, temporaryPanels } = action.payload; state.temporaryPanels = castDraft(temporaryPanels); - fixActiveTabOnRemove(state, removedEntry); + fixActiveTabOnRemoveInPlace(state, removedEntry); } }) .addCase(resolveTemporaryPanel.fulfilled, (state, action) => { @@ -399,7 +409,7 @@ const sidebarSlice = createSlice({ const { resolvedEntry, temporaryPanels } = action.payload; state.temporaryPanels = castDraft(temporaryPanels); - fixActiveTabOnRemove(state, resolvedEntry); + fixActiveTabOnRemoveInPlace(state, resolvedEntry); } }); }, diff --git a/src/store/sidebar/thunks/addFormPanel.ts b/src/store/sidebar/thunks/addFormPanel.ts index 7019330afd..f9b744a452 100644 --- a/src/store/sidebar/thunks/addFormPanel.ts +++ b/src/store/sidebar/thunks/addFormPanel.ts @@ -41,13 +41,14 @@ const addFormPanel = createAsyncThunk< return; } - const [thisExtensionForms, otherForms] = partition( + const [thisModComponentForms, otherForms] = partition( forms, - ({ extensionId }) => extensionId === form.extensionId, + ({ modComponentRef }) => + modComponentRef.extensionId === form.modComponentRef.extensionId, ); // The UUID must be fetched synchronously to ensure the `form` Proxy element doesn't expire - await cancelPreexistingForms(thisExtensionForms.map((form) => form.nonce)); + await cancelPreexistingForms(thisModComponentForms.map((form) => form.nonce)); return { forms: [ diff --git a/src/store/sidebar/thunks/addTemporaryPanel.ts b/src/store/sidebar/thunks/addTemporaryPanel.ts index 99055a5dfe..92807a71f7 100644 --- a/src/store/sidebar/thunks/addTemporaryPanel.ts +++ b/src/store/sidebar/thunks/addTemporaryPanel.ts @@ -40,7 +40,7 @@ const addTemporaryPanel = createAsyncThunk< const [existingExtensionTemporaryPanels, otherTemporaryPanels] = partition( temporaryPanels, - (x) => x.extensionId === panel.extensionId, + (x) => x.modComponentRef.extensionId === panel.modComponentRef.extensionId, ); // Cancel all panels for the extension, except if there's a placeholder that was added in setInitialPanels diff --git a/src/telemetry/deployments.ts b/src/telemetry/deployments.ts index eb05edadd7..b3a374cb52 100644 --- a/src/telemetry/deployments.ts +++ b/src/telemetry/deployments.ts @@ -4,7 +4,8 @@ import { type MessageContext } from "@/types/loggerTypes"; import { isRegistryId } from "@/types/helpers"; /** - * Select data to report to the team admins for the deployment + * Select data to report to the team admins for the deployment. + * @see mapModComponentRefToEventData */ export function selectEventData( modComponent: Nullishable, diff --git a/src/telemetry/telemetryHelpers.test.ts b/src/telemetry/telemetryHelpers.test.ts index 001d02d47f..dc9e6917aa 100644 --- a/src/telemetry/telemetryHelpers.test.ts +++ b/src/telemetry/telemetryHelpers.test.ts @@ -15,7 +15,12 @@ * along with this program. If not, see . */ -import { cleanDatadogVersionName } from "@/telemetry/telemetryHelpers"; +import { + cleanDatadogVersionName, + mapModComponentRefToEventData, +} from "@/telemetry/telemetryHelpers"; +import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories"; +import { mapMessageContextToModComponentRef } from "@/utils/modUtils"; // Disable automatic __mocks__ resolution #6799 jest.mock("@/telemetry/telemetryHelpers", () => @@ -35,3 +40,32 @@ describe("cleanDatadogVersionName", () => { ); }); }); + +describe("mapModComponentRefToEventData", () => { + it("maps fields", () => { + const value = modComponentRefFactory(); + expect(mapModComponentRefToEventData(value)).toStrictEqual({ + extensionId: value.extensionId, + blueprintId: value.blueprintId, + extensionPointId: value.extensionPointId, + }); + }); + + it("replaces null with undefined", () => { + const value = modComponentRefFactory({ + blueprintId: null, + }); + expect(mapModComponentRefToEventData(value)).toStrictEqual({ + extensionId: value.extensionId, + blueprintId: undefined, + extensionPointId: value.extensionPointId, + }); + }); + + it("round trips mod component reference", () => { + const value = modComponentRefFactory(); + expect( + mapMessageContextToModComponentRef(mapModComponentRefToEventData(value)), + ).toStrictEqual(value); + }); +}); diff --git a/src/telemetry/telemetryHelpers.ts b/src/telemetry/telemetryHelpers.ts index dca63053a8..20d5f254b4 100644 --- a/src/telemetry/telemetryHelpers.ts +++ b/src/telemetry/telemetryHelpers.ts @@ -20,6 +20,8 @@ import { uuidv4 } from "@/types/helpers"; import type { UUID } from "@/types/stringTypes"; import { once } from "lodash"; import { StorageItem } from "webext-storage"; +import type { ModComponentRef } from "@/types/modComponentTypes"; +import type { MessageContext } from "@/types/loggerTypes"; /** * The Person model for application error telemetry. @@ -97,3 +99,19 @@ export async function mapAppUserToTelemetryUser( organizationId: telemetryOrganizationId ?? organizationId, }; } + +/** + * Returns the event data for a ModComponentRef. + * @see selectEventData + */ +export function mapModComponentRefToEventData( + modComponentRef: ModComponentRef, +): MessageContext { + // Fields are currently named the same. In the future, the fields might temporarily diverge. + return { + extensionId: modComponentRef.extensionId, + extensionPointId: modComponentRef.extensionPointId, + // MessageContext expects undefined instead of null/undefined + blueprintId: modComponentRef.blueprintId ?? undefined, + }; +} diff --git a/src/testUtils/factories/modComponentFactories.ts b/src/testUtils/factories/modComponentFactories.ts index 7ba26254d2..d8ece2f8c0 100644 --- a/src/testUtils/factories/modComponentFactories.ts +++ b/src/testUtils/factories/modComponentFactories.ts @@ -19,9 +19,11 @@ import { type Config, define, extend } from "cooky-cutter"; import { type ActivatedModComponent, type ModComponentBase, + type ModComponentRef, type ModMetadata, } from "@/types/modComponentTypes"; import { + registryIdFactory, timestampFactory, uuidSequence, } from "@/testUtils/factories/stringFactories"; @@ -33,6 +35,12 @@ import { metadataFactory } from "@/testUtils/factories/metadataFactory"; import { type StandaloneModDefinition } from "@/types/contract"; import { type Metadata, DefinitionKinds } from "@/types/registryTypes"; +export const modComponentRefFactory = define({ + extensionId: uuidSequence, + blueprintId: registryIdFactory, + extensionPointId: registryIdFactory, +}); + export const modMetadataFactory = extend( metadataFactory, { diff --git a/src/testUtils/factories/runtimeFactories.ts b/src/testUtils/factories/runtimeFactories.ts index f8a3d66c0d..cab6f92a12 100644 --- a/src/testUtils/factories/runtimeFactories.ts +++ b/src/testUtils/factories/runtimeFactories.ts @@ -18,8 +18,8 @@ import { type BrickOptions, type RunMetadata } from "@/types/runtimeTypes"; import { define, derive } from "cooky-cutter"; import ConsoleLogger from "@/utils/ConsoleLogger"; -import { uuidSequence } from "@/testUtils/factories/stringFactories"; import contentScriptPlatform from "@/contentScript/contentScriptPlatform"; +import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories"; /** * Factory for BrickOptions to pass to Brick.run method. @@ -32,11 +32,15 @@ export const brickOptionsFactory = define({ ctxt() { return {}; }, - platform: () => contentScriptPlatform, - logger: (i: number) => - new ConsoleLogger({ - extensionId: uuidSequence(i), - }), + platform: (_i: number) => contentScriptPlatform, + logger(_i: number) { + const { blueprintId, ...rest } = modComponentRefFactory(); + // MessageContext expects undefined instead of null for blueprintId + return new ConsoleLogger({ + ...rest, + blueprintId: blueprintId ?? undefined, + }); + }, root: (_i: number) => document, runPipeline: (_i: number) => jest.fn().mockRejectedValue(new Error("runPipeline mock not implemented")), diff --git a/src/testUtils/factories/sidebarEntryFactories.ts b/src/testUtils/factories/sidebarEntryFactories.ts index 9d3e425594..db2705f307 100644 --- a/src/testUtils/factories/sidebarEntryFactories.ts +++ b/src/testUtils/factories/sidebarEntryFactories.ts @@ -28,6 +28,7 @@ import { import { validateRegistryId } from "@/types/helpers"; import { uuidSequence } from "@/testUtils/factories/stringFactories"; import { type FormDefinition } from "@/platform/forms/formTypes"; +import { modComponentRefFactory } from "@/testUtils/factories/modComponentFactories"; const activateModPanelEntryFactory = define({ type: "activateMods", @@ -55,52 +56,44 @@ const formDefinitionFactory = define({ }); export const formEntryFactory = define({ type: "form", - extensionId: uuidSequence, - blueprintId: (n: number) => - validateRegistryId(`@test/form-panel-recipe-test-${n}`), + modComponentRef: modComponentRefFactory, nonce: uuidSequence, form: formDefinitionFactory, }); const temporaryPanelEntryFactory = define({ type: "temporaryPanel", - extensionId: uuidSequence, - blueprintId: null, + modComponentRef: modComponentRefFactory, heading: (n: number) => `Temporary Panel Test ${n}`, payload: null, nonce: uuidSequence, }); const panelEntryFactory = define({ type: "panel", - extensionId: uuidSequence, - blueprintId: (n: number) => - validateRegistryId(`@test/panel-recipe-test-${n}`), + modComponentRef: modComponentRefFactory, heading: (n: number) => `Panel Test ${n}`, payload: null, - extensionPointId: (n: number) => - validateRegistryId(`@test/panel-extension-point-test-${n}`), }); -export function sidebarEntryFactory( +export function sidebarEntryFactory( type: "panel", - override?: FactoryConfig, + override?: FactoryConfig, ): PanelEntry; -export function sidebarEntryFactory( +export function sidebarEntryFactory( type: "temporaryPanel", - override?: FactoryConfig, + override?: FactoryConfig, ): TemporaryPanelEntry; -export function sidebarEntryFactory( +export function sidebarEntryFactory( type: "form", - override?: FactoryConfig, + override?: FactoryConfig, ): FormPanelEntry; -export function sidebarEntryFactory( +export function sidebarEntryFactory( type: "activateMods", - override?: FactoryConfig, + override?: FactoryConfig, ): ModActivationPanelEntry; - -export function sidebarEntryFactory( +export function sidebarEntryFactory( type: "staticPanel", - override?: FactoryConfig, + override?: FactoryConfig, ): StaticPanelEntry; -export function sidebarEntryFactory( +export function sidebarEntryFactory( type: EntryType, override?: FactoryConfig, ): SidebarEntry { diff --git a/src/types/modComponentTypes.ts b/src/types/modComponentTypes.ts index dfe812feed..f5744846b7 100644 --- a/src/types/modComponentTypes.ts +++ b/src/types/modComponentTypes.ts @@ -275,7 +275,9 @@ export type HydratedModComponent = }; /** - * A reference to an ModComponentBase. + * A reference to a ModComponentBase, including the associated mod and starter brick. Prefer using the mod component's + * UUID directly if information about the mod and/or starter brick are not required. + * @see ModComponentBase */ export type ModComponentRef = { /** @@ -284,12 +286,12 @@ export type ModComponentRef = { extensionId: UUID; /** - * Registry id of the StarterBrick. + * Mod the ModComponent is from, or nullish for a standalone ModComponent. */ - extensionPointId: RegistryId; + blueprintId: Nullishable; /** - * Mod the ModComponent is from. + * Registry id of the mod component's StarterBrick. */ - blueprintId: Nullishable; + extensionPointId: RegistryId; }; diff --git a/src/types/sidebarTypes.ts b/src/types/sidebarTypes.ts index 144cbe8ab0..7db3b67ba2 100644 --- a/src/types/sidebarTypes.ts +++ b/src/types/sidebarTypes.ts @@ -28,7 +28,8 @@ import { type ModComponentState } from "@/store/extensionsTypes"; import { isObject } from "@/utils/objectUtils"; import { type RunMetadata } from "@/types/runtimeTypes"; import type { ModActivationConfig } from "@/types/modTypes"; -import { type Nullishable } from "@/utils/nullishUtils"; +import type { Nullishable } from "@/utils/nullishUtils"; +import type { ModComponentRef } from "@/types/modComponentTypes"; /** * Entry types supported by the sidebar. @@ -138,19 +139,15 @@ type BasePanelEntry = { */ export type BaseModComponentPanelEntry = BasePanelEntry & { /** - * The id of the ModComponent that added the panel - */ - extensionId: UUID; - /** - * The blueprint associated with the ModComponent that added the panel. + * Reference to the ModComponent that added the panel. * - * Used to: - * - Give preference to blueprint side panels when using the "Show Sidebar" brick. - * - Pass to the panel for actions that require the blueprint id, e.g., Get Page State, Set Page State, etc. + * The mod is used to: + * - Give preference to mod side panels when using the "Show Sidebar" brick. + * - Pass to the panel for actions that require the mod id, e.g., Get Page State, Set Page State, etc. * - * @since 1.6.5 + * @since 2.0.5 refactored to use a single property for the mod component id and the containing mod id */ - blueprintId: Nullishable; + modComponentRef: ModComponentRef; /** * Heading for tab name in the sidebar */ @@ -171,7 +168,7 @@ export type BaseModComponentPanelEntry = BasePanelEntry & { export function isBaseModComponentPanelEntry( panel: unknown, ): panel is BaseModComponentPanelEntry { - return (panel as BaseModComponentPanelEntry)?.extensionId != null; + return (panel as BaseModComponentPanelEntry)?.modComponentRef != null; } /** @@ -180,11 +177,6 @@ export function isBaseModComponentPanelEntry( */ export type PanelEntry = BaseModComponentPanelEntry & { type: "panel"; - /** - * The sidebar extension point - * @see SidebarStarterBrickABC - */ - extensionPointId: RegistryId; }; export function isPanelEntry(panel: unknown): panel is PanelEntry { @@ -219,20 +211,14 @@ export function isTemporaryPanelEntry( */ export type FormPanelEntry = BasePanelEntry & { type: "form"; - /** - * The extension that created the form - */ - extensionId: UUID; - /** - * The blueprint of the extension panel to show - * - * @since 1.7.33 - */ - blueprintId?: RegistryId; /** * Unique identifier for the form instance. Used to correlate form submission/cancellation. */ nonce: UUID; + /** + * The mod component that created the form. + */ + modComponentRef: ModComponentRef; /** * The form schema and configuration */ diff --git a/src/utils/modUtils.ts b/src/utils/modUtils.ts index a9ad27afba..3f98a7f99a 100644 --- a/src/utils/modUtils.ts +++ b/src/utils/modUtils.ts @@ -34,6 +34,7 @@ import { type ModComponentBase, type HydratedModComponent, type SerializedModComponent, + type ModComponentRef, } from "@/types/modComponentTypes"; import { DefinitionKinds, type RegistryId } from "@/types/registryTypes"; import { type UUID } from "@/types/stringTypes"; @@ -54,6 +55,32 @@ import { import { produce } from "immer"; import { isStarterBrickDefinitionLike } from "@/starterBricks/types"; import { normalizeStarterBrickDefinitionProp } from "@/starterBricks/starterBrickUtils"; +import { type MessageContext } from "@/types/loggerTypes"; + +/** + * Returns the ModComponentRef for a given Logger MessageContext. Only call from running bricks with an associated + * mod component and starter brick in the context. + * + * @throws TypeError if the extensionId or extensionPointId is missing + */ +export function mapMessageContextToModComponentRef( + context: MessageContext, +): ModComponentRef { + assertNotNullish( + context.extensionId, + "extensionId is required for ModComponentRef", + ); + assertNotNullish( + context.extensionPointId, + "extensionPointId is required for ModComponentRef", + ); + + return { + extensionId: context.extensionId, + blueprintId: context.blueprintId, + extensionPointId: context.extensionPointId, + }; +} /** * Returns true if the mod is an UnavailableMod, i.e., a mod the user no longer has access to.