diff --git a/scripts/DiscardFilePlugin.mjs b/scripts/DiscardFilePlugin.mjs index 322dfc75a8..4afd567755 100644 --- a/scripts/DiscardFilePlugin.mjs +++ b/scripts/DiscardFilePlugin.mjs @@ -19,11 +19,6 @@ export default class DiscardFilePlugin { // If `delete assets[]` causes issues in the future, try replacing the content instead: // assets["DocumentView.js"] = new webpack.sources.RawSource('"Dropped"'); } - - // TODO: Use and move these to isolatedComponentList - delete assets["DocumentView.js"]; - delete assets["EphemeralFormContent.js"]; - delete assets["CustomFormComponent.js"]; }, ); }); diff --git a/src/bricks/renderers/CustomFormComponent.tsx b/src/bricks/renderers/CustomFormComponent.tsx index 8551f80228..9d081cb6eb 100644 --- a/src/bricks/renderers/CustomFormComponent.tsx +++ b/src/bricks/renderers/CustomFormComponent.tsx @@ -15,6 +15,9 @@ * along with this program. If not, see . */ +import "@/vendors/bootstrapWithoutRem.css"; +import "@/sidebar/sidebarBootstrapOverrides.scss"; +import "@/bricks/renderers/customForm.css"; import React, { useEffect, useRef, useState } from "react"; import { type Schema, type UiSchema } from "@/types/schemaTypes"; import { type JsonObject } from "type-fest"; @@ -24,14 +27,13 @@ import { Stylesheets } from "@/components/Stylesheets"; import JsonSchemaForm from "@rjsf/bootstrap-4"; import validator from "@/validators/formValidator"; import { type IChangeEvent } from "@rjsf/core"; +import { templates } from "@/components/formBuilder/RjsfTemplates"; import ImageCropWidget from "@/components/formBuilder/ImageCropWidget"; import RjsfSelectWidget from "@/components/formBuilder/RjsfSelectWidget"; import DescriptionField from "@/components/formBuilder/DescriptionField"; import TextAreaWidget from "@/components/formBuilder/TextAreaWidget"; import RjsfSubmitContext from "@/components/formBuilder/RjsfSubmitContext"; -import { templates } from "@/components/formBuilder/RjsfTemplates"; import { cloneDeep } from "lodash"; -import { useStylesheetsContextWithFormDefault } from "@/components/StylesheetsContext"; const FIELDS = { DescriptionField, @@ -43,7 +45,7 @@ const UI_WIDGETS = { TextareaWidget: TextAreaWidget, } as const; -const CustomFormComponent: React.FunctionComponent<{ +export type CustomFormComponentProps = { schema: Schema; uiSchema: UiSchema; submitCaption: string; @@ -63,8 +65,11 @@ const CustomFormComponent: React.FunctionComponent<{ resetOnSubmit?: boolean; className?: string; stylesheets?: string[]; - disableParentStyles?: boolean; -}> = ({ +}; + +const CustomFormComponent: React.FunctionComponent< + CustomFormComponentProps +> = ({ schema, uiSchema, submitCaption, @@ -73,8 +78,7 @@ const CustomFormComponent: React.FunctionComponent<{ className, onSubmit, resetOnSubmit = false, - stylesheets: newStylesheets, - disableParentStyles = false, + stylesheets, }) => { // 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 @@ -95,11 +99,6 @@ 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/customForm.test.tsx b/src/bricks/renderers/customForm.test.tsx index d882062302..07db46a69c 100644 --- a/src/bricks/renderers/customForm.test.tsx +++ b/src/bricks/renderers/customForm.test.tsx @@ -16,7 +16,8 @@ */ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render } from "@testing-library/react"; +import { screen } from "shadow-dom-testing-library"; import ImageCropWidget from "@/components/formBuilder/ImageCropWidget"; import DescriptionField from "@/components/formBuilder/DescriptionField"; import JsonSchemaForm from "@rjsf/bootstrap-4"; @@ -26,7 +27,6 @@ import { normalizeIncomingFormData, normalizeOutgoingFormData, } from "./customForm"; -import { waitForEffect } from "@/testUtils/testHelpers"; import userEvent from "@testing-library/user-event"; import { dataStore } from "@/background/messenger/strict/api"; @@ -38,6 +38,16 @@ import { toExpression } from "@/utils/expressionUtils"; const dataStoreGetMock = jest.mocked(dataStore.get); const dataStoreSetSpy = jest.spyOn(dataStore, "set"); +// I couldn't get shadow-dom-testing-library working +jest.mock("react-shadow/emotion", () => ({ + __esModule: true, + default: { + div(props: any) { + return
; + }, + }, +})); + describe("form data normalization", () => { const normalizationTestCases = [ { @@ -186,7 +196,7 @@ describe("form data normalization", () => { />, ); - await waitForEffect(); + await expect(screen.findByRole("button")).resolves.toBeInTheDocument(); // Make sure the form renders the data without errors expect(asFragment()).toMatchSnapshot(); @@ -230,7 +240,7 @@ describe("CustomFormRenderer", () => { render(); expect(screen.queryByText("Submit")).not.toBeInTheDocument(); - expect(screen.getByRole("textbox")).toBeInTheDocument(); + await expect(screen.findByRole("textbox")).resolves.toBeInTheDocument(); }); test("Supports postSubmitAction reset", async () => { @@ -261,7 +271,7 @@ describe("CustomFormRenderer", () => { render(); - const textBox = screen.getByRole("textbox"); + const textBox = await screen.findByRole("textbox"); await userEvent.type(textBox, "Some text"); expect(textBox).toHaveValue("Some text"); await userEvent.click(screen.getByRole("button", { name: "Submit" })); @@ -305,8 +315,10 @@ describe("CustomFormRenderer", () => { render(); const value = "Some text"; - const textBox = screen.getByRole("textbox"); + const textBox = await screen.findByRole("textbox"); + await userEvent.type(textBox, value); + await userEvent.click(screen.getByRole("button", { name: "Submit" })); expect(runPipelineMock).toHaveBeenCalledOnce(); diff --git a/src/bricks/renderers/customForm.ts b/src/bricks/renderers/customForm.tsx similarity index 95% rename from src/bricks/renderers/customForm.ts rename to src/bricks/renderers/customForm.tsx index f862c019e8..b514d769dd 100644 --- a/src/bricks/renderers/customForm.ts +++ b/src/bricks/renderers/customForm.tsx @@ -15,6 +15,7 @@ * along with this program. If not, see . */ +import React from "react"; import { type JsonObject } from "type-fest"; import { dataStore } from "@/background/messenger/strict/api"; import { validateRegistryId } from "@/types/helpers"; @@ -46,6 +47,8 @@ import { getOutputReference, validateOutputKey } from "@/runtime/runtimeTypes"; import { type BrickConfig } from "@/bricks/types"; import { isExpression } from "@/utils/expressionUtils"; import { getPlatform } from "@/platform/platformContext"; +import IsolatedComponent from "@/components/IsolatedComponent"; +import { type CustomFormComponentProps } from "./CustomFormComponent"; interface DatabaseResult { success: boolean; @@ -297,10 +300,20 @@ export class CustomFormRenderer extends RendererABC { normalizedData, }); - // Changed webpackChunkName to de-conflict with the manual entry in webpack used to load in the stylesheets - const { default: CustomFormComponent } = await import( - /* webpackChunkName: "CustomFormRendererComponent" */ - "./CustomFormComponent" + const CustomFormComponent: React.FunctionComponent< + CustomFormComponentProps + > = (props) => ( + + import( + /* webpackChunkName: "isolated/CustomFormComponent" */ + "./CustomFormComponent" + ) + } + factory={(CustomFormComponent) => } + /> ); return { @@ -314,7 +327,6 @@ export class CustomFormRenderer extends RendererABC { submitCaption, className, stylesheets, - disableParentStyles, // Option only applies if a custom onSubmit handler is provided resetOnSubmit: onSubmit != null && postSubmitAction === "reset", async onSubmit( diff --git a/src/bricks/renderers/document.ts b/src/bricks/renderers/document.tsx similarity index 76% rename from src/bricks/renderers/document.ts rename to src/bricks/renderers/document.tsx index b7739fe732..bee5d75207 100644 --- a/src/bricks/renderers/document.ts +++ b/src/bricks/renderers/document.tsx @@ -15,8 +15,8 @@ * along with this program. If not, see . */ +import React from "react"; import { RendererABC } from "@/types/bricks/rendererTypes"; -import DocumentViewLazy from "./documentView/DocumentViewLazy"; import { validateRegistryId } from "@/types/helpers"; import { type BrickArgs, @@ -28,6 +28,8 @@ import { DOCUMENT_ELEMENT_TYPES, type DocumentElement, } from "@/components/documentBuilder/documentBuilderTypes"; +import IsolatedComponent from "@/components/IsolatedComponent"; +import { type DocumentViewProps } from "./documentView/DocumentViewProps"; export const DOCUMENT_SCHEMA: Schema = { $schema: "https://json-schema.org/draft/2019-09/schema#", @@ -107,12 +109,30 @@ export class DocumentRenderer extends RendererABC { }>, options: BrickOptions, ): Promise { + const DocumentView: React.FC = (props) => ( + + import( + /* webpackChunkName: "isolated/DocumentView" */ + "./documentView/DocumentView" + ) + } + factory={(DocumentView) => } + // It must fill the frame even if `noStyle` is set, so set it as a style prop + // TODO: The parent node should instead make sure that the children fill + // the sidebar vertically (via a simple `.d-flex`), but this this requires + // verifying that other components aren't broken by this. + style={{ height: "100%" }} + /> + ); + return { - Component: DocumentViewLazy, + Component: DocumentView, props: { body, stylesheets, - disableParentStyles, options, }, }; diff --git a/src/bricks/renderers/documentView/DocumentView.tsx b/src/bricks/renderers/documentView/DocumentView.tsx index e989b8699b..d1f65c5d24 100644 --- a/src/bricks/renderers/documentView/DocumentView.tsx +++ b/src/bricks/renderers/documentView/DocumentView.tsx @@ -15,21 +15,18 @@ * along with this program. If not, see . */ +import "@/vendors/bootstrapWithoutRem.css"; +import "@/sidebar/sidebarBootstrapOverrides.scss"; import { buildDocumentBranch } from "@/components/documentBuilder/documentTree"; import React from "react"; -import EmotionShadowRoot from "@/components/EmotionShadowRoot"; 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: newStylesheets, - disableParentStyles, + stylesheets, options, meta, onAction, @@ -44,35 +41,26 @@ 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/DocumentViewLazy.tsx b/src/bricks/renderers/documentView/DocumentViewLazy.tsx deleted file mode 100644 index 8ff3ef2fcf..0000000000 --- a/src/bricks/renderers/documentView/DocumentViewLazy.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * 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, { Suspense } from "react"; -import { type DocumentViewProps } from "./DocumentViewProps"; - -const DocumentView = React.lazy( - async () => - import( - /* webpackChunkName: "components-lazy" */ - "./DocumentView" - ), -); - -const DocumentViewLazy: React.FC = (props) => ( - - - -); - -export default DocumentViewLazy; diff --git a/src/bricks/renderers/documentView/DocumentViewProps.tsx b/src/bricks/renderers/documentView/DocumentViewProps.tsx index 7ebc9c1dd5..65fca3255c 100644 --- a/src/bricks/renderers/documentView/DocumentViewProps.tsx +++ b/src/bricks/renderers/documentView/DocumentViewProps.tsx @@ -33,10 +33,7 @@ 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: { runId: UUID; diff --git a/src/bricks/transformers/ephemeralForm/EphemeralForm.tsx b/src/bricks/transformers/ephemeralForm/EphemeralForm.tsx index 3c18691848..1a84ad4056 100644 --- a/src/bricks/transformers/ephemeralForm/EphemeralForm.tsx +++ b/src/bricks/transformers/ephemeralForm/EphemeralForm.tsx @@ -26,10 +26,10 @@ import { type Target } from "@/types/messengerTypes"; import { validateUUID } from "@/types/helpers"; import { TOP_LEVEL_FRAME_ID } from "@/domConstants"; import useAsyncState from "@/hooks/useAsyncState"; -import { EphemeralFormContent } from "./EphemeralFormContent"; -import EmotionShadowRoot from "@/components/EmotionShadowRoot"; import ErrorBoundary from "@/components/ErrorBoundary"; import useReportError from "@/hooks/useReportError"; +import IsolatedComponent from "@/components/IsolatedComponent"; +import { type EphemeralFormContentProps } from "./EphemeralFormContent"; const ModalLayout: React.FC = ({ children }) => ( // Don't use React Bootstrap's Modal because we want to customize the classes in the layout @@ -42,6 +42,22 @@ const PanelLayout: React.FC = ({ children }) => (
{children}
); +const EphemeralFormContent: React.FunctionComponent< + EphemeralFormContentProps +> = (props) => ( + + import( + /* webpackChunkName: "isolated/EphemeralFormContent" */ + "./EphemeralFormContent" + ) + } + factory={(EphemeralFormContent) => } + /> +); + /** * @see FormTransformer */ @@ -101,14 +117,12 @@ const EphemeralForm: React.FC = () => { return ( - - - + ); diff --git a/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx b/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx index e5166ca363..689ba4c3e4 100644 --- a/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx +++ b/src/bricks/transformers/ephemeralForm/EphemeralFormContent.tsx @@ -15,16 +15,18 @@ * along with this program. If not, see . */ +import "@/vendors/bootstrapWithoutRem.css"; +import "@/sidebar/sidebarBootstrapOverrides.scss"; +import "@/bricks/renderers/customForm.css"; import React from "react"; import validator from "@/validators/formValidator"; import JsonSchemaForm from "@rjsf/bootstrap-4"; import { cancelForm, resolveForm } from "@/contentScript/messenger/strict/api"; import { type Target } from "@/types/messengerTypes"; -import { templates } from "@/components/formBuilder/RjsfTemplates"; import { cloneDeep } from "lodash"; -import { useStylesheetsContextWithFormDefault } from "@/components/StylesheetsContext"; import { type FormDefinition } from "@/platform/forms/formTypes"; import { type UUID } from "@/types/stringTypes"; +import { templates } from "@/components/formBuilder/RjsfTemplates"; import ImageCropWidget from "@/components/formBuilder/ImageCropWidget"; import DescriptionField from "@/components/formBuilder/DescriptionField"; import RjsfSelectWidget from "@/components/formBuilder/RjsfSelectWidget"; @@ -40,27 +42,21 @@ export const uiWidgets = { TextareaWidget: TextAreaWidget, } as const; -export const EphemeralFormContent: React.FC<{ +export type EphemeralFormContentProps = { definition: FormDefinition; target: Target; nonce: UUID; isModal: boolean; -}> = ({ definition, target, nonce, isModal }) => { - const { - schema, - uiSchema, - cancelable, - submitCaption, - stylesheets: newStylesheets, - disableParentStyles = false, - } = definition; +}; - // Ephemeral form can never be nested, but we use this to pull in - // the (boostrap) base themes - const { stylesheets } = useStylesheetsContextWithFormDefault({ - newStylesheets, - disableParentStyles, - }); +const EphemeralFormContent: React.FC = ({ + definition, + target, + nonce, + isModal, +}) => { + const { schema, uiSchema, cancelable, submitCaption, stylesheets } = + definition; return ( @@ -98,3 +94,5 @@ export const EphemeralFormContent: React.FC<{ ); }; + +export default EphemeralFormContent; diff --git a/src/components/EmotionShadowRoot.ts b/src/components/EmotionShadowRoot.ts index 8d0fad6a7a..697962c619 100644 --- a/src/components/EmotionShadowRoot.ts +++ b/src/components/EmotionShadowRoot.ts @@ -20,7 +20,7 @@ Also strictNullChecks config mismatch */ */ // eslint-disable-next-line no-restricted-imports -- All roads lead here -import EmotionShadowRoot from "react-shadow/emotion"; +import ShadowRoot from "react-shadow/emotion"; import { type CSSProperties } from "react"; /** @@ -28,7 +28,7 @@ import { type CSSProperties } from "react"; * the host website. To support react-select and any future potential emotion * components we used the emotion variant of the react-shadow library. */ -const ShadowRoot = EmotionShadowRoot.div!; +const EmotionShadowRoot = ShadowRoot.div!; // TODO: Use EmotionShadowRoot["pixiebrix-widget"] to avoid any CSS conflicts. Requires snapshot/test updates export const styleReset: CSSProperties = { @@ -36,4 +36,4 @@ export const styleReset: CSSProperties = { font: "16px / 1.5 sans-serif", }; -export default ShadowRoot; +export default EmotionShadowRoot; diff --git a/src/components/IsolatedComponent.tsx b/src/components/IsolatedComponent.tsx index c7a073c955..e1fc944463 100644 --- a/src/components/IsolatedComponent.tsx +++ b/src/components/IsolatedComponent.tsx @@ -69,7 +69,10 @@ async function discardStylesheetsWhilePending( } } -type Props = { +type Props = React.DetailedHTMLProps< + React.HTMLAttributes, + HTMLDivElement +> & { /** * It must match the `import()`ed component's filename */ @@ -100,6 +103,8 @@ type Props = { /** * Isolate component loaded via React.lazy() in a shadow DOM, including its styles. * + * Additional props will be passed to the Shadow DOM root element. + * * @example * render( *