Repository: Thinkmill/keystatic
Pull request #250 (2023-04-20 04:13:12)

Add `validation.length` to `fields.array`
This is non-parse-blocking validation just like `validation.length` on `fields.text`

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: bbfd9a8c341e5664ebde772470b5efbf08944360

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/main/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/Thinkmill/keystatic/new/array-length-validation?filename=.changeset/cyan-houses-dance.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0AAdd%20%60validation.length%60%20to%20%60fields.array%60%0A)


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

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **keystatic** | ✅ Ready ([Inspect](https://vercel.com/thinkmill-labs/keystatic/2PbkQJ7YFBtZrRu6iivXtoFNXqvx)) | [Visit Preview](https://keystatic-git-array-length-validation-thinkmill-labs.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/keystatic-git-array-length-validation-thinkmill-labs.vercel.app) | Apr 20, 2023 4:15am |



Patch:
diff --git a/.changeset/pretty-baboons-dress.md b/.changeset/pretty-baboons-dress.md
new file mode 100644
index 00000000..ec603247
--- /dev/null
+++ b/.changeset/pretty-baboons-dress.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Add `validation.length` to `fields.array`
diff --git a/keystatic/local-config.tsx b/keystatic/local-config.tsx
index 222be7fa..79ead4d3 100644
--- a/keystatic/local-config.tsx
+++ b/keystatic/local-config.tsx
@@ -187,7 +187,16 @@ export default config({
               links: true,
             }),
           }),
-          { label: 'Authors', itemLabel: props => props.fields.name.value }
+          {
+            label: 'Authors',
+            itemLabel: props => props.fields.name.value,
+            validation: {
+              length: {
+                max: 5,
+                min: 1,
+              },
+            },
+          }
         ),
       },
     }),
diff --git a/packages/keystatic/src/app/useItemData.ts b/packages/keystatic/src/app/useItemData.ts
index a685ef57..fd2e407c 100644
--- a/packages/keystatic/src/app/useItemData.ts
+++ b/packages/keystatic/src/app/useItemData.ts
@@ -112,7 +112,8 @@ function parseEntry(args: UseItemDataArgs, files: Map<string, Uint8Array>) {
         }
 
         return schema.parse(value, undefined);
-      }
+      },
+      false
     );
   } catch (err) {
     throw toFormattedFormDataError(err);
diff --git a/packages/keystatic/src/form/api.tsx b/packages/keystatic/src/form/api.tsx
index f3792a31..b32887df 100644
--- a/packages/keystatic/src/form/api.tsx
+++ b/packages/keystatic/src/form/api.tsx
@@ -201,6 +201,12 @@ export type ArrayField<ElementField extends ComponentSchema> = {
   itemLabel?(props: unknown): string;
   asChildTag?: string;
   slugField?: string;
+  validation?: {
+    length?: {
+      min?: number;
+      max?: number;
+    };
+  };
 };
 
 export interface ObjectField<
@@ -240,6 +246,12 @@ type ArrayFieldInComponentSchema = {
   itemLabel?(props: unknown): string;
   asChildTag?: string;
   slugField?: string;
+  validation?: {
+    length?: {
+      min?: number;
+      max?: number;
+    };
+  };
 };
 
 export type ComponentSchema =
diff --git a/packages/keystatic/src/form/errors.ts b/packages/keystatic/src/form/errors.ts
index 5509ba6b..abf2dfd2 100644
--- a/packages/keystatic/src/form/errors.ts
+++ b/packages/keystatic/src/form/errors.ts
@@ -4,6 +4,7 @@ import { SlugFieldInfo } from './fields/text/ui';
 import { FieldDataError } from './fields/error';
 import { PropValidationError } from './parse-props';
 import { ReadonlyPropPath } from './fields/document/DocumentEditor/component-blocks/utils';
+import { validateArrayLength } from './validate-array-length';
 
 function flattenErrors(error: unknown): unknown[] {
   if (error instanceof AggregateError) {
@@ -118,8 +119,12 @@ export function validateValueWithSchema(
         };
       }
       const errors: unknown[] = [];
-
-      for (const [idx, innerVal] of (value as unknown[]).entries()) {
+      const val = value as unknown[];
+      const error = validateArrayLength(schema, value, path);
+      if (error !== undefined) {
+        errors.push(error);
+      }
+      for (const [idx, innerVal] of val.entries()) {
         try {
           validateValueWithSchema(
             schema.element,
diff --git a/packages/keystatic/src/form/fields/array.tsx b/packages/keystatic/src/form/fields/array.tsx
index 08146d3f..8606f27f 100644
--- a/packages/keystatic/src/form/fields/array.tsx
+++ b/packages/keystatic/src/form/fields/array.tsx
@@ -24,6 +24,12 @@ export function array<ElementField extends ComponentSchema>(
             : never;
         }[keyof ElementField['fields']]
       : never;
+    validation?: {
+      length?: {
+        min?: number;
+        max?: number;
+      };
+    };
   }
 ): ArrayField<ElementField> {
   return {
@@ -34,5 +40,6 @@ export function array<ElementField extends ComponentSchema>(
     itemLabel: opts?.itemLabel,
     asChildTag: opts?.asChildTag,
     slugField: opts?.slugField as string,
+    validation: opts?.validation,
   };
 }
diff --git a/packages/keystatic/src/form/form-from-preview.tsx b/packages/keystatic/src/form/form-from-preview.tsx
index 03971a30..0d7d291a 100644
--- a/packages/keystatic/src/form/form-from-preview.tsx
+++ b/packages/keystatic/src/form/form-from-preview.tsx
@@ -14,7 +14,7 @@ import {
 import { ActionButton, Button, ButtonGroup } from '@voussoir/button';
 import { Dialog, DialogContainer } from '@voussoir/dialog';
 import { ItemDropTarget, useDragAndDrop } from '@voussoir/drag-and-drop';
-import { FieldLabel } from '@voussoir/field';
+import { FieldLabel, FieldMessage } from '@voussoir/field';
 import { Icon } from '@voussoir/icon';
 import { trash2Icon } from '@voussoir/icon/icons/trash2Icon';
 import { Flex } from '@voussoir/layout';
@@ -268,7 +268,20 @@ function ArrayFieldPreview(props: DefaultFieldProps<'array'>) {
           );
         }}
       </ListView>
-
+      {props.forceValidation &&
+        (props.schema.validation?.length?.min !== undefined &&
+        props.elements.length < props.schema.validation.length.min ? (
+          <FieldMessage>
+            Must have at least {props.schema.validation.length.min} item
+            {props.schema.validation.length.min === 1 ? '' : 's'}
+          </FieldMessage>
+        ) : props.schema.validation?.length?.max !== undefined &&
+          props.elements.length > props.schema.validation.length.max ? (
+          <FieldMessage>
+            Must have at most {props.schema.validation.length.max} item
+            {props.schema.validation.length.max === 1 ? '' : 's'}
+          </FieldMessage>
+        ) : undefined)}
       <DialogContainer
         onDismiss={() => {
           setModalState({ state: 'closed' });
diff --git a/packages/keystatic/src/form/parse-props.ts b/packages/keystatic/src/form/parse-props.ts
index 08601b29..ed2b7db9 100644
--- a/packages/keystatic/src/form/parse-props.ts
+++ b/packages/keystatic/src/form/parse-props.ts
@@ -3,6 +3,7 @@ import { ComponentSchema } from './api';
 import { ReadonlyPropPath } from './fields/document/DocumentEditor/component-blocks/utils';
 import { FormField, FormFieldStoredValue, JsonValue } from '..';
 import { FieldDataError } from './fields/error';
+import { validateArrayLength } from './validate-array-length';
 
 export class PropValidationError extends Error {
   path: ReadonlyPropPath;
@@ -37,7 +38,9 @@ export function parseProps(
     value: FormFieldStoredValue,
     path: ReadonlyPropPath,
     pathWithArrayFieldSlugs: readonly string[]
-  ) => any
+  ) => any,
+  /** This should be true for the reader and false elsewhere */
+  validateArrayFieldLength: boolean
 ): any {
   let value = toFormFieldStoredValue(_value);
   if (schema.kind === 'form') {
@@ -74,7 +77,8 @@ export function parseProps(
       value.discriminant,
       path.concat('discriminant'),
       pathWithArrayFieldSlugs.concat('discriminant'),
-      parseFormField
+      parseFormField,
+      validateArrayFieldLength
     );
 
     return {
@@ -84,7 +88,8 @@ export function parseProps(
         value.value,
         path.concat('value'),
         pathWithArrayFieldSlugs.concat('value'),
-        parseFormField
+        parseFormField,
+        validateArrayFieldLength
       ),
     };
   }
@@ -118,7 +123,8 @@ export function parseProps(
           individualVal,
           path.concat(key),
           pathWithArrayFieldSlugs.concat(key),
-          parseFormField
+          parseFormField,
+          validateArrayFieldLength
         );
         val[key] = propVal;
       } catch (err) {
@@ -143,6 +149,12 @@ export function parseProps(
     }
     const errors: unknown[] = [];
     try {
+      if (validateArrayFieldLength) {
+        const error = validateArrayLength(schema, value, path);
+        if (error !== undefined) {
+          errors.push(error);
+        }
+      }
       return value.map((innerVal, i) => {
         try {
           let slug = i.toString();
@@ -190,7 +202,8 @@ export function parseProps(
             innerVal,
             path.concat(i),
             pathWithArrayFieldSlugs.concat(slug),
-            parseFormField
+            parseFormField,
+            validateArrayFieldLength
           );
         } catch (err) {
           errors.push(err);
diff --git a/packages/keystatic/src/form/validate-array-length.ts b/packages/keystatic/src/form/validate-array-length.ts
new file mode 100644
index 00000000..fc270d03
--- /dev/null
+++ b/packages/keystatic/src/form/validate-array-length.ts
@@ -0,0 +1,39 @@
+import { ArrayField, ComponentSchema } from '..';
+import { ReadonlyPropPath } from './fields/document/DocumentEditor/component-blocks/utils';
+import { FieldDataError } from './fields/error';
+import { PropValidationError } from './parse-props';
+
+export function validateArrayLength(
+  schema: ArrayField<ComponentSchema>,
+  val: readonly unknown[],
+  path: ReadonlyPropPath
+) {
+  if (
+    schema.validation?.length?.min !== undefined &&
+    val.length < schema.validation.length.min
+  ) {
+    return new PropValidationError(
+      new FieldDataError(
+        `Must have at least ${schema.validation.length.min} element${
+          schema.validation.length.min === 1 ? '' : 's'
+        }`
+      ),
+      path,
+      schema
+    );
+  }
+  if (
+    schema.validation?.length?.max !== undefined &&
+    val.length > schema.validation.length.max
+  ) {
+    return new PropValidationError(
+      new FieldDataError(
+        `Must have at most ${schema.validation.length.max} element${
+          schema.validation.length.max === 1 ? '' : 's'
+        }}`
+      ),
+      path,
+      schema
+    );
+  }
+}
diff --git a/packages/keystatic/src/reader.ts b/packages/keystatic/src/reader.ts
index 2acfafb0..1b8b338b 100644
--- a/packages/keystatic/src/reader.ts
+++ b/packages/keystatic/src/reader.ts
@@ -357,7 +357,8 @@ async function readItem(
           });
         }
         return schema.reader.parse(value);
-      }
+      },
+      true
     );
 
     if (contentFieldPathsToEagerlyResolve?.length) {


Test patch:
diff --git a/packages/keystatic/src/form/fields/document/markdoc/find-children.test.ts b/packages/keystatic/src/form/fields/document/markdoc/find-children.test.ts
index 3ad33307..b4ff0511 100644
--- a/packages/keystatic/src/form/fields/document/markdoc/find-children.test.ts
+++ b/packages/keystatic/src/form/fields/document/markdoc/find-children.test.ts
@@ -167,6 +167,7 @@ test('array', () => {
         "kind": "array",
         "label": "Items",
         "slugField": undefined,
+        "validation": undefined,
       },
       "kind": "array",
       "relativePath": [],