diff --git a/src/SliceZone.tsx b/src/SliceZone.tsx index b68c242..286046b 100644 --- a/src/SliceZone.tsx +++ b/src/SliceZone.tsx @@ -1,6 +1,19 @@ import * as React from "react"; import * as prismic from "@prismicio/client"; +import { pascalCase, PascalCase } from "./lib/pascalCase"; + +/** + * Returns the type of a `SliceLike` type. + * + * @typeParam Slice - The Slice from which the type will be extracted. + */ +type ExtractSliceType<Slice extends SliceLike> = Slice extends SliceLikeRestV2 + ? Slice["slice_type"] + : Slice extends SliceLikeGraphQL + ? Slice["type"] + : never; + /** * The minimum required properties to represent a Prismic Slice from the Prismic * Rest API V2 for the `<SliceZone>` component. @@ -101,6 +114,37 @@ export type SliceComponentType< TContext = unknown, > = React.ComponentType<SliceComponentProps<TSlice, TContext>>; +/** + * A record of Slice types mapped to a React component. The component will be + * rendered for each instance of its Slice. + * + * @deprecated This type is no longer used by `@prismicio/react`. Prefer using + * `Record<string, SliceComponentType<any>>` instead. + * @typeParam TSlice - The type(s) of a Slice in the Slice Zone. + * @typeParam TContext - Arbitrary data made available to all Slice components. + */ +export type SliceZoneComponents< + TSlice extends SliceLike = SliceLike, + TContext = unknown, +> = + // This is purposely not wrapped in Partial to ensure a component is provided + // for all Slice types. <SliceZone> will render a default component if one is + // not provided, but it *should* be a type error if an explicit component is + // missing. + // + // If a developer purposely does not want to provide a component, they can + // assign it to the TODOSliceComponent exported from this package. This + // signals to future developers that it is a placeholder and should be + // implemented. + { + [SliceType in ExtractSliceType<TSlice>]: SliceComponentType< + Extract<TSlice, SliceLike<SliceType>> extends never + ? SliceLike + : Extract<TSlice, SliceLike<SliceType>>, + TContext + >; + }; + /** * This Slice component can be used as a reminder to provide a proper * implementation. @@ -130,6 +174,54 @@ export const TODOSliceComponent = <TSlice extends SliceLike, TContext>({ } }; +/** + * Arguments for a `<SliceZone>` `resolver` function. + */ +type SliceZoneResolverArgs< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TSlice extends SliceLike = any, +> = { + /** + * The Slice to resolve to a React component. + */ + slice: TSlice; + + /** + * The name of the Slice. + */ + sliceName: PascalCase<ExtractSliceType<TSlice>>; + + /** + * The index of the Slice in the Slice Zone. + */ + i: number; +}; + +/** + * A function that determines the rendered React component for each Slice in the + * Slice Zone. If a nullish value is returned, the component will fallback to + * the `components` or `defaultComponent` props to determine the rendered + * component. + * + * @deprecated Use the `components` prop instead. + * + * @param args - Arguments for the resolver function. + * + * @returns The React component to render for a Slice. + */ +export type SliceZoneResolver< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + TSlice extends SliceLike = any, + TContext = unknown, +> = (args: SliceZoneResolverArgs<TSlice>) => + | SliceComponentType< + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any, + TContext + > + | undefined + | null; + /** * React props for the `<SliceZone>` component. * @@ -148,6 +240,19 @@ export type SliceZoneProps<TContext = unknown> = { // eslint-disable-next-line @typescript-eslint/no-explicit-any components?: Record<string, SliceComponentType<any, TContext>>; + /** + * A function that determines the rendered React component for each Slice in + * the Slice Zone. + * + * @deprecated Use the `components` prop instead. + * + * @param args - Arguments for the resolver function. + * + * @returns The React component to render for a Slice. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + resolver?: SliceZoneResolver<any, TContext>; + /** * The React component rendered if a component mapping from the `components` * prop cannot be found. @@ -179,6 +284,7 @@ export type SliceZoneProps<TContext = unknown> = { export const SliceZone = <TContext,>({ slices = [], components = {}, + resolver, defaultComponent = TODOSliceComponent, context = {} as TContext, }: SliceZoneProps<TContext>): JSX.Element => { @@ -186,9 +292,22 @@ export const SliceZone = <TContext,>({ return slices.map((slice, index) => { const type = "slice_type" in slice ? slice.slice_type : slice.type; - const Comp = + let Comp = components[type as keyof typeof components] || defaultComponent; + // TODO: Remove `resolver` in v3 in favor of `components`. + if (resolver) { + const resolvedComp = resolver({ + slice, + sliceName: pascalCase(type), + i: index, + }); + + if (resolvedComp) { + Comp = resolvedComp as typeof Comp; + } + } + const key = "id" in slice && slice.id ? slice.id @@ -204,7 +323,7 @@ export const SliceZone = <TContext,>({ /> ); }); - }, [components, context, defaultComponent, slices]); + }, [components, context, defaultComponent, slices, resolver]); return <>{renderedSlices}</>; }; diff --git a/src/index.ts b/src/index.ts index ff60f32..ec965a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,9 +28,10 @@ export { SliceZone, TODOSliceComponent } from "./SliceZone"; export type { SliceComponentProps, SliceComponentType, - SliceLikeRestV2, - SliceLikeGraphQL, SliceLike, + SliceLikeGraphQL, + SliceLikeRestV2, + SliceZoneComponents, SliceZoneLike, SliceZoneProps, } from "./SliceZone"; diff --git a/test/SliceZone.test.tsx b/test/SliceZone.test.tsx index 143e56c..75b9986 100644 --- a/test/SliceZone.test.tsx +++ b/test/SliceZone.test.tsx @@ -5,6 +5,7 @@ import { it, expect, vi } from "vitest"; import { renderJSON } from "./__testutils__/renderJSON"; import { SliceZone, TODOSliceComponent, SliceComponentProps } from "../src"; +import { SliceZoneResolver } from "../src/SliceZone"; type StringifySliceComponentProps = { /** @@ -201,6 +202,67 @@ it("TODO component renders null in production", () => { process.env.NODE_ENV = originalNodeEnv; }); +it("renders components from a resolver function for backwards compatibility with next-slicezone", async () => { + const slices = [ + { + slice_type: "foo_bar", + }, + { + slice_type: "barFoo", + }, + { + slice_type: "baz-qux", + }, + ] as const; + + const resolver: SliceZoneResolver<(typeof slices)[number]> = ({ + sliceName, + }) => { + switch (sliceName) { + case "FooBar": { + return (props) => <StringifySliceComponent id="foo_bar" {...props} />; + } + + case "BarFoo": { + return (props) => <StringifySliceComponent id="barFoo" {...props} />; + } + + case "BazQux": { + return (props) => <StringifySliceComponent id="baz-qux" {...props} />; + } + } + }; + + const actual = renderJSON(<SliceZone slices={slices} resolver={resolver} />); + const expected = renderJSON( + <> + <StringifySliceComponent + id="foo_bar" + slice={slices[0]} + index={0} + slices={slices} + context={{}} + /> + <StringifySliceComponent + id="barFoo" + slice={slices[1]} + index={1} + slices={slices} + context={{}} + /> + <StringifySliceComponent + id="baz-qux" + slice={slices[2]} + index={2} + slices={slices} + context={{}} + /> + </>, + ); + + expect(actual).toStrictEqual(expected); +}); + it("supports the GraphQL API", () => { const slices = [{ type: "foo" }, { type: "bar" }] as const;