@tinacms/app 0.0.0-00f6525-20241015065741

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