Skip to content

Commit

Permalink
feat: add resolveProp API for modifying props dynamically
Browse files Browse the repository at this point in the history
  • Loading branch information
chrisvxd committed Oct 26, 2023
1 parent c8c02fd commit c1181ad
Show file tree
Hide file tree
Showing 11 changed files with 152 additions and 40 deletions.
14 changes: 12 additions & 2 deletions apps/demo/app/[...puckPath]/client.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";

import { Data } from "@measured/puck/types/Config";
import { Data, resolveData } from "@measured/puck";
import { Puck } from "@measured/puck/components/Puck";
import { Render } from "@measured/puck/components/Render";
import { useEffect, useState } from "react";
Expand Down Expand Up @@ -30,6 +30,16 @@ export function Client({ path, isEdit }: { path: string; isEdit: boolean }) {
}
});

// Normally this would happen on the server, but we can't
// do that because we're using local storage as a database
const [resolvedData, setResolvedData] = useState(data);

useEffect(() => {
if (data && !isEdit) {
resolveData(data, config).then(setResolvedData);
}
}, [data, isEdit]);

useEffect(() => {
if (!isEdit) {
document.title = data?.root?.title || "";
Expand Down Expand Up @@ -60,7 +70,7 @@ export function Client({ path, isEdit }: { path: string; isEdit: boolean }) {
}

if (data) {
return <Render config={config} data={data} />;
return <Render config={config} data={resolvedData} />;
}

return (
Expand Down
31 changes: 28 additions & 3 deletions apps/demo/config/blocks/Hero/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { quotes } from "./quotes";
const getClassName = getClassNameFactory("Hero", styles);

export type HeroProps = {
_data?: object;
quote?: { index: number };
title: string;
description: string;
align?: string;
Expand All @@ -20,18 +20,20 @@ export type HeroProps = {
buttons: { label: string; href: string; variant?: "primary" | "secondary" }[];
};

// TODO add resolveValue prop so the fetch can return different data to the adaptor
const quotesAdaptor = {
name: "Quotes API",
fetchList: async (): Promise<Partial<HeroProps>[]> =>
quotes.map((quote) => ({
quotes.map((quote, idx) => ({
index: idx,
title: quote.author,
description: quote.content,
})),
};

export const Hero: ComponentConfig<HeroProps> = {
fields: {
_data: {
quote: {
type: "external",
adaptor: quotesAdaptor,
getItemSummary: (item: Partial<HeroProps>) => item.description,
Expand Down Expand Up @@ -77,6 +79,29 @@ export const Hero: ComponentConfig<HeroProps> = {
buttons: [{ label: "Learn more", href: "#" }],
padding: "64px",
},
/**
* The resolveProps method allows us to modify props after the Puck
* data has been set.
*
* It is called after the page data is changed, but before a component
* is rendered. This allows us to make dynamic changes to the props
* without storing the data in Puck.
*
* For example, requesting a third-party API for the latest content.
*/
resolveProps: async (props) => {
if (!props.quote)
return { props, readOnly: { title: false, description: false } };

return {
props: {
...props,
title: quotes[props.quote.index].author,
description: quotes[props.quote.index].content,
},
readOnly: { title: true, description: true },
};
},
render: ({
align,
title,
Expand Down
10 changes: 0 additions & 10 deletions apps/demo/config/blocks/Hero/quotes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,6 @@ export const quotes = [
"True terror is to wake up one morning and discover that your high school class is running the country.",
author: "Kurt Vonnegut",
},
{
content:
"A diplomat is a man who always remembers a woman's birthday but never remembers her age.",
author: "Robert Frost",
},
{
content:
"As I grow older, I pay less attention to what men say. I just watch what they do.",
Expand All @@ -48,9 +43,4 @@ export const quotes = [
"Nobody grows old merely by living a number of years. We grow old by deserting our ideals. Years may wrinkle the skin, but to give up enthusiasm wrinkles the soul.",
author: "Samuel Ullman",
},
{
content:
"An archaeologist is the best husband a woman can have. The older she gets the more interested he is in her.",
author: "Agatha Christie",
},
];
3 changes: 2 additions & 1 deletion packages/core/components/DropZone/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
useCallback,
useState,
} from "react";
import { Config, Data } from "../../types/Config";
import { Config, Data, MappedItem } from "../../types/Config";
import { DragStart, DragUpdate } from "react-beautiful-dnd";
import { ItemSelector, getItem } from "../../lib/get-item";
import { PuckAction } from "../../reducer";
Expand All @@ -18,6 +18,7 @@ export type PathData = Record<string, { path: string[]; label: string }>;
export type DropZoneContext = {
data: Data;
config: Config;
dynamicProps?: Record<string, any>;
itemSelector?: ItemSelector | null;
setItemSelector?: (newIndex: ItemSelector | null) => void;
dispatch?: (action: PuckAction) => void;
Expand Down
3 changes: 2 additions & 1 deletion packages/core/components/DropZone/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ function DropZoneEdit({ zone, style }: DropZoneProps) {
const {
// These all need setting via context
data,
dynamicProps = {},
dispatch = () => null,
config,
itemSelector,
Expand Down Expand Up @@ -170,7 +171,7 @@ function DropZoneEdit({ zone, style }: DropZoneProps) {

const defaultedProps = {
...config.components[item.type]?.defaultProps,
...item.props,
...(dynamicProps[item.props.id] || item.props),
editMode: true,
};

Expand Down
51 changes: 35 additions & 16 deletions packages/core/components/Puck/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { flushZones } from "../../lib/flush-zones";
import { usePuckHistory } from "../../lib/use-puck-history";
import { AppProvider, defaultAppState } from "./context";
import { useComponentList } from "../../lib/use-component-list";
import { resolveAllProps } from "../../lib/resolve-all-props";

const Field = () => {};

Expand Down Expand Up @@ -104,6 +105,8 @@ export function Puck({
headerTitle?: string;
headerPath?: string;
}) {
const [dynamicProps, setDynamicProps] = useState<Record<string, any>>({});

const [reducer] = useState(() => createReducer({ config }));

const initialAppState: AppState = {
Expand Down Expand Up @@ -139,6 +142,25 @@ export function Puck({

const { data, ui } = appState;

useEffect(() => {
// Flatten zones
const flatContent = Object.keys(data.zones || {}).reduce(
(acc, zone) => [...acc, ...data.zones![zone]],
data.content
);

resolveAllProps(flatContent, config).then((dynamicContent) => {
const newDynamicProps = dynamicContent.reduce<Record<string, any>>(
(acc, item) => {
return { ...acc, [item.props.id]: item.props };
},
{}
);

setDynamicProps(newDynamicProps);
});
}, [data]);

const { canForward, canRewind, rewind, forward } = usePuckHistory({
appState,
dispatch,
Expand All @@ -156,7 +178,9 @@ export function Puck({
[]
);

const selectedItem = itemSelector ? getItem(itemSelector, data) : null;
const selectedItem = itemSelector
? getItem(itemSelector, data, dynamicProps)
: null;

const Page = useCallback(
(pageProps) => (
Expand Down Expand Up @@ -318,6 +342,7 @@ export function Puck({
value={{
data,
itemSelector,
dynamicProps,
setItemSelector,
config,
dispatch,
Expand Down Expand Up @@ -598,12 +623,12 @@ export function Puck({
currentProps = data.root;
}

const { readOnly, ..._meta } =
currentProps._meta || {};

if (fieldName === "_data") {
// Reset the link if value is falsey
if (!value) {
const { locked, ..._meta } =
currentProps._meta || {};

newProps = {
...currentProps,
_data: undefined,
Expand Down Expand Up @@ -649,35 +674,29 @@ export function Puck({
};

if (selectedItem && itemSelector) {
const { readOnly = {} } =
selectedItem.props._meta || {};
return (
<InputOrGroup
key={`${selectedItem.props.id}_${fieldName}`}
field={field}
name={fieldName}
label={field.label}
readOnly={
getItem(
itemSelector,
data
)!.props._meta?.locked?.indexOf(fieldName) >
-1
}
readOnly={readOnly[fieldName]}
value={selectedItem.props[fieldName]}
onChange={onChange}
/>
);
} else {
const { readOnly = {} } = data.root._meta || {};

return (
<InputOrGroup
key={`page_${fieldName}`}
field={field}
name={fieldName}
label={field.label}
readOnly={
data.root._meta?.locked?.indexOf(
fieldName
) > -1
}
readOnly={readOnly[fieldName]}
value={data.root[fieldName]}
onChange={onChange}
/>
Expand Down
2 changes: 2 additions & 0 deletions packages/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,6 @@ export * from "./components/IconButton";
export * from "./components/Puck";
export * from "./components/Render";

export * from "./lib/resolve-data";

export { FieldLabel } from "./components/InputOrGroup";
15 changes: 11 additions & 4 deletions packages/core/lib/get-item.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Data } from "../types/Config";
import { Data, MappedItem } from "../types/Config";
import { rootDroppableId } from "./root-droppable-id";
import { setupZone } from "./setup-zone";

Expand All @@ -9,11 +9,18 @@ export type ItemSelector = {

export const getItem = (
selector: ItemSelector,
data: Data
data: Data,
dynamicProps: Record<string, any> = {}
): Data["content"][0] | undefined => {
if (!selector.zone || selector.zone === rootDroppableId) {
return data.content[selector.index];
const item = data.content[selector.index];

return { ...item, props: dynamicProps[item.props.id] || item.props };
}

return setupZone(data, selector.zone).zones[selector.zone][selector.index];
const item = setupZone(data, selector.zone).zones[selector.zone][
selector.index
];

return { ...item, props: dynamicProps[item.props.id] || item.props };
};
30 changes: 30 additions & 0 deletions packages/core/lib/resolve-all-props.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Config, MappedItem } from "../types/Config";

export const resolveAllProps = async (
content: MappedItem[],
config: Config
) => {
return await Promise.all(
content.map(async (item) => {
const configForItem = config.components[item.type];

if (configForItem.resolveProps) {
const { props: resolvedProps, readOnly = {} } =
await configForItem.resolveProps(item.props);

const { _meta: { readOnly: existingReadOnly = {} } = {} } =
item.props || {};

return {
...item,
props: {
...resolvedProps,
_meta: { readOnly: { ...existingReadOnly, ...readOnly } },
},
};
}

return item;
})
);
};
20 changes: 20 additions & 0 deletions packages/core/lib/resolve-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { Config, Data, MappedItem } from "../types/Config";
import { resolveAllProps } from "./resolve-all-props";

export const resolveData = async (data: Data, config: Config) => {
const { zones = {} } = data;

const zoneKeys = Object.keys(zones);
const resolvedZones: Record<string, MappedItem[]> = {};

for (let i = 0; i < zoneKeys.length; i++) {
const zoneKey = zoneKeys[i];
resolvedZones[zoneKey] = await resolveAllProps(zones[zoneKey], config);
}

return {
...data,
content: await resolveAllProps(data.content, config),
zones: resolvedZones,
};
};
13 changes: 10 additions & 3 deletions packages/core/types/Config.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,11 @@ export type Adaptor<AdaptorParams = {}> = {
) => Promise<Record<string, any>[] | null>;
};

type WithId<T> = T & {
type WithPuckProps<Props> = Props & {
id: string;
_meta: {
readOnly: Partial<Record<keyof Props, boolean>>;
};
};

export type Field<
Expand Down Expand Up @@ -72,9 +75,13 @@ export type ComponentConfig<
ComponentProps extends DefaultComponentProps = DefaultComponentProps,
DefaultProps = ComponentProps
> = {
render: (props: WithId<ComponentProps>) => ReactElement;
render: (props: WithPuckProps<ComponentProps>) => ReactElement;
defaultProps?: DefaultProps;
fields?: Fields<ComponentProps>;
resolveProps?: (props: WithPuckProps<ComponentProps>) => Promise<{
props: WithPuckProps<ComponentProps>;
readOnly?: Partial<Record<keyof ComponentProps, boolean>>;
}>;
};

type Category<ComponentName> = {
Expand Down Expand Up @@ -108,7 +115,7 @@ export type MappedItem<
Props extends { [key: string]: any } = { [key: string]: any }
> = {
type: keyof Props;
props: WithId<{
props: WithPuckProps<{
[key: string]: any;
}>;
};
Expand Down

0 comments on commit c1181ad

Please sign in to comment.