diff --git a/README.md b/README.md index 7d04ab3f55..4c3aaca566 100644 --- a/README.md +++ b/README.md @@ -111,10 +111,11 @@ The plugin API follows a React paradigm. Each plugin passed to the Puck editor c - `renderRoot` (`Component`): Render the root node of the preview content - `renderRootFields` (`Component`): Render the root fields - `renderFields` (`Component`): Render the fields for the currently selected component +- `renderComponentList` (`Component`): Render the component list Each render function receives three props: -- **children** (`ReactNode`): The normal contents of the root or field. You must render this. +- **children** (`ReactNode`): The normal contents of the root or field. You must render this if provided. - **state** (`AppState`): The current application state, including data and UI state - **dispatch** (`(action: PuckAction) => void`): The Puck dispatcher, used for making data changes or updating the UI. See the [action definitions](https://github.com/measuredco/puck/blob/main/packages/core/reducer/actions.tsx) for a full reference of available mutations. @@ -241,6 +242,7 @@ The `` component renders the Puck editor. - **data** (`Data`): Initial data to render - **onChange** (`(Data) => void` [optional]): Callback that triggers when the user makes a change - **onPublish** (`(Data) => void` [optional]): Callback that triggers when the user hits the "Publish" button +- **renderComponentList** (`Component` [optional]): Render function for wrapping the component list - **renderHeader** (`Component` [optional]): Render function for overriding the Puck header component - **renderHeaderActions** (`Component` [optional]): Render function for overriding the Puck header actions. Use a fragment. - **headerTitle** (`string` [optional]): Set the title shown in the header title @@ -313,6 +315,11 @@ The `AppState` object stores the puck application state. - **leftSideBarVisible** (boolean): Whether or not the left side bar is visible - **itemSelector** (object): An object describing which item is selected - **arrayState** (object): An object describing the internal state of array items + - **componentList** (object): An object describing the component list. Similar shape to `Config.categories`. + - **components** (`sting[]`, [optional]): Array containing the names of components in this category + - **title** (`sting`, [optional]): Title of the category + - **visible** (`boolean`, [optional]): Whether or not the category is visible in the side bar + - **expanded** (`boolean`, [optional]): Whether or not the category is expanded in the side bar ### `Data` diff --git a/packages/core/components/ComponentList/index.tsx b/packages/core/components/ComponentList/index.tsx index 07011bbd79..133dcc8bfa 100644 --- a/packages/core/components/ComponentList/index.tsx +++ b/packages/core/components/ComponentList/index.tsx @@ -4,7 +4,7 @@ import styles from "./styles.module.css"; import getClassNameFactory from "../../lib/get-class-name-factory"; import { Draggable } from "../Draggable"; import { DragIcon } from "../DragIcon"; -import { ReactNode, useState } from "react"; +import { ReactNode } from "react"; import { useAppContext } from "../Puck/context"; import { ChevronDown, ChevronUp } from "react-feather"; @@ -44,31 +44,41 @@ const ComponentListItem = ({ const ComponentList = ({ children, title, - defaultExpanded = true, + id, }: { + id: string; children?: ReactNode; title?: string; - defaultExpanded?: boolean; }) => { - const { config } = useAppContext(); + const { config, state, setUi } = useAppContext(); - const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const { expanded = true } = state.ui.componentList[id] || {}; return ( -
+
{title && (
setIsExpanded(!isExpanded)} + onClick={() => + setUi({ + componentList: { + ...state.ui.componentList, + [id]: { + ...state.ui.componentList[id], + expanded: !expanded, + }, + }, + }) + } title={ - isExpanded + expanded ? `Collapse${title ? ` ${title}` : ""}` : `Expand${title ? ` ${title}` : ""}` } >
{title}
- {isExpanded ? : } + {expanded ? : }
)} diff --git a/packages/core/components/Puck/context.tsx b/packages/core/components/Puck/context.tsx index f21d609860..f20dec95b4 100644 --- a/packages/core/components/Puck/context.tsx +++ b/packages/core/components/Puck/context.tsx @@ -9,6 +9,7 @@ export const defaultAppState: AppState = { leftSideBarVisible: true, arrayState: {}, itemSelector: null, + componentList: {}, }, }; diff --git a/packages/core/components/Puck/index.tsx b/packages/core/components/Puck/index.tsx index d85a713e1e..362424fbb1 100644 --- a/packages/core/components/Puck/index.tsx +++ b/packages/core/components/Puck/index.tsx @@ -51,7 +51,11 @@ const PluginRenderer = ({ dispatch: (action: PuckAction) => void; state: AppState; plugins; - renderMethod: "renderRoot" | "renderRootFields" | "renderFields"; + renderMethod: + | "renderRoot" + | "renderRootFields" + | "renderFields" + | "renderComponentList"; }) => { return plugins .filter((item) => item[renderMethod]) @@ -72,6 +76,7 @@ export function Puck({ onChange, onPublish, plugins = [], + renderComponentList, renderHeader, renderHeaderActions, headerTitle, @@ -82,6 +87,11 @@ export function Puck({ onChange?: (data: Data) => void; onPublish: (data: Data) => void; plugins?: Plugin[]; + renderComponentList?: (props: { + children: ReactNode; + dispatch: (action: PuckAction) => void; + state: AppState; + }) => ReactElement; renderHeader?: (props: { children: ReactNode; dispatch: (action: PuckAction) => void; @@ -99,6 +109,27 @@ export function Puck({ const initialAppState: AppState = { ...defaultAppState, data: initialData, + ui: { + ...defaultAppState.ui, + + // Store categories under componentList on state to allow render functions and plugins to modify + componentList: config.categories + ? Object.entries(config.categories).reduce( + (acc, [categoryName, category]) => { + return { + ...acc, + [categoryName]: { + title: category.title, + components: category.components, + expanded: category.defaultExpanded, + visible: category.visible, + }, + }; + }, + {} + ) + : {}, + }, }; const [appState, dispatch] = useReducer( @@ -171,6 +202,28 @@ export function Puck({ [] ); + const ComponentListWrapper = useCallback((props) => { + const children = ( + + {props.children} + + ); + + // User's render method wraps the plugin render methods + return renderComponentList + ? renderComponentList({ + children, + dispatch, + state: appState, + }) + : children; + }, []); + const FieldWrapper = itemSelector ? ComponentFieldWrapper : PageFieldWrapper; const rootFields = config.root?.fields || defaultPageFields; @@ -192,7 +245,7 @@ export function Puck({ DragStart & Partial >(); - const componentList = useComponentList(config); + const componentList = useComponentList(config, appState.ui); return (
@@ -428,7 +481,13 @@ export function Puck({ }} > - {componentList ? componentList : } + + {componentList ? ( + componentList + ) : ( + + )} + {ctx?.activeZones && diff --git a/packages/core/lib/use-component-list.tsx b/packages/core/lib/use-component-list.tsx index 6000ca0a5a..f3a3189857 100644 --- a/packages/core/lib/use-component-list.tsx +++ b/packages/core/lib/use-component-list.tsx @@ -1,17 +1,17 @@ import { ReactNode, useEffect, useState } from "react"; -import { Config } from "../types/Config"; +import { Config, UiState } from "../types/Config"; import { ComponentList } from "../components/ComponentList"; -export const useComponentList = (config: Config) => { +export const useComponentList = (config: Config, ui: UiState) => { const [componentList, setComponentList] = useState(); useEffect(() => { - if (config.categories) { + if (ui.componentList) { const matchedComponents: string[] = []; let _componentList: ReactNode[]; - _componentList = Object.entries(config.categories).map( + _componentList = Object.entries(ui.componentList).map( ([categoryKey, category]) => { if (category.visible === false || !category.components) { return null; @@ -19,9 +19,9 @@ export const useComponentList = (config: Config) => { return ( {category.components.map((componentName, i) => { matchedComponents.push(componentName as string); @@ -45,15 +45,11 @@ export const useComponentList = (config: Config) => { if ( remainingComponents.length > 0 && - !config.categories["other"]?.components && - config.categories["other"]?.visible !== false + !ui.componentList.other?.components && + ui.componentList.other?.visible !== false ) { _componentList.push( - + {remainingComponents.map((componentName, i) => { return ( { setComponentList(_componentList); } - }, [config.categories, config.components]); + }, [config.categories, ui.componentList]); return componentList; }; diff --git a/packages/core/types/Config.tsx b/packages/core/types/Config.tsx index 1c29dba2ba..06afe2f0dc 100644 --- a/packages/core/types/Config.tsx +++ b/packages/core/types/Config.tsx @@ -136,6 +136,15 @@ export type UiState = { leftSideBarVisible: boolean; itemSelector: ItemSelector | null; arrayState: Record; + componentList: Record< + string, + { + components?: string[]; + title?: string; + visible?: boolean; + expanded?: boolean; + } + >; }; export type AppState = { data: Data; ui: UiState };