From 528581dd0fda06a84ff048b64689f5c187532724 Mon Sep 17 00:00:00 2001 From: lihbr <lihbr@users.noreply.github.com> Date: Wed, 27 Mar 2024 13:47:50 +0100 Subject: [PATCH] feat: support @prismicio/client `mapSliceZone` --- playground/src/App.vue | 3 + playground/src/router/index.ts | 8 + playground/src/views/components/slicezone.vue | 22 +++ playground/src/views/helpers/composition.vue | 6 +- src/components/SliceZone.ts | 110 ++++++++---- test/components-SliceZone.test.ts | 170 ++++++++++++++++-- 6 files changed, 263 insertions(+), 56 deletions(-) create mode 100644 playground/src/views/components/slicezone.vue diff --git a/playground/src/App.vue b/playground/src/App.vue index 606d933..9ec040f 100644 --- a/playground/src/App.vue +++ b/playground/src/App.vue @@ -33,6 +33,9 @@ <li> <router-link to="/components/richtext">richtext</router-link> </li> + <li> + <router-link to="/components/slicezone">slicezone</router-link> + </li> </ul> </li> </ul> diff --git a/playground/src/router/index.ts b/playground/src/router/index.ts index c527841..4c34fb7 100644 --- a/playground/src/router/index.ts +++ b/playground/src/router/index.ts @@ -65,6 +65,14 @@ const routes: Array<RouteRecordRaw> = [ /* webpackChunkName: "components--richtext" */ "../views/components/richtext.vue" ), }, + { + path: "/components/slicezone", + name: "ComponentsSliceZone", + component: () => + import( + /* webpackChunkName: "components--slicezone" */ "../views/components/slicezone.vue" + ), + }, ]; const router = createRouter({ diff --git a/playground/src/views/components/slicezone.vue b/playground/src/views/components/slicezone.vue new file mode 100644 index 0000000..e429e51 --- /dev/null +++ b/playground/src/views/components/slicezone.vue @@ -0,0 +1,22 @@ +<template> + <div class="componentsRichText"> + <slice-zone :slices="slices" :components="{}" /> + </div> +</template> + +<script lang="ts"> +import { defineComponent } from "vue"; + +export default defineComponent({ + name: "ComponentsSliceZone", + data() { + return { + slices: [ + { __mapped: true, id: "1", slice_type: "foo", abc: "123" }, + { __mapped: true, id: "2", slice_type: "bar", def: "456" }, + { id: "3", slice_type: "baz", primary: { ghi: "789" } }, + ], + }; + }, +}); +</script> diff --git a/playground/src/views/helpers/composition.vue b/playground/src/views/helpers/composition.vue index 124e2cf..e755852 100644 --- a/playground/src/views/helpers/composition.vue +++ b/playground/src/views/helpers/composition.vue @@ -23,9 +23,9 @@ import { usePrismic } from "../../../../src"; export default defineComponent({ setup(): { - resolvedSimple: string; - resolvedBlank: string; - resolvedInternal: string; + resolvedSimple: string | null; + resolvedBlank: string | null; + resolvedInternal: string | null; } { const prismic = usePrismic(); diff --git a/src/components/SliceZone.ts b/src/components/SliceZone.ts index 85aebaf..b720357 100644 --- a/src/components/SliceZone.ts +++ b/src/components/SliceZone.ts @@ -41,10 +41,10 @@ type ExtractSliceType<TSlice extends SliceLike> = TSlice extends SliceLikeRestV2 * * @typeParam TSliceType - Type name of the Slice. */ -export type SliceLikeRestV2<TSliceType extends string = string> = { - slice_type: Slice<TSliceType>["slice_type"]; - id?: string; -}; +export type SliceLikeRestV2<TSliceType extends string = string> = Pick< + Slice<TSliceType>, + "id" | "slice_type" +>; /** * The minimum required properties to represent a Prismic Slice from the Prismic @@ -60,14 +60,23 @@ export type SliceLikeGraphQL<TSliceType extends string = string> = { * The minimum required properties to represent a Prismic Slice for the * `<SliceZone />` component. * - * If using Prismic's REST API, use the `Slice` export from `@prismicio/client` - * for a full interface. + * If using Prismic's Rest API V2, use the `Slice` export from + * `@prismicio/client` for a full interface. * * @typeParam TSliceType - Type name of the Slice */ -export type SliceLike<TSliceType extends string = string> = +export type SliceLike<TSliceType extends string = string> = ( | SliceLikeRestV2<TSliceType> - | SliceLikeGraphQL<TSliceType>; + | SliceLikeGraphQL<TSliceType> +) & { + /** + * If `true`, this Slice has been modified from its original value using a + * mapper and `@prismicio/client`'s `mapSliceZone()`. + * + * @internal + */ + __mapped?: true; +}; /** * A looser version of the `SliceZone` type from `@prismicio/client` using @@ -78,8 +87,9 @@ export type SliceLike<TSliceType extends string = string> = * * @typeParam TSlice - The type(s) of slices in the Slice Zone */ -export type SliceZoneLike<TSlice extends SliceLike = SliceLike> = - readonly TSlice[]; +export type SliceZoneLike< + TSlice extends SliceLike = SliceLike & Record<string, unknown>, +> = readonly TSlice[]; /** * Vue props for a component rendering content from a Prismic Slice using the @@ -90,8 +100,7 @@ export type SliceZoneLike<TSlice extends SliceLike = SliceLike> = * available to all Slice components */ export type SliceComponentProps< - // eslint-disable-next-line @typescript-eslint/no-explicit-any - TSlice extends SliceLike = any, + TSlice extends SliceLike = SliceLike, TContext = unknown, > = { /** @@ -111,7 +120,9 @@ export type SliceComponentProps< // reference limtiations. If we had another generic to determine the full // union of Slice types, it would include TSlice. This causes TypeScript to // throw a compilation error. - slices: SliceZoneLike<SliceLike>; + slices: SliceZoneLike< + TSlice extends SliceLikeGraphQL ? SliceLikeGraphQL : SliceLikeRestV2 + >; /** * Arbitrary data passed to `<SliceZone />` and made available to all Slice @@ -246,19 +257,29 @@ export const TODOSliceComponent = __PRODUCTION__ ? ((() => null) as FunctionalComponent<SliceComponentProps>) : /*#__PURE__*/ (defineComponent({ name: "TODOSliceComponent", - props: getSliceComponentProps(), - setup(props) { + props: [], + inheritAttrs: false, + setup(_props, { attrs }) { const type = computed(() => - "slice_type" in props.slice - ? props.slice.slice_type - : props.slice.type, + attrs.slice && typeof attrs.slice === "object" + ? "slice_type" in attrs.slice + ? attrs.slice.slice_type + : "type" in attrs.slice + ? attrs.slice.type + : null + : null, ); watchEffect(() => { - console.warn( - `[SliceZone] Could not find a component for Slice type "${type.value}"`, - props.slice, - ); + type.value + ? console.warn( + `[SliceZone] Could not find a component for Slice type "${type.value}"`, + attrs.slice, + ) + : console.warn( + "[SliceZone] Could not find a component for mapped Slice", + attrs, + ); }); return () => { @@ -266,9 +287,13 @@ export const TODOSliceComponent = __PRODUCTION__ "section", { "data-slice-zone-todo-component": "", - "data-slice-type": type.value, + "data-slice-type": type.value ? type.value : null, }, - [`Could not find a component for Slice type "${type.value}"`], + [ + type.value + ? `Could not find a component for Slice type "${type.value}"` + : "Could not find a component for mapped Slice", + ], ); }; }, @@ -426,6 +451,7 @@ export type SliceZoneProps<TContext = unknown> = { * * @returns The Vue component to render for a Slice. */ + // TODO: Remove in v5 when the `resolver` prop is removed. // eslint-disable-next-line @typescript-eslint/no-explicit-any resolver?: SliceZoneResolver<any, TContext>; @@ -499,11 +525,21 @@ export const SliceZoneImpl = /*#__PURE__*/ defineComponent({ return () => null; } + // TODO: Remove in v3 when the `resolver` prop is removed. + if (!__PRODUCTION__) { + if (props.resolver) { + console.warn( + "The `resolver` prop is deprecated. Please replace it with a components map using the `components` prop.", + ); + } + } + const { options } = usePrismic(); const renderedSlices = computed(() => { return props.slices.map((slice, index) => { - const type = "slice_type" in slice ? slice.slice_type : slice.type; + const type = + "slice_type" in slice ? (slice.slice_type as string) : slice.type; let component = props.components && type in props.components @@ -512,7 +548,7 @@ export const SliceZoneImpl = /*#__PURE__*/ defineComponent({ options.components?.sliceZoneDefaultComponent || TODOSliceComponent; - // TODO: Remove `resolver` in v3 in favor of `components`. + // TODO: Remove `resolver` in v5 in favor of `components`. if (props.resolver) { const resolvedComponent = props.resolver({ slice, @@ -526,17 +562,23 @@ export const SliceZoneImpl = /*#__PURE__*/ defineComponent({ } const key = - "id" in slice && slice.id + "id" in slice && typeof slice.id === "string" ? slice.id : `${index}-${JSON.stringify(slice)}`; - const p = { - key, - slice, - index, - context: props.context, - slices: props.slices, - }; + let p; + if (slice.__mapped) { + const { __mapped, ...mappedProps } = slice; + p = { key, ...mappedProps }; + } else { + p = { + key, + slice, + index, + context: props.context, + slices: props.slices, + }; + } return h(simplyResolveComponent(component as ConcreteComponent), p); }); diff --git a/test/components-SliceZone.test.ts b/test/components-SliceZone.test.ts index 6e4564e..ac094f5 100644 --- a/test/components-SliceZone.test.ts +++ b/test/components-SliceZone.test.ts @@ -37,9 +37,9 @@ it("renders slice zone with correct component mapping from components", async () const wrapper = mount(SliceZoneImpl, { props: { slices: [ - { slice_type: "foo" }, - { slice_type: "bar" }, - { slice_type: "baz" }, + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + { id: "3", slice_type: "baz" }, ], components: defineSliceZoneComponents({ foo: Foo, @@ -110,7 +110,12 @@ it("renders slice zone with correct component mapping from components with slice ); }); +// TODO: Remove in v5 when the `resolver` prop is removed. it("renders slice zone with correct component mapping from resolver", async () => { + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0); + const Foo = createWrapperComponent<SliceComponentType>( "Foo", getSliceComponentProps(), @@ -127,9 +132,9 @@ it("renders slice zone with correct component mapping from resolver", async () = const wrapper = mount(SliceZoneImpl, { props: { slices: [ - { slice_type: "foo" }, - { slice_type: "bar" }, - { slice_type: "baz" }, + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + { id: "3", slice_type: "baz" }, ], resolver: (({ sliceName }) => { const components = defineSliceZoneComponents({ @@ -157,6 +162,34 @@ it("renders slice zone with correct component mapping from resolver", async () = <div class="wrapperComponentBar"></div> <div class="wrapperComponentBaz"></div>`, ); + + consoleWarnSpy.mockRestore(); +}); + +// TODO: Remove in v5 when the `resolver` prop is removed. +it("logs a deprecation warning when using a resolver only in development", async () => { + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "development"; + + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0); + + mount(SliceZoneImpl, { + props: { + slices: [], + resolver: (() => void 0) as SliceZoneResolver, + }, + }); + + await flushPromises(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringMatching(/the `resolver` prop is deprecated/i), + ); + + consoleWarnSpy.mockRestore(); + process.env.NODE_ENV = originalNodeEnv; }); it("supports GraphQL API", async () => { @@ -187,6 +220,51 @@ it("supports GraphQL API", async () => { ); }); +it("supports mapped slices from @prismicio/client's mapSliceZone()", async () => { + const Foo = createWrapperComponent<{ id: string; slice_type: string }>( + "Foo", + ["id", "slice_type"], + ); + const Bar = createWrapperComponent<{ id: string; slice_type: string }>( + "Bar", + ["id", "slice_type"], + ); + const Baz = createWrapperComponent<SliceComponentType>( + "Baz", + getSliceComponentProps(), + ); + + const wrapper = mount(SliceZoneImpl, { + props: { + slices: [ + { __mapped: true, id: "1", slice_type: "foo", abc: "123" }, + { __mapped: true, id: "2", slice_type: "bar", def: "456" }, + { id: "3", slice_type: "baz" }, + ], + components: defineSliceZoneComponents({ + foo: Foo, + bar: defineAsyncComponent( + () => new Promise<SliceComponentType>((res) => res(Bar)), + ), + baz: "Baz", + }), + }, + global: { + components: { + Baz, + }, + }, + }); + + await flushPromises(); + + expect(wrapper.html()).toBe( + `<div class="wrapperComponentFoo" abc="123"></div> +<div class="wrapperComponentBar" def="456"></div> +<div class="wrapperComponentBaz"></div>`, + ); +}); + it("provides context to each slice", () => { const Foo = createWrapperComponent<SliceComponentType>( "Foo", @@ -201,7 +279,10 @@ it("provides context to each slice", () => { const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], components: defineSliceZoneComponents({ foo: Foo, bar: Bar, @@ -230,7 +311,11 @@ it("renders TODO component if component mapping is missing", () => { const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + { __mapped: true, id: "3", slice_type: "baz", abc: "123" }, + ], components: defineSliceZoneComponents({ foo: Foo, }), @@ -239,12 +324,16 @@ it("renders TODO component if component mapping is missing", () => { expect(wrapper.html()).toBe( `<div class="wrapperComponentFoo"></div> -<section data-slice-zone-todo-component="" data-slice-type="bar">Could not find a component for Slice type "bar"</section>`, +<section data-slice-zone-todo-component="" data-slice-type="bar">Could not find a component for Slice type "bar"</section> +<section data-slice-zone-todo-component="">Could not find a component for mapped Slice</section>`, ); - expect(console.warn).toHaveBeenCalledOnce(); + expect(console.warn).toHaveBeenCalledTimes(2); expect(vi.mocked(console.warn).mock.calls[0]).toMatch( /could not find a component/i, ); + expect(vi.mocked(console.warn).mock.calls[1]).toMatch( + /could not find a component/i, + ); vi.resetAllMocks(); }); @@ -295,7 +384,10 @@ it("renders plugin provided TODO component if component mapping is missing", () const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], components: defineSliceZoneComponents({ foo: Foo, }), @@ -332,7 +424,10 @@ it("renders provided TODO component over plugin provided if component mapping is const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], components: defineSliceZoneComponents({ foo: Foo, }), @@ -350,9 +445,37 @@ it("renders provided TODO component over plugin provided if component mapping is }); it.skip("doesn't render TODO component in production", () => { - // ts-eager does not allow esbuild configuration. - // We cannot override the `process.env.NODE_ENV` inline replacement. - // As a result, we cannot test for production currently. + const originalNodeEnv = process.env.NODE_ENV; + process.env.NODE_ENV = "production"; + const consoleWarnSpy = vi + .spyOn(console, "warn") + .mockImplementation(() => void 0); + + const Foo = createWrapperComponent<SliceComponentType>( + "Foo", + getSliceComponentProps(), + ); + + const wrapper = mount(SliceZoneImpl, { + props: { + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], + components: defineSliceZoneComponents({ + foo: Foo, + }), + }, + }); + + expect(wrapper.html()).toBe(`<div class="wrapperComponentFoo"></div>`); + expect(consoleWarnSpy).not.toHaveBeenCalledWith( + expect.stringMatching(/could not find a component/i), + { id: "2", slice_type: "bar" }, + ); + + consoleWarnSpy.mockRestore(); + process.env.NODE_ENV = originalNodeEnv; }); it("wraps output with provided wrapper tag", () => { @@ -367,7 +490,10 @@ it("wraps output with provided wrapper tag", () => { const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], components: defineSliceZoneComponents({ foo: Foo, bar: Bar, @@ -396,7 +522,10 @@ it("wraps output with provided wrapper component", () => { const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], components: defineSliceZoneComponents({ foo: Foo, bar: Bar, @@ -444,7 +573,10 @@ it("reacts to changes properly", async () => { const wrapper = mount(SliceZoneImpl, { props: { - slices: [{ slice_type: "foo" }, { slice_type: "bar" }], + slices: [ + { id: "1", slice_type: "foo" }, + { id: "2", slice_type: "bar" }, + ], components: defineSliceZoneComponents({ foo: Foo, bar: Bar, @@ -455,7 +587,7 @@ it("reacts to changes properly", async () => { const firstRender = wrapper.html(); await wrapper.setProps({ - slices: [{ slice_type: "bar" }], + slices: [{ id: "2", slice_type: "bar" }], components: defineSliceZoneComponents({ foo: Foo, bar: Bar,