Repository: Thinkmill/keystatic
Pull request #86 (2023-02-22 04:47:29)

Relationship fields

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

Latest commit: 2f95b5963bdf9fd9222fad2cf06c9683c12fe55e

**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/relationships?filename=.changeset/sharp-dots-sit.md&value=---%0A%22%40keystatic%2Ftest-next-app%22%3A%20patch%0A%22%40keystatic%2Fcore%22%3A%20patch%0A---%0A%0ARelationship%20fields%0A)


vercel[bot]: [vc]: #1vJcjrrECC2KSbivw3mgtlmgkc+GiSfFss8lediIJhc=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZXlzdGF0aWMiLCJyb290RGlyZWN0b3J5Ijoia2V5c3RhdGljIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC9rZXlzdGF0aWMvREJSclVvMW1UbTFHOFhEbmVwcm52TFR1eFk2VCIsIm5leHRDb21taXRTdGF0dXMiOiJQRU5ESU5HIn0seyJuYW1lIjoidm91c3NvaXIiLCJyb290RGlyZWN0b3J5IjoiZGVzaWduLXN5c3RlbS9kb2NzIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL3RoaW5rbWlsbC92b3Vzc29pci9HeWlKakd1SEhMVFFjeGNicXlwYXFha2NFSE5ZIiwicHJldmlld1VybCI6InZvdXNzb2lyLWdpdC1yZWxhdGlvbnNoaXBzLXRoaW5rbWlsbC52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIn1dfQ==
**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/DBRrUo1mTm1G8XDneprnvLTuxY6T)) |  | Feb 22, 2023 at 4:47AM (UTC) |
| **voussoir** | ✅ Ready ([Inspect](https://vercel.com/thinkmill/voussoir/GyiJjGuHHLTQcxcbqypaqakcEHNY)) | [Visit Preview](https://voussoir-git-relationships-thinkmill.vercel.app) | Feb 22, 2023 at 4:47AM (UTC) |




Patch:
diff --git a/.changeset/sweet-bottles-fetch.md b/.changeset/sweet-bottles-fetch.md
new file mode 100644
index 00000000..fe1d0281
--- /dev/null
+++ b/.changeset/sweet-bottles-fetch.md
@@ -0,0 +1,5 @@
+---
+'@keystatic/core': patch
+---
+
+Add `fields.relationship` and `fields.fileReference`
diff --git a/keystatic/local-config.tsx b/keystatic/local-config.tsx
index a079b00d..1cdc66b8 100644
--- a/keystatic/local-config.tsx
+++ b/keystatic/local-config.tsx
@@ -252,6 +252,10 @@ export default config({
           label: 'Username',
           validation: { length: { min: 4 } },
         }),
+        favouritePost: fields.relationship({
+          label: 'Favourite Post',
+          collection: 'posts',
+        }),
       },
     }),
     packages: collection({
@@ -261,6 +265,7 @@ export default config({
       format: 'json',
       schema: {
         name: fields.text({ label: 'Name' }),
+        someFilepath: fields.pathReference({ label: 'Some Filepath' }),
       },
     }),
     singlefileposts: collection({
diff --git a/packages/keystatic/DocumentEditor/component-blocks/api.tsx b/packages/keystatic/DocumentEditor/component-blocks/api.tsx
index 29ea972e..88e81ea6 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/api.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/api.tsx
@@ -16,6 +16,7 @@ import { Picker, Item } from '@voussoir/picker';
 import { css, tokenSchema } from '@voussoir/style';
 import { TextArea, TextField } from '@voussoir/text-field';
 import { Editor } from 'slate';
+import { matcher, capture } from 'micromatch';
 
 import { fromMarkdoc } from '../../markdoc/from-markdoc';
 import { toMarkdocDocument } from '../../markdoc/to-markdoc';
@@ -34,6 +35,14 @@ import {
   deserializeFiles,
 } from './document-field';
 import { ElementFromValidation } from '../../structure-validation';
+import { Combobox } from '@voussoir/combobox';
+import { useTree } from '../../app/shell/data';
+import { useConfig } from '../../app/shell';
+import {
+  getCollectionFormat,
+  getCollectionItemPath,
+  getDataFileExtension,
+} from '../../app/path-utils';
 
 const textEncoder = new TextEncoder();
 const textDecoder = new TextDecoder();
@@ -559,6 +568,96 @@ export const fields = {
       validate,
     };
   },
+  pathReference({
+    label,
+    pattern,
+  }: {
+    label: string;
+    pattern?: string;
+  }): BasicFormField<string | null, undefined> {
+    const match = pattern ? matcher(pattern) : () => true;
+    return {
+      kind: 'form',
+      Input({ value, onChange, autoFocus }) {
+        const tree = useTree().current;
+        const options = useMemo(() => {
+          const files =
+            tree.kind === 'loaded' ? [...tree.data.entries.values()] : [];
+          return files.filter(val => match(val.path));
+        }, [tree]);
+        return (
+          <Combobox
+            label={label}
+            selectedKey={value}
+            onSelectionChange={key => {
+              if (typeof key === 'string' || key === null) {
+                onChange(key);
+              }
+            }}
+            autoFocus={autoFocus}
+            defaultItems={options}
+            width="auto"
+          >
+            {item => <Item key={item.path}>{item.path}</Item>}
+          </Combobox>
+        );
+      },
+      options: undefined,
+      defaultValue: null,
+      validate: val => typeof val === 'string' || val === null,
+    };
+  },
+  relationship({
+    label,
+    collection,
+  }: {
+    label: string;
+    collection: string;
+  }): BasicFormField<string | null, undefined> {
+    return {
+      kind: 'form',
+      Input({ value, onChange, autoFocus }) {
+        const config = useConfig();
+        const tree = useTree().current;
+
+        const options = useMemo(() => {
+          const files =
+            tree.kind === 'loaded' ? [...tree.data.entries.values()] : [];
+          const formatInfo = getCollectionFormat(config, collection);
+          const pattern =
+            getCollectionItemPath(config, collection, '*') +
+            `/index${getDataFileExtension(formatInfo)}`;
+
+          return files.flatMap(val => {
+            const captured = capture(pattern, val.path);
+            if (!captured) {
+              return [];
+            }
+            return [{ slug: captured[0] }];
+          });
+        }, [config, tree]);
+        return (
+          <Combobox
+            label={label}
+            selectedKey={value}
+            onSelectionChange={key => {
+              if (typeof key === 'string' || key === null) {
+                onChange(key);
+              }
+            }}
+            autoFocus={autoFocus}
+            defaultItems={options}
+            width="auto"
+          >
+            {item => <Item key={item.slug}>{item.slug}</Item>}
+          </Combobox>
+        );
+      },
+      options: undefined,
+      defaultValue: null,
+      validate: val => typeof val === 'string' || val === null,
+    };
+  },
   select<Option extends { label: string; value: string }>({
     label,
     options,
@@ -1062,26 +1161,6 @@ export const fields = {
       },
     };
   },
-  relationship<Many extends boolean | undefined = false>({
-    listKey,
-    selection,
-    label,
-    many,
-  }: {
-    listKey: string;
-    label: string;
-    selection?: string;
-  } & (Many extends undefined | false
-    ? { many?: Many }
-    : { many: Many })): RelationshipField<Many extends true ? true : false> {
-    return {
-      kind: 'relationship',
-      listKey,
-      selection,
-      label,
-      many: (many ? true : false) as any,
-    };
-  },
   array<ElementField extends ComponentSchema>(
     element: ElementField,
     opts?: {
diff --git a/packages/keystatic/app/shell/index.tsx b/packages/keystatic/app/shell/index.tsx
index 2b54f696..513a56a9 100644
--- a/packages/keystatic/app/shell/index.tsx
+++ b/packages/keystatic/app/shell/index.tsx
@@ -21,6 +21,15 @@ import {
 import { SidebarProvider, Sidebar, SIDEBAR_WIDTH } from './sidebar';
 import { isGitHubConfig } from '../utils';
 
+const ConfigContext = createContext<Config | null>(null);
+export function useConfig(): Config {
+  const config = useContext(ConfigContext);
+  if (!config) {
+    throw new Error('ConfigContext.Provider not found');
+  }
+  return config;
+}
+
 export const AppShell = (props: {
   config: Config;
   children: ReactNode;
@@ -28,28 +37,30 @@ export const AppShell = (props: {
   basePath: string;
 }) => {
   const inner = (
-    <SidebarProvider>
-      <Flex
-        direction={{ mobile: 'column', tablet: 'row' }}
-        width="100vw"
-        minHeight="100vh"
-      >
-        <Sidebar hrefBase={props.basePath} config={props.config} />
-        <AppShellErrorContext.Consumer>
-          {error =>
-            error ? (
-              <EmptyState
-                icon={alertCircleIcon}
-                title="Failed to load shell"
-                message={error.message}
-              />
-            ) : (
-              props.children
-            )
-          }
-        </AppShellErrorContext.Consumer>
-      </Flex>
-    </SidebarProvider>
+    <ConfigContext.Provider value={props.config}>
+      <SidebarProvider>
+        <Flex
+          direction={{ mobile: 'column', tablet: 'row' }}
+          width="100vw"
+          minHeight="100vh"
+        >
+          <Sidebar hrefBase={props.basePath} config={props.config} />
+          <AppShellErrorContext.Consumer>
+            {error =>
+              error ? (
+                <EmptyState
+                  icon={alertCircleIcon}
+                  title="Failed to load shell"
+                  message={error.message}
+                />
+              ) : (
+                props.children
+              )
+            }
+          </AppShellErrorContext.Consumer>
+        </Flex>
+      </SidebarProvider>
+    </ConfigContext.Provider>
   );
   if (isGitHubConfig(props.config)) {
     return (
diff --git a/packages/keystatic/package.json b/packages/keystatic/package.json
index c6746643..d89856cd 100644
--- a/packages/keystatic/package.json
+++ b/packages/keystatic/package.json
@@ -111,6 +111,7 @@
     "mdast-util-gfm-strikethrough": "^0.2.3",
     "micromark-extension-gfm-autolink-literal": "0.5.7",
     "micromark-extension-gfm-strikethrough": "0.6.5",
+    "micromatch": "^4.0.5",
     "mime": "^3.0.0",
     "pretty-format": "^29.0.1",
     "prismjs": "^1.29.0",
@@ -129,6 +130,7 @@
     "@types/cookie": "^0.5.1",
     "@types/is-hotkey": "^0.1.7",
     "@types/js-yaml": "^4.0.5",
+    "@types/micromatch": "^4.0.2",
     "@types/prismjs": "^1.26.0",
     "@voussoir/test-utils": "^2.0.0",
     "eslint": "^8.18.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 97a1c566..8b0e883b 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1841,6 +1841,7 @@ importers:
       '@types/cookie': ^0.5.1
       '@types/is-hotkey': ^0.1.7
       '@types/js-yaml': ^4.0.5
+      '@types/micromatch': ^4.0.2
       '@types/node': 16.11.13
       '@types/prismjs': ^1.26.0
       '@types/react': ^18.0.25
@@ -1900,6 +1901,7 @@ importers:
       mdast-util-gfm-strikethrough: ^0.2.3
       micromark-extension-gfm-autolink-literal: 0.5.7
       micromark-extension-gfm-strikethrough: 0.6.5
+      micromatch: ^4.0.5
       mime: ^3.0.0
       pretty-format: ^29.0.1
       prismjs: ^1.29.0
@@ -1985,6 +1987,7 @@ importers:
       mdast-util-gfm-strikethrough: 0.2.3
       micromark-extension-gfm-autolink-literal: 0.5.7
       micromark-extension-gfm-strikethrough: 0.6.5
+      micromatch: 4.0.5
       mime: 3.0.0
       pretty-format: 29.4.3
       prismjs: 1.29.0
@@ -2002,6 +2005,7 @@ importers:
       '@types/cookie': 0.5.1
       '@types/is-hotkey': 0.1.7
       '@types/js-yaml': 4.0.5
+      '@types/micromatch': 4.0.2
       '@types/prismjs': 1.26.0
       '@voussoir/test-utils': link:../../design-system/packages/test-utils
       eslint: 8.34.0
@@ -8138,6 +8142,10 @@ packages:
       '@types/node': 16.11.13
     dev: true
 
+  /@types/braces/3.0.1:
+    resolution: {integrity: sha512-+euflG6ygo4bn0JHtn4pYqcXwRtLvElQ7/nnjDu7iYG56H0+OhCd7d6Ug0IE3WcFpZozBKW2+80FUbv5QGk5AQ==}
+    dev: true
+
   /@types/cacheable-request/6.0.3:
     resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
     dependencies:
@@ -8378,6 +8386,12 @@ packages:
   /@types/mdurl/1.0.2:
     resolution: {integrity: sha512-eC4U9MlIcu2q0KQmXszyn5Akca/0jrQmwDRgpAMJai7qBWq4amIQhZyNau4VYGtCeALvW1/NtjzJJ567aZxfKA==}
 
+  /@types/micromatch/4.0.2:
+    resolution: {integrity: sha512-oqXqVb0ci19GtH0vOA/U2TmHTcRY9kuZl4mqUxe0QmJAlIW13kzhuK5pi1i9+ngav8FjpSb9FVS/GE00GLX1VA==}
+    dependencies:
+      '@types/braces': 3.0.1
+    dev: true
+
   /@types/mime/3.0.1:
     resolution: {integrity: sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA==}
     dev: true


Test patch:
diff --git a/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts b/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts
index 7d5ae9c9..9f9900d6 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts
+++ b/packages/keystatic/DocumentEditor/component-blocks/field-assertions.test.ts
@@ -158,25 +158,3 @@ test('exceeds the call stack size for an infinitely recursive field where all fi
     assertValidComponentSchema(blah(), new Set());
   }).toThrowErrorMatchingInlineSnapshot(`"Maximum call stack size exceeded"`);
 });
-
-test('relationship field where no list exists with that name', () => {
-  expect(() => {
-    assertValidComponentSchema(
-      fields.object({
-        a: fields.relationship({ listKey: 'Blah', label: 'Something' }),
-      }),
-      new Set()
-    );
-  }).toThrowErrorMatchingInlineSnapshot(
-    `"The relationship field at "object.a" has the listKey "Blah" but no list named "Blah" exists."`
-  );
-});
-
-test('relationship field where a list exists with that name', () => {
-  assertValidComponentSchema(
-    fields.object({
-      a: fields.relationship({ listKey: 'Blah', label: 'Something' }),
-    }),
-    new Set(['Blah'])
-  );
-});
diff --git a/packages/keystatic/DocumentEditor/component-blocks/insert-break-and-delete.test.tsx b/packages/keystatic/DocumentEditor/component-blocks/insert-break-and-delete.test.tsx
index 78f800da..2c936c10 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/insert-break-and-delete.test.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/insert-break-and-delete.test.tsx
@@ -40,7 +40,7 @@ const componentBlocks = {
             true: fields.child({ kind: 'block', placeholder: '' }),
             false: fields.relationship({
               label: 'Relationship',
-              listKey: 'Something',
+              collection: 'Something',
             }),
           }
         ),
diff --git a/packages/keystatic/DocumentEditor/component-blocks/insertion-and-preview-props.test.tsx b/packages/keystatic/DocumentEditor/component-blocks/insertion-and-preview-props.test.tsx
index c2fc112c..cb62ff55 100644
--- a/packages/keystatic/DocumentEditor/component-blocks/insertion-and-preview-props.test.tsx
+++ b/packages/keystatic/DocumentEditor/component-blocks/insertion-and-preview-props.test.tsx
@@ -15,8 +15,7 @@ const objectProp = fields.object({
   inline: fields.child({ kind: 'inline', placeholder: '' }),
   many: fields.relationship({
     label: 'Relationship',
-    listKey: 'many',
-    many: true,
+    collection: 'many',
   }),
   select: fields.select({
     label: 'Select',
@@ -28,7 +27,7 @@ const objectProp = fields.object({
   }),
   conditional: fields.conditional(fields.checkbox({ label: 'Conditional' }), {
     true: fields.child({ kind: 'block', placeholder: '' }),
-    false: fields.relationship({ label: 'Relationship', listKey: 'one' }),
+    false: fields.relationship({ label: 'Relationship', collection: 'one' }),
   }),
   conditionalSelect: fields.conditional(
     fields.select({
@@ -134,7 +133,7 @@ test('inserting a complex component block', () => {
                 "value": "",
               },
               "inline": null,
-              "many": [],
+              "many": null,
               "prop": "",
               "select": "a",
             },
@@ -205,7 +204,7 @@ const makeEditorWithComplexComponentBlock = () =>
               value: '',
             },
             inline: null,
-            many: [],
+            many: '',
           },
         }}
       >
@@ -257,6 +256,7 @@ test('preview props api', () => {
                   .false,
               onChange: expect.any(Function) as any,
               value: null,
+              options: undefined,
             },
           },
           conditionalSelect: {
@@ -285,8 +285,9 @@ test('preview props api', () => {
           },
           many: {
             schema: componentBlocks.complex.schema.object.fields.many,
-            value: [],
+            value: '',
             onChange: expect.any(Function) as any,
+            options: undefined,
           },
           prop: {
             schema: componentBlocks.complex.schema.object.fields.prop,
@@ -334,7 +335,7 @@ test('preview props conditional change', () => {
                 "value": "",
               },
               "inline": null,
-              "many": [],
+              "many": "",
               "prop": "",
               "select": "a",
             },
@@ -413,38 +414,12 @@ test('preview props form change', () => {
   expect(getPreviewProps(editor).fields.object.fields.select.value).toBe('b');
 });
 
-test('relationship many change', () => {
-  let editor = makeEditorWithComplexComponentBlock();
-
-  let previewProps = getPreviewProps(editor);
-  const val = [{ data: {}, id: 'some-id', label: 'some-id' }];
-  previewProps.fields.object.fields.many.onChange(val);
-  expect((editor.children[0] as any).props.object.many).toEqual(val);
-  expect(getPreviewProps(editor).fields.object.fields.many.value).toEqual(val);
-});
-
 function assert(condition: boolean): asserts condition {
   if (!condition) {
     throw new Error('condition is false');
   }
 }
 
-test('relationship single change', () => {
-  let editor = makeEditorWithComplexComponentBlock();
-
-  let previewProps = getPreviewProps(editor);
-  assert(previewProps.fields.object.fields.conditional.discriminant === false);
-  const val = { data: {}, id: 'some-id', label: 'some-id' };
-  previewProps.fields.object.fields.conditional.value.onChange(val);
-  expect((editor.children[0] as any).props.object.conditional.value).toEqual(
-    val
-  );
-  expect(
-    (getPreviewProps(editor).fields.object.fields.conditional.value as any)
-      .value
-  ).toEqual(val);
-});
-
 test('changing conditional with form inside', () => {
   let editor = makeEditorWithComplexComponentBlock();