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/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/tsconfig.strictNullChecks.json b/src/tsconfig.strictNullChecks.json index 182122cf49..2422965741 100644 --- a/src/tsconfig.strictNullChecks.json +++ b/src/tsconfig.strictNullChecks.json @@ -250,6 +250,7 @@ "./components/StopPropagation.tsx", "./components/Stylesheets.test.tsx", "./components/Stylesheets.tsx", + "./components/StylesheetsContext.ts", "./components/TooltipIconButton.tsx", "./components/UnstyledButton.tsx", "./components/addBlockModal/TagList.tsx",