Skip to content

Commit

Permalink
#8347 Fix custom document stylesheets nested stylesheet inheritance (#…
Browse files Browse the repository at this point in the history
…8353)

* introduce empty sidebarPanelTheme spec file

* introduce sidebar theme test

* add steps for opening the sidebar

* finish failing test with green assertions

* replace StylesheetsContext

* replace StylesheetsContext provider and hooks usage

* fix lint errors and add StylesheetsContext to strictnull

* replace ephemeralformcontent logic

* fix strict null error
  • Loading branch information
mnholtz authored and twschiller committed Apr 30, 2024
1 parent d34e30b commit 57830e4
Show file tree
Hide file tree
Showing 7 changed files with 220 additions and 19 deletions.
54 changes: 54 additions & 0 deletions 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 <http://www.gnu.org/licenses/>.
*/

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),
),
);
});
10 changes: 9 additions & 1 deletion src/bricks/renderers/CustomFormComponent.tsx
Expand Up @@ -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,
Expand Down Expand Up @@ -65,6 +66,7 @@ export type CustomFormComponentProps = {
resetOnSubmit?: boolean;
className?: string;
stylesheets?: string[];
disableParentStyles?: boolean;
};

const CustomFormComponent: React.FunctionComponent<
Expand All @@ -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
Expand All @@ -99,6 +102,11 @@ const CustomFormComponent: React.FunctionComponent<
setKey((prev) => prev + 1);
};

const { stylesheets } = useStylesheetsContextWithFormDefault({
newStylesheets,
disableParentStyles,
});

const submitData = async (data: UnknownObject): Promise<void> => {
submissionCountRef.current += 1;
await onSubmit(data, {
Expand Down
43 changes: 27 additions & 16 deletions src/bricks/renderers/documentView/DocumentView.tsx
Expand Up @@ -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<DocumentViewProps> = ({
body,
stylesheets,
stylesheets: newStylesheets,
disableParentStyles,
options,
meta,
onAction,
Expand All @@ -41,26 +45,33 @@ const DocumentView: React.FC<DocumentViewProps> = ({
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
<DocumentContext.Provider value={{ options, onAction }}>
<Stylesheets href={stylesheets}>
{body.map((documentElement, index) => {
const documentBranch = buildDocumentBranch(documentElement, {
staticId: joinPathParts("body", "children"),
// Root of the document, so no branches taken yet
branches: [],
});
<StylesheetsContext.Provider value={{ stylesheets }}>
<Stylesheets href={stylesheets}>
{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 <Component key={index} {...props} />;
})}
</Stylesheets>
const { Component, props } = documentBranch;
// eslint-disable-next-line react/no-array-index-key -- They have no other unique identifier
return <Component key={index} {...props} />;
})}
</Stylesheets>
</StylesheetsContext.Provider>
</DocumentContext.Provider>
);
};
Expand Down
4 changes: 4 additions & 0 deletions src/bricks/renderers/documentView/DocumentViewProps.tsx
Expand Up @@ -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<BrickArgsContext>;
meta: {
Expand Down
18 changes: 16 additions & 2 deletions src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx
Expand Up @@ -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,
Expand All @@ -55,8 +56,21 @@ const EphemeralFormContent: React.FC<EphemeralFormContentProps> = ({
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 (
<Stylesheets href={stylesheets}>
Expand Down
109 changes: 109 additions & 0 deletions 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 <http://www.gnu.org/licenses/>.
*/

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<StylesheetsContextType>({
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;
1 change: 1 addition & 0 deletions src/tsconfig.strictNullChecks.json
Expand Up @@ -247,6 +247,7 @@
"./components/StopPropagation.tsx",
"./components/Stylesheets.test.tsx",
"./components/Stylesheets.tsx",
"./components/StylesheetsContext.ts",
"./components/TooltipIconButton.tsx",
"./components/UnstyledButton.tsx",
"./components/addBlockModal/TagList.tsx",
Expand Down

0 comments on commit 57830e4

Please sign in to comment.