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,