diff --git a/README.md b/README.md index 0f452bc44d..95c486cfa7 100644 --- a/README.md +++ b/README.md @@ -524,9 +524,9 @@ The `AppState` object stores the puck application state. The `Data` object stores the puck page data. -- **root** (`object`): - - **title** (string): Title of the content, typically used for the page title - - **[prop]** (string): User defined data from `root` fields +- **root** (`ComponentData`): The component data for the root of your configuration. + - **props** (object): Extends `ComponentData.props`, with some additional props + - **title** (string, optional): Title of the content, typically used for the page title - **content** (`ComponentData[]`): Component data for the main content - **zones** (`object`, optional): Component data for all DropZones **[zoneCompound]** (`ComponentData[]`): Component data for a specific DropZone `zone` within a component instance diff --git a/apps/demo/app/[...puckPath]/client.tsx b/apps/demo/app/[...puckPath]/client.tsx index 77b577576e..91978ef11d 100644 --- a/apps/demo/app/[...puckPath]/client.tsx +++ b/apps/demo/app/[...puckPath]/client.tsx @@ -43,7 +43,8 @@ export function Client({ path, isEdit }: { path: string; isEdit: boolean }) { useEffect(() => { if (!isEdit) { - document.title = data?.root?.title || ""; + const title = data?.root.props?.title || data.root.title; + document.title = title || ""; } }, [data, isEdit]); diff --git a/apps/demo/config/index.tsx b/apps/demo/config/index.tsx index 7258106da9..5cdce1c200 100644 --- a/apps/demo/config/index.tsx +++ b/apps/demo/config/index.tsx @@ -278,7 +278,7 @@ export const initialData: Record = { props: { size: "96px", id: "VerticalSpace-1687284290127" }, }, ], - root: { title: "Puck Example" }, + root: { props: { title: "Puck Example" } }, zones: { "Columns-2d650a8ceb081a2c04f3a2d17a7703ca6efb0d06:column-0": [ { @@ -398,11 +398,11 @@ export const initialData: Record = { }, "/pricing": { content: [], - root: { title: "Pricing" }, + root: { props: { title: "Pricing" } }, }, "/about": { content: [], - root: { title: "About Us" }, + root: { props: { title: "About Us" } }, }, }; diff --git a/packages/core/components/Puck/index.tsx b/packages/core/components/Puck/index.tsx index 5c1e8d4ede..6f2eaec79a 100644 --- a/packages/core/components/Puck/index.tsx +++ b/packages/core/components/Puck/index.tsx @@ -72,7 +72,7 @@ const PluginRenderer = ({ export function Puck({ config, - data: initialData = { content: [], root: { title: "" } }, + data: initialData = { content: [], root: { props: { title: "" } } }, onChange, onPublish, plugins = [], @@ -253,6 +253,18 @@ export function Puck({ const componentList = useComponentList(config, appState.ui); + // DEPRECATED + const rootProps = data.root.props || data.root; + + // DEPRECATED + useEffect(() => { + if (Object.keys(data.root).length > 0 && !data.root.props) { + console.error( + "Warning: Defining props on `root` is deprecated. Please use `root.props`. This will be a breaking change in a future release " + ); + } + }, []); + return (
- {headerTitle || data.root.title || "Page"} + {headerTitle || rootProps.title || "Page"} {headerPath && ( ); diff --git a/packages/core/components/Render/index.tsx b/packages/core/components/Render/index.tsx index 966bffe895..f054dea3bc 100644 --- a/packages/core/components/Render/index.tsx +++ b/packages/core/components/Render/index.tsx @@ -5,10 +5,20 @@ import { Config, Data } from "../../types/Config"; import { DropZone, DropZoneProvider } from "../DropZone"; export function Render({ config, data }: { config: Config; data: Data }) { - if (config.root) { + // DEPRECATED + const rootProps = data.root.props || data.root; + + const title = rootProps.title || ""; + + if (config.root?.render) { return ( - + diff --git a/packages/core/lib/__tests__/use-resolved-data.spec.tsx b/packages/core/lib/__tests__/use-resolved-data.spec.tsx index 996785432f..d0cec8f43c 100644 --- a/packages/core/lib/__tests__/use-resolved-data.spec.tsx +++ b/packages/core/lib/__tests__/use-resolved-data.spec.tsx @@ -9,7 +9,7 @@ const item2 = { type: "MyComponent", props: { id: "MyComponent-2" } }; const item3 = { type: "MyComponent", props: { id: "MyComponent-3" } }; const data: Data = { - root: { title: "" }, + root: { props: { title: "" } }, content: [item1], zones: { "MyComponent-1:zone": [item2], @@ -18,6 +18,15 @@ const data: Data = { }; const config: Config = { + root: { + resolveData: (data) => { + return { + ...data, + props: { title: "Resolved title" }, + readOnly: { title: true }, + }; + }, + }, components: { MyComponent: { defaultProps: { prop: "example" }, @@ -76,7 +85,12 @@ describe("use-resolved-data", () => { }, ], "root": { - "title": "", + "props": { + "title": "Resolved title", + }, + "readOnly": { + "title": true, + }, }, "zones": { "MyComponent-1:zone": [ @@ -117,6 +131,7 @@ describe("use-resolved-data", () => { data, { ...config, + root: {}, components: { ...config.components, MyComponent: { diff --git a/packages/core/lib/apply-dynamic-props.ts b/packages/core/lib/apply-dynamic-props.ts index 2c7b04df24..2fd780836a 100644 --- a/packages/core/lib/apply-dynamic-props.ts +++ b/packages/core/lib/apply-dynamic-props.ts @@ -1,11 +1,16 @@ -import { Data } from "../types/Config"; +import { ComponentData, Data, RootData } from "../types/Config"; export const applyDynamicProps = ( data: Data, - dynamicProps: Record + dynamicProps: Record, + rootData?: RootData ) => { return { ...data, + root: { + ...data.root, + ...(rootData ? rootData : {}), + }, content: data.content.map((item) => { return dynamicProps[item.props.id] ? { ...item, ...dynamicProps[item.props.id] } diff --git a/packages/core/lib/resolve-all-data.ts b/packages/core/lib/resolve-all-data.ts index c995dfd70c..80dbb6b6a7 100644 --- a/packages/core/lib/resolve-all-data.ts +++ b/packages/core/lib/resolve-all-data.ts @@ -1,5 +1,6 @@ import { Config, Data, MappedItem } from "../types/Config"; import { resolveAllProps } from "./resolve-all-props"; +import { resolveRootData } from "./resolve-root-data"; export const resolveAllData = async ( data: Data, @@ -7,6 +8,8 @@ export const resolveAllData = async ( onResolveStart?: (item: MappedItem) => void, onResolveEnd?: (item: MappedItem) => void ) => { + const dynamicRoot = await resolveRootData(data, config); + const { zones = {} } = data; const zoneKeys = Object.keys(zones); @@ -24,6 +27,7 @@ export const resolveAllData = async ( return { ...data, + root: dynamicRoot, content: await resolveAllProps( data.content, config, diff --git a/packages/core/lib/resolve-root-data.ts b/packages/core/lib/resolve-root-data.ts new file mode 100644 index 0000000000..762471fedc --- /dev/null +++ b/packages/core/lib/resolve-root-data.ts @@ -0,0 +1,50 @@ +import { Config, Data, RootDataWithProps } from "../types/Config"; + +export const cache: { + lastChange?: { original: RootDataWithProps; resolved: RootDataWithProps }; +} = {}; + +export const resolveRootData = async (data: Data, config: Config) => { + if (config.root?.resolveData && data.root.props) { + let changed = Object.keys(data.root.props).reduce( + (acc, item) => ({ ...acc, [item]: true }), + {} + ); + + if (cache.lastChange) { + const { original, resolved } = cache.lastChange; + + if (original === data.root) { + return resolved; + } + + Object.keys(data.root.props).forEach((propName) => { + if (original.props[propName] === data.root.props![propName]) { + changed[propName] = false; + } + }); + } + + const rootWithProps = data.root as RootDataWithProps; + + const resolvedRoot = await config.root?.resolveData(rootWithProps, { + changed, + }); + + cache.lastChange = { + original: data.root as RootDataWithProps, + resolved: resolvedRoot as RootDataWithProps, + }; + + return { + ...data.root, + ...resolvedRoot, + props: { + ...data.root.props, + ...resolvedRoot.props, + }, + }; + } + + return data.root; +}; diff --git a/packages/core/lib/use-resolved-data.ts b/packages/core/lib/use-resolved-data.ts index fa74cfe0d5..2cc46c6269 100644 --- a/packages/core/lib/use-resolved-data.ts +++ b/packages/core/lib/use-resolved-data.ts @@ -3,6 +3,7 @@ import { Dispatch, useEffect, useState } from "react"; import { PuckAction } from "../reducer"; import { resolveAllProps } from "./resolve-all-props"; import { applyDynamicProps } from "./apply-dynamic-props"; +import { resolveRootData } from "./resolve-root-data"; export const useResolvedData = ( data: Data, @@ -36,7 +37,9 @@ export const useResolvedData = ( [item.props.id]: { ...prev[item.props.id], loading: false }, })); } - ).then((dynamicContent) => { + ).then(async (dynamicContent) => { + const dynamicRoot = await resolveRootData(data, config); + const newDynamicProps = dynamicContent.reduce>( (acc, item) => { return { ...acc, [item.props.id]: item }; @@ -44,7 +47,7 @@ export const useResolvedData = ( {} ); - const processed = applyDynamicProps(data, newDynamicProps); + const processed = applyDynamicProps(data, newDynamicProps, dynamicRoot); const containsChanges = JSON.stringify(data) !== JSON.stringify(processed); @@ -52,7 +55,7 @@ export const useResolvedData = ( if (containsChanges) { dispatch({ type: "setData", - data: (prev) => applyDynamicProps(prev, newDynamicProps), + data: (prev) => applyDynamicProps(prev, newDynamicProps, dynamicRoot), recordHistory: true, }); } diff --git a/packages/core/reducer/index.ts b/packages/core/reducer/index.ts index 6edffdc153..ac259956a7 100644 --- a/packages/core/reducer/index.ts +++ b/packages/core/reducer/index.ts @@ -36,7 +36,11 @@ const storeInterceptor = (reducer: StateReducer) => { }; }; -export const createReducer = ({ config }: { config: Config }): StateReducer => +export const createReducer = ({ + config, +}: { + config: Config; +}): StateReducer => storeInterceptor((state, action) => { const data = reduceData(state.data, action, config); const ui = reduceUi(state.ui, action); diff --git a/packages/core/types/Config.tsx b/packages/core/types/Config.tsx index 0c519f5c6a..094537df76 100644 --- a/packages/core/types/Config.tsx +++ b/packages/core/types/Config.tsx @@ -75,12 +75,14 @@ export type Field< | CustomField; export type DefaultRootProps = { - children: ReactNode; - title: string; - editMode: boolean; + title?: string; [key: string]: any; }; +export type DefaultRootRenderProps = { + editMode: boolean; +} & DefaultRootProps; + export type DefaultComponentProps = { [key: string]: any; editMode?: boolean }; export type Fields< @@ -98,13 +100,14 @@ export type Content< export type ComponentConfig< ComponentProps extends DefaultComponentProps = DefaultComponentProps, - DefaultProps = ComponentProps + DefaultProps = ComponentProps, + DataShape = ComponentData > = { render: (props: WithPuckProps) => ReactElement; defaultProps?: DefaultProps; fields?: Fields; resolveData?: ( - data: ComponentData, + data: DataShape, params: { changed: Partial> } ) => | Promise>> @@ -132,20 +135,44 @@ export type Config< "type" >; }; - root?: ComponentConfig< - RootProps & { children: ReactNode }, - Partial + root?: Partial< + ComponentConfig< + RootProps & { children: ReactNode }, + Partial, + RootDataWithProps + > >; }; -export type ComponentData< +export type BaseData< Props extends { [key: string]: any } = { [key: string]: any } +> = { + readOnly?: Partial>; +}; + +export type ComponentData< + Props extends DefaultComponentProps = DefaultComponentProps > = { type: keyof Props; props: WithPuckProps; - readOnly?: Partial>; +} & BaseData; + +export type RootDataWithProps< + Props extends DefaultRootProps = DefaultRootProps +> = { + props: Props; }; +// DEPRECATED +export type RootDataWithoutProps< + Props extends DefaultRootProps = DefaultRootProps +> = Props; + +export type RootData = + BaseData & + Partial> & + Partial>; // DEPRECATED + type ComponentDataWithOptionalProps< Props extends { [key: string]: any } = { [key: string]: any } > = Omit & { @@ -156,15 +183,12 @@ type ComponentDataWithOptionalProps< export type MappedItem = ComponentData; export type Data< - Props extends { [key: string]: any } = { [key: string]: any }, - RootProps extends { title: string; [key: string]: any } = { - title: string; - [key: string]: any; - } + Props extends DefaultComponentProps = DefaultComponentProps, + RootProps extends DefaultRootProps = DefaultRootProps > = { - root: RootProps; - content: Content; - zones?: Record>; + root: RootData; + content: Content>; + zones?: Record>>; }; export type ItemWithId = {