Repository: Thinkmill/keystatic
Pull request #136 (2023-03-03 02:11:38)

slugField in array fields
This lets people avoid having the files within an array field be organised by index

changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 7141444812462554802e03c47dda40eef116c0a3

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 6 packages</summary>

  | Name                           | Type  |
| ------------------------------ | ----- |
| @keystatic/core                | Patch |
| @keystatic/test-next-app       | Patch |
| @keystatic/test-astro          | Patch |
| localization                   | Patch |
| @keystatic/test-next-pages-dir | Patch |
| @keystatic/remix-test-app      | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/Thinkmill/keystatic/new/array-field-slug?filename=.changeset/many-taxis-cheer.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0AslugField%20in%20array%20fields%0A)


vercel[bot]: [vc]: #1tA9392u4BpyCcz8iZv99di9H3uCDyU0WyqHtF6spyc=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5Ijoia2V5c3RhdGljIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC9rZXlzdGF0aWMvNHBLcm84YTZ6Z05MS25SWGo5REZiNXc3bm1nVSIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn0seyJuYW1lIjoidm91c3NvaXIiLCJyb290RGlyZWN0b3J5IjoiZGVzaWduLXN5c3RlbS9kb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC92b3Vzc29pci9CWmFMNVhVZ25YZ3VtYTE2MnZvYUM0YTVCUjU0IiwibmV4dENvbW1pdFN0YXR1cyI6IlBFTkRJTkcifV19
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Updated |
| :--- | :----- | :------ | :------ |
| **keystatic** | 🔄 Building ([Inspect](https://vercel.com/thinkmill/keystatic/4pKro8a6zgNLKnRXj9DFb5w7nmgU)) |  | Mar 3, 2023 at 2:11AM (UTC) |
| **voussoir** | 🔄 Building ([Inspect](https://vercel.com/thinkmill/voussoir/BZaL5XUgnXguma162voaC4a5BR54)) |  | Mar 3, 2023 at 2:11AM (UTC) |




Patch:
diff --git a/.changeset/thin-lemons-grin.md b/.changeset/thin-lemons-grin.md
new file mode 100644
index 00000000..18c69905
--- /dev/null
+++ b/.changeset/thin-lemons-grin.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Add `slugField` option to array fields
diff --git a/packages/keystatic/DocumentEditor/component-blocks/api.tsx b/packages/keystatic/DocumentEditor/component-blocks/api.tsx
index 214a3da8..7398b16a 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/api.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/api.tsx
@@ -58,9 +58,7 @@ export type SlugFormField<Value, Options, SerializedValue> = Omit<
   };
   validate(
     value: unknown,
-    slugFieldInfo:
-      | { slugs: Set<string>; currentSlug: string | undefined }
-      | undefined
+    slugFieldInfo: { slugs: Set<string> } | undefined
   ): boolean;
 };
 
@@ -190,6 +188,7 @@ export type ArrayField<ElementField extends ComponentSchema> = {
   // this is written with unknown to avoid typescript being annoying about circularity or variance things
   itemLabel?(props: unknown): string;
   asChildTag?: string;
+  slugField?: string;
 };
 
 export interface ObjectField<
@@ -221,6 +220,7 @@ type ArrayFieldInComponentSchema = {
   // this is written with unknown to avoid typescript being annoying about circularity or variance things
   itemLabel?(props: unknown): string;
   asChildTag?: string;
+  slugField?: string;
 };
 
 export type ComponentSchema =
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx
index d959fb8f..e4e9c384 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/array.tsx
@@ -1,4 +1,9 @@
-import { ComponentSchema, GenericPreviewProps, ArrayField } from '../api';
+import {
+  ComponentSchema,
+  GenericPreviewProps,
+  ArrayField,
+  SlugFormField,
+} from '../api';
 
 export function array<ElementField extends ComponentSchema>(
   element: ElementField,
@@ -6,6 +11,17 @@ export function array<ElementField extends ComponentSchema>(
     label?: string;
     itemLabel?: (props: GenericPreviewProps<ElementField, unknown>) => string;
     asChildTag?: string;
+    slugField?: ElementField extends { kind: 'object' }
+      ? {
+          [K in keyof ElementField['fields']]: ElementField['fields'][K] extends SlugFormField<
+            any,
+            any,
+            any
+          >
+            ? K
+            : never;
+        }[keyof ElementField['fields']]
+      : never;
   }
 ): ArrayField<ElementField> {
   return {
@@ -14,5 +30,6 @@ export function array<ElementField extends ComponentSchema>(
     label: opts?.label ?? 'Items',
     itemLabel: opts?.itemLabel,
     asChildTag: opts?.asChildTag,
+    slugField: opts?.slugField as string,
   };
 }
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/slug.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/slug.tsx
index 4424c582..5dfe5a08 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/slug.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/slug.tsx
@@ -3,8 +3,7 @@ import slugify from '@sindresorhus/slugify';
 import { ActionButton } from '@voussoir/button';
 import { Flex, Box } from '@voussoir/layout';
 import { TextField } from '@voussoir/text-field';
-import { useState, useMemo, useContext } from 'react';
-import { useSlugsInCollection } from '../../../app/useSlugsInCollection';
+import { useState, useContext } from 'react';
 import {
   validateText,
   isValidSlug,
@@ -42,140 +41,108 @@ export function slug(args: {
     name: args.name.defaultValue ?? '',
     slug: naiveGenerateSlug(args.name.defaultValue ?? ''),
   };
-  function SlugFieldInner(props: {
-    value: { name: string; slug: string };
-    onChange(value: { name: string; slug: string }): void;
-    autoFocus: boolean;
-    forceValidation: boolean;
-    slugs: Set<string>;
-  }) {
-    const [blurredName, setBlurredName] = useState(false);
-    const [blurredSlug, setBlurredSlug] = useState(false);
 
-    const [shouldGenerateSlug, setShouldGenerateSlug] = useState(
-      props.value === defaultValue
-    );
-    const generateSlug = (name: string) => {
-      const generated = naiveGenerateSlug(name);
-      if (props.slugs.has(generated)) {
-        let i = 1;
-        while (props.slugs.has(`${generated}-${i}`)) {
-          i++;
-        }
-        return `${generated}-${i}`;
-      }
-      return generated;
-    };
+  const emptySet = new Set<string>();
+  return {
+    kind: 'form',
+    Input(props) {
+      const slugContext = useContext(SlugFieldContext);
+      const path = useContext(PathContext);
+      const slugs =
+        path.length === 1 && path[0] === slugContext?.field
+          ? slugContext.slugs
+          : emptySet;
 
-    const slugErrorMessage =
-      props.forceValidation || blurredSlug
-        ? validateText(
-            props.value.slug,
-            args.slug?.validation?.length?.min ?? 1,
-            args.slug?.validation?.length?.max ?? Infinity,
-            args.slug?.label ?? 'Slug',
-            props.slugs
-          )
-        : undefined;
+      const [blurredName, setBlurredName] = useState(false);
+      const [blurredSlug, setBlurredSlug] = useState(false);
 
-    return (
-      <Flex gap="xlarge" direction="column">
-        <TextField
-          label={args.name.label}
-          description={args.name.description}
-          autoFocus={props.autoFocus}
-          value={props.value.name}
-          onChange={name => {
-            props.onChange({
-              name,
-              slug: shouldGenerateSlug ? generateSlug(name) : props.value.slug,
-            });
-          }}
-          onBlur={() => setBlurredName(true)}
-          errorMessage={
-            props.forceValidation || blurredName
-              ? validateText(
-                  props.value.name,
-                  args.name.validation?.length?.min ?? 0,
-                  args.name.validation?.length?.max ?? Infinity,
-                  args.name.label
-                )
-              : undefined
+      const [shouldGenerateSlug, setShouldGenerateSlug] = useState(
+        props.value === defaultValue
+      );
+      const generateSlug = (name: string) => {
+        const generated = naiveGenerateSlug(name);
+        if (slugs.has(generated)) {
+          let i = 1;
+          while (slugs.has(`${generated}-${i}`)) {
+            i++;
           }
-        />
-        <Flex gap="regular" alignItems="end">
+          return `${generated}-${i}`;
+        }
+        return generated;
+      };
+
+      const slugErrorMessage =
+        props.forceValidation || blurredSlug
+          ? validateText(
+              props.value.slug,
+              args.slug?.validation?.length?.min ?? 1,
+              args.slug?.validation?.length?.max ?? Infinity,
+              args.slug?.label ?? 'Slug',
+              slugs
+            )
+          : undefined;
+
+      return (
+        <Flex gap="xlarge" direction="column">
           <TextField
-            flex={1}
-            label={args.slug?.label ?? 'Slug'}
-            description={args.slug?.description}
-            value={props.value.slug}
-            onChange={slug => {
-              setShouldGenerateSlug(false);
-              props.onChange({ name: props.value.name, slug });
+            label={args.name.label}
+            description={args.name.description}
+            autoFocus={props.autoFocus}
+            value={props.value.name}
+            onChange={name => {
+              props.onChange({
+                name,
+                slug: shouldGenerateSlug
+                  ? generateSlug(name)
+                  : props.value.slug,
+              });
             }}
-            onBlur={() => setBlurredSlug(true)}
-            errorMessage={slugErrorMessage}
+            onBlur={() => setBlurredName(true)}
+            errorMessage={
+              props.forceValidation || blurredName
+                ? validateText(
+                    props.value.name,
+                    args.name.validation?.length?.min ?? 0,
+                    args.name.validation?.length?.max ?? Infinity,
+                    args.name.label
+                  )
+                : undefined
+            }
           />
-          <Flex gap="regular" direction="column">
-            <ActionButton
-              onPress={() => {
-                props.onChange({
-                  name: props.value.name,
-                  slug: generateSlug(props.value.name),
-                });
+          <Flex gap="regular" alignItems="end">
+            <TextField
+              flex={1}
+              label={args.slug?.label ?? 'Slug'}
+              description={args.slug?.description}
+              value={props.value.slug}
+              onChange={slug => {
+                setShouldGenerateSlug(false);
+                props.onChange({ name: props.value.name, slug });
               }}
-            >
-              Regenerate
-            </ActionButton>
-            {/* display shim to offset the error message */}
-            {slugErrorMessage !== undefined && <Box height="xsmall" />}
+              onBlur={() => setBlurredSlug(true)}
+              errorMessage={slugErrorMessage}
+            />
+            <Flex gap="regular" direction="column">
+              <ActionButton
+                onPress={() => {
+                  props.onChange({
+                    name: props.value.name,
+                    slug: generateSlug(props.value.name),
+                  });
+                }}
+              >
+                Regenerate
+              </ActionButton>
+              {/* display shim to offset the error message */}
+              {slugErrorMessage !== undefined && <Box height="xsmall" />}
+            </Flex>
           </Flex>
         </Flex>
-      </Flex>
-    );
-  }
-  function SlugFieldAsSlugField(props: {
-    value: { name: string; slug: string };
-    onChange(value: { name: string; slug: string }): void;
-    autoFocus: boolean;
-    forceValidation: boolean;
-    collection: string;
-    currentSlug: string | undefined;
-  }) {
-    const items = useSlugsInCollection(props.collection);
-    const slugs = useMemo(() => {
-      const otherSlugs = new Set(items);
-      if (props.currentSlug !== undefined) {
-        otherSlugs.delete(props.currentSlug);
-      }
-      return otherSlugs;
-    }, [items, props.currentSlug]);
-    return <SlugFieldInner {...props} slugs={slugs} />;
-  }
-  const emptySet = new Set<string>();
-  return {
-    kind: 'form',
-    Input(props) {
-      const slugContext = useContext(SlugFieldContext);
-      const path = useContext(PathContext);
-      if (path.length === 1 && path[0] === slugContext?.slugField) {
-        return (
-          <SlugFieldAsSlugField
-            {...props}
-            collection={slugContext.collection}
-            currentSlug={slugContext.currentSlug}
-          />
-        );
-      }
-      return <SlugFieldInner {...props} slugs={emptySet} />;
+      );
     },
     options: undefined,
     defaultValue,
     validate(value, slugInfo) {
-      const slugs = new Set(slugInfo?.slugs);
-      if (slugInfo?.currentSlug !== undefined) {
-        slugs.delete(slugInfo.currentSlug);
-      }
       return (
         typeof value === 'object' &&
         value !== null &&
@@ -188,7 +155,7 @@ export function slug(args: {
         typeof value.slug === 'string' &&
         value.slug.length >= (args.slug?.validation?.length?.min ?? 1) &&
         value.slug.length <= (args.slug?.validation?.length?.max ?? Infinity) &&
-        isValidSlug(value.slug, slugs)
+        isValidSlug(value.slug, slugInfo?.slugs ?? emptySet)
       );
     },
     slug: {
diff --git a/packages/keystatic/DocumentEditor/component-blocks/fields/text.tsx b/packages/keystatic/DocumentEditor/component-blocks/fields/text.tsx
index 10964095..249f5b30 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/fields/text.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/fields/text.tsx
@@ -1,12 +1,10 @@
 import { TextArea, TextField } from '@voussoir/text-field';
 import { useState, useMemo, useContext, createContext, ReactNode } from 'react';
-import { useSlugsInCollection } from '../../../app/useSlugsInCollection';
 import { SlugFormField } from '../api';
 import { ReadonlyPropPath } from '../utils';
 
 export const SlugFieldContext = createContext<
-  | { slugField: string; collection: string; currentSlug: string | undefined }
-  | undefined
+  { field: string; slugs: Set<string> } | undefined
 >(undefined);
 
 export const SlugFieldProvider = SlugFieldContext.Provider;
@@ -90,20 +88,9 @@ export function text({
     onChange: (value: string) => void;
     autoFocus?: boolean;
     forceValidation?: boolean;
-    collection: string;
-    currentSlug: string | undefined;
+    slugs: Set<string>;
   }) {
     const [blurred, setBlurred] = useState(false);
-
-    const items = useSlugsInCollection(props.collection);
-    const slugs = useMemo(() => {
-      const otherSlugs = new Set(items);
-      if (props.currentSlug !== undefined) {
-        otherSlugs.delete(props.currentSlug);
-      }
-      return otherSlugs;
-    }, [items, props.currentSlug]);
-
     return (
       <TextFieldComponent
         label={label}
@@ -114,7 +101,13 @@ export function text({
         onBlur={() => setBlurred(true)}
         errorMessage={
           props.forceValidation || blurred
-            ? validateText(props.value, Math.max(min, 1), max, label, slugs)
+            ? validateText(
+                props.value,
+                Math.max(min, 1),
+                max,
+                label,
+                props.slugs
+              )
             : undefined
         }
       />
@@ -126,14 +119,8 @@ export function text({
       const [blurred, setBlurred] = useState(false);
       const slugContext = useContext(SlugFieldContext);
       const path = useContext(PathContext);
-      if (path.length === 1 && slugContext?.slugField === path[0]) {
-        return (
-          <SlugTextField
-            collection={slugContext.collection}
-            currentSlug={slugContext.currentSlug}
-            {...props}
-          />
-        );
+      if (path.length === 1 && slugContext?.field === path[0]) {
+        return <SlugTextField slugs={slugContext.slugs} {...props} />;
       }
       return (
         <TextFieldComponent
@@ -164,11 +151,7 @@ export function text({
         return false;
       }
       if (slugInfo) {
-        const slugs = new Set(slugInfo.slugs);
-        if (slugInfo.currentSlug !== undefined) {
-          slugs.delete(slugInfo.currentSlug);
-        }
-        return isValidSlug(value, slugs);
+        return isValidSlug(value, slugInfo.slugs);
       }
       return true;
     },
diff --git a/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx b/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
index 941f3611..97b0272f 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/form-from-preview.tsx
@@ -42,6 +42,7 @@ import {
   PathContextProvider,
   SlugFieldProvider,
 } from './fields/text';
+import { getSlugFromState } from '../../app/utils';
 
 type DefaultFieldProps<Key> = GenericPreviewProps<
   Extract<ComponentSchema, { kind: Key }>,
@@ -143,6 +144,28 @@ function ArrayFieldPreview(props: DefaultFieldProps<'array'>) {
       { key: undefined },
     ]);
   };
+  const modalStateIndex =
+    modalState.state === 'open' ? modalState.index : undefined;
+  const slugInfo = useMemo(() => {
+    if (
+      props.schema.slugField === undefined ||
+      modalStateIndex === undefined ||
+      props.schema.element.kind !== 'object'
+    ) {
+      return;
+    }
+    const val: unknown[] = previewPropsToValue(props);
+    const schema = props.schema.element.fields;
+    const slugField = props.schema.slugField;
+    const slugs = new Set(
+      val
+        .filter((x, i) => i !== modalStateIndex)
+        .map(x =>
+          getSlugFromState({ schema, slugField }, x as Record<string, unknown>)
+        )
+    );
+    return { slugs, field: slugField };
+  }, [modalStateIndex, props]);
 
   return (
     <Flex
@@ -268,6 +291,7 @@ function ArrayFieldPreview(props: DefaultFieldProps<'array'>) {
                       onChange={onModalChange}
                       schema={elementProps.schema}
                       value={modalState.value}
+                      slugField={slugInfo}
                     />
                   </AddToPathProvider>
                 </Flex>
@@ -466,9 +490,8 @@ export const FormValueContentFromPreviewProps: MemoExoticComponent<
       autoFocus?: boolean;
       forceValidation?: boolean;
       slugField?: {
-        collection: string;
-        slugField: string;
-        currentSlug: string | undefined;
+        slugs: Set<string>;
+        field: string;
       };
     }
   ) => ReactElement
@@ -486,12 +509,24 @@ function ArrayFieldItemModalContent(props: {
   schema: NonChildFieldComponentSchema;
   value: unknown;
   onChange: (cb: (value: unknown) => unknown) => void;
+  slugField:
+    | {
+        slugs: Set<string>;
+        field: string;
+      }
+    | undefined;
 }) {
   const previewProps = useMemo(
     () => createGetPreviewProps(props.schema, props.onChange, () => undefined),
     [props.schema, props.onChange]
   )(props.value);
-  return <InnerFormValueContentFromPreviewProps autoFocus {...previewProps} />;
+  return (
+    <FormValueContentFromPreviewProps
+      slugField={props.slugField}
+      autoFocus
+      {...previewProps}
+    />
+  );
 }
 
 function findFocusableObjectFieldKey(schema: ObjectField): string | undefined {
diff --git a/packages/keystatic/DocumentEditor/component-blocks/utils.ts b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
index cb6c7b42..7f4ad4d9 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/utils.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/utils.ts
@@ -1,3 +1,4 @@
+import { getSlugFromState } from '../../app/utils';
 import { DocumentFeatures } from '../document-features';
 import { DocumentFeaturesForNormalization } from '../document-features-normalization';
 import { assert, Mark } from '../utils';
@@ -231,9 +232,7 @@ export function getSchemaAtPropPath(
 export function clientSideValidateProp(
   schema: ComponentSchema,
   value: any,
-  slugField:
-    | { field: string; slugs: Set<string>; currentSlug: string | undefined }
-    | undefined,
+  slugField: { field: string; slugs: Set<string> } | undefined,
   path: ReadonlyPropPath = []
 ): boolean {
   switch (schema.kind) {
@@ -241,9 +240,8 @@ export function clientSideValidateProp(
       return true;
     }
     case 'form': {
-      if (path.length === 1 && path[0] === slugField?.field) {
+      if (path[path.length - 1] === slugField?.field) {
         return schema.validate(value, {
-          currentSlug: slugField.currentSlug,
           slugs: slugField.slugs,
         });
       }
@@ -256,7 +254,7 @@ export function clientSideValidateProp(
       return clientSideValidateProp(
         schema.values[value.discriminant],
         value.value,
-        slugField,
+        undefined,
         path.concat('value')
       );
     }
@@ -266,7 +264,7 @@ export function clientSideValidateProp(
           !clientSideValidateProp(
             childProp,
             value[key],
-            slugField,
+            key === slugField?.field ? slugField : undefined,
             path.concat(key)
           )
         ) {
@@ -276,12 +274,31 @@ export function clientSideValidateProp(
       return true;
     }
     case 'array': {
+      let slugInfo: undefined | { slugField: string; slugs: string[] };
+      if (schema.slugField !== undefined && schema.element.kind === 'object') {
+        const innerSchema = schema.element.fields;
+        const { slugField } = schema;
+        slugInfo = {
+          slugField,
+          slugs: (value as unknown[]).map(val =>
+            getSlugFromState(
+              { schema: innerSchema, slugField },
+              val as Record<string, unknown>
+            )
+          ),
+        };
+      }
       for (const [idx, innerVal] of (value as unknown[]).entries()) {
         if (
           !clientSideValidateProp(
             schema.element,
             innerVal,
-            slugField,
+            slugInfo === undefined
+              ? undefined
+              : {
+                  field: slugInfo.slugField,
+                  slugs: new Set(slugInfo.slugs.filter((_, i) => idx !== i)),
+                },
             path.concat(idx)
           )
         ) {
diff --git a/packages/keystatic/app/ItemPage.tsx b/packages/keystatic/app/ItemPage.tsx
index 312e7973..53074201 100644
--- a/packages/keystatic/app/ItemPage.tsx
+++ b/packages/keystatic/app/ItemPage.tsx
@@ -133,14 +133,17 @@ function ItemPage(props: ItemPageProps) {
     basePath: getCollectionItemPath(config, collection, itemSlug),
     currentTree,
   });
+  const slugFieldInfo = useMemo(() => {
+    const slugs = new Set(props.slugs);
+    slugs.delete(props.itemSlug);
+    return {
+      slugs,
+      field: collectionConfig.slugField,
+    };
+  }, [props.slugs, props.itemSlug, collectionConfig.slugField]);
+
   const onUpdate = async () => {
-    if (
-      !clientSideValidateProp(schema, state, {
-        currentSlug: props.itemSlug,
-        field: collectionConfig.slugField,
-        slugs: props.slugs,
-      })
-    ) {
+    if (!clientSideValidateProp(schema, state, slugFieldInfo)) {
       setForceValidation(true);
       return;
     }
@@ -156,15 +159,6 @@ function ItemPage(props: ItemPageProps) {
   };
   const formID = 'item-edit-form';
 
-  const slugFieldInfo = useMemo(
-    () => ({
-      collection: props.collection,
-      currentSlug: props.itemSlug,
-      slugField: collectionConfig.slugField,
-    }),
-    [collectionConfig.slugField, props.collection, props.itemSlug]
-  );
-
   return (
     <>
       <ItemPageShell
diff --git a/packages/keystatic/app/create-item.tsx b/packages/keystatic/app/create-item.tsx
index 9ea875c0..a5eec20c 100644
--- a/packages/keystatic/app/create-item.tsx
+++ b/packages/keystatic/app/create-item.tsx
@@ -84,12 +84,15 @@ export function CreateItem(props: {
       ? slug
       : undefined;
   const slugInfo = useMemo(() => {
+    const slugs = new Set(slugsArr);
+    if (currentSlug) {
+      slugs.delete(currentSlug);
+    }
     return {
-      currentSlug,
       field: collectionConfig.slugField,
-      slugs: new Set(slugsArr),
+      slugs,
     };
-  }, [collectionConfig.slugField, currentSlug, slugsArr]);
+  }, [collectionConfig.slugField, slugsArr, currentSlug]);
 
   const onCreate = async () => {
     if (!clientSideValidateProp(schema, state, slugInfo)) {
@@ -109,15 +112,6 @@ export function CreateItem(props: {
 
   const formID = 'item-create-form';
 
-  const slugFieldInfo = useMemo(
-    () => ({
-      collection: props.collection,
-      currentSlug,
-      slugField: collectionConfig.slugField,
-    }),
-    [collectionConfig.slugField, currentSlug, props.collection]
-  );
-
   return (
     <>
       <AppShellRoot>
@@ -171,7 +165,7 @@ export function CreateItem(props: {
 
             <FormValueContentFromPreviewProps
               forceValidation={forceValidation}
-              slugField={slugFieldInfo}
+              slugField={slugInfo}
               {...previewProps}
             />
           </Flex>
diff --git a/packages/keystatic/app/required-files.ts b/packages/keystatic/app/required-files.ts
index 69b8fca5..7070b183 100644
--- a/packages/keystatic/app/required-files.ts
+++ b/packages/keystatic/app/required-files.ts
@@ -9,6 +9,7 @@ import {
   traverseProps,
 } from '../DocumentEditor/component-blocks/utils';
 import { FormatInfo } from './path-utils';
+import { getSlugFromState } from './utils';
 
 export type RequiredFile = {
   path: ReadonlyPropPath;
@@ -16,15 +17,59 @@ export type RequiredFile = {
   file: { filename: string; parent: string | undefined } | undefined;
 };
 
-export function getRequiredFiles(value: unknown, schema: ComponentSchema) {
+export function getPropPathPortion(
+  path: ReadonlyPropPath,
+  schema: ComponentSchema,
+  value: unknown
+): string {
+  const end: (string | number)[] = [];
+  for (const portion of path) {
+    if (schema.kind === 'array') {
+      value = (value as any)[portion];
+      if (schema.slugField && schema.element.kind === 'object') {
+        const slug = getSlugFromState(
+          { schema: schema.element.fields, slugField: schema.slugField },
+          value as Record<string, unknown>
+        );
+        end.push(slug);
+      } else {
+        end.push(portion);
+      }
+      schema = schema.element;
+      continue;
+    }
+    end.push(portion);
+    if (schema.kind === 'object') {
+      value = (value as any)[portion];
+      schema = schema.fields[portion];
+      continue;
+    }
+    if (schema.kind === 'conditional') {
+      if (portion === 'discriminant') {
+        schema = schema.discriminant;
+      } else if (portion === 'value') {
+        schema = schema.values[(value as any).discriminant];
+      }
+      value = (value as any)[portion];
+      continue;
+    }
+    throw new Error(`unexpected ${schema.kind}`);
+  }
+  return end.join('/');
+}
+
+export function getRequiredFiles(
+  rootValue: unknown,
+  rootSchema: ComponentSchema
+) {
   const requiredFiles: RequiredFile[] = [];
-  traverseProps(schema, value, (schema, val, propPath) => {
+  traverseProps(rootSchema, rootValue, (schema, val, propPath) => {
     if (schema.kind === 'form') {
       if ('serializeToFile' in schema && schema.serializeToFile) {
         if (schema.serializeToFile.kind === 'asset') {
           const filename = schema.serializeToFile.filename(
-            value,
-            propPath.join('/')
+            val,
+            getPropPathPortion(propPath, rootSchema, rootValue)
           );
           const files: string[] = [];
           if (filename) {
@@ -39,7 +84,7 @@ export function getRequiredFiles(value: unknown, schema: ComponentSchema) {
           });
         }
         if (schema.serializeToFile.kind === 'multi') {
-          const basePath = propPath.join('/');
+          const basePath = getPropPathPortion(propPath, rootSchema, rootValue);
           requiredFiles.push({
             path: propPath,
             schema,
@@ -64,13 +109,22 @@ export function parseSerializedFormField(
   loadedBinaryFiles: Map<string, Uint8Array>,
   mode: 'read' | 'edit',
   basePath: string,
-  slug: string | undefined
+  slug: string | undefined,
+  rootValue: unknown,
+  rootSchema: ComponentSchema
 ) {
   const serializationConfig = file.schema.serializeToFile!;
 
   if (serializationConfig.kind === 'asset') {
-    const suggestedFilenamePrefix = file.path.join('/');
-    const filepath = serializationConfig.filename(value, file.path.join('/'));
+    const suggestedFilenamePrefix = getPropPathPortion(
+      file.path,
+      rootSchema,
+      rootValue
+    );
+    const filepath = serializationConfig.filename(
+      value,
+      suggestedFilenamePrefix
+    );
     if (
       mode === 'read' &&
       !serializationConfig.reader.requiresContentInReader
@@ -100,7 +154,11 @@ export function parseSerializedFormField(
     return parsed;
   }
   if (serializationConfig.kind === 'multi') {
-    const rootPath = `${basePath}/${file.path.join('/')}`;
+    const rootPath = `${basePath}/${getPropPathPortion(
+      file.path,
+      rootSchema,
+      rootValue
+    )}`;
     const mainFilepath = rootPath + serializationConfig.primaryExtension;
     if (mode === 'read') {
       if (serializationConfig.reader.requiresContentInReader) {
diff --git a/packages/keystatic/app/useItemData.ts b/packages/keystatic/app/useItemData.ts
index 5210f3a8..f6c923b8 100644
--- a/packages/keystatic/app/useItemData.ts
+++ b/packages/keystatic/app/useItemData.ts
@@ -48,6 +48,7 @@ function parseEntry(args: UseItemDataArgs, files: Map<string, Uint8Array>) {
       extraFakeFile.contents
     );
   }
+  const rootSchema = schema;
   const initialState = transformProps(schema, validated, {
     form(schema, val, path) {
       if ('serializeToFile' in schema) {
@@ -57,7 +58,9 @@ function parseEntry(args: UseItemDataArgs, files: Map<string, Uint8Array>) {
           filesWithFakeFile,
           'edit',
           args.dirpath,
-          args.slug?.slug
+          args.slug?.slug,
+          validated,
+          rootSchema
         );
       }
       return val;
diff --git a/packages/keystatic/markdoc/to-markdoc.tsx b/packages/keystatic/markdoc/to-markdoc.tsx
index 1745eca3..02127783 100644
--- a/packages/keystatic/markdoc/to-markdoc.tsx
+++ b/packages/keystatic/markdoc/to-markdoc.tsx
@@ -276,14 +276,9 @@ function toMarkdoc(
       node.children.length === 1 &&
       node.children[0].type === 'component-inline-prop' &&
       node.children[0].propPath === undefined;
-    const componentBlock = componentBlocks[node.component];
-    let singleChildField;
-    if (componentBlock) {
-      singleChildField = findSingleChildField({
-        kind: 'object',
-        fields: componentBlock.schema,
-      });
-    }
+    const componentBlock = componentBlocks[node.component] as
+      | ComponentBlock
+      | undefined;
 
     const childrenAsMarkdoc: {
       type: 'component-block-prop' | 'component-inline-prop';
@@ -308,25 +303,29 @@ function toMarkdoc(
       }
     }
 
-    const schema = { kind: 'object' as const, fields: componentBlock.schema };
-
     const attributes = componentBlock
       ? (removeUnnecessaryChildFieldValues(
-          schema,
+          { kind: 'object' as const, fields: componentBlock.schema },
           undefined,
           node.props
         ) as Record<string, unknown>)
       : node.props;
 
-    if (singleChildField) {
-      const children: Node[] = [];
-      toChildrenAndProps(
-        childrenAsMarkdoc,
-        children,
-        attributes,
-        singleChildField
-      );
-      return new Ast.Node('tag', attributes, children, node.component);
+    if (componentBlock) {
+      const singleChildField = findSingleChildField({
+        kind: 'object',
+        fields: componentBlock.schema,
+      });
+      if (singleChildField) {
+        const children: Node[] = [];
+        toChildrenAndProps(
+          childrenAsMarkdoc,
+          children,
+          attributes,
+          singleChildField
+        );
+        return new Ast.Node('tag', attributes, children, node.component);
+      }
     }
     const children = isVoid
       ? []
diff --git a/packages/keystatic/src/reader.ts b/packages/keystatic/src/reader.ts
index d9655bca..5c7be6d6 100644
--- a/packages/keystatic/src/reader.ts
+++ b/packages/keystatic/src/reader.ts
@@ -172,7 +172,9 @@ async function readItem(
           loadedFiles,
           'read',
           itemDir,
-          slugField?.slug
+          slugField?.slug,
+          validated,
+          schema
         );
       };
     } else {
@@ -182,7 +184,9 @@ async function readItem(
         new Map(),
         'read',
         itemDir,
-        slugField?.slug
+        slugField?.slug,
+        validated,
+        schema
       );
     }
   }
diff --git a/packages/keystatic/utils.tsx b/packages/keystatic/utils.tsx
index 152ac589..aca75b70 100644
--- a/packages/keystatic/utils.tsx
+++ b/packages/keystatic/utils.tsx
@@ -30,6 +30,7 @@ import { Config } from './src';
 import { getAuth } from './app/auth';
 import { isSlugFormField } from './app/utils';
 import { getDirectoriesForTreeKey, getTreeKey } from './app/tree-key';
+import { getPropPathPortion } from './app/required-files';
 
 const textEncoder = new TextEncoder();
 
@@ -389,8 +390,8 @@ export function useDeleteItem(args: {
 }
 
 export async function toFiles(
-  value: unknown,
-  schema: ComponentSchema,
+  rootValue: unknown,
+  rootSchema: ComponentSchema,
   slug: { field: string; value: string } | undefined
 ) {
   const extraFiles: {
@@ -399,7 +400,7 @@ export async function toFiles(
     contents: Uint8Array;
   }[] = [];
   return {
-    value: await asyncTransformProps(schema, value, {
+    value: await asyncTransformProps(rootSchema, rootValue, {
       async form(schema, value, propPath) {
         if (propPath.length === 1 && slug?.field === propPath[0]) {
           if (!isSlugFormField(schema)) {
@@ -409,7 +410,11 @@ export async function toFiles(
         }
         if ('serializeToFile' in schema && schema.serializeToFile) {
           if (schema.serializeToFile.kind === 'asset') {
-            const suggestedFilenamePrefix = propPath.join('/');
+            const suggestedFilenamePrefix = getPropPathPortion(
+              propPath,
+              rootSchema,
+              rootValue
+            );
 
             const { content, value: forYaml } =
               schema.serializeToFile.serialize(value, suggestedFilenamePrefix);
@@ -438,14 +443,18 @@ export async function toFiles(
             if (primary) {
               extraFiles.push({
                 path:
-                  propPath.join('/') + schema.serializeToFile.primaryExtension,
+                  getPropPathPortion(propPath, rootSchema, rootValue) +
+                  schema.serializeToFile.primaryExtension,
                 contents: primary,
                 parent: undefined,
               });
             }
             for (const [key, contents] of other) {
               extraFiles.push({
-                path: propPath.join('/') + '/' + key,
+                path:
+                  getPropPathPortion(propPath, rootSchema, rootValue) +
+                  '/' +
+                  key,
                 contents,
                 parent: undefined,
               });
diff --git a/packages/keystatic/validate-component-block-props.ts b/packages/keystatic/validate-component-block-props.ts
index ab3e5a97..61cdc7e7 100644
--- a/packages/keystatic/validate-component-block-props.ts
+++ b/packages/keystatic/validate-component-block-props.ts
@@ -1,5 +1,5 @@
 import { assertNever } from 'emery';
-import { isSlugFormField } from './app/utils';
+import { getSlugFromState, isSlugFormField } from './app/utils';
 import { ComponentSchema } from './DocumentEditor/component-blocks/api';
 import { getInitialPropsValue } from './DocumentEditor/component-blocks/initial-values';
 import { ReadonlyPropPath } from './DocumentEditor/component-blocks/utils';
@@ -18,7 +18,12 @@ export function validateComponentBlockProps(
   path: ReadonlyPropPath,
   slugField:
     | {
-        mode: 'state' | 'parse' | 'read';
+        mode: 'state';
+        field: string;
+        slugs: Set<string>;
+      }
+    | {
+        mode: 'parse' | 'read';
         slug: string;
         field: string;
         slugs: Set<string>;
@@ -26,12 +31,11 @@ export function validateComponentBlockProps(
     | undefined
 ): any {
   if (schema.kind === 'form') {
-    if (path.length === 1 && slugField?.field === path[0]) {
+    if (slugField?.field === path[path.length - 1]) {
       if (!isSlugFormField(schema)) {
         throw new Error('slugField is not a slug field');
       }
       const slugInfo = {
-        currentSlug: slugField.slug,
         slugs: slugField.slugs,
       };
       if (slugField.mode === 'state') {
@@ -87,7 +91,7 @@ export function validateComponentBlockProps(
       schema.discriminant,
       discriminant,
       path.concat('discriminant'),
-      slugField
+      undefined
     );
     if (discriminantVal !== undefined) {
       obj.discriminant = discriminantVal;
@@ -96,7 +100,7 @@ export function validateComponentBlockProps(
       schema.values[discriminant],
       val,
       path.concat('value'),
-      slugField
+      undefined
     );
     if (conditionalFieldValue !== undefined) {
       obj.value = conditionalFieldValue;
@@ -127,7 +131,7 @@ export function validateComponentBlockProps(
         schema.fields[key],
         individualVal,
         path.concat(key),
-        slugField
+        slugField?.field === key ? slugField : undefined
       );
 
       // for some reason mongo or mongoose or something is saving undefined as null
@@ -142,12 +146,29 @@ export function validateComponentBlockProps(
     if (!Array.isArray(value)) {
       throw new PropValidationError('Array field value must be an array', path);
     }
+    let slugInfo: undefined | { slugField: string; slugs: string[] };
+    if (schema.slugField !== undefined && schema.element.kind === 'object') {
+      const innerSchema = schema.element.fields;
+      const { slugField } = schema;
+      slugInfo = {
+        slugField,
+        slugs: value.map(val =>
+          getSlugFromState({ schema: innerSchema, slugField }, val)
+        ),
+      };
+    }
     return value.map((innerVal, i) => {
       return validateComponentBlockProps(
         schema.element,
         innerVal,
         path.concat(i),
-        slugField
+        slugInfo === undefined
+          ? undefined
+          : {
+              field: slugInfo.slugField,
+              slugs: new Set(slugInfo.slugs.filter((_, idx) => i !== idx)),
+              mode: 'state',
+            }
       );
     });
   }


Test patch:
diff --git a/packages/keystatic/markdoc/find-children.test.ts b/packages/keystatic/markdoc/find-children.test.ts
index d63056f2..819192ca 100644
--- a/packages/keystatic/markdoc/find-children.test.ts
+++ b/packages/keystatic/markdoc/find-children.test.ts
@@ -152,6 +152,7 @@ test('array', () => {
         "itemLabel": undefined,
         "kind": "array",
         "label": "Items",
+        "slugField": undefined,
       },
       "kind": "array",
       "relativePath": [],