@tinacms/app 1.2.7 → 1.2.9

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,753 @@
1
+ import React from 'react'
2
+ import * as G from 'graphql'
3
+ import { getIn } from 'final-form'
4
+ import { z, ZodError } from 'zod'
5
+ // @ts-expect-error
6
+ import schemaJson from 'SCHEMA_IMPORT'
7
+ import { expandQuery, 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
+ } from 'tinacms'
22
+ import { createForm, createGlobalForm, FormifyCallback } from './build-form'
23
+ import type {
24
+ PostMessage,
25
+ Payload,
26
+ SystemInfo,
27
+ Document,
28
+ ResolvedDocument,
29
+ } from './types'
30
+
31
+ const sysSchema = z.object({
32
+ breadcrumbs: z.array(z.string()),
33
+ basename: z.string(),
34
+ filename: z.string(),
35
+ path: z.string(),
36
+ extension: z.string(),
37
+ relativePath: z.string(),
38
+ title: z.string().optional().nullable(),
39
+ template: z.string(),
40
+ collection: z.object({
41
+ name: z.string(),
42
+ slug: z.string(),
43
+ label: z.string(),
44
+ path: z.string(),
45
+ format: z.string().optional().nullable(),
46
+ matches: z.string().optional().nullable(),
47
+ }),
48
+ })
49
+
50
+ const documentSchema: z.ZodType<ResolvedDocument> = z.object({
51
+ _internalValues: z.record(z.unknown()),
52
+ _internalSys: sysSchema,
53
+ })
54
+
55
+ const astNode = schemaJson as G.DocumentNode
56
+ const astNodeWithMeta: G.DocumentNode = {
57
+ ...astNode,
58
+ definitions: astNode.definitions.map((def) => {
59
+ if (def.kind === 'ObjectTypeDefinition') {
60
+ return {
61
+ ...def,
62
+ fields: [
63
+ ...(def.fields || []),
64
+ {
65
+ kind: 'FieldDefinition',
66
+ name: {
67
+ kind: 'Name',
68
+ value: '_tina_metadata',
69
+ },
70
+ arguments: [],
71
+ type: {
72
+ kind: 'NonNullType',
73
+ type: {
74
+ kind: 'NamedType',
75
+ name: {
76
+ kind: 'Name',
77
+ value: 'JSON',
78
+ },
79
+ },
80
+ },
81
+ },
82
+ ],
83
+ }
84
+ }
85
+ return def
86
+ }),
87
+ }
88
+ const schema = G.buildASTSchema(astNode)
89
+ const schemaForResolver = G.buildASTSchema(astNodeWithMeta)
90
+
91
+ export const useGraphQLReducer = (
92
+ iframe: React.MutableRefObject<HTMLIFrameElement>,
93
+ url: string
94
+ ) => {
95
+ const cms = useCMS()
96
+ const tinaSchema = cms.api.tina.schema as TinaSchema
97
+ const [payloads, setPayloads] = React.useState<Payload[]>([])
98
+ const [documentsToResolve, setDocumentsToResolve] = React.useState<string[]>(
99
+ []
100
+ )
101
+ const [resolvedDocuments, setResolvedDocuments] = React.useState<
102
+ ResolvedDocument[]
103
+ >([])
104
+ const [operationIndex, setOperationIndex] = React.useState(0)
105
+
106
+ React.useEffect(() => {
107
+ const run = async () => {
108
+ return Promise.all(
109
+ documentsToResolve.map(async (documentId) => {
110
+ return await getDocument(documentId, cms.api.tina)
111
+ })
112
+ )
113
+ }
114
+ if (documentsToResolve.length) {
115
+ run().then((docs) => {
116
+ setResolvedDocuments((resolvedDocs) => [...resolvedDocs, ...docs])
117
+ setDocumentsToResolve([])
118
+ setOperationIndex((i) => i + 1)
119
+ })
120
+ }
121
+ }, [documentsToResolve.join('.')])
122
+
123
+ /**
124
+ * Note: since React runs effects twice in development this will run twice for a given query
125
+ * which results in duplicate network requests in quick succession
126
+ */
127
+ React.useEffect(() => {
128
+ const run = async () => {
129
+ return Promise.all(
130
+ payloads.map(async (payload) => {
131
+ // This payload has already been expanded, skip it.
132
+ if (payload.expandedQuery) {
133
+ return payload
134
+ } else {
135
+ const expandedPayload = await expandPayload(payload, cms)
136
+ processPayload(expandedPayload)
137
+ return expandedPayload
138
+ }
139
+ })
140
+ )
141
+ }
142
+ if (payloads.length) {
143
+ run().then((updatedPayloads) => {
144
+ setPayloads(updatedPayloads)
145
+ })
146
+ }
147
+ }, [payloads.map(({ id }) => id).join('.'), cms])
148
+
149
+ const processPayload = React.useCallback(
150
+ (payload: Payload) => {
151
+ const { expandedQueryForResolver, variables, expandedData } = payload
152
+ if (!expandedQueryForResolver || !expandedData) {
153
+ throw new Error(`Unable to process payload which has not been expanded`)
154
+ }
155
+
156
+ const result = G.graphqlSync({
157
+ schema: schemaForResolver,
158
+ source: expandedQueryForResolver,
159
+ variableValues: variables,
160
+ rootValue: expandedData,
161
+ fieldResolver: (source, args, context, info) => {
162
+ const fieldName = info.fieldName
163
+ /**
164
+ * Since the `source` for this resolver is the query that
165
+ * ran before passing it into `useTina`, we need to take aliases
166
+ * into consideration, so if an alias is provided we try to
167
+ * see if that has the value we're looking for. This isn't a perfect
168
+ * solution as the `value` gets overwritten depending on the alias
169
+ * query.
170
+ */
171
+ const aliases: string[] = []
172
+ info.fieldNodes.forEach((fieldNode) => {
173
+ if (fieldNode.alias) {
174
+ aliases.push(fieldNode.alias.value)
175
+ }
176
+ })
177
+ let value = source[fieldName] as unknown
178
+ if (!value) {
179
+ aliases.forEach((alias) => {
180
+ const aliasValue = source[alias]
181
+ if (aliasValue) {
182
+ value = aliasValue
183
+ }
184
+ })
185
+ }
186
+ if (fieldName === '_sys') {
187
+ return source._internalSys
188
+ }
189
+ if (fieldName === '_values') {
190
+ return source._internalValues
191
+ }
192
+ if (info.fieldName === '_tina_metadata') {
193
+ if (value) {
194
+ return value
195
+ }
196
+ // TODO: ensure all fields that have _tina_metadata
197
+ // actually need it
198
+ return {
199
+ id: null,
200
+ fields: [],
201
+ }
202
+ }
203
+ if (isNodeType(info.returnType)) {
204
+ if (!value) {
205
+ return
206
+ }
207
+ let resolvedDocument: ResolvedDocument
208
+ // This is a reference from another form
209
+ if (typeof value === 'string') {
210
+ const valueFromSetup = getIn(
211
+ expandedData,
212
+ G.responsePathAsArray(info.path).join('.')
213
+ )
214
+ const maybeResolvedDocument = resolvedDocuments.find(
215
+ (doc) => doc._internalSys.path === value
216
+ )
217
+ // If we already have this document, use it.
218
+ if (maybeResolvedDocument) {
219
+ resolvedDocument = maybeResolvedDocument
220
+ } else if (valueFromSetup) {
221
+ // Else, even though in this context the value is a string because it's
222
+ // resolved from a parent form, if the reference hasn't changed
223
+ // from when we ran the setup query, we can avoid a data fetch
224
+ // here and just grab it from the response
225
+ const maybeResolvedDocument =
226
+ documentSchema.parse(valueFromSetup)
227
+ if (maybeResolvedDocument._internalSys.path === value) {
228
+ resolvedDocument = maybeResolvedDocument
229
+ } else {
230
+ throw new NoFormError(`No form found`, value)
231
+ }
232
+ } else {
233
+ throw new NoFormError(`No form found`, value)
234
+ }
235
+ } else {
236
+ resolvedDocument = documentSchema.parse(value)
237
+ }
238
+ const id = resolvedDocument._internalSys.path
239
+ let existingForm = cms.forms.find(id)
240
+ if (!existingForm) {
241
+ cms.plugins
242
+ .getType('screen')
243
+ .all()
244
+ .forEach((plugin) => {
245
+ // @ts-ignore
246
+ if (plugin?.form && plugin.form?.id === id) {
247
+ // @ts-ignore
248
+ existingForm = plugin.form
249
+ }
250
+ })
251
+ }
252
+ if (!existingForm) {
253
+ const { form, template } = buildForm({
254
+ resolvedDocument,
255
+ tinaSchema,
256
+ payloadId: payload.id,
257
+ cms,
258
+ })
259
+ form.subscribe(
260
+ () => {
261
+ setOperationIndex((i) => i + 1)
262
+ },
263
+ { values: true }
264
+ )
265
+ return resolveDocument(resolvedDocument, template, form)
266
+ } else {
267
+ existingForm.addQuery(payload.id)
268
+ const { template } = getTemplateForDocument(
269
+ resolvedDocument,
270
+ tinaSchema
271
+ )
272
+ existingForm.addQuery(payload.id)
273
+ return resolveDocument(resolvedDocument, template, existingForm)
274
+ }
275
+ }
276
+ return value
277
+ },
278
+ })
279
+ if (result.errors) {
280
+ result.errors.forEach((error) => {
281
+ if (
282
+ error instanceof G.GraphQLError &&
283
+ error.originalError instanceof NoFormError
284
+ ) {
285
+ const id = error.originalError.id
286
+ setDocumentsToResolve((docs) => [
287
+ ...docs.filter((doc) => doc !== id),
288
+ id,
289
+ ])
290
+ } else {
291
+ console.log(error)
292
+ // throw new Error(
293
+ // `Error processing value change, please contact support`
294
+ // )
295
+ }
296
+ })
297
+ } else {
298
+ iframe.current?.contentWindow?.postMessage({
299
+ type: 'updateData',
300
+ id: payload.id,
301
+ data: result.data,
302
+ })
303
+
304
+ // This can be improved, for now we just need something to test with
305
+ const elements =
306
+ iframe.current?.contentWindow?.document.querySelectorAll<HTMLElement>(
307
+ `[data-tinafield]`
308
+ )
309
+ if (elements) {
310
+ for (let i = 0; i < elements.length; i++) {
311
+ const el = elements[i]
312
+ el.onclick = () => {
313
+ const tinafield = el.getAttribute('data-tinafield')
314
+ cms.events.dispatch({
315
+ type: 'field:selected',
316
+ value: tinafield,
317
+ })
318
+ }
319
+ }
320
+ }
321
+ }
322
+ },
323
+ [resolvedDocuments.map((doc) => doc._internalSys.path).join('.')]
324
+ )
325
+
326
+ const notifyEditMode = React.useCallback(
327
+ (event: MessageEvent<PostMessage>) => {
328
+ if (event?.data?.type === 'isEditMode') {
329
+ iframe?.current?.contentWindow?.postMessage({
330
+ type: 'tina:editMode',
331
+ })
332
+ }
333
+ },
334
+ []
335
+ )
336
+ const handleOpenClose = React.useCallback(
337
+ (event: MessageEvent<PostMessage>) => {
338
+ if (event.data.type === 'close') {
339
+ const payloadSchema = z.object({ id: z.string() })
340
+ const { id } = payloadSchema.parse(event.data)
341
+ setPayloads((previous) =>
342
+ previous.filter((payload) => payload.id !== id)
343
+ )
344
+ cms.forms.all().map((form) => {
345
+ form.removeQuery(id)
346
+ })
347
+ cms.removeOrphanedForms()
348
+ }
349
+ if (event.data.type === 'open') {
350
+ const payloadSchema = z.object({
351
+ id: z.string(),
352
+ query: z.string(),
353
+ variables: z.record(z.unknown()),
354
+ data: z.record(z.unknown()),
355
+ })
356
+ const payload = payloadSchema.parse(event.data)
357
+ setPayloads((payloads) => [
358
+ ...payloads.filter(({ id }) => id !== payload.id),
359
+ payload,
360
+ ])
361
+ }
362
+ },
363
+ [cms]
364
+ )
365
+
366
+ React.useEffect(() => {
367
+ payloads.forEach((payload) => {
368
+ if (payload.expandedData) {
369
+ processPayload(payload)
370
+ }
371
+ })
372
+ }, [operationIndex])
373
+
374
+ React.useEffect(() => {
375
+ return () => {
376
+ setPayloads([])
377
+ cms.removeAllForms()
378
+ }
379
+ }, [url])
380
+
381
+ React.useEffect(() => {
382
+ if (iframe) {
383
+ window.addEventListener('message', handleOpenClose)
384
+ window.addEventListener('message', notifyEditMode)
385
+ }
386
+
387
+ return () => {
388
+ window.removeEventListener('message', handleOpenClose)
389
+ window.removeEventListener('message', notifyEditMode)
390
+ cms.removeAllForms()
391
+ }
392
+ }, [iframe.current])
393
+ }
394
+
395
+ const onSubmit = async (
396
+ collection: Collection<true>,
397
+ relativePath: string,
398
+ payload: Record<string, unknown>,
399
+ cms: TinaCMS
400
+ ) => {
401
+ const tinaSchema = cms.api.tina.schema
402
+ try {
403
+ const mutationString = `#graphql
404
+ mutation UpdateDocument($collection: String!, $relativePath: String!, $params: DocumentUpdateMutation!) {
405
+ updateDocument(collection: $collection, relativePath: $relativePath, params: $params) {
406
+ __typename
407
+ }
408
+ }
409
+ `
410
+
411
+ await cms.api.tina.request(mutationString, {
412
+ variables: {
413
+ collection: collection.name,
414
+ relativePath: relativePath,
415
+ params: tinaSchema.transformPayload(collection.name, payload),
416
+ },
417
+ })
418
+ cms.alerts.success('Document saved!')
419
+ } catch (e) {
420
+ cms.alerts.error('There was a problem saving your document')
421
+ console.error(e)
422
+ }
423
+ }
424
+
425
+ type Path = (string | number)[]
426
+
427
+ const resolveDocument = (
428
+ doc: ResolvedDocument,
429
+ template: Template<true>,
430
+ form: Form
431
+ ): ResolvedDocument => {
432
+ // @ts-ignore AnyField and TinaField don't mix
433
+ const fields = form.fields as TinaField<true>[]
434
+ const id = doc._internalSys.path
435
+ const path: Path = []
436
+ const formValues = resolveFormValue({
437
+ fields: fields,
438
+ values: form.values,
439
+ path,
440
+ id,
441
+ })
442
+ const metadataFields: Record<string, string> = {}
443
+ Object.keys(formValues).forEach((key) => {
444
+ metadataFields[key] = [...path, key].join('.')
445
+ })
446
+
447
+ return {
448
+ ...formValues,
449
+ id,
450
+ sys: doc._internalSys,
451
+ values: form.values,
452
+ _tina_metadata: {
453
+ id: doc._internalSys.path,
454
+ fields: metadataFields,
455
+ },
456
+ _internalSys: doc._internalSys,
457
+ _internalValues: doc._internalValues,
458
+ __typename: NAMER.dataTypeName(template.namespace),
459
+ }
460
+ }
461
+
462
+ const resolveFormValue = <T extends Record<string, unknown>>({
463
+ fields,
464
+ values,
465
+ path,
466
+ id,
467
+ }: // tinaSchema,
468
+ {
469
+ fields: TinaField<true>[]
470
+ values: T
471
+ path: Path
472
+ id: string
473
+ // tinaSchema: TinaSchema
474
+ }): T & { __typename?: string } => {
475
+ const accum: Record<string, unknown> = {}
476
+ fields.forEach((field) => {
477
+ const v = values[field.name]
478
+ if (typeof v === 'undefined') {
479
+ return
480
+ }
481
+ if (v === null) {
482
+ return
483
+ }
484
+ accum[field.name] = resolveFieldValue({
485
+ field,
486
+ value: v,
487
+ path,
488
+ id,
489
+ })
490
+ })
491
+ return accum as T & { __typename?: string }
492
+ }
493
+ const resolveFieldValue = ({
494
+ field,
495
+ value,
496
+ path,
497
+ id,
498
+ }: {
499
+ field: TinaField<true>
500
+ value: unknown
501
+ path: Path
502
+ id: string
503
+ }) => {
504
+ switch (field.type) {
505
+ case 'object': {
506
+ if (field.templates) {
507
+ if (field.list) {
508
+ if (Array.isArray(value)) {
509
+ return value.map((item, index) => {
510
+ const template = field.templates[item._template]
511
+ if (typeof template === 'string') {
512
+ throw new Error('Global templates not supported')
513
+ }
514
+ const nextPath = [...path, field.name, index]
515
+ const metadataFields: Record<string, string> = {}
516
+ template.fields.forEach((field) => {
517
+ metadataFields[field.name] = [...nextPath, field.name].join('.')
518
+ })
519
+ return {
520
+ __typename: NAMER.dataTypeName(template.namespace),
521
+ _tina_metadata: {
522
+ id,
523
+ fields: metadataFields,
524
+ },
525
+ ...resolveFormValue({
526
+ fields: template.fields,
527
+ values: item,
528
+ path: nextPath,
529
+ id,
530
+ }),
531
+ }
532
+ })
533
+ }
534
+ } else {
535
+ // not implemented
536
+ }
537
+ }
538
+
539
+ const templateFields = field.fields
540
+ if (typeof templateFields === 'string') {
541
+ throw new Error('Global templates not supported')
542
+ }
543
+ if (!templateFields) {
544
+ throw new Error(`Expected to find sub-fields on field ${field.name}`)
545
+ }
546
+ if (field.list) {
547
+ if (Array.isArray(value)) {
548
+ return value.map((item, index) => {
549
+ const nextPath = [...path, field.name, index]
550
+ const metadataFields: Record<string, string> = {}
551
+ templateFields.forEach((field) => {
552
+ metadataFields[field.name] = [...nextPath, field.name].join('.')
553
+ })
554
+ return {
555
+ __typename: NAMER.dataTypeName(field.namespace),
556
+ _tina_metadata: {
557
+ id,
558
+ fields: metadataFields,
559
+ },
560
+ ...resolveFormValue({
561
+ fields: templateFields,
562
+ values: item,
563
+ path,
564
+ id,
565
+ }),
566
+ }
567
+ })
568
+ }
569
+ } else {
570
+ const nextPath = [...path, field.name]
571
+ const metadataFields: Record<string, string> = {}
572
+ templateFields.forEach((field) => {
573
+ metadataFields[field.name] = [...nextPath, field.name].join('.')
574
+ })
575
+ return {
576
+ __typename: NAMER.dataTypeName(field.namespace),
577
+ _tina_metadata: {
578
+ id,
579
+ fields: metadataFields,
580
+ },
581
+ ...resolveFormValue({
582
+ fields: templateFields,
583
+ values: value as any,
584
+ path,
585
+ id,
586
+ }),
587
+ }
588
+ }
589
+ }
590
+ default: {
591
+ return value
592
+ }
593
+ }
594
+ }
595
+
596
+ const getDocument = async (id: string, tina: Client) => {
597
+ const response = await tina.request<{
598
+ node: { _internalSys: SystemInfo; _internalValues: Record<string, unknown> }
599
+ }>(
600
+ `query GetNode($id: String!) {
601
+ node(id: $id) {
602
+ ...on Document {
603
+ _internalValues: _values
604
+ _internalSys: _sys {
605
+ breadcrumbs
606
+ basename
607
+ filename
608
+ path
609
+ extension
610
+ relativePath
611
+ title
612
+ template
613
+ collection {
614
+ name
615
+ slug
616
+ label
617
+ path
618
+ format
619
+ matches
620
+ templates
621
+ fields
622
+ __typename
623
+ }
624
+ __typename
625
+ }
626
+ }
627
+ }
628
+ }`,
629
+ { variables: { id: id } }
630
+ )
631
+ return response.node
632
+ }
633
+
634
+ const expandPayload = async (payload: Payload, cms: TinaCMS) => {
635
+ const { query, variables } = payload
636
+ const documentNode = G.parse(query)
637
+ const expandedDocumentNode = expandQuery({ schema, documentNode })
638
+ const expandedQuery = G.print(expandedDocumentNode)
639
+ const expandedData = await cms.api.tina.request(expandedQuery, {
640
+ variables,
641
+ })
642
+
643
+ const expandedDocumentNodeForResolver = expandQuery({
644
+ schema: schemaForResolver,
645
+ documentNode,
646
+ })
647
+ const expandedQueryForResolver = G.print(expandedDocumentNodeForResolver)
648
+ return { ...payload, expandQuery, expandedData, expandedQueryForResolver }
649
+ }
650
+
651
+ /**
652
+ * When we resolve the graphql data we check for these errors,
653
+ * if we find one we enqueue the document to be generated, and then
654
+ * process it once we have that document
655
+ */
656
+ class NoFormError extends Error {
657
+ id: string
658
+ constructor(msg: string, id: string) {
659
+ super(msg)
660
+ this.id = id
661
+ Object.setPrototypeOf(this, NoFormError.prototype)
662
+ }
663
+ }
664
+
665
+ const getTemplateForDocument = (
666
+ resolvedDocument: ResolvedDocument,
667
+ tinaSchema: TinaSchema
668
+ ) => {
669
+ const id = resolvedDocument._internalSys.path
670
+ const collection = tinaSchema.getCollectionByFullPath(id)
671
+ if (!collection) {
672
+ throw new Error(`Unable to determine collection for path ${id}`)
673
+ }
674
+
675
+ const template = tinaSchema.getTemplateForData({
676
+ data: resolvedDocument._internalValues,
677
+ collection,
678
+ })
679
+ return { template, collection }
680
+ }
681
+
682
+ const buildForm = ({
683
+ resolvedDocument,
684
+ tinaSchema,
685
+ payloadId,
686
+ cms,
687
+ }: {
688
+ resolvedDocument: ResolvedDocument
689
+ tinaSchema: TinaSchema
690
+ payloadId: string
691
+ cms: TinaCMS
692
+ }) => {
693
+ const { template, collection } = getTemplateForDocument(
694
+ resolvedDocument,
695
+ tinaSchema
696
+ )
697
+ const id = resolvedDocument._internalSys.path
698
+ let form: Form | undefined
699
+ let shouldRegisterForm = true
700
+ const formConfig: FormOptions<any> = {
701
+ id,
702
+ initialValues: resolvedDocument._internalValues,
703
+ fields: template.fields.map((field) => resolveField(field, tinaSchema)),
704
+ onSubmit: (payload) =>
705
+ onSubmit(
706
+ collection,
707
+ resolvedDocument._internalSys.relativePath,
708
+ payload,
709
+ cms
710
+ ),
711
+ label: collection.label || collection.name,
712
+ }
713
+ if (tinaSchema.config.config?.formifyCallback) {
714
+ const callback = tinaSchema.config.config
715
+ ?.formifyCallback as FormifyCallback
716
+ form =
717
+ callback(
718
+ {
719
+ createForm: createForm,
720
+ createGlobalForm: createGlobalForm,
721
+ skip: () => {},
722
+ formConfig,
723
+ },
724
+ cms
725
+ ) || undefined
726
+ if (!form) {
727
+ // If the form isn't created from formify, we still
728
+ // need it, just don't show it to the user.
729
+ shouldRegisterForm = false
730
+ form = new Form(formConfig)
731
+ }
732
+ } else {
733
+ if (collection.ui?.global) {
734
+ form = createGlobalForm(formConfig)
735
+ } else {
736
+ form = createForm(formConfig)
737
+ }
738
+ }
739
+ if (form) {
740
+ if (shouldRegisterForm) {
741
+ form.subscribe(() => {}, { values: true })
742
+ if (collection.ui?.global) {
743
+ cms.plugins.add(new GlobalFormPlugin(form))
744
+ } else {
745
+ cms.forms.add(form)
746
+ }
747
+ }
748
+ }
749
+ if (!form) {
750
+ throw new Error(`No form registered for ${id}.`)
751
+ }
752
+ return { template, form }
753
+ }