@tinacms/app 0.0.0-003e348-20251023020516

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1014 @@
1
+ // @ts-expect-error
2
+ import schemaJson from 'SCHEMA_IMPORT';
3
+ import { getIn } from 'final-form';
4
+ import * as G from 'graphql';
5
+ import React from 'react';
6
+ import { useSearchParams } from 'react-router-dom';
7
+ import {
8
+ Client,
9
+ Collection,
10
+ ErrorDialog,
11
+ Form,
12
+ FormOptions,
13
+ GlobalFormPlugin,
14
+ NAMER,
15
+ Template,
16
+ TinaCMS,
17
+ TinaField,
18
+ TinaSchema,
19
+ TinaState,
20
+ resolveField,
21
+ useCMS,
22
+ } from 'tinacms';
23
+ import { z } from 'zod';
24
+ import { FormifyCallback, createForm, createGlobalForm } from './build-form';
25
+ import { showErrorModal } from './errors';
26
+ import { expandQuery, isConnectionType, isNodeType } from './expand-query';
27
+ import type {
28
+ Payload,
29
+ PostMessage,
30
+ ResolvedDocument,
31
+ SystemInfo,
32
+ } from './types';
33
+ import { getFormAndFieldNameFromMetadata } from './util';
34
+
35
+ const sysSchema = z.object({
36
+ breadcrumbs: z.array(z.string()),
37
+ basename: z.string(),
38
+ filename: z.string(),
39
+ path: z.string(),
40
+ extension: z.string(),
41
+ relativePath: z.string(),
42
+ title: z.string().optional().nullable(),
43
+ template: z.string(),
44
+ hasReferences: z.boolean().optional().nullable(),
45
+ collection: z.object({
46
+ name: z.string(),
47
+ slug: z.string(),
48
+ label: z.string().optional().nullable(),
49
+ path: z.string(),
50
+ format: z.string().optional().nullable(),
51
+ matches: z.string().optional().nullable(),
52
+ }),
53
+ });
54
+
55
+ const documentSchema: z.ZodType<ResolvedDocument> = z.object({
56
+ _internalValues: z.record(z.unknown()),
57
+ _internalSys: sysSchema,
58
+ });
59
+
60
+ const astNode = schemaJson as G.DocumentNode;
61
+ const astNodeWithMeta: G.DocumentNode = {
62
+ ...astNode,
63
+ definitions: astNode.definitions.map((def) => {
64
+ if (def.kind === 'InterfaceTypeDefinition') {
65
+ return {
66
+ ...def,
67
+ fields: [
68
+ ...(def.fields || []),
69
+ {
70
+ kind: 'FieldDefinition',
71
+ name: {
72
+ kind: 'Name',
73
+ value: '_tina_metadata',
74
+ },
75
+ arguments: [],
76
+ type: {
77
+ kind: 'NonNullType',
78
+ type: {
79
+ kind: 'NamedType',
80
+ name: {
81
+ kind: 'Name',
82
+ value: 'JSON',
83
+ },
84
+ },
85
+ },
86
+ },
87
+ {
88
+ kind: 'FieldDefinition',
89
+ name: {
90
+ kind: 'Name',
91
+ value: '_content_source',
92
+ },
93
+ arguments: [],
94
+ type: {
95
+ kind: 'NonNullType',
96
+ type: {
97
+ kind: 'NamedType',
98
+ name: {
99
+ kind: 'Name',
100
+ value: 'JSON',
101
+ },
102
+ },
103
+ },
104
+ },
105
+ ],
106
+ };
107
+ }
108
+ if (def.kind === 'ObjectTypeDefinition') {
109
+ return {
110
+ ...def,
111
+ fields: [
112
+ ...(def.fields || []),
113
+ {
114
+ kind: 'FieldDefinition',
115
+ name: {
116
+ kind: 'Name',
117
+ value: '_tina_metadata',
118
+ },
119
+ arguments: [],
120
+ type: {
121
+ kind: 'NonNullType',
122
+ type: {
123
+ kind: 'NamedType',
124
+ name: {
125
+ kind: 'Name',
126
+ value: 'JSON',
127
+ },
128
+ },
129
+ },
130
+ },
131
+ {
132
+ kind: 'FieldDefinition',
133
+ name: {
134
+ kind: 'Name',
135
+ value: '_content_source',
136
+ },
137
+ arguments: [],
138
+ type: {
139
+ kind: 'NonNullType',
140
+ type: {
141
+ kind: 'NamedType',
142
+ name: {
143
+ kind: 'Name',
144
+ value: 'JSON',
145
+ },
146
+ },
147
+ },
148
+ },
149
+ ],
150
+ };
151
+ }
152
+ return def;
153
+ }),
154
+ };
155
+ const schema = G.buildASTSchema(astNode);
156
+ const schemaForResolver = G.buildASTSchema(astNodeWithMeta);
157
+
158
+ const isRejected = (
159
+ input: PromiseSettledResult<unknown>
160
+ ): input is PromiseRejectedResult => input.status === 'rejected';
161
+
162
+ const isFulfilled = <T>(
163
+ input: PromiseSettledResult<T>
164
+ ): input is PromiseFulfilledResult<T> => input.status === 'fulfilled';
165
+
166
+ export const useGraphQLReducer = (
167
+ iframe: React.MutableRefObject<HTMLIFrameElement>,
168
+ url: string
169
+ ) => {
170
+ const cms = useCMS();
171
+ const tinaSchema = cms.api.tina.schema as TinaSchema;
172
+ const [payloads, setPayloads] = React.useState<Payload[]>([]);
173
+ const [requestErrors, setRequestErrors] = React.useState<string[]>([]);
174
+ const [searchParams, setSearchParams] = useSearchParams();
175
+ const [results, setResults] = React.useState<
176
+ {
177
+ id: string;
178
+ data:
179
+ | {
180
+ [key: string]: any;
181
+ }
182
+ | null
183
+ | undefined;
184
+ }[]
185
+ >([]);
186
+ const [documentsToResolve, setDocumentsToResolve] = React.useState<string[]>(
187
+ []
188
+ );
189
+ const [resolvedDocuments, setResolvedDocuments] = React.useState<
190
+ ResolvedDocument[]
191
+ >([]);
192
+ const [operationIndex, setOperationIndex] = React.useState(0);
193
+
194
+ const activeField = searchParams.get('active-field');
195
+
196
+ React.useEffect(() => {
197
+ const run = async () => {
198
+ return Promise.all(
199
+ documentsToResolve.map(async (documentId) => {
200
+ return await getDocument(documentId, cms.api.tina);
201
+ })
202
+ );
203
+ };
204
+ if (documentsToResolve.length) {
205
+ run().then((docs) => {
206
+ setResolvedDocuments((resolvedDocs) => [...resolvedDocs, ...docs]);
207
+ setDocumentsToResolve([]);
208
+ setOperationIndex((i) => i + 1);
209
+ });
210
+ }
211
+ }, [documentsToResolve.join('.')]);
212
+
213
+ /**
214
+ * Note: since React runs effects twice in development this will run twice for a given query
215
+ * which results in duplicate network requests in quick succession
216
+ */
217
+ React.useEffect(() => {
218
+ const run = async () => {
219
+ setRequestErrors([]);
220
+ // gather the errors and display an error message containing each error unique message
221
+ return Promise.allSettled(
222
+ payloads.map(async (payload) => {
223
+ // This payload has already been expanded, skip it.
224
+ if (payload.expandedQuery) {
225
+ return payload;
226
+ } else {
227
+ const expandedPayload = await expandPayload(payload, cms);
228
+ processPayload(expandedPayload);
229
+ return expandedPayload;
230
+ }
231
+ })
232
+ );
233
+ };
234
+ if (payloads.length) {
235
+ run().then((updatedPayloads) => {
236
+ setPayloads(updatedPayloads.filter(isFulfilled).map((p) => p.value));
237
+ setRequestErrors(
238
+ updatedPayloads.filter(isRejected).map((p) => String(p.reason))
239
+ );
240
+ });
241
+ }
242
+ }, [JSON.stringify(payloads), cms]);
243
+
244
+ const processPayload = React.useCallback(
245
+ (payload: Payload) => {
246
+ const { expandedQueryForResolver, variables, expandedData } = payload;
247
+ if (!expandedQueryForResolver || !expandedData) {
248
+ throw new Error(
249
+ `Unable to process payload which has not been expanded`
250
+ );
251
+ }
252
+ const formListItems: TinaState['formLists'][number]['items'] = [];
253
+ const formIds: string[] = [];
254
+
255
+ const result = G.graphqlSync({
256
+ schema: schemaForResolver,
257
+ source: expandedQueryForResolver,
258
+ variableValues: variables,
259
+ rootValue: expandedData,
260
+ fieldResolver: (source, args, context, info) => {
261
+ const fieldName = info.fieldName;
262
+ /**
263
+ * Since the `source` for this resolver is the query that
264
+ * ran before passing it into `useTina`, we need to take aliases
265
+ * into consideration, so if an alias is provided we try to
266
+ * see if that has the value we're looking for. This isn't a perfect
267
+ * solution as the `value` gets overwritten depending on the alias
268
+ * query.
269
+ */
270
+ const aliases: string[] = [];
271
+ info.fieldNodes.forEach((fieldNode) => {
272
+ if (fieldNode.alias) {
273
+ aliases.push(fieldNode.alias.value);
274
+ }
275
+ });
276
+ let value = source[fieldName] as unknown;
277
+ aliases.forEach((alias) => {
278
+ const aliasValue = source[alias];
279
+ if (aliasValue) {
280
+ value = aliasValue;
281
+ }
282
+ });
283
+ if (fieldName === '_sys') {
284
+ return source._internalSys;
285
+ }
286
+ if (fieldName === '_values') {
287
+ return source._internalValues;
288
+ }
289
+ if (info.fieldName === '_content_source') {
290
+ const pathArray = G.responsePathAsArray(info.path);
291
+ return {
292
+ queryId: payload.id,
293
+ path: pathArray.slice(0, pathArray.length - 1),
294
+ };
295
+ }
296
+ if (info.fieldName === '_tina_metadata') {
297
+ if (value) {
298
+ return value;
299
+ }
300
+ // TODO: ensure all fields that have _tina_metadata
301
+ // actually need it
302
+ return {
303
+ id: null,
304
+ fields: [],
305
+ prefix: '',
306
+ };
307
+ }
308
+
309
+ if (isConnectionType(info.returnType)) {
310
+ const name = G.getNamedType(info.returnType).name;
311
+ const connectionCollection = tinaSchema
312
+ .getCollections()
313
+ .find((collection) => {
314
+ const collectionName = NAMER.referenceConnectionType(
315
+ collection.namespace
316
+ );
317
+ if (collectionName === name) {
318
+ return true;
319
+ }
320
+ return false;
321
+ });
322
+ if (connectionCollection) {
323
+ formListItems.push({
324
+ type: 'list',
325
+ label: connectionCollection.label || connectionCollection.name,
326
+ });
327
+ }
328
+ }
329
+ if (isNodeType(info.returnType)) {
330
+ if (!value) {
331
+ return;
332
+ }
333
+ let resolvedDocument: ResolvedDocument;
334
+ // This is a reference from another form
335
+ if (typeof value === 'string') {
336
+ const valueFromSetup = getIn(
337
+ expandedData,
338
+ G.responsePathAsArray(info.path).join('.')
339
+ );
340
+ const maybeResolvedDocument = resolvedDocuments.find(
341
+ (doc) => doc._internalSys.path === value
342
+ );
343
+
344
+ // If we already have this document, use it.
345
+ if (maybeResolvedDocument) {
346
+ resolvedDocument = maybeResolvedDocument;
347
+ } else if (valueFromSetup) {
348
+ // Else, even though in this context the value is a string because it's
349
+ // resolved from a parent form, if the reference hasn't changed
350
+ // from when we ran the setup query, we can avoid a data fetch
351
+ // here and just grab it from the response
352
+ const maybeResolvedDocument =
353
+ documentSchema.parse(valueFromSetup);
354
+
355
+ if (maybeResolvedDocument._internalSys.path === value) {
356
+ resolvedDocument = maybeResolvedDocument;
357
+ } else {
358
+ throw new NoFormError(`No form found`, value);
359
+ }
360
+ } else {
361
+ throw new NoFormError(`No form found`, value);
362
+ }
363
+ } else {
364
+ resolvedDocument = documentSchema.parse(value);
365
+ }
366
+ const id = resolvedDocument._internalSys.path;
367
+ formIds.push(id);
368
+ const existingForm = cms.state.forms.find(
369
+ (f) => f.tinaForm.id === id
370
+ );
371
+
372
+ const pathArray = G.responsePathAsArray(info.path);
373
+ const pathString = pathArray.join('.');
374
+ const ancestors = formListItems.filter((item) => {
375
+ if (item.type === 'document') {
376
+ return pathString.startsWith(item.path);
377
+ }
378
+ });
379
+ const parent = ancestors[ancestors.length - 1];
380
+ if (parent) {
381
+ if (parent.type === 'document') {
382
+ parent.subItems.push({
383
+ type: 'document',
384
+ path: pathString,
385
+ formId: id,
386
+ subItems: [],
387
+ });
388
+ }
389
+ } else {
390
+ formListItems.push({
391
+ type: 'document',
392
+ path: pathString,
393
+ formId: id,
394
+ subItems: [],
395
+ });
396
+ }
397
+
398
+ if (!existingForm) {
399
+ const { form, template } = buildForm({
400
+ resolvedDocument,
401
+ tinaSchema,
402
+ payloadId: payload.id,
403
+ cms,
404
+ });
405
+ form.subscribe(
406
+ () => {
407
+ setOperationIndex((i) => i + 1);
408
+ },
409
+ { values: true }
410
+ );
411
+ return resolveDocument(
412
+ resolvedDocument,
413
+ template,
414
+ form,
415
+ pathString
416
+ );
417
+ } else {
418
+ existingForm.tinaForm.addQuery(payload.id);
419
+ const { template } = getTemplateForDocument(
420
+ resolvedDocument,
421
+ tinaSchema
422
+ );
423
+ existingForm.tinaForm.addQuery(payload.id);
424
+ return resolveDocument(
425
+ resolvedDocument,
426
+ template,
427
+ existingForm.tinaForm,
428
+ pathString
429
+ );
430
+ }
431
+ }
432
+ return value;
433
+ },
434
+ });
435
+ if (result.errors) {
436
+ result.errors.forEach((error) => {
437
+ if (
438
+ error instanceof G.GraphQLError &&
439
+ error.originalError instanceof NoFormError
440
+ ) {
441
+ const id = error.originalError.id;
442
+ setDocumentsToResolve((docs) => [
443
+ ...docs.filter((doc) => doc !== id),
444
+ id,
445
+ ]);
446
+ } else {
447
+ console.log(error);
448
+ // throw new Error(
449
+ // `Error processing value change, please contact support`
450
+ // )
451
+ }
452
+ });
453
+ } else {
454
+ if (result.data) {
455
+ setResults((results) => [
456
+ ...results.filter((result) => result.id !== payload.id),
457
+ { id: payload.id, data: result.data },
458
+ ]);
459
+ }
460
+ if (activeField) {
461
+ setSearchParams({});
462
+ const [queryId, eventFieldName] = activeField.split('---');
463
+ if (queryId === payload.id) {
464
+ if (result?.data) {
465
+ cms.dispatch({
466
+ type: 'forms:set-active-field-name',
467
+ value: getFormAndFieldNameFromMetadata(
468
+ result.data,
469
+ eventFieldName
470
+ ),
471
+ });
472
+ }
473
+ cms.dispatch({
474
+ type: 'sidebar:set-display-state',
475
+ value: 'openOrFull',
476
+ });
477
+ }
478
+ }
479
+ iframe.current?.contentWindow?.postMessage({
480
+ type: 'updateData',
481
+ id: payload.id,
482
+ data: result.data,
483
+ });
484
+ }
485
+ cms.dispatch({
486
+ type: 'form-lists:add',
487
+ value: {
488
+ id: payload.id,
489
+ label: 'Anonymous Query', // TODO: grab the name of the query if it exists
490
+ items: formListItems,
491
+ formIds,
492
+ },
493
+ });
494
+ },
495
+ [
496
+ resolvedDocuments.map((doc) => doc._internalSys.path).join('.'),
497
+ activeField,
498
+ ]
499
+ );
500
+
501
+ const handleMessage = React.useCallback(
502
+ (event: MessageEvent<PostMessage>) => {
503
+ if (event.data.type === 'user-select-form') {
504
+ cms.dispatch({
505
+ type: 'forms:set-active-form-id',
506
+ value: event.data.formId,
507
+ });
508
+ }
509
+
510
+ if (event?.data?.type === 'quick-edit') {
511
+ cms.dispatch({
512
+ type: 'set-quick-editing-supported',
513
+ value: event.data.value,
514
+ });
515
+ iframe.current?.contentWindow?.postMessage({
516
+ type: 'quickEditEnabled',
517
+ value: cms.state.sidebarDisplayState === 'open',
518
+ });
519
+ }
520
+ if (event?.data?.type === 'isEditMode') {
521
+ iframe?.current?.contentWindow?.postMessage({
522
+ type: 'tina:editMode',
523
+ });
524
+ }
525
+ if (event.data.type === 'field:selected') {
526
+ const [queryId, eventFieldName] = event.data.fieldName.split('---');
527
+ const result = results.find((res) => res.id === queryId);
528
+ if (result?.data) {
529
+ cms.dispatch({
530
+ type: 'forms:set-active-field-name',
531
+ value: getFormAndFieldNameFromMetadata(result.data, eventFieldName),
532
+ });
533
+ }
534
+ cms.dispatch({
535
+ type: 'sidebar:set-display-state',
536
+ value: 'openOrFull',
537
+ });
538
+ }
539
+ if (event.data.type === 'close') {
540
+ const payloadSchema = z.object({ id: z.string() });
541
+ const { id } = payloadSchema.parse(event.data);
542
+ setPayloads((previous) =>
543
+ previous.filter((payload) => payload.id !== id)
544
+ );
545
+ setResults((previous) => previous.filter((result) => result.id !== id));
546
+ cms.forms.all().map((form) => {
547
+ form.removeQuery(id);
548
+ });
549
+ cms.removeOrphanedForms();
550
+ cms.dispatch({ type: 'form-lists:remove', value: id });
551
+ }
552
+ if (event.data.type === 'open') {
553
+ const payloadSchema = z.object({
554
+ id: z.string(),
555
+ query: z.string(),
556
+ variables: z.record(z.unknown()),
557
+ data: z.record(z.unknown()),
558
+ });
559
+ const payload = payloadSchema.parse(event.data);
560
+ setPayloads((payloads) => [
561
+ ...payloads.filter(({ id }) => id !== payload.id),
562
+ payload,
563
+ ]);
564
+ }
565
+ // TODO: This is causing a webpack HMR issue - look into refactoring this logic
566
+ // if (event.data.type === 'url-changed') {
567
+ // console.log('[EVENT_TRIGGERED] url-changed: ', event);
568
+ // cms.dispatch({
569
+ // type: 'sidebar:set-loading-state',
570
+ // value: true,
571
+ // });
572
+ // }
573
+ },
574
+ [cms, JSON.stringify(results)]
575
+ );
576
+
577
+ React.useEffect(() => {
578
+ payloads.forEach((payload) => {
579
+ if (payload.expandedData) {
580
+ processPayload(payload);
581
+ }
582
+ });
583
+ }, [operationIndex]);
584
+
585
+ React.useEffect(() => {
586
+ return () => {
587
+ setPayloads([]);
588
+ setResults([]);
589
+ cms.removeAllForms();
590
+ cms.dispatch({ type: 'form-lists:clear' });
591
+ };
592
+ }, [url]);
593
+
594
+ React.useEffect(() => {
595
+ iframe.current?.contentWindow?.postMessage({
596
+ type: 'quickEditEnabled',
597
+ value: cms.state.sidebarDisplayState === 'open',
598
+ });
599
+ }, [cms.state.sidebarDisplayState]);
600
+
601
+ React.useEffect(() => {
602
+ cms.dispatch({ type: 'set-edit-mode', value: 'visual' });
603
+ if (iframe) {
604
+ window.addEventListener('message', handleMessage);
605
+ }
606
+
607
+ return () => {
608
+ window.removeEventListener('message', handleMessage);
609
+ cms.removeAllForms();
610
+ cms.dispatch({ type: 'set-edit-mode', value: 'basic' });
611
+ };
612
+ }, [iframe.current, JSON.stringify(results)]);
613
+
614
+ React.useEffect(() => {
615
+ if (requestErrors.length) {
616
+ showErrorModal('Unexpected error querying content', requestErrors, cms);
617
+ }
618
+ }, [requestErrors]);
619
+ };
620
+
621
+ const onSubmit = async (
622
+ collection: Collection<true>,
623
+ relativePath: string,
624
+ payload: Record<string, unknown>,
625
+ cms: TinaCMS
626
+ ) => {
627
+ const tinaSchema = cms.api.tina.schema;
628
+ try {
629
+ const mutationString = `#graphql
630
+ mutation UpdateDocument($collection: String!, $relativePath: String!, $params: DocumentUpdateMutation!) {
631
+ updateDocument(collection: $collection, relativePath: $relativePath, params: $params) {
632
+ __typename
633
+ }
634
+ }
635
+ `;
636
+
637
+ await cms.api.tina.request(mutationString, {
638
+ variables: {
639
+ collection: collection.name,
640
+ relativePath: relativePath,
641
+ params: tinaSchema.transformPayload(collection.name, payload),
642
+ },
643
+ });
644
+ cms.alerts.success('Document saved!');
645
+ } catch (e) {
646
+ cms.alerts.error(() =>
647
+ ErrorDialog({
648
+ title: 'There was a problem saving your document',
649
+ message: 'Tina caught an error while updating the page',
650
+ error: e,
651
+ })
652
+ );
653
+ console.error(e);
654
+ }
655
+ };
656
+
657
+ type Path = (string | number)[];
658
+
659
+ const resolveDocument = (
660
+ doc: ResolvedDocument,
661
+ template: Template<true>,
662
+ form: Form,
663
+ pathToDocument: string
664
+ ): ResolvedDocument => {
665
+ // @ts-ignore AnyField and TinaField don't mix
666
+ const fields = form.fields as TinaField<true>[];
667
+ const id = doc._internalSys.path;
668
+ const path: Path = [];
669
+ const formValues = resolveFormValue({
670
+ fields: fields,
671
+ values: form.values,
672
+ path,
673
+ id,
674
+ pathToDocument,
675
+ });
676
+ const metadataFields: Record<string, string> = {};
677
+ Object.keys(formValues).forEach((key) => {
678
+ metadataFields[key] = [...path, key].join('.');
679
+ });
680
+
681
+ return {
682
+ ...formValues,
683
+ id,
684
+ sys: doc._internalSys,
685
+ values: form.values,
686
+ _tina_metadata: {
687
+ prefix: pathToDocument,
688
+ id: doc._internalSys.path,
689
+ name: '',
690
+ fields: metadataFields,
691
+ },
692
+ _internalSys: doc._internalSys,
693
+ _internalValues: doc._internalValues,
694
+ __typename: NAMER.dataTypeName(template.namespace),
695
+ };
696
+ };
697
+
698
+ const resolveFormValue = <T extends Record<string, unknown>>({
699
+ fields,
700
+ values,
701
+ path,
702
+ id,
703
+ pathToDocument,
704
+ }: // tinaSchema,
705
+ {
706
+ fields: TinaField<true>[];
707
+ values: T;
708
+ path: Path;
709
+ id: string;
710
+ pathToDocument: string;
711
+ // tinaSchema: TinaSchema
712
+ }): T & { __typename?: string } => {
713
+ const accum: Record<string, unknown> = {};
714
+ fields.forEach((field) => {
715
+ const v = values[field.name];
716
+ if (typeof v === 'undefined') {
717
+ return;
718
+ }
719
+ if (v === null) {
720
+ return;
721
+ }
722
+ accum[field.name] = resolveFieldValue({
723
+ field,
724
+ value: v,
725
+ path,
726
+ id,
727
+ pathToDocument,
728
+ });
729
+ });
730
+ return accum as T & { __typename?: string };
731
+ };
732
+ const resolveFieldValue = ({
733
+ field,
734
+ value,
735
+ path,
736
+ id,
737
+ pathToDocument,
738
+ }: {
739
+ field: TinaField<true>;
740
+ value: unknown;
741
+ path: Path;
742
+ id: string;
743
+ pathToDocument: string;
744
+ }) => {
745
+ switch (field.type) {
746
+ case 'object': {
747
+ if (field.templates) {
748
+ if (field.list) {
749
+ if (Array.isArray(value)) {
750
+ return value.map((item, index) => {
751
+ const template = field.templates[item._template];
752
+ if (typeof template === 'string') {
753
+ throw new Error('Global templates not supported');
754
+ }
755
+ const nextPath = [...path, field.name, index];
756
+ const metadataFields: Record<string, string> = {};
757
+ template.fields.forEach((field) => {
758
+ metadataFields[field.name] = [...nextPath, field.name].join(
759
+ '.'
760
+ );
761
+ });
762
+ return {
763
+ __typename: NAMER.dataTypeName(template.namespace),
764
+ _tina_metadata: {
765
+ id,
766
+ name: nextPath.join('.'),
767
+ fields: metadataFields,
768
+ prefix: pathToDocument,
769
+ },
770
+ ...resolveFormValue({
771
+ fields: template.fields,
772
+ values: item,
773
+ path: nextPath,
774
+ id,
775
+ pathToDocument,
776
+ }),
777
+ };
778
+ });
779
+ }
780
+ } else {
781
+ // not implemented
782
+ }
783
+ }
784
+
785
+ const templateFields = field.fields;
786
+ if (typeof templateFields === 'string') {
787
+ throw new Error('Global templates not supported');
788
+ }
789
+ if (!templateFields) {
790
+ throw new Error(`Expected to find sub-fields on field ${field.name}`);
791
+ }
792
+ if (field.list) {
793
+ if (Array.isArray(value)) {
794
+ return value.map((item, index) => {
795
+ const nextPath = [...path, field.name, index];
796
+ const metadataFields: Record<string, string> = {};
797
+ templateFields.forEach((field) => {
798
+ metadataFields[field.name] = [...nextPath, field.name].join('.');
799
+ });
800
+ return {
801
+ __typename: NAMER.dataTypeName(field.namespace),
802
+ _tina_metadata: {
803
+ id,
804
+ name: nextPath.join('.'),
805
+ fields: metadataFields,
806
+ prefix: pathToDocument,
807
+ },
808
+ ...resolveFormValue({
809
+ fields: templateFields,
810
+ values: item,
811
+ path: nextPath,
812
+ id,
813
+ pathToDocument,
814
+ }),
815
+ };
816
+ });
817
+ }
818
+ } else {
819
+ const nextPath = [...path, field.name];
820
+ const metadataFields: Record<string, string> = {};
821
+ templateFields.forEach((field) => {
822
+ metadataFields[field.name] = [...nextPath, field.name].join('.');
823
+ });
824
+ return {
825
+ __typename: NAMER.dataTypeName(field.namespace),
826
+ _tina_metadata: {
827
+ id,
828
+ name: nextPath.join('.'),
829
+ fields: metadataFields,
830
+ prefix: pathToDocument,
831
+ },
832
+ ...resolveFormValue({
833
+ fields: templateFields,
834
+ values: value as any,
835
+ path: nextPath,
836
+ id,
837
+ pathToDocument,
838
+ }),
839
+ };
840
+ }
841
+ }
842
+ default: {
843
+ return value;
844
+ }
845
+ }
846
+ };
847
+
848
+ const getDocument = async (id: string, tina: Client) => {
849
+ const response = await tina.request<{
850
+ node: {
851
+ _internalSys: SystemInfo;
852
+ _internalValues: Record<string, unknown>;
853
+ };
854
+ }>(
855
+ `query GetNode($id: String!) {
856
+ node(id: $id) {
857
+ ...on Document {
858
+ _internalValues: _values
859
+ _internalSys: _sys {
860
+ breadcrumbs
861
+ basename
862
+ filename
863
+ path
864
+ extension
865
+ relativePath
866
+ title
867
+ hasReferences
868
+ template
869
+ collection {
870
+ name
871
+ slug
872
+ label
873
+ path
874
+ format
875
+ matches
876
+ templates
877
+ fields
878
+ __typename
879
+ }
880
+ __typename
881
+ }
882
+ }
883
+ }
884
+ }`,
885
+ { variables: { id: id } }
886
+ );
887
+ return response.node;
888
+ };
889
+
890
+ const expandPayload = async (
891
+ payload: Payload,
892
+ cms: TinaCMS
893
+ ): Promise<Payload> => {
894
+ const { query, variables } = payload;
895
+ const documentNode = G.parse(query);
896
+ const expandedDocumentNode = expandQuery({ schema, documentNode });
897
+ const expandedQuery = G.print(expandedDocumentNode);
898
+ const expandedData = await cms.api.tina.request<object>(expandedQuery, {
899
+ variables,
900
+ });
901
+
902
+ const expandedDocumentNodeForResolver = expandQuery({
903
+ schema: schemaForResolver,
904
+ documentNode,
905
+ });
906
+ const expandedQueryForResolver = G.print(expandedDocumentNodeForResolver);
907
+ return { ...payload, expandedQuery, expandedData, expandedQueryForResolver };
908
+ };
909
+
910
+ /**
911
+ * When we resolve the graphql data we check for these errors,
912
+ * if we find one we enqueue the document to be generated, and then
913
+ * process it once we have that document
914
+ */
915
+ class NoFormError extends Error {
916
+ id: string;
917
+ constructor(msg: string, id: string) {
918
+ super(msg);
919
+ this.id = id;
920
+ Object.setPrototypeOf(this, NoFormError.prototype);
921
+ }
922
+ }
923
+
924
+ const getTemplateForDocument = (
925
+ resolvedDocument: ResolvedDocument,
926
+ tinaSchema: TinaSchema
927
+ ) => {
928
+ const id = resolvedDocument._internalSys.path;
929
+ let collection: Collection<true> | undefined;
930
+ try {
931
+ collection = tinaSchema.getCollectionByFullPath(id);
932
+ } catch (e) {}
933
+
934
+ if (!collection) {
935
+ throw new Error(`Unable to determine collection for path ${id}`);
936
+ }
937
+
938
+ const template = tinaSchema.getTemplateForData({
939
+ data: resolvedDocument._internalValues,
940
+ collection,
941
+ });
942
+ return { template, collection };
943
+ };
944
+
945
+ const buildForm = ({
946
+ resolvedDocument,
947
+ tinaSchema,
948
+ payloadId,
949
+ cms,
950
+ }: {
951
+ resolvedDocument: ResolvedDocument;
952
+ tinaSchema: TinaSchema;
953
+ payloadId: string;
954
+ cms: TinaCMS;
955
+ }) => {
956
+ const { template, collection } = getTemplateForDocument(
957
+ resolvedDocument,
958
+ tinaSchema
959
+ );
960
+ const id = resolvedDocument._internalSys.path;
961
+ let form: Form | undefined;
962
+ let shouldRegisterForm = true;
963
+ const formConfig: FormOptions<any> = {
964
+ id,
965
+ initialValues: resolvedDocument._internalValues,
966
+ fields: template.fields.map((field) => resolveField(field, tinaSchema)),
967
+ onSubmit: (payload) =>
968
+ onSubmit(
969
+ collection,
970
+ resolvedDocument._internalSys.relativePath,
971
+ payload,
972
+ cms
973
+ ),
974
+ label: collection.label || collection.name,
975
+ };
976
+ if (tinaSchema.config.config?.formifyCallback) {
977
+ const callback = tinaSchema.config.config
978
+ ?.formifyCallback as FormifyCallback;
979
+ form =
980
+ callback(
981
+ {
982
+ createForm: createForm,
983
+ createGlobalForm: createGlobalForm,
984
+ skip: () => {},
985
+ formConfig,
986
+ },
987
+ cms
988
+ ) || undefined;
989
+ if (!form) {
990
+ // If the form isn't created from formify, we still
991
+ // need it, just don't show it to the user.
992
+ shouldRegisterForm = false;
993
+ form = new Form(formConfig);
994
+ }
995
+ } else {
996
+ if (collection.ui?.global) {
997
+ form = createGlobalForm(formConfig);
998
+ } else {
999
+ form = createForm(formConfig);
1000
+ }
1001
+ }
1002
+ if (form) {
1003
+ if (shouldRegisterForm) {
1004
+ if (collection.ui?.global) {
1005
+ cms.plugins.add(new GlobalFormPlugin(form));
1006
+ }
1007
+ cms.dispatch({ type: 'forms:add', value: form });
1008
+ }
1009
+ }
1010
+ if (!form) {
1011
+ throw new Error(`No form registered for ${id}.`);
1012
+ }
1013
+ return { template, form };
1014
+ };