diff --git a/.adiorc.js b/.adiorc.js index 7d211635294..f35cebb33b2 100644 --- a/.adiorc.js +++ b/.adiorc.js @@ -35,6 +35,7 @@ module.exports = { "aws-sdk", "url", "worker_threads", + "~tests", "~" ], dependencies: [ diff --git a/jest.config.base.js b/jest.config.base.js index 16cd4c93582..a8f9dc3ed36 100644 --- a/jest.config.base.js +++ b/jest.config.base.js @@ -22,6 +22,7 @@ module.exports = function ({ path }, presets = []) { }, moduleDirectories: ["node_modules"], moduleNameMapper: { + "~tests/(.*)": `${path}/__tests__/$1`, "~/(.*)": `${path}/src/$1` }, modulePathIgnorePatterns: [], diff --git a/jest.config.js b/jest.config.js index 660077f4535..8bbf7c78b03 100644 --- a/jest.config.js +++ b/jest.config.js @@ -161,5 +161,6 @@ if (projects.length === 0) { module.exports = { projects, modulePathIgnorePatterns: ["dist"], - testTimeout: 30000 + testTimeout: 30000, + watchman: false }; diff --git a/packages/react-properties/.babelrc.js b/packages/react-properties/.babelrc.js new file mode 100644 index 00000000000..a2e870526e6 --- /dev/null +++ b/packages/react-properties/.babelrc.js @@ -0,0 +1 @@ +module.exports = require("../../.babel.react")({ path: __dirname }); diff --git a/packages/react-properties/LICENSE b/packages/react-properties/LICENSE new file mode 100644 index 00000000000..f772d04d4db --- /dev/null +++ b/packages/react-properties/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) Webiny + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/react-properties/README.md b/packages/react-properties/README.md new file mode 100644 index 00000000000..aaa5214e379 --- /dev/null +++ b/packages/react-properties/README.md @@ -0,0 +1,65 @@ +# React Properties + +[![](https://img.shields.io/npm/dw/@webiny/react-properties.svg)](https://www.npmjs.com/package/@webiny/react-properties) +[![](https://img.shields.io/npm/v/@webiny/react-properties.svg)](https://www.npmjs.com/package/@webiny/react-properties) +[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg?style=flat-square)](https://github.com/prettier/prettier) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) + +A tiny React properties framework, to build dynamic data objects using React components, which can be customized after initial creation. The usage is very similar to how you write XML data structures, but in this case you're using actual React. + +## Basic Example + +```jsx +import React, { useCallback } from "react"; +import { Properties, Property, toObject } from "@webiny/react-properties"; + +const View = () => { + const onChange = useCallback(properties => { + console.log(toObject(properties)); + }, []); + + return ( + + + + + + + + + + + + + + + + + ); +}; +``` + +Output: + +```json +{ + "group": [ + { + "name": "layout", + "label": "Layout", + "toolbar": { + "name": "basic" + } + }, + { + "name": "heroes", + "label": "Heroes", + "toolbar": { + "name": "heroes" + } + } + ] +} +``` + +For more examples, check out the test files. diff --git a/packages/react-properties/__tests__/cases/dashboard/App.tsx b/packages/react-properties/__tests__/cases/dashboard/App.tsx new file mode 100644 index 00000000000..7754b695440 --- /dev/null +++ b/packages/react-properties/__tests__/cases/dashboard/App.tsx @@ -0,0 +1,115 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import { + Compose, + HigherOrderComponent, + makeComposable, + CompositionProvider +} from "@webiny/react-composition"; +import { Property, Properties } from "~/index"; + +interface AddWidgetProps { + name: string; + type: string; +} + +const AddWidget = >(props: T & AddWidgetProps) => { + return ( + + {Object.keys(props).map(name => ( + + ))} + + ); +}; + +export interface CardWidget extends Record { + title: string; + description: string; + button: React.ReactElement; +} + +const createHOC = + (newChildren: React.ReactNode): HigherOrderComponent => + BaseComponent => { + return function ConfigHOC({ children }) { + return ( + + {newChildren} + {children} + + ); + }; + }; + +const DashboardConfigApply = makeComposable("DashboardConfigApply", ({ children }) => { + return <>{children}; +}); + +interface DashboardConfig extends React.FC { + AddWidget: typeof AddWidget; + DashboardRenderer: typeof DashboardRenderer; +} + +export const DashboardConfig: DashboardConfig = ({ children }) => { + return ; +}; + +const DashboardRenderer = makeComposable("DashboardRenderer", () => { + return
Renderer not implemented!
; +}); + +DashboardConfig.AddWidget = AddWidget; +DashboardConfig.DashboardRenderer = DashboardRenderer; + +interface ViewContext { + properties: Property[]; +} + +const defaultContext = { properties: [] }; + +const ViewContext = React.createContext(defaultContext); + +interface DashboardViewProps { + onProperties(properties: Property[]): void; +} + +const DashboardView: React.FC = ({ onProperties }) => { + const [properties, setProperties] = useState([]); + const context = { properties }; + + useEffect(() => { + onProperties(properties); + }, [properties]); + + const stateUpdater = (properties: Property[]) => { + setProperties(properties); + }; + + return ( + + + + + + + ); +}; + +export const App: React.FC = ({ onProperties, children }) => { + return ( + + + + + name="my-widget" + type="card" + title="My Widget" + description="Custom widget that shows current weather." + button={} + /> + + {children} + + ); +}; diff --git a/packages/react-properties/__tests__/cases/dashboard/dashboard.test.tsx b/packages/react-properties/__tests__/cases/dashboard/dashboard.test.tsx new file mode 100644 index 00000000000..e9cf0549c45 --- /dev/null +++ b/packages/react-properties/__tests__/cases/dashboard/dashboard.test.tsx @@ -0,0 +1,162 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { Compose, HigherOrderComponent } from "@webiny/react-composition"; +import { Property, toObject, useProperties } from "~/index"; +import { App, DashboardConfig } from "./App"; +import { getLastCall } from "~tests/utils"; + +const { AddWidget, DashboardRenderer } = DashboardConfig; + +describe("Dashboard", () => { + it("should contain 2 widgets (the built-in one, and the custom one)", async () => { + const onChange = jest.fn(); + /** + * contains the built-in widget, and we're using the component to register more widgets. + */ + const view = ( + + + + name={"new-widget"} + title={"Latest News"} + type={"card"} + button={
} + /> + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toMatchObject({ + widget: [ + { + name: "my-widget", + title: "My Widget", + type: "card", + button: expect.anything() + }, + { + name: "new-widget", + title: "Latest News", + type: "card", + button:
+ } + ] + }); + }); + + it("should contain the built-in widget with modified values", async () => { + const onChange = jest.fn(); + const view = ( + + + + name={"my-widget"} + title={"My own title!"} + type={"card"} + button={null} + /> + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toMatchObject({ + widget: [ + { + name: "my-widget", + title: "My own title!", + type: "card", + button: null + } + ] + }); + }); + + it("should contain new custom properties", async () => { + /** + * Let's create a custom Dashboard renderer to render links (which are our custom property). + */ + const CustomDashboard: HigherOrderComponent = () => { + return function CustomDashboard() { + const { getObject } = useProperties(); + const { link } = getObject<{ link: { title: string; url: string }[] }>(); + + return ( +
    + {link.map(item => ( +
  • + {item.title}: {item.url} +
  • + ))} +
+ ); + }; + }; + + /** + * This custom component will allow us to expose a user-friendly API to developers, hook into the existing + * data structure of the Dashboard, and add our new properties (links in this case). + */ + interface LinkProps { + url: string; + title: string; + } + + const Link: React.FC = ({ url, title }) => { + return ( + + + + + ); + }; + + const onChange = jest.fn(); + const view = ( + + + {/* Compose the renderer, to intercept the rendering process and render custom Links. */} + + {/* Register new properties. */} + + + + + ); + + const { container } = render(view); + + // Verify the new data structure + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toMatchObject({ + widget: [ + { + name: "my-widget", + title: "My Widget", + type: "card", + button: expect.anything() + } + ], + link: [ + { title: "Webiny", url: "www.webiny.com" }, + { title: "Google", url: "www.google.com" } + ] + }); + + // Verify that our custom renderer is rendering the expected output. + expect(container.innerHTML).toEqual( + "
  • Webiny: www.webiny.com
  • Google: www.google.com
" + ); + }); +}); diff --git a/packages/react-properties/__tests__/cases/pbEditorSettings/PbEditorSettingsView.tsx b/packages/react-properties/__tests__/cases/pbEditorSettings/PbEditorSettingsView.tsx new file mode 100644 index 00000000000..ca66e18ba52 --- /dev/null +++ b/packages/react-properties/__tests__/cases/pbEditorSettings/PbEditorSettingsView.tsx @@ -0,0 +1,101 @@ +import React, { useCallback } from "react"; +import { createConfigurableView } from "./createConfigurableView"; +import { Property, useParentProperty } from "~/index"; + +interface SettingsGroupProps { + name: string; + title?: string; + icon?: string; + remove?: boolean; + replace?: string; +} + +type DynamicProps = T & { + [key: string]: any; +}; + +const SettingsGroup: React.FC = ({ + children, + replace, + remove = false, + ...rest +}) => { + const props: DynamicProps = rest; + const id = `group:${props.name}`; + const toReplace = replace !== undefined ? `group:${replace}` : undefined; + + return ( + + {Object.keys(props).map(name => ( + + ))} + {children} + + ); +}; + +interface FormFieldProps extends Record { + name: string; + component?: string; + after?: string; + remove?: boolean; + replace?: string; +} + +const FormField: React.FC = ({ + children, + after, + before, + replace, + remove = false, + ...props +}) => { + const parent = useParentProperty(); + if (!parent) { + throw Error(` must be a child of a element.`); + } + + const { id } = parent; + + const getId = useCallback( + (suffix = undefined) => [id, "field", props.name, suffix].filter(Boolean).join(":"), + [] + ); + + const toReplace = replace !== undefined ? `${id}:field:${replace}` : undefined; + const placeAfter = after !== undefined ? `${id}:field:${after}` : undefined; + const placeBefore = before !== undefined ? `${id}:field:${before}` : undefined; + + return ( + + {Object.keys(props).map(name => ( + + ))} + {children} + + ); +}; + +// Base view components. +const { View, Config } = createConfigurableView("PageSettings"); + +// Create a named alias. +const PageSettingsView = View; + +// Assign custom components +const PageSettingsConfig = Object.assign(Config, { FormField, SettingsGroup }); + +export { PageSettingsView, PageSettingsConfig }; diff --git a/packages/react-properties/__tests__/cases/pbEditorSettings/createConfigurableView.tsx b/packages/react-properties/__tests__/cases/pbEditorSettings/createConfigurableView.tsx new file mode 100644 index 00000000000..3b025817f2b --- /dev/null +++ b/packages/react-properties/__tests__/cases/pbEditorSettings/createConfigurableView.tsx @@ -0,0 +1,78 @@ +import * as React from "react"; +import { useEffect, useState } from "react"; +import { Compose, HigherOrderComponent, makeComposable } from "@webiny/react-composition"; +import { Property, Properties } from "~/index"; + +const createHOC = + (newChildren: React.ReactNode): HigherOrderComponent => + BaseComponent => { + return function ConfigHOC({ children }) { + return ( + + {newChildren} + {children} + + ); + }; + }; + +export interface ViewProps { + onProperties?(properties: Property[]): void; +} + +export function createConfigurableView(name: string) { + /** + * This component is used when we want to mount all composed configs. + */ + const ConfigApply = makeComposable(`${name}ConfigApply`, ({ children }) => { + return <>{children}; + }); + + /** + * This component is used to configure the view (it can be mounted many times). + */ + const Config: React.FC = ({ children }) => { + return ; + }; + + const Renderer = makeComposable(`${name}Renderer`, () => { + return
{name}Renderer is not implemented!
; + }); + + interface ViewContext { + properties: Property[]; + } + + const defaultContext = { properties: [] }; + + const ViewContext = React.createContext(defaultContext); + + const View: React.FC = ({ onProperties }) => { + const [properties, setProperties] = useState([]); + const context = { properties }; + + useEffect(() => { + if (typeof onProperties === "function") { + onProperties(properties); + } + }, [properties]); + + const stateUpdater = (properties: Property[]) => { + setProperties(properties); + }; + + return ( + + + + + + + ); + }; + + return { + View, + Config + }; +} diff --git a/packages/react-properties/__tests__/cases/pbEditorSettings/pbEditorSettings.test.tsx b/packages/react-properties/__tests__/cases/pbEditorSettings/pbEditorSettings.test.tsx new file mode 100644 index 00000000000..e7538050ff6 --- /dev/null +++ b/packages/react-properties/__tests__/cases/pbEditorSettings/pbEditorSettings.test.tsx @@ -0,0 +1,461 @@ +import React from "react"; +import { render } from "@testing-library/react"; +import { CompositionProvider } from "@webiny/react-composition"; +import { Property, toObject } from "~/index"; +import { PageSettingsView, PageSettingsConfig } from "./PbEditorSettingsView"; +import { getLastCall } from "~tests/utils"; + +const { SettingsGroup, FormField } = PageSettingsConfig; + +const BaseConfig = () => { + return ( + <> + + + + + + + + + ); +}; + +describe("PB Editor", () => { + it("should contain 1 settings groups with 3 fields", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything() + }, + { + name: "snippet", + label: "Snippet", + component: expect.anything() + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should contain a new settings group with 1 field", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything() + }, + { + name: "snippet", + label: "Snippet", + component: expect.anything() + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + }, + { + name: "social", + title: "Social Media", + icon: "like", + fields: [ + { + name: "ogImage", + label: "OG Image", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should allow group customization", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "Main Settings", + icon: "page", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything() + }, + { + name: "snippet", + label: "Snippet", + component: "textarea" + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should allow group removal", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject<{ groups?: Array }>(properties); + + expect(data.groups).toBe(undefined); + }); + + it("should allow field customization", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + {/* Add custom prop via component props. */} + + {/* Add custom prop via children. */} + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything(), + description: "Field #1" + }, + { + name: "snippet", + label: "Snippet", + component: expect.anything(), + description: "Field #2" + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should allow adding fields after a specific field", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything() + }, + { + name: "email", + label: "Email", + component: "email" + }, + { + name: "snippet", + label: "Snippet", + component: expect.anything() + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should allow adding fields before a specific field", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything() + }, + { + name: "snippet", + label: "Snippet", + component: expect.anything() + }, + { + name: "email", + label: "Email", + component: "email" + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should allow field removal", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "snippet", + label: "Snippet", + component: "textarea" + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); + + it("should allow field replacement", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + const data = toObject(properties); + + expect(data).toEqual({ + groups: [ + { + name: "general", + title: "General Settings", + icon: "cog", + fields: [ + { + name: "title", + label: "Title", + component: expect.anything() + }, + { + name: "description", + label: "Description", + component: "richtext" + }, + { + name: "layout", + label: "Layout", + component: expect.anything() + } + ] + } + ] + }); + }); +}); diff --git a/packages/react-properties/__tests__/properties.test.tsx b/packages/react-properties/__tests__/properties.test.tsx new file mode 100644 index 00000000000..18cb1ad64f1 --- /dev/null +++ b/packages/react-properties/__tests__/properties.test.tsx @@ -0,0 +1,432 @@ +import React, { useCallback } from "react"; +import { render } from "@testing-library/react"; +import { Properties, Property, useParentProperty, toObject } from "~/index"; +import { getLastCall } from "./utils"; + +interface GroupProps { + name: string; + label?: string; +} + +const Group: React.FC = ({ name, label, children }) => { + return ( + + + {label ? : null} + {children} + + ); +}; + +interface FieldProps { + name: string; + label?: string; + remove?: boolean; + replace?: string; +} + +const Field: React.FC = ({ name, label, replace, remove = false }) => { + const parentProperty = useParentProperty(); + + const id = parentProperty ? parentProperty.id : undefined; + + const getId = useCallback( + (suffix = undefined) => [id, "field", name, suffix].filter(Boolean).join(":"), + [] + ); + const toReplace = replace !== undefined ? `${id}:field:${replace}` : undefined; + + return ( + + + {label ? : null} + + ); +}; + +describe("Test Properties", () => { + it("should create 2 properties", async () => { + const onChange = jest.fn(); + const view = ( + + + + + ); + + render(view); + + expect(onChange).toHaveBeenLastCalledWith([ + { id: "label", name: "label", value: "Label", parent: "", array: false }, + { id: expect.any(String), name: "name", value: "basic", parent: "", array: false } + ]); + }); + + it("should create nested properties", async () => { + const onChange = jest.fn(); + const view = ( + + + + + + + + + + ); + + render(view); + + expect(onChange).toHaveBeenLastCalledWith([ + { id: expect.any(String), name: "name", value: "layout", parent: "1", array: false }, + { id: expect.any(String), name: "label", value: "Layout", parent: "1", array: false }, + { id: expect.any(String), name: "name", value: "basic", parent: "2", array: false }, + { + id: expect.any(String), + name: "toolbar", + value: undefined, + parent: "1", + array: false + }, + { id: "1", name: "group", value: undefined, parent: "", array: false } + ]); + }); + + it("should convert to a single object", async () => { + const onChange = jest.fn(); + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + group: { + name: "layout", + label: "Layout", + toolbar: { + name: "basic" + } + } + }); + }); + + it("should treat a single object as an array (array prop)", async () => { + const onChange = jest.fn(); + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + group: [ + { + name: "layout", + label: "Layout", + toolbar: { + name: "basic" + } + } + ] + }); + }); + + it("should convert to an array of objects", async () => { + const onChange = jest.fn(); + const view = ( + + + + + + + + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + group: [ + { + name: "layout", + label: "Layout", + toolbar: { + name: "basic" + } + }, + { + name: "heroes", + label: "Heroes", + toolbar: { + name: "heroes" + } + } + ] + }); + }); + + it("should convert to an array of objects using custom components", async () => { + interface GroupProps { + name: string; + label?: string; + } + + const Group: React.FC = ({ name, label, children }) => { + return ( + + + {label ? : null} + {children} + + ); + }; + + interface ToolbarProps { + name: string; + } + + const Toolbar: React.FC = ({ name }) => { + return ( + + + + ); + }; + + const onChange = jest.fn(); + + const view = ( + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + group: [ + { + name: "layout", + label: "Layout", + toolbar: { + name: "basic" + } + }, + { + name: "heroes", + label: "Heroes", + toolbar: { + name: "heroes" + } + } + ] + }); + }); + + it("should merge properties for matching 'name' prop", async () => { + const onChange = jest.fn(); + + const view = ( + + + + + + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + settingsGroup: [ + { + name: "styleSettings", + label: "Style", + field: [ + { + name: "color", + label: "Color" + }, + { + name: "component", + label: "Render" + }, + { name: "url", label: "Open URL" } + ] + }, + { + name: "elementSettings", + label: "Element Settings", + field: [ + { name: "url", label: "URL" }, + { name: "newTab", label: "Open in new tab" } + ] + } + ] + }); + }); + + it("should allow addition of custom properties to predefined components", async () => { + const onChange = jest.fn(); + + const Tutorial: React.FC<{ label: string }> = ({ label }) => { + return ; + }; + + const view = ( + + + + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + settingsGroup: [ + { + name: "styleSettings", + label: "Style Settings", + field: [ + { + name: "color", + label: "Color" + }, + { + name: "component", + label: "Component" + } + ], + tutorial: "Learn more" + } + ] + }); + }); + + it("should remove existing property by 'name' and add a new one", async () => { + const onChange = jest.fn(); + + const view = ( + + {/* Define base properties */} + + + + + {/* Remove existing fields and add a new one */} + + + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(toObject(properties)).toEqual({ + settingsGroup: [ + { + name: "styleSettings", + label: "Style Settings", + field: [{ name: "link", label: "Link" }] + } + ] + }); + }); + + it("should replace existing property with a new one", async () => { + const onChange = jest.fn(); + + const view = ( + + {/* Define base properties */} + + + + + {/* Remove existing fields and add a new one */} + + + + + ); + + render(view); + + const properties = getLastCall(onChange); + + expect(properties.length).toBe(9); + expect(toObject(properties)).toEqual({ + settingsGroup: [ + { + name: "styleSettings", + label: "Style Settings", + field: [ + { name: "link", label: "Link" }, + { name: "component", label: "Component" } + ] + } + ] + }); + }); +}); diff --git a/packages/react-properties/__tests__/utils.ts b/packages/react-properties/__tests__/utils.ts new file mode 100644 index 00000000000..fb838788e81 --- /dev/null +++ b/packages/react-properties/__tests__/utils.ts @@ -0,0 +1,3 @@ +export function getLastCall(callable: jest.Mock) { + return callable.mock.calls[callable.mock.calls.length - 1][0]; +} diff --git a/packages/react-properties/jest.config.js b/packages/react-properties/jest.config.js new file mode 100644 index 00000000000..cc5ac2bb64f --- /dev/null +++ b/packages/react-properties/jest.config.js @@ -0,0 +1,5 @@ +const base = require("../../jest.config.base"); + +module.exports = { + ...base({ path: __dirname }) +}; diff --git a/packages/react-properties/package.json b/packages/react-properties/package.json new file mode 100644 index 00000000000..716a57e8528 --- /dev/null +++ b/packages/react-properties/package.json @@ -0,0 +1,33 @@ +{ + "private": true, + "name": "@webiny/react-properties", + "version": "5.28.0", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/webiny/webiny-js.git" + }, + "description": "Build pluggable data objects using React components.", + "author": "Webiny Ltd", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.3", + "@types/react": "^16.14.0", + "nanoid": "^3.3.4", + "react": "^16.14.0" + }, + "devDependencies": { + "@testing-library/react": "^12.1.5", + "@webiny/cli": "^5.28.0", + "@webiny/project-utils": "^5.28.0", + "@webiny/react-composition": "^5.28.0" + }, + "publishConfig": { + "access": "public", + "directory": "dist" + }, + "scripts": { + "build": "yarn webiny run build", + "watch": "yarn webiny run watch" + } +} diff --git a/packages/react-properties/src/Properties.tsx b/packages/react-properties/src/Properties.tsx new file mode 100644 index 00000000000..0eaec7d340d --- /dev/null +++ b/packages/react-properties/src/Properties.tsx @@ -0,0 +1,195 @@ +import React, { createContext, useContext, useEffect, useMemo, useState } from "react"; +import { getUniqueId, toObject } from "./utils"; + +export interface Property { + id: string; + parent: string; + name: string; + value: unknown; + array?: boolean; +} + +function removeByParent(id: string, properties: Property[]): Property[] { + return properties + .filter(prop => prop.parent === id) + .reduce((acc, item) => { + return removeByParent( + item.id, + acc.filter(prop => prop.id !== item.id) + ); + }, properties); +} + +interface AddPropertyOptions { + after?: string; + before?: string; +} + +interface PropertiesContext { + properties: Property[]; + getObject(): T; + addProperty(property: Property, options?: AddPropertyOptions): void; + removeProperty(id: string): void; + replaceProperty(id: string, property: Property): void; +} + +const PropertiesContext = createContext(undefined); + +interface PropertiesProps { + onChange?(properties: Property[]): void; +} + +export const Properties: React.FC = ({ onChange, children }) => { + const [properties, setProperties] = useState([]); + + useEffect(() => { + if (onChange) { + onChange(properties); + } + }, [properties]); + + const context: PropertiesContext = useMemo( + () => ({ + properties, + getObject() { + return toObject(properties) as T; + }, + addProperty(property, options = {}) { + setProperties(properties => { + // If a property with this ID already exists, merge the two properties. + const index = properties.findIndex(prop => prop.id === property.id); + if (index > -1) { + return [ + ...properties.slice(0, index), + { ...properties[index], ...property }, + ...properties.slice(index + 1) + ]; + } + + if (options.after) { + const index = properties.findIndex(prop => prop.id === options.after); + if (index > -1) { + return [ + ...properties.slice(0, index + 1), + property, + ...properties.slice(index + 1) + ]; + } + } + + if (options.before) { + const index = properties.findIndex(prop => prop.id === options.before); + if (index > -1) { + return [ + ...properties.slice(0, index), + property, + ...properties.slice(index) + ]; + } + } + + return [...properties, property]; + }); + }, + removeProperty(id) { + setProperties(properties => { + return removeByParent( + id, + properties.filter(prop => prop.id !== id) + ); + }); + }, + replaceProperty(id, property) { + setProperties(properties => { + const toReplace = properties.findIndex(prop => prop.id === id); + + if (toReplace > -1) { + // Replace the property and remove all remaining child properties. + return removeByParent(id, [ + ...properties.slice(0, toReplace), + property, + ...properties.slice(toReplace + 1) + ]); + } + return properties; + }); + } + }), + [properties] + ); + + return {children}; +}; + +export function useProperties() { + const properties = useContext(PropertiesContext); + if (!properties) { + throw Error("Properties context provider is missing!"); + } + + return properties; +} + +interface PropertyProps { + id?: string; + name: string; + value?: unknown; + array?: boolean; + after?: string; + before?: string; + replace?: string; + remove?: boolean; +} + +const PropertyContext = createContext(undefined); + +export function useParentProperty() { + return useContext(PropertyContext); +} + +export const Property: React.FC = ({ + id, + name, + value, + children, + after = undefined, + before = undefined, + replace = undefined, + remove = false, + array = false +}) => { + const uniqueId = useMemo(() => id || getUniqueId(), []); + const parent = useParentProperty(); + const properties = useProperties(); + + if (!properties) { + throw Error(" provider is missing higher in the hierarchy!"); + } + + const { addProperty, removeProperty, replaceProperty } = properties; + const property = { id: uniqueId, name, value, parent: parent ? parent.id : "", array }; + + useEffect(() => { + if (remove) { + removeProperty(uniqueId); + return; + } + + if (replace) { + replaceProperty(replace, property); + return; + } + + addProperty(property, { after, before }); + + return () => { + removeProperty(uniqueId); + }; + }, []); + + if (children) { + return {children}; + } + + return null; +}; diff --git a/packages/react-properties/src/index.ts b/packages/react-properties/src/index.ts new file mode 100644 index 00000000000..d9e23437dbd --- /dev/null +++ b/packages/react-properties/src/index.ts @@ -0,0 +1,2 @@ +export * from "./utils"; +export * from "./Properties"; diff --git a/packages/react-properties/src/utils.ts b/packages/react-properties/src/utils.ts new file mode 100644 index 00000000000..a77eebd4462 --- /dev/null +++ b/packages/react-properties/src/utils.ts @@ -0,0 +1,33 @@ +import { customAlphabet } from "nanoid"; +const nanoid = customAlphabet("1234567890abcdef", 6); +import { Property } from "./Properties"; + +function buildRoots(roots: Property[], properties: Property[]) { + const obj: Record = roots.reduce((acc, item) => { + const isArray = item.array === true || roots.filter(r => r.name === item.name).length > 1; + return { ...acc, [item.name]: isArray ? [] : {} }; + }, {}); + + roots.forEach(root => { + const isArray = root.array === true || Array.isArray(obj[root.name]); + if (root.value !== undefined) { + obj[root.name] = isArray ? [...(obj[root.name] as Array), root.value] : root.value; + return; + } + + const nextRoots = properties.filter(p => p.parent === root.id); + const value = buildRoots(nextRoots, properties); + obj[root.name] = isArray ? [...(obj[root.name] as Property[]), value] : value; + }); + + return obj; +} + +export function toObject(properties: Property[]): T { + const roots = properties.filter(prop => prop.parent === ""); + return buildRoots(roots, properties) as T; +} + +export function getUniqueId() { + return nanoid(); +} diff --git a/packages/react-properties/tsconfig.build.json b/packages/react-properties/tsconfig.build.json new file mode 100644 index 00000000000..64908a85ff0 --- /dev/null +++ b/packages/react-properties/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [{ "path": "../react-composition/tsconfig.build.json" }], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~tests/*": ["./__tests__/*"], + "~/*": ["./src/*"] + }, + "baseUrl": "." + } +} diff --git a/packages/react-properties/tsconfig.json b/packages/react-properties/tsconfig.json new file mode 100644 index 00000000000..c65472a7bc0 --- /dev/null +++ b/packages/react-properties/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "include": ["src", "__tests__"], + "references": [{ "path": "../react-composition" }], + "compilerOptions": { + "rootDirs": ["./src", "./__tests__"], + "outDir": "./dist", + "declarationDir": "./dist", + "paths": { + "~tests/*": ["./__tests__/*"], + "~/*": ["./src/*"] + }, + "baseUrl": "." + } +} diff --git a/packages/react-properties/webiny.config.js b/packages/react-properties/webiny.config.js new file mode 100644 index 00000000000..6dff86766c9 --- /dev/null +++ b/packages/react-properties/webiny.config.js @@ -0,0 +1,8 @@ +const { createWatchPackage, createBuildPackage } = require("@webiny/project-utils"); + +module.exports = { + commands: { + build: createBuildPackage({ cwd: __dirname }), + watch: createWatchPackage({ cwd: __dirname }) + } +}; diff --git a/yarn.lock b/yarn.lock index 74ff35f2374..4d4cb0971a1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13264,6 +13264,21 @@ __metadata: languageName: unknown linkType: soft +"@webiny/react-properties@workspace:packages/react-properties": + version: 0.0.0-use.local + resolution: "@webiny/react-properties@workspace:packages/react-properties" + dependencies: + "@babel/runtime": ^7.16.3 + "@testing-library/react": ^12.1.5 + "@types/react": ^16.14.0 + "@webiny/cli": ^5.28.0 + "@webiny/project-utils": ^5.28.0 + "@webiny/react-composition": ^5.28.0 + nanoid: ^3.3.4 + react: ^16.14.0 + languageName: unknown + linkType: soft + "@webiny/react-rich-text-renderer@^5.28.0, @webiny/react-rich-text-renderer@workspace:packages/react-rich-text-renderer": version: 0.0.0-use.local resolution: "@webiny/react-rich-text-renderer@workspace:packages/react-rich-text-renderer"