diff --git a/.eslintrc.js b/.eslintrc.js index a0854fb6d6..f7ef12e046 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -154,6 +154,14 @@ module.exports = { allowConditional: true, }, ], + "playwright/no-wait-for-timeout": "error", + "playwright/no-useless-not": "error", + "playwright/expect-expect": [ + "error", + { assertFunctionNames: ["checkUnavailibilityForNavigationMethod"] }, + ], + "playwright/no-conditional-in-test": "error", + "playwright/no-conditional-expect": "error", "playwright/no-commented-out-tests": "error", "playwright/no-hooks": "error", // Use fixtures instead to share common setup / teardown code "playwright/no-get-by-title": "error", diff --git a/end-to-end-tests/fixtures/envSetup.ts b/end-to-end-tests/fixtures/envSetup.ts index 2ef13b00e9..875e929204 100644 --- a/end-to-end-tests/fixtures/envSetup.ts +++ b/end-to-end-tests/fixtures/envSetup.ts @@ -28,6 +28,7 @@ export const test = base.extend<{ assertRequiredEnvVariables(); for (const key of additionalRequiredEnvVariables) { + // eslint-disable-next-line security/detect-object-injection -- internally controlled if (process.env[key] === undefined) { throw new Error( `This test requires additional environment variable ${key} to be configured. Configure it in your .env.development file and re-build the extension.`, diff --git a/end-to-end-tests/tests/bricks/sidebarEffects.spec.ts b/end-to-end-tests/tests/bricks/sidebarEffects.spec.ts index 30072d7cef..e9adeb533f 100644 --- a/end-to-end-tests/tests/bricks/sidebarEffects.spec.ts +++ b/end-to-end-tests/tests/bricks/sidebarEffects.spec.ts @@ -32,7 +32,6 @@ test.describe("sidebar effect bricks", () => { await page.goto("/"); // Ensure the page is focused by clicking on an element before running the keyboard shortcut, see runModViaQuickbar - await page.getByText("Index of /").click(); await runModViaQuickBar(page, "Toggle Sidebar"); // Will error if page/frame not available diff --git a/end-to-end-tests/tests/extensionConsoleActivation.spec.ts b/end-to-end-tests/tests/extensionConsoleActivation.spec.ts index 30b24978a1..a445135ca8 100644 --- a/end-to-end-tests/tests/extensionConsoleActivation.spec.ts +++ b/end-to-end-tests/tests/extensionConsoleActivation.spec.ts @@ -83,8 +83,11 @@ test("can activate a mod with built-in integration", async ({ await modActivationPage.clickActivateAndWaitForModsPageRedirect(); await page.goto("/"); - // Ensure the page is focused by clicking on an element before running the keyboard shortcut, see runModViaQuickbar - await page.getByText("Index of /").click(); + // Ensure the QuickBar is ready + await expect( + page.getByRole("button", { name: "open the PixieBrix quick bar" }), + ).toBeVisible(); + await runModViaQuickBar(page, "GIPHY Search"); // Search for "kitten" keyword diff --git a/end-to-end-tests/tests/regressions/formFlicker.spec.ts b/end-to-end-tests/tests/regressions/formFlicker.spec.ts index 7ff18b261b..ed81c90463 100644 --- a/end-to-end-tests/tests/regressions/formFlicker.spec.ts +++ b/end-to-end-tests/tests/regressions/formFlicker.spec.ts @@ -41,7 +41,6 @@ test.describe("forms flickering due to components unexpectedly unmounting/remoun await page.goto("/bootstrap-5"); - await page.getByRole("heading", { name: "PixieBrix" }).click(); await runModViaQuickBar(page, "Open Sidebar"); const sideBarPage = await getSidebarPage(page, extensionId); diff --git a/end-to-end-tests/tests/runtime/sidebarController.spec.ts b/end-to-end-tests/tests/runtime/sidebarController.spec.ts index 445e140627..2022281555 100644 --- a/end-to-end-tests/tests/runtime/sidebarController.spec.ts +++ b/end-to-end-tests/tests/runtime/sidebarController.spec.ts @@ -70,7 +70,7 @@ test.describe("sidebar controller", () => { // The focus dialog should not be shown in the iframe. Check after checking the top-level frame // because it's a positive check for the dialog being shown. - await expect(frame.getByRole("button", { name: "OK" })).not.toBeVisible(); + await expect(frame.getByRole("button", { name: "OK" })).toBeHidden(); // Will error if page/frame not available await getSidebarPage(page, extensionId); diff --git a/end-to-end-tests/tests/runtime/sidebarNavigation.spec.ts b/end-to-end-tests/tests/runtime/sidebarNavigation.spec.ts index cb1915e818..5f24525b73 100644 --- a/end-to-end-tests/tests/runtime/sidebarNavigation.spec.ts +++ b/end-to-end-tests/tests/runtime/sidebarNavigation.spec.ts @@ -22,7 +22,7 @@ import { type Page, test as base } from "@playwright/test"; import { getSidebarPage, runModViaQuickBar } from "../../utils"; import { MV, SERVICE_URL } from "../../env"; -test("sidebar is persistent during navigation", async ({ +test("sidebar mod panels are persistent during navigation", async ({ page, extensionId, }) => { @@ -38,8 +38,6 @@ test("sidebar is persistent during navigation", async ({ await page.goto("/"); - // Ensure the page is focused by clicking on an element before running the keyboard shortcut, see runModViaQuickbar - await page.getByText("Index of /").click(); await runModViaQuickBar(page, "Open Sidebar"); const sideBarPage = (await getSidebarPage(page, extensionId)) as Page; // MV3 sidebar is a separate page @@ -108,3 +106,133 @@ test("sidebar is persistent during navigation", async ({ expect(sideBarPageClosed).toBe(true); }).toPass({ timeout: 5000 }); }); + +const navigationMethods: Array<{ + name: string; + navigationMethod: (page: Page) => Promise; +}> = [ + { + name: "refresh", + async navigationMethod(page: Page) { + await page.reload(); + }, + }, + { + name: "back button", + async navigationMethod(page: Page) { + await page.goBack(); + await page.goBack(); + }, + }, + { + name: "goto new page", + async navigationMethod(page: Page) { + await page.goto(SERVICE_URL); + }, + }, +]; + +// Helper method for checking that the sidebar panels are unavailable after a navigation method +async function checkUnavailibilityForNavigationMethod( + page: Page, + extensionId: string, + navigationMethod: (page: Page) => Promise, +) { + await page.goto("/advanced-fields"); + await runModViaQuickBar(page, "Open form"); + + const sideBarPage = (await getSidebarPage(page, extensionId)) as Page; // MV3 sidebar is a separate page + // Set up close listener for sidebar page + let sideBarPageClosed = false; + sideBarPage.on("close", () => { + sideBarPageClosed = true; + }); + + await expect( + sideBarPage + .frameLocator("iframe") + .getByRole("heading", { name: "Example Form" }), + ).toBeVisible(); + await expect( + sideBarPage.getByRole("tab", { name: "Example form" }), + ).toBeVisible(); + + await runModViaQuickBar(page, "Open temp panel"); + await expect( + sideBarPage.getByRole("heading", { name: "Example document" }), + ).toBeVisible(); + await expect( + sideBarPage.getByRole("tab", { name: "Example info" }), + ).toBeVisible(); + + // Click on "contentEditable" header, which updates the url to .../#contenteditable + await page.getByRole("link", { name: "contentEditable" }).click(); + expect(page.url()).toBe( + "https://pbx.vercel.app/advanced-fields/#contenteditable", + ); + // Should not cause the temporary panel to become unavailable + await expect( + sideBarPage + .getByLabel("Example Info") + .getByText("Panel no longer available"), + ).toBeHidden(); + + await navigationMethod(page); + + await expect( + sideBarPage + .getByLabel("Example Info") + .getByText("Panel no longer available"), + ).toBeVisible(); + await sideBarPage + .getByLabel("Example Info") + .getByLabel("Close the unavailable panel") + .click(); + await expect( + sideBarPage.getByRole("tab", { name: "Example info" }), + ).toBeHidden(); + + // The unavailable overlay is still displayed for the form panel + await expect( + sideBarPage + .getByLabel("Example form") + .getByText("Panel no longer available"), + ).toBeVisible(); + await sideBarPage + .getByLabel("Example form") + .getByLabel("Close the unavailable panel") + .click(); + + // Closing the last panel should close the sidebar + await expect(() => { + expect(sideBarPageClosed).toBe(true); + }).toPass({ timeout: 5000 }); +} + +test("sidebar form and temporary panels are unavailable after navigation", async ({ + page, + extensionId, +}) => { + test.skip(MV === "2", "Navigation is not supported for MV2 sidebar"); + // This mod has two quickbar actions for opening a temporary panel and a form panel in the sidebar. + const modId = "@e2e-testing/temp-panel-unavailable-on-navigation"; + + const modActivationPage = new ActivateModPage(page, extensionId, modId); + await modActivationPage.goto(); + + await modActivationPage.clickActivateAndWaitForModsPageRedirect(); + + // Prime the browser history with an initial navigation + await page.goto(SERVICE_URL); + + for (const { navigationMethod, name } of navigationMethods) { + // eslint-disable-next-line no-await-in-loop -- check each navigation method sequentially + await test.step(`Checking navigation method: ${name}`, async () => { + await checkUnavailibilityForNavigationMethod( + page, + extensionId, + navigationMethod, + ); + }); + } +}); diff --git a/end-to-end-tests/tests/runtime/sidebarPanelTheme.spec.ts b/end-to-end-tests/tests/runtime/sidebarPanelTheme.spec.ts new file mode 100644 index 0000000000..74360a015b --- /dev/null +++ b/end-to-end-tests/tests/runtime/sidebarPanelTheme.spec.ts @@ -0,0 +1,54 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { test, expect } from "../../fixtures/extensionBase"; +import { ActivateModPage } from "../../pageObjects/extensionConsole/modsPage"; +import { getSidebarPage, runModViaQuickBar } from "../../utils"; +import type { Page } from "@playwright/test"; + +test("custom sidebar theme css file is applied to all levels of sidebar document", async ({ + page, + extensionId, +}) => { + const modId = "@pixies/testing/panel-theme"; + + const modActivationPage = new ActivateModPage(page, extensionId, modId); + await modActivationPage.goto(); + + await modActivationPage.clickActivateAndWaitForModsPageRedirect(); + + await page.goto("/"); + + // Ensure the page is focused by clicking on an element before running the keyboard shortcut, see runModViaQuickbar + await page.getByText("Index of /").click(); + await runModViaQuickBar(page, "Show Sidebar"); + + const sidebarPage = (await getSidebarPage(page, extensionId)) as Page; + await expect( + sidebarPage.getByText("#8347: Theme Inheritance", { exact: true }), + ).toBeVisible(); + + const green = "rgb(0, 128, 0)"; + const elementsThatShouldBeGreen = await sidebarPage + .getByText("This should be green") + .all(); + await Promise.all( + elementsThatShouldBeGreen.map(async (element) => + expect(element).toHaveCSS("color", green), + ), + ); +}); diff --git a/end-to-end-tests/tests/telemetry/errors.spec.ts b/end-to-end-tests/tests/telemetry/errors.spec.ts index cff34cf956..9209bc1b2b 100644 --- a/end-to-end-tests/tests/telemetry/errors.spec.ts +++ b/end-to-end-tests/tests/telemetry/errors.spec.ts @@ -1,10 +1,37 @@ import { test, expect } from "../../fixtures/extensionBase"; -import { type Request } from "playwright-core"; // @ts-expect-error -- https://youtrack.jetbrains.com/issue/AQUA-711/Provide-a-run-configuration-for-Playwright-tests-in-specs-with-fixture-imports-only -import { type Page, test as base } from "@playwright/test"; +import { type BrowserContext, type Page, test as base } from "@playwright/test"; import { getBaseExtensionConsoleUrl } from "../../pageObjects/constants"; import { MV } from "../../env"; +async function waitForBackgroundPageRequest( + context: BrowserContext, + extensionId: string, + errorServiceEndpoint: string, +) { + if (MV === "3") { + // Due to service worker limitations with the Datadog SDK, we need to report errors via an offscreen document + // (see https://github.com/pixiebrix/pixiebrix-extension/issues/8268). The offscreen document is created when + // the first error is reported, so we need to wait for it to be created before we can interact with it. + let offscreenPage: Page | undefined; + await expect(async () => { + offscreenPage = context + .pages() + .find((value) => + value + .url() + .startsWith(`chrome-extension://${extensionId}/offscreen.html`), + ); + + expect(offscreenPage?.url()).toBeDefined(); + }).toPass({ timeout: 5000 }); + return offscreenPage?.waitForRequest(errorServiceEndpoint); + } + + const backgroundPage = context.backgroundPages()[0]; + return backgroundPage?.waitForRequest(errorServiceEndpoint); +} + test.use({ additionalRequiredEnvVariables: [ "DATADOG_CLIENT_TOKEN", @@ -39,32 +66,14 @@ test("can report application error to telemetry service", async ({ await page.goto(getBaseExtensionConsoleUrl(extensionId)); await expect(page.getByText("Something went wrong.")).toBeVisible(); - let waitForRequest: Promise | undefined; - if (MV === "3") { - // Due to service worker limitations with the Datadog SDK, we need to report errors via an offscreen document - // (see https://github.com/pixiebrix/pixiebrix-extension/issues/8268). The offscreen document is created when - // the first error is reported, so we need to wait for it to be created before we can interact with it. - let offscreenPage: Page | undefined; - await expect(async () => { - offscreenPage = context - .pages() - .find((value) => - value - .url() - .startsWith(`chrome-extension://${extensionId}/offscreen.html`), - ); - - expect(offscreenPage?.url()).toBeDefined(); - }).toPass({ timeout: 5000 }); - waitForRequest = offscreenPage?.waitForRequest(errorServiceEndpoint); - } else { - const backgroundPage = context.backgroundPages()[0]; - waitForRequest = backgroundPage?.waitForRequest(errorServiceEndpoint); - } - // TODO: due to Datadog SDK implementation, it will take ~30 seconds for the // request to be sent. We should figure out a way to induce the request to be sent sooner. - const request = await waitForRequest; + const request = await waitForBackgroundPageRequest( + context, + extensionId, + errorServiceEndpoint, + ); + const errorLogsJson = request ?.postData() ?.split("\n") diff --git a/end-to-end-tests/utils.ts b/end-to-end-tests/utils.ts index a6326ac686..aafe70b0ab 100644 --- a/end-to-end-tests/utils.ts +++ b/end-to-end-tests/utils.ts @@ -68,11 +68,14 @@ export async function ensureVisibility( } // Run a mod via the Quickbar. -// NOTE: Page needs to be focused before running this function, e.g. by clicking on the page. -// TODO: Fix the page-focus precondition by generalizing the page-focusing logic to be page-agnostic export async function runModViaQuickBar(page: Page, modName: string) { + await waitForQuickBarReadiness(page); + await page.locator("html").focus(); // Ensure the page is focused before running the keyboard shortcut await page.keyboard.press("Meta+M"); // MacOS await page.keyboard.press("Control+M"); // Windows and Linux + // Short delay to allow the quickbar to finish opening + // eslint-disable-next-line playwright/no-wait-for-timeout -- TODO: Find a better way to detect when the quickbar is done loading opening + await page.waitForTimeout(500); await page.getByRole("option", { name: modName }).click(); } @@ -161,6 +164,15 @@ export async function waitForSelectionMenuReadiness(page: Page) { }).toPass({ timeout: 5000 }); } +// Waits for the quick bar to be ready to use +async function waitForQuickBarReadiness(page: Page) { + await expect(async () => { + await expect(page.locator("html")).toHaveAttribute( + "data-pb-quick-bar-ready", + ); + }).toPass({ timeout: 5000 }); +} + // Simulates mouse entering the sidebar to track focus on MV2 // https://github.com/pixiebrix/pixiebrix-extension/blob/1794863937f343fbc8e3a4434eace74191f8dfbd/src/contentScript/sidebarController.tsx#L563-L563 export async function conditionallyHoverOverMV2Sidebar(page: Page) { diff --git a/package-lock.json b/package-lock.json index 62b69ce204..5a836abf56 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,7 +155,7 @@ "@axe-core/playwright": "^4.9.0", "@fortawesome/fontawesome-common-types": "^0.2.36", "@playwright/test": "^1.43.1", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.13", "@shopify/jest-dom-mocks": "^5.0.0", "@sindresorhus/tsconfig": "^5.0.0", "@sinonjs/fake-timers": "^11.2.2", @@ -4749,16 +4749,14 @@ } }, "node_modules/@pmmmwh/react-refresh-webpack-plugin": { - "version": "0.5.11", - "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.11.tgz", - "integrity": "sha512-7j/6vdTym0+qZ6u4XbSAxrWBGYSdCfTzySkj7WAFgDLmSyWlOrWvpyzxlFh5jtw9dn0oL/jtW+06XfFiisN3JQ==", + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/@pmmmwh/react-refresh-webpack-plugin/-/react-refresh-webpack-plugin-0.5.13.tgz", + "integrity": "sha512-odZVYXly+JwzYri9rKqqUAk0cY6zLpv4dxoKinhoJNShV36Gpxf+CyDIILJ4tYsJ1ZxIWs233Y39iVnynvDA/g==", "dev": true, "dependencies": { "ansi-html-community": "^0.0.8", - "common-path-prefix": "^3.0.0", "core-js-pure": "^3.23.3", "error-stack-parser": "^2.0.6", - "find-up": "^5.0.0", "html-entities": "^2.1.0", "loader-utils": "^2.0.4", "schema-utils": "^3.0.0", @@ -4773,7 +4771,7 @@ "sockjs-client": "^1.4.0", "type-fest": ">=0.17.0 <5.0.0", "webpack": ">=4.43.0 <6.0.0", - "webpack-dev-server": "3.x || 4.x", + "webpack-dev-server": "3.x || 4.x || 5.x", "webpack-hot-middleware": "2.x", "webpack-plugin-serve": "0.x || 1.x" }, diff --git a/package.json b/package.json index 6cd208bc9b..88d40b281c 100644 --- a/package.json +++ b/package.json @@ -181,7 +181,7 @@ "@axe-core/playwright": "^4.9.0", "@fortawesome/fontawesome-common-types": "^0.2.36", "@playwright/test": "^1.43.1", - "@pmmmwh/react-refresh-webpack-plugin": "^0.5.11", + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.13", "@shopify/jest-dom-mocks": "^5.0.0", "@sindresorhus/tsconfig": "^5.0.0", "@sinonjs/fake-timers": "^11.2.2", diff --git a/src/bricks/renderers/CustomFormComponent.tsx b/src/bricks/renderers/CustomFormComponent.tsx index 9d081cb6eb..66263fa042 100644 --- a/src/bricks/renderers/CustomFormComponent.tsx +++ b/src/bricks/renderers/CustomFormComponent.tsx @@ -34,6 +34,7 @@ import DescriptionField from "@/components/formBuilder/DescriptionField"; import TextAreaWidget from "@/components/formBuilder/TextAreaWidget"; import RjsfSubmitContext from "@/components/formBuilder/RjsfSubmitContext"; import { cloneDeep } from "lodash"; +import { useStylesheetsContextWithFormDefault } from "@/components/StylesheetsContext"; const FIELDS = { DescriptionField, @@ -65,6 +66,7 @@ export type CustomFormComponentProps = { resetOnSubmit?: boolean; className?: string; stylesheets?: string[]; + disableParentStyles?: boolean; }; const CustomFormComponent: React.FunctionComponent< @@ -78,7 +80,8 @@ const CustomFormComponent: React.FunctionComponent< className, onSubmit, resetOnSubmit = false, - stylesheets, + disableParentStyles = false, + stylesheets: newStylesheets, }) => { // Use useRef instead of useState because we don't need/want a re-render when count changes // This ref is used to track the onSubmit run number for runtime tracing @@ -99,6 +102,11 @@ const CustomFormComponent: React.FunctionComponent< setKey((prev) => prev + 1); }; + const { stylesheets } = useStylesheetsContextWithFormDefault({ + newStylesheets, + disableParentStyles, + }); + const submitData = async (data: UnknownObject): Promise => { submissionCountRef.current += 1; await onSubmit(data, { diff --git a/src/bricks/renderers/documentView/DocumentView.tsx b/src/bricks/renderers/documentView/DocumentView.tsx index d1f65c5d24..fdb7cc3cba 100644 --- a/src/bricks/renderers/documentView/DocumentView.tsx +++ b/src/bricks/renderers/documentView/DocumentView.tsx @@ -23,10 +23,14 @@ import { type DocumentViewProps } from "./DocumentViewProps"; import DocumentContext from "@/components/documentBuilder/render/DocumentContext"; import { Stylesheets } from "@/components/Stylesheets"; import { joinPathParts } from "@/utils/formUtils"; +import StylesheetsContext, { + useStylesheetsContextWithDocumentDefault, +} from "@/components/StylesheetsContext"; const DocumentView: React.FC = ({ body, - stylesheets, + stylesheets: newStylesheets, + disableParentStyles, options, meta, onAction, @@ -41,26 +45,33 @@ const DocumentView: React.FC = ({ throw new Error("meta.extensionId is required for DocumentView"); } + const { stylesheets } = useStylesheetsContextWithDocumentDefault({ + newStylesheets, + disableParentStyles, + }); + return ( // Wrap in a React context provider that passes BrickOptions down to any embedded bricks - - {body.map((documentElement, index) => { - const documentBranch = buildDocumentBranch(documentElement, { - staticId: joinPathParts("body", "children"), - // Root of the document, so no branches taken yet - branches: [], - }); + + + {body.map((documentElement, index) => { + const documentBranch = buildDocumentBranch(documentElement, { + staticId: joinPathParts("body", "children"), + // Root of the document, so no branches taken yet + branches: [], + }); - if (documentBranch == null) { - return null; - } + if (documentBranch == null) { + return null; + } - const { Component, props } = documentBranch; - // eslint-disable-next-line react/no-array-index-key -- They have no other unique identifier - return ; - })} - + const { Component, props } = documentBranch; + // eslint-disable-next-line react/no-array-index-key -- They have no other unique identifier + return ; + })} + + ); }; diff --git a/src/bricks/renderers/documentView/DocumentViewProps.tsx b/src/bricks/renderers/documentView/DocumentViewProps.tsx index 65fca3255c..f677846a6f 100644 --- a/src/bricks/renderers/documentView/DocumentViewProps.tsx +++ b/src/bricks/renderers/documentView/DocumentViewProps.tsx @@ -33,6 +33,10 @@ export type DocumentViewProps = { * Remote stylesheets (URLs) to include in the document. */ stylesheets?: string[]; + /** + * Whether to disable the base (bootstrap) styles, plus any inherited styles, on the document (and children). + */ + disableParentStyles?: boolean; options: BrickOptions; meta: { diff --git a/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx b/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx index 689ba4c3e4..d4e2a42748 100644 --- a/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx +++ b/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx @@ -32,6 +32,7 @@ import DescriptionField from "@/components/formBuilder/DescriptionField"; import RjsfSelectWidget from "@/components/formBuilder/RjsfSelectWidget"; import TextAreaWidget from "@/components/formBuilder/TextAreaWidget"; import { Stylesheets } from "@/components/Stylesheets"; +import { useStylesheetsContextWithFormDefault } from "@/components/StylesheetsContext"; export const fields = { DescriptionField, @@ -55,8 +56,21 @@ const EphemeralFormContent: React.FC = ({ nonce, isModal, }) => { - const { schema, uiSchema, cancelable, submitCaption, stylesheets } = - definition; + const { + schema, + uiSchema, + cancelable, + submitCaption, + stylesheets: newStylesheets, + disableParentStyles, + } = definition; + + // Ephemeral form can never be nested, but we use this to pull in + // the (boostrap) base themes + const { stylesheets } = useStylesheetsContextWithFormDefault({ + newStylesheets, + disableParentStyles: disableParentStyles ?? false, + }); return ( diff --git a/src/components/StylesheetsContext.ts b/src/components/StylesheetsContext.ts new file mode 100644 index 0000000000..d5a7c831da --- /dev/null +++ b/src/components/StylesheetsContext.ts @@ -0,0 +1,109 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import React, { useContext } from "react"; +import bootstrap from "@/vendors/bootstrapWithoutRem.css?loadAsUrl"; +import bootstrapOverrides from "@/sidebar/sidebarBootstrapOverrides.scss?loadAsUrl"; +import custom from "@/bricks/renderers/customForm.css?loadAsUrl"; + +export type StylesheetsContextType = { + stylesheets: string[] | null; +}; + +const StylesheetsContext = React.createContext({ + stylesheets: null, +}); + +function useStylesheetsContextWithDefaultValues({ + newStylesheets, + defaultStylesheets, + disableParentStyles, +}: { + newStylesheets: string[] | undefined; + defaultStylesheets: string[]; + disableParentStyles: boolean; +}): { + stylesheets: string[]; +} { + const { stylesheets: inheritedStylesheets } = useContext(StylesheetsContext); + + const stylesheets: string[] = []; + + if (!disableParentStyles) { + if (inheritedStylesheets == null) { + stylesheets.push(...defaultStylesheets); + } else { + stylesheets.push(...inheritedStylesheets); + } + } + + if (newStylesheets != null) { + stylesheets.push(...newStylesheets); + } + + return { stylesheets }; +} + +export function useStylesheetsContextWithDocumentDefault({ + newStylesheets, + disableParentStyles, +}: { + newStylesheets: string[] | undefined; + disableParentStyles: boolean; +}): { + stylesheets: string[]; +} { + return useStylesheetsContextWithDefaultValues({ + newStylesheets, + defaultStylesheets: [ + bootstrap, + bootstrapOverrides, + // DocumentView.css is an artifact produced by webpack, see the DocumentView entrypoint included in + // `webpack.config.mjs`. We build styles needed to render documents separately from the rest of the sidebar + // in order to isolate the rendered document from the custom Bootstrap theme included in the Sidebar app + "/DocumentView.css", + // Required because it can be nested in the DocumentView. + "/CustomFormComponent.css", + ], + disableParentStyles, + }); +} + +export function useStylesheetsContextWithFormDefault({ + newStylesheets, + disableParentStyles, +}: { + newStylesheets: string[] | undefined; + disableParentStyles: boolean; +}): { + stylesheets: string[]; +} { + return useStylesheetsContextWithDefaultValues({ + newStylesheets, + defaultStylesheets: [ + bootstrap, + bootstrapOverrides, + // CustomFormComponent.css and EphemeralFormContent.css are artifacts produced by webpack, see the entrypoints. + "/EphemeralFormContent.css", + "/CustomFormComponent.css", + custom, + ], + disableParentStyles, + }); +} + +export default StylesheetsContext; diff --git a/src/components/quickBar/QuickBarApp.tsx b/src/components/quickBar/QuickBarApp.tsx index 4f12d03a16..4dc2db20f8 100644 --- a/src/components/quickBar/QuickBarApp.tsx +++ b/src/components/quickBar/QuickBarApp.tsx @@ -33,6 +33,7 @@ import { once } from "lodash"; import { MAX_Z_INDEX, PIXIEBRIX_QUICK_BAR_CONTAINER_CLASS, + QUICK_BAR_READY_ATTRIBUTE, } from "@/domConstants"; import useEventListener from "@/hooks/useEventListener"; import { Stylesheets } from "@/components/Stylesheets"; @@ -203,6 +204,11 @@ export const QuickBarApp: React.FC = () => ( ); +function markQuickBarReady() { + const html = globalThis.document?.documentElement; + html.setAttribute(QUICK_BAR_READY_ATTRIBUTE, "true"); +} + export const initQuickBarApp = once(async () => { expectContext("contentScript"); @@ -226,6 +232,8 @@ export const initQuickBarApp = once(async () => { ReactDOM.render(, container); console.debug("Initialized quick bar"); + markQuickBarReady(); + onContextInvalidated.addListener(() => { console.debug("Removed quick bar due to context invalidation"); ReactDOM.unmountComponentAtNode(container); diff --git a/src/domConstants.ts b/src/domConstants.ts index 0a14cb3a0c..e3a594ab11 100644 --- a/src/domConstants.ts +++ b/src/domConstants.ts @@ -23,6 +23,8 @@ export const MAX_Z_INDEX = NOTIFICATIONS_Z_INDEX - 1; // Let notifications alway export const SELECTION_MENU_READY_ATTRIBUTE = "data-pb-selection-menu-ready"; +export const QUICK_BAR_READY_ATTRIBUTE = "data-pb-quick-bar-ready"; + export const PANEL_FRAME_ID = "pixiebrix-extension"; export const PIXIEBRIX_DATA_ATTR = "data-pb-uuid"; diff --git a/src/sidebar/ConnectedSidebar.test.tsx b/src/sidebar/ConnectedSidebar.test.tsx index 8b674a0df3..260f562884 100644 --- a/src/sidebar/ConnectedSidebar.test.tsx +++ b/src/sidebar/ConnectedSidebar.test.tsx @@ -32,8 +32,17 @@ import { } from "@/testUtils/factories/authFactories"; import { appApiMock } from "@/testUtils/appApiMock"; import { valueToAsyncState } from "@/utils/asyncStateUtils"; +import { isMV3 } from "@/mv3/api"; jest.mock("@/auth/useLinkState"); +jest.mock("@/mv3/api"); + +browser.webNavigation.onBeforeNavigate = { + addListener: jest.fn(), + removeListener: jest.fn(), + hasListener: jest.fn(), + hasListeners: jest.fn(), +}; jest.mock("@/contentScript/messenger/strict/api", () => ({ ensureExtensionPointsInstalled: jest.fn(), @@ -96,8 +105,45 @@ describe("SidebarApp", () => { }, ); + // The navigation listener should not be added for MV2 + expect( + browser.webNavigation.onBeforeNavigate.addListener, + ).not.toHaveBeenCalled(); + await waitForEffect(); expect(asFragment()).toMatchSnapshot(); }); + + describe("mv3", () => { + beforeEach(() => { + jest.mocked(isMV3).mockReturnValue(true); + }); + + test("it registers the navigation listener", async () => { + await mockAuthenticatedMeApiResponse(); + const { unmount } = render( + + + , + { + setupRedux(dispatch) { + dispatch(authActions.setAuth(authStateFactory())); + }, + }, + ); + + // The navigation listener should be added for MV3 + expect( + browser.webNavigation.onBeforeNavigate.addListener, + ).toHaveBeenCalledWith(expect.any(Function)); + + unmount(); + + // Removed on unmount + expect( + browser.webNavigation.onBeforeNavigate.removeListener, + ).toHaveBeenCalledWith(expect.any(Function)); + }); + }); }); diff --git a/src/sidebar/ConnectedSidebar.tsx b/src/sidebar/ConnectedSidebar.tsx index f46dc02a41..d303d77dfb 100644 --- a/src/sidebar/ConnectedSidebar.tsx +++ b/src/sidebar/ConnectedSidebar.tsx @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -import React, { useMemo } from "react"; +import React, { useEffect, useMemo } from "react"; import { addListener, removeListener, @@ -40,7 +40,10 @@ import DefaultPanel from "@/sidebar/DefaultPanel"; import { MOD_LAUNCHER } from "@/store/sidebar/constants"; import { ensureExtensionPointsInstalled } from "@/contentScript/messenger/api"; import { getReservedSidebarEntries } from "@/contentScript/messenger/strict/api"; -import { getConnectedTarget } from "@/sidebar/connectedTarget"; +import { + getConnectedTabIdMv3, + getConnectedTarget, +} from "@/sidebar/connectedTarget"; import useAsyncEffect from "use-async-effect"; import activateLinkClickHandler from "@/activation/activateLinkClickHandler"; import addFormPanel from "@/store/sidebar/thunks/addFormPanel"; @@ -48,6 +51,9 @@ import addTemporaryPanel from "@/store/sidebar/thunks/addTemporaryPanel"; import removeTemporaryPanel from "@/store/sidebar/thunks/removeTemporaryPanel"; import { type AsyncDispatch } from "@/sidebar/store"; import useEventListener from "@/hooks/useEventListener"; +import { WebNavigation } from "webextension-polyfill"; +import OnBeforeNavigateDetailsType = WebNavigation.OnBeforeNavigateDetailsType; +import { isMV3 } from "@/mv3/api"; /** * Listeners to update the Sidebar's Redux state upon receiving messages from the contentScript. @@ -98,7 +104,30 @@ const ConnectedSidebar: React.VFC = () => { const listener = useConnectedListener(); const sidebarIsEmpty = useSelector(selectIsSidebarEmpty); - // `useAsyncEffect` will run once on component mount since listener and formsRef don't change on renders. + // Listen for navigation events to mark temporary panels as unavailable. + // Not used in MV2 because the sidebar closes automatically on navigation. + useEffect(() => { + const navigationListenerMV3 = (details: OnBeforeNavigateDetailsType) => { + const { frameId, tabId } = details; + const connectedTabId = getConnectedTabIdMv3(); + if (tabId === connectedTabId && frameId === 0) { + console.log("navigationListener:connectedTabId", connectedTabId); + dispatch(sidebarSlice.actions.markTemporaryPanelsAsUnavailable()); + } + }; + + if (isMV3()) { + browser.webNavigation.onBeforeNavigate.addListener(navigationListenerMV3); + } + + return () => { + browser.webNavigation.onBeforeNavigate.removeListener( + navigationListenerMV3, + ); + }; + }, [dispatch]); + + // `useAsyncEffect` will run once on component mount since listeners and formsRef don't change on renders. // We could instead consider moving the initial panel logic to SidebarApp.tsx and pass the entries as the // initial state to the sidebarSlice reducer. useAsyncEffect(async () => { diff --git a/src/sidebar/Tabs.module.scss b/src/sidebar/Tabs.module.scss index a2758d42c5..c8fb4cb6b7 100644 --- a/src/sidebar/Tabs.module.scss +++ b/src/sidebar/Tabs.module.scss @@ -24,6 +24,8 @@ .tabContainer { flex-wrap: nowrap; + // Position relative is needed so that the blur overlay only covers the panel content area, and does not cover the whole sidebar (including the tabs and logo). + position: relative; } .tabWrapper { diff --git a/src/sidebar/Tabs.tsx b/src/sidebar/Tabs.tsx index 1c37f44fa9..4f102ce359 100644 --- a/src/sidebar/Tabs.tsx +++ b/src/sidebar/Tabs.tsx @@ -57,12 +57,12 @@ import { selectEventData } from "@/telemetry/deployments"; import ErrorBoundary from "@/sidebar/SidebarErrorBoundary"; import { TemporaryPanelTabPane } from "./TemporaryPanelTabPane"; import { MOD_LAUNCHER } from "@/store/sidebar/constants"; -import { getConnectedTarget } from "@/sidebar/connectedTarget"; -import { cancelForm } from "@/contentScript/messenger/strict/api"; import { useHideEmptySidebar } from "@/sidebar/useHideEmptySidebar"; import removeTemporaryPanel from "@/store/sidebar/thunks/removeTemporaryPanel"; import { type AsyncDispatch } from "@/sidebar/store"; import useOnMountOnly from "@/hooks/useOnMountOnly"; +import UnavailableOverlay from "@/sidebar/UnavailableOverlay"; +import removeFormPanel from "@/store/sidebar/thunks/removeFormPanel"; const ActivateModPanel = lazy( async () => @@ -172,8 +172,7 @@ const Tabs: React.FC = () => { if (isTemporaryPanelEntry(panel)) { await dispatch(removeTemporaryPanel(panel.nonce)); } else if (isFormPanelEntry(panel)) { - const frame = await getConnectedTarget(); - cancelForm(frame, panel.nonce); + await dispatch(removeFormPanel(panel.nonce)); } else if (isModActivationPanelEntry(panel)) { dispatch(sidebarSlice.actions.hideModActivationPanel()); } else { @@ -350,6 +349,11 @@ const Tabs: React.FC = () => { }); }} > + {form.isUnavailable && ( + dispatch(removeFormPanel(form.nonce))} + /> + )} diff --git a/src/sidebar/TemporaryPanelTabPane.tsx b/src/sidebar/TemporaryPanelTabPane.tsx index 73922ea27e..f04f64d7a7 100644 --- a/src/sidebar/TemporaryPanelTabPane.tsx +++ b/src/sidebar/TemporaryPanelTabPane.tsx @@ -29,6 +29,8 @@ import { useDispatch } from "react-redux"; import ErrorBoundary from "@/sidebar/SidebarErrorBoundary"; 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"; // 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 @@ -64,6 +66,11 @@ export const TemporaryPanelTabPane: React.FC<{ }); }} > + {panel.isUnavailable && ( + dispatch(removeTemporaryPanel(panel.nonce))} + /> + )} . + */ + +import React from "react"; +import styles from "./unavailableOverlay.module.scss"; +import cx from "classnames"; +import { Button, Modal } from "react-bootstrap"; + +const UnavailableOverlay = ({ onClose }: { onClose: () => void }) => ( +
+
+ + + Panel no longer available + + + +

The browser navigated away from the page

+ +
+
+
+
+); + +export default UnavailableOverlay; diff --git a/src/sidebar/connectedTarget.tsx b/src/sidebar/connectedTarget.tsx index 530482ee95..a97d832a66 100644 --- a/src/sidebar/connectedTarget.tsx +++ b/src/sidebar/connectedTarget.tsx @@ -22,7 +22,7 @@ import { once } from "lodash"; import { type TopLevelFrame, getTopLevelFrame } from "webext-messenger"; import { getTabUrl } from "webext-tools"; -function getConnectedTabIdMv3(): number { +export function getConnectedTabIdMv3(): number { expectContext("sidebar"); const tabId = new URLSearchParams(window.location.search).get("tabId"); assertNotNullish( diff --git a/src/sidebar/unavailableOverlay.module.scss b/src/sidebar/unavailableOverlay.module.scss new file mode 100644 index 0000000000..a55e6418a7 --- /dev/null +++ b/src/sidebar/unavailableOverlay.module.scss @@ -0,0 +1,58 @@ +/*! + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +.unavailableOverlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(255, 255, 255, 0.5); + justify-content: center; + align-items: flex-start; + backdrop-filter: blur(2.5px); + pointer-events: all; + z-index: 1; // ensure overlay is on top of the sidebar panel content + > div { + display: block; + margin-top: 10vh; + } +} + +.modalDialog { + text-align: center; + margin: 23px; + padding: 16px; + border-radius: 12px; + background: white; + border: 1px solid #cfcbd6; + > div { + border: 0; + } +} + +.modalHeader { + display: block; + padding-bottom: 0; + background: white; + border: 0; +} + +.modalBody { + background: white; + border: 0; +} diff --git a/src/store/sidebar/sidebarSlice.ts b/src/store/sidebar/sidebarSlice.ts index abf183f3cd..a2013beb09 100644 --- a/src/store/sidebar/sidebarSlice.ts +++ b/src/store/sidebar/sidebarSlice.ts @@ -45,6 +45,7 @@ import addTemporaryPanel from "@/store/sidebar/thunks/addTemporaryPanel"; import removeTemporaryPanel from "@/store/sidebar/thunks/removeTemporaryPanel"; import resolveTemporaryPanel from "@/store/sidebar/thunks/resolveTemporaryPanel"; import { initialSidebarState } from "@/store/sidebar/initialState"; +import removeFormPanel from "@/store/sidebar/thunks/removeFormPanel"; function eventKeyExists( state: SidebarState, @@ -217,6 +218,15 @@ const sidebarSlice = createSlice({ fixActiveTabOnRemove(state, entry); }, + markTemporaryPanelsAsUnavailable(state) { + for (const form of state.forms) { + form.isUnavailable = true; + } + + for (const temporaryPanel of state.temporaryPanels) { + temporaryPanel.isUnavailable = true; + } + }, updateTemporaryPanel( state, action: PayloadAction<{ panel: TemporaryPanelEntry }>, @@ -336,6 +346,14 @@ const sidebarSlice = createSlice({ state.activeKey = eventKeyForEntry(newForm); } }) + .addCase(removeFormPanel.fulfilled, (state, action) => { + if (action.payload) { + const { removedEntry, forms } = action.payload; + + state.forms = castDraft(forms); + fixActiveTabOnRemove(state, removedEntry); + } + }) .addCase(addTemporaryPanel.fulfilled, (state, action) => { const { temporaryPanels, activeKey } = action.payload; diff --git a/src/store/sidebar/thunks/removeFormPanel.ts b/src/store/sidebar/thunks/removeFormPanel.ts new file mode 100644 index 0000000000..9431f36dce --- /dev/null +++ b/src/store/sidebar/thunks/removeFormPanel.ts @@ -0,0 +1,57 @@ +/* + * Copyright (C) 2024 PixieBrix, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { cancelForm } from "@/contentScript/messenger/strict/api"; +import { getConnectedTarget } from "@/sidebar/connectedTarget"; +import { type SidebarState } from "@/types/sidebarTypes"; +import { type UUID } from "@/types/stringTypes"; +import { createAsyncThunk } from "@reduxjs/toolkit"; +import { partition } from "lodash"; + +type RemoveFormPanelReturn = + | { + removedEntry: SidebarState["forms"][number]; + forms: SidebarState["forms"]; + } + | undefined; + +const removeFormPanel = createAsyncThunk< + RemoveFormPanelReturn, + UUID, + { state: { sidebar: SidebarState } } +>("sidebar/removeFormPanel", async (nonce, { getState }) => { + const { forms } = getState().sidebar; + + const [[removedEntry], otherFormPanels] = partition( + forms, + (panel) => panel.nonce === nonce, + ); + + if (!removedEntry) { + return; + } + + const topLevelFrame = await getConnectedTarget(); + cancelForm(topLevelFrame, nonce); + + return { + removedEntry, + forms: otherFormPanels, + }; +}); + +export default removeFormPanel; diff --git a/src/testUtils/factories/sidebarEntryFactories.ts b/src/testUtils/factories/sidebarEntryFactories.ts index a242960b81..cd38d2c025 100644 --- a/src/testUtils/factories/sidebarEntryFactories.ts +++ b/src/testUtils/factories/sidebarEntryFactories.ts @@ -17,9 +17,9 @@ import { define, type FactoryConfig } from "cooky-cutter"; import { - type ModActivationPanelEntry, type EntryType, type FormPanelEntry, + type ModActivationPanelEntry, type PanelEntry, type SidebarEntry, type StaticPanelEntry, diff --git a/src/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index b06750e361..e07f159464 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -252,6 +252,7 @@ "./components/StopPropagation.tsx", "./components/Stylesheets.test.tsx", "./components/Stylesheets.tsx", + "./components/StylesheetsContext.ts", "./components/TooltipIconButton.tsx", "./components/UnstyledButton.tsx", "./components/addBlockModal/TagList.tsx", @@ -787,6 +788,7 @@ "./sidebar/sidePanel.tsx", "./sidebar/sidebarSelectors.ts", "./sidebar/staticPanelUtils.tsx", + "./sidebar/UnavailableOverlay.tsx", "./sidebar/useHideEmptySidebar.ts", "./starterBricks/contextMenu/contextMenuReader.ts", "./starterBricks/contextMenu/types.ts", @@ -837,6 +839,7 @@ "./store/sidebar/sidebarStorage.ts", "./store/sidebar/thunks/addFormPanel.ts", "./store/sidebar/thunks/addTemporaryPanel.ts", + "./store/sidebar/thunks/removeFormPanel.ts", "./store/sidebar/thunks/removeTemporaryPanel.ts", "./store/sidebar/thunks/resolveTemporaryPanel.ts", "./store/sidebar/utils.ts", diff --git a/src/types/sidebarTypes.ts b/src/types/sidebarTypes.ts index e833977c77..8bd99a2fcd 100644 --- a/src/types/sidebarTypes.ts +++ b/src/types/sidebarTypes.ts @@ -111,6 +111,15 @@ type BasePanelEntry = { * The panel type. */ type: EntryType; + + /** + * Determines if the panel cannot be displayed for the current tab. Used + * to show an overlay over the panel to indicate it is unavailable. Added this + * field to account for MV3 side panel that persists across page navigation + * + * @since 1.8.14 + */ + isUnavailable?: boolean; }; /** diff --git a/webpack.config.mjs b/webpack.config.mjs index 99f7e0e9ca..e3d83f079b 100644 --- a/webpack.config.mjs +++ b/webpack.config.mjs @@ -203,7 +203,7 @@ const createConfig = (env, options) => }), // Only notifies when watching. `zsh-notify` is suggested for the `build` script - options.watch && + !isProd(options) && process.env.DEV_NOTIFY !== "false" && new WebpackBuildNotifierPlugin({ title: "PB Extension",