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"