Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#1976 Document Builder. Lists #2077

Merged
merged 14 commits into from
Dec 9, 2021
106 changes: 106 additions & 0 deletions src/components/documentBuilder/DocumentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* Copyright (C) 2021 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 DocumentContext from "./DocumentContext";
import { UnknownObject } from "@/types";
import { Args, mapArgs } from "@/runtime/mapArgs";
import { useAsyncState } from "@/hooks/common";
import { GridLoader } from "react-spinners";
import { BuildDocumentBranch, DocumentElement } from "./documentBuilderTypes";
import { produce } from "immer";
import ErrorBoundary from "@/components/ErrorBoundary";
import { getErrorMessage } from "@/errors";

type DocumentListProps = {
array: UnknownObject[];
elementKey?: string;
config: Args;
buildDocumentBranch: BuildDocumentBranch;
};

const DocumentListInternal: React.FC<DocumentListProps> = ({
array,
elementKey,
config,
buildDocumentBranch,
}) => {
// Should be 'element' for any falsy value including empty string.
elementKey = elementKey || "element";

const documentContext = useContext(DocumentContext);

const [rootDefinitions, isLoading, error] = useAsyncState(
async () =>
Promise.all(
array.map(async (itemData) => {
const elementContext = produce(documentContext, (draft) => {
draft.options.ctxt[`@${elementKey}`] = itemData;
});

return (mapArgs(config, elementContext.options.ctxt, {
implicitRender: null,
autoescape: true,
}) as Promise<DocumentElement>).then((documentElement) => ({
documentElement,
elementContext,
}));
})
),
[array, elementKey, config, documentContext]
);

if (isLoading) {
return <GridLoader />;
}

if (error) {
return (
<details>
<summary className="text-danger">{getErrorMessage(error)}</summary>

<pre className="mt-2">
{((error as Error).stack ?? "").replaceAll(
`chrome-extension://${process.env.CHROME_EXTENSION_ID}/`,
""
)}
</pre>
</details>
);
}

return (
<>
{rootDefinitions.map(({ documentElement, elementContext }, i) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Somehow I'm getting an Cannot read properties of undefined (reading 'map') error here

I think it happens when there's an error calculating the asyncState?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I updated this to display the error when the factory of useAsyncState fails

const { Component, props } = buildDocumentBranch(documentElement);
return (
<DocumentContext.Provider key={i} value={elementContext}>
<Component {...props} />
</DocumentContext.Provider>
);
})}
</>
);
};

const DocumentList: React.FC<DocumentListProps> = (props) => (
<ErrorBoundary>
<DocumentListInternal {...props} />
</ErrorBoundary>
);

export default DocumentList;
64 changes: 59 additions & 5 deletions src/components/documentBuilder/ElementEdit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,27 @@
*/

import { useField } from "formik";
import React from "react";
import { DocumentElement, DocumentElementType } from "./documentBuilderTypes";
import React, { ChangeEventHandler } from "react";
import {
DocumentElement,
DocumentElementType,
isListDocument,
ListDocumentElement,
} from "./documentBuilderTypes";
import SchemaField from "@/components/fields/schemaFields/SchemaField";
import { getElementEditSchemas } from "./elementEditSchemas";
import { getProperty } from "@/utils";
import { Row, Col } from "react-bootstrap";
import { getProperty, joinName } from "@/utils";
import { Col, Row } from "react-bootstrap";
import styles from "./DocumentEditor.module.scss";
import RemoveElementAction from "./RemoveElementAction";
import MoveElementAction from "./MoveElementAction";
import SelectWidget from "@/components/form/widgets/SelectWidget";
import FieldTemplate from "@/components/form/FieldTemplate";
import { getAllowedChildTypes } from "@/components/documentBuilder/allowedElementTypes";
import { produce } from "immer";
import { createNewElement } from "@/components/documentBuilder/createNewElement";
import ConnectedFieldTemplate from "@/components/form/ConnectedFieldTemplate";
import KeyNameWidget from "@/components/form/widgets/KeyNameWidget";

type ElementEditProps = {
elementName: string;
Expand All @@ -42,16 +54,36 @@ const elementTypeLabels: Record<DocumentElementType, string> = {
text: "Text",
button: "Button",
block: "Block",
list: "List",
};

const ElementEdit: React.FC<ElementEditProps> = ({
elementName,
setActiveElement,
}) => {
const [{ value: documentElement }] = useField<DocumentElement>(elementName);
const [
{ value: documentElement },
,
{ setValue: setDocumentElement },
] = useField<DocumentElement>(elementName);

const editSchemas = getElementEditSchemas(documentElement, elementName);

const isList = isListDocument(documentElement);

const onElementTypeChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const nextType = event.target.value as DocumentElementType;

const nextDocumentElement = produce(
documentElement,
(draft: ListDocumentElement) => {
draft.config.element.__value__ = createNewElement(nextType);
}
);

setDocumentElement(nextDocumentElement);
};

return (
<>
<Row className={styles.currentFieldRow}>
Expand Down Expand Up @@ -79,6 +111,28 @@ const ElementEdit: React.FC<ElementEditProps> = ({
{editSchemas.map((editSchema) => (
<SchemaField key={editSchema.name} {...editSchema} />
))}

{isList && (
<>
<ConnectedFieldTemplate
label="Element key"
name={joinName(elementName, "config", "elementKey")}
as={KeyNameWidget}
/>
<FieldTemplate
label="Item type"
name="elementType"
value={documentElement.config.element.__value__.type}
onChange={onElementTypeChange}
as={SelectWidget}
options={getAllowedChildTypes(documentElement).map((x) => ({
// eslint-disable-next-line security/detect-object-injection -- x is a know string
label: elementTypeLabels[x],
value: x,
}))}
/>
</>
)}
</Col>
</Row>
<Row>
Expand Down
16 changes: 15 additions & 1 deletion src/components/documentBuilder/ElementPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import React, { MouseEventHandler } from "react";
import styles from "./ElementPreview.module.scss";
import cx from "classnames";
import { DocumentElement } from "./documentBuilderTypes";
import { DocumentElement, isListDocument } from "./documentBuilderTypes";
import { getPreviewComponentDefinition } from "./documentTree";
import AddElementAction from "./AddElementAction";
import { useField } from "formik";
Expand Down Expand Up @@ -63,8 +63,12 @@ const ElementPreview: React.FC<ElementPreviewTemplateProps> = ({
}
};

// Render children and Add Menu for the container element
const isContainer = Array.isArray(documentElement.children);

// Render the item template and the Item Type Selector for the list element
const isList = isListDocument(documentElement);

const { Component: PreviewComponent, props } = getPreviewComponentDefinition(
documentElement
);
Expand Down Expand Up @@ -101,6 +105,16 @@ const ElementPreview: React.FC<ElementPreviewTemplateProps> = ({
menuBoundary={menuBoundary}
/>
)}
{isList && (
<ElementPreview
elementName={`${elementName}.config.element.__value__`}
activeElement={activeElement}
setActiveElement={setActiveElement}
menuBoundary={menuBoundary}
hoveredElement={hoveredElement}
setHoveredElement={setHoveredElement}
/>
)}
</PreviewComponent>
);
};
Expand Down
3 changes: 3 additions & 0 deletions src/components/documentBuilder/RemoveElementAction.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const RemoveElementAction: React.FC<RemoveElementActionProps> = ({
elementName,
setActiveElement,
}) => {
// Gives the name of the elements's collection
// In case of a list item element point to the collection of the list element,
// i.e. removing the item of the list will actually remove the list itself.
const { collectionName, elementIndex } = getElementCollectionName(
elementName
);
Expand Down
15 changes: 12 additions & 3 deletions src/components/documentBuilder/allowedElementTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

import { DocumentElement, DocumentElementType } from "./documentBuilderTypes";
import {
DOCUMENT_ELEMENT_TYPES,
DocumentElement,
DocumentElementType,
} from "./documentBuilderTypes";

export const ROOT_ELEMENT_TYPES: DocumentElementType[] = [
"header_1",
Expand All @@ -26,11 +30,12 @@ export const ROOT_ELEMENT_TYPES: DocumentElementType[] = [
"card",
"block",
"button",
"list",
];

const allowedChildTypes: Record<string, DocumentElementType[]> = {
container: ["row"],
row: ["column"],
container: ["row", "list"],
row: ["column", "list"],
column: [
"header_1",
"header_2",
Expand All @@ -39,6 +44,7 @@ const allowedChildTypes: Record<string, DocumentElementType[]> = {
"card",
"block",
"button",
"list",
],
card: [
"header_1",
Expand All @@ -48,7 +54,10 @@ const allowedChildTypes: Record<string, DocumentElementType[]> = {
"container",
"block",
"button",
"list",
],
// Any element we can add to the list
list: (DOCUMENT_ELEMENT_TYPES as unknown) as DocumentElementType[],
};

export function getAllowedChildTypes(
Expand Down
8 changes: 8 additions & 0 deletions src/components/documentBuilder/createNewElement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { DocumentElement, DocumentElementType } from "./documentBuilderTypes";
import { Expression } from "@/core";

export function createNewElement(elementType: DocumentElementType) {
const element: DocumentElement = {
Expand Down Expand Up @@ -59,6 +60,13 @@ export function createNewElement(elementType: DocumentElementType) {
element.config.title = "Click me";
break;

case "list":
element.config.element = {
__type__: "defer",
__value__: createNewElement("text"),
} as Expression<DocumentElement, "defer">;
break;

default:
throw new Error(
`Can't create new element. Type "${elementType} is not supported.`
Expand Down
26 changes: 23 additions & 3 deletions src/components/documentBuilder/documentBuilderTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
*/

import { UnknownObject } from "@/types";
import { Expression } from "@/core";

export const DOCUMENT_ELEMENT_TYPES = [
"header_1",
Expand All @@ -28,17 +29,36 @@ export const DOCUMENT_ELEMENT_TYPES = [
"card",
"block",
"button",
"list",
] as const;

export type DocumentElementType = typeof DOCUMENT_ELEMENT_TYPES[number];

export type DocumentElement = {
type: DocumentElementType;
config: UnknownObject;
export type DocumentElement<
TType extends DocumentElementType = DocumentElementType,
TConfig = UnknownObject
> = {
type: TType;
config: TConfig;
children?: DocumentElement[];
};

type ListConfig = {
array: Expression;
elementKey?: string;
element: Expression<DocumentElement, "defer">;
};
export type ListDocumentElement = DocumentElement<"list", ListConfig>;

export function isListDocument(
element: DocumentElement
): element is ListDocumentElement {
return element.type === "list";
}

export type DocumentComponent = {
Component: React.ElementType;
props?: UnknownObject | undefined;
};

export type BuildDocumentBranch = (root: DocumentElement) => DocumentComponent;
5 changes: 5 additions & 0 deletions src/components/documentBuilder/documentTree.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@
}
}

:global(.container) > .listContainer {
margin-left: -12px;
padding-left: 15px;
}

.inlineWrapper {
display: inline-block;
}
Loading