@tinacms/app 0.0.22 → 0.0.24

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,630 @@
1
+ /**
2
+ Copyright 2021 Forestry.io Holdings, Inc.
3
+ Licensed under the Apache License, Version 2.0 (the "License");
4
+ you may not use this file except in compliance with the License.
5
+ You may obtain a copy of the License at
6
+ http://www.apache.org/licenses/LICENSE-2.0
7
+ Unless required by applicable law or agreed to in writing, software
8
+ distributed under the License is distributed on an "AS IS" BASIS,
9
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
10
+ See the License for the specific language governing permissions and
11
+ limitations under the License.
12
+ */
13
+ import { assign, ContextFrom, createMachine, spawn } from 'xstate'
14
+ import {
15
+ Form,
16
+ TinaCMS,
17
+ NAMER,
18
+ Template,
19
+ TinaFieldEnriched,
20
+ TinaCollection,
21
+ TinaSchema,
22
+ GlobalFormPlugin,
23
+ Client,
24
+ } from 'tinacms'
25
+ import * as G from 'graphql'
26
+ import { formify } from '../formify'
27
+ import { documentMachine, FieldType, FormValues } from './document-machine'
28
+ import type { ActorRefFrom } from 'xstate'
29
+ import { Blueprint2 } from '../formify'
30
+
31
+ export type DataType = Record<string, unknown>
32
+ type DocumentInfo = {
33
+ ref: ActorRefFrom<typeof documentMachine>
34
+ }
35
+ type DocumentMap = {
36
+ [documentId: string]: DocumentInfo & {
37
+ /** We don't support nested forms or forms for list queries */
38
+ skipFormRegister: boolean
39
+ }
40
+ }
41
+
42
+ type ContextType = {
43
+ id: null | string
44
+ data: null | DataType
45
+ cms: TinaCMS
46
+ selectedDocument: string | null
47
+ iframe: null | HTMLIFrameElement
48
+ formifyCallback: (args: any) => Form
49
+ documentMap: DocumentMap
50
+ blueprints: Blueprint2[]
51
+ }
52
+ export const initialContext: Omit<ContextType, 'cms' | 'formifyCallback'> = {
53
+ id: null,
54
+ data: null,
55
+ selectedDocument: null,
56
+ blueprints: [],
57
+ documentMap: {},
58
+ iframe: null,
59
+ }
60
+ export const queryMachine =
61
+ /** @xstate-layout N4IgpgJg5mDOIC5QAoC2BDAxgCwJYDswBKAOgFcAnAGxNwirAGIBlAUQBUB9AVQCUAZRKAAOAe1i4ALrlH4hIAB6IAjAAYALCXXqAnACZVAVmUAOAGxmAzMp1mANCACeidXpMkdnnSZvLDh1X11AF9ghzQsPEJSSho6BhYOTgBJADkABW4uPkEkEDEJaVl5JQQAdh0SMtVqvTqassN1M0MHZwRLdWUPLx8dPwCg0PCMHAJicmpaeiY2LgARZOZ0-gBBAE0eAXkCqRk5PNKyvRJVM-OL8+U2xD0mns8+gcC9ELCQCLHoybiZxm50vNVuxWFtciJxHtiodECZKqoLOpVMpjiYTGUyp0bghlGYyg9vL5-C83iNIuNSMJcMIwFRxtMEqt5vNOABFbisXjrHaQooHUBHPSWEiGSw6VSWUWYvRWHTYsro07nSzIpE+CrDD6jKITKk0umEBmzbgAIXmAHkAMLcACyrFS7GYPMK+xKiGOwtF4sllmlsuxOlcIquJlUJm04pMms+Osp1Np9PiTGSADFeKs7ZwbebuA7WPNnVD+YpEKKzCKFWG0dZkSZWk5YRKlWdDMc8f5SVryd89QnDQQ9ug6QAvAhQRgQWRgWj4ABuogA1tOYxSSL2DdOB9Ih7hR-goAgCPPMOg+QBtVQAXULfLdOLU+Neej8yksNdM9faCs0l3FrleorRtqq7rom+CDiOY6MGAFAUKIFBrlQp4AGbwagJArj28YbjOEG7mOh5zqIJ7nleN6ujC941CQT4vm+agftiZg6Piv4aHU6iAe8mG6th9IAO7oHs+6MBa1p2g6nC8KwTLcnkuy3pRygPjRrh0e+dbYspujNmcf4cU0QHdrx+r0jS+AQFBk79kRS4YcBWGmYa5mWfuhHHqe+wXte8m8hRAoqKYmiSn4Mr0bWn4qJYipeJ4gSto0zRGV8Jl9tOLlQTBcEIcISGSKhFDoTxcZOelYAWQRR7EZ5sjeeR0IBVRj5qcY4WMQ2OIIicbH-pxnbFWufGGhQYDoBAjj-ICwKgjk9XFqUlh4iQnS+kiOgJU09gdfo5YXCqyhqiiOjJbGg2lSQI1jRN0nZgAaqC7KcnJEIug1JYdEtK1lGtG3NNi6LuBc6gmHUophmUJ0gUN06XeNiT8KwloLFatr2uwc13sphgijYooJcpdRmCYWnKeWsXiutGKbZDjlpRdo1wymySsPwLKWgAEqsqQAOKsBjlGLfiX0-VTf0dcoMruIYoTvPgogQHA8gDbERr841ynuDK63WHclyWNihgGASTzEvoNMTLEavvcDumXBcB0Btj5PxaLZh6ObJV00mVulE02Nu7oROSs+XR6P9626a2MqNIZ3EOalOFbrgO57lAPuIH+VQ+M+0UaZF5SGLt5xR+2sdkilns4YJwlp75r3zRn3inDovoaAq6LSiTb4kCYb4qvoBh4sdcfGZXZnla5tcvUWmO+t0am99WDGaeL1juAqraGCDRg2O7I8V2ddOw+00+KY1GJaQY7jO5TiVmB7h84Vl8Hpzic9aM+i+98v+dlGoBIU1+vffep1QKEFfqKJidxdKmDDBGBEHtX6vFtnbM4DsOpCgAeTXowDQhAA */
62
+ createMachine(
63
+ {
64
+ tsTypes: {} as import('./query-machine.typegen').Typegen0,
65
+ schema: {
66
+ context: {} as ContextType,
67
+ services: {} as {
68
+ initializer: {
69
+ data: {
70
+ data: DataType
71
+ blueprints: Blueprint2[]
72
+ }
73
+ }
74
+ setter: {
75
+ data: { data: DataType }
76
+ }
77
+ subDocumentResolver: {
78
+ data: {
79
+ id: string
80
+ location: string
81
+ }[]
82
+ }
83
+ onChangeCallback: {
84
+ data: undefined
85
+ }
86
+ },
87
+ events: {} as
88
+ | {
89
+ type: 'IFRAME_MOUNTED'
90
+ value: HTMLIFrameElement
91
+ }
92
+ | {
93
+ type: 'SELECT_DOCUMENT'
94
+ value: string
95
+ }
96
+ | {
97
+ type: 'DOCUMENT_READY'
98
+ value: string
99
+ }
100
+ | {
101
+ type: 'NAVIGATE'
102
+ }
103
+ | {
104
+ type: 'ADD_QUERY'
105
+ value: {
106
+ id: string
107
+ type: 'open' | 'close'
108
+ query: string
109
+ data: object
110
+ variables: object
111
+ }
112
+ }
113
+ | {
114
+ type: 'REMOVE_QUERY'
115
+ value: string
116
+ }
117
+ | {
118
+ type: 'SUBDOCUMENTS'
119
+ value: { id: string; location: string }[]
120
+ }
121
+ | {
122
+ type: 'FIELD_CHANGE'
123
+ },
124
+ },
125
+ id: '(machine)',
126
+ type: 'parallel',
127
+ states: {
128
+ pipeline: {
129
+ initial: 'idle',
130
+ states: {
131
+ idle: {
132
+ entry: 'clear',
133
+ on: {
134
+ ADD_QUERY: {
135
+ target: 'initializing',
136
+ },
137
+ SUBDOCUMENTS: {
138
+ target: 'pending',
139
+ },
140
+ IFRAME_MOUNTED: {
141
+ actions: 'setIframe',
142
+ },
143
+ },
144
+ },
145
+ initializing: {
146
+ invoke: {
147
+ src: 'initializer',
148
+ onDone: [
149
+ {
150
+ actions: 'storeInitialValues',
151
+ target: 'pending',
152
+ },
153
+ ],
154
+ onError: [
155
+ {
156
+ actions: 'handleError',
157
+ target: 'error',
158
+ },
159
+ ],
160
+ },
161
+ },
162
+ waiting: {
163
+ on: {
164
+ DOCUMENT_READY: {
165
+ target: 'pending',
166
+ },
167
+ },
168
+ },
169
+ pending: {
170
+ invoke: {
171
+ src: 'setter',
172
+ onDone: [
173
+ {
174
+ target: 'ready',
175
+ },
176
+ ],
177
+ onError: [
178
+ {
179
+ actions: 'handleMissingDocument',
180
+ target: 'waiting',
181
+ },
182
+ ],
183
+ },
184
+ },
185
+ ready: {
186
+ entry: 'resolveData',
187
+ invoke: {
188
+ src: 'onChangeCallback',
189
+ },
190
+ on: {
191
+ NAVIGATE: {
192
+ target: 'idle',
193
+ },
194
+ REMOVE_QUERY: {
195
+ target: 'idle',
196
+ },
197
+ SELECT_DOCUMENT: {
198
+ actions: 'selectDocument',
199
+ },
200
+ FIELD_CHANGE: {
201
+ target: 'pending',
202
+ },
203
+ },
204
+ },
205
+ error: {},
206
+ },
207
+ },
208
+ },
209
+ },
210
+ {
211
+ actions: {
212
+ handleError: (_context, event) => console.error(event.data),
213
+ handleMissingDocument: assign((context, event) => {
214
+ count = count + 1
215
+ if (count > 50) {
216
+ throw new Error('infinite loop')
217
+ }
218
+ if (event.data instanceof QueryError) {
219
+ if (context.documentMap[event.data.id]) {
220
+ // Already exists
221
+ return context
222
+ }
223
+ if (!event.data.id) {
224
+ return context
225
+ }
226
+ const doc = {
227
+ ref: spawn(
228
+ documentMachine.withContext({
229
+ id: event.data.id,
230
+ cms: context.cms,
231
+ formifyCallback: context.formifyCallback,
232
+ form: null,
233
+ data: null,
234
+ })
235
+ ),
236
+ }
237
+
238
+ return {
239
+ ...context,
240
+ documentMap: {
241
+ ...context.documentMap,
242
+ [event.data.id]: {
243
+ ...doc,
244
+ skipFormRegister: event.data.skipFormRegister,
245
+ },
246
+ },
247
+ }
248
+ } else {
249
+ console.error(event.data)
250
+ return context
251
+ }
252
+ }),
253
+ clear: assign((context) => {
254
+ context.cms.forms.all().forEach((form) => {
255
+ context.cms.forms.remove(form.id)
256
+ })
257
+ return {
258
+ ...initialContext,
259
+ formifyCallback: context.formifyCallback,
260
+ cms: context.cms,
261
+ // documentMap: context.documentMap, // to preserve docs across pages
262
+ iframe: context.iframe,
263
+ }
264
+ }),
265
+ storeInitialValues: assign((context, event) => {
266
+ return {
267
+ ...context,
268
+ ...event.data,
269
+ }
270
+ }),
271
+ selectDocument: assign((context, event) => {
272
+ return {
273
+ ...context,
274
+ selectedDocument: event.value,
275
+ }
276
+ }),
277
+ setIframe: assign((context, event) => {
278
+ return {
279
+ ...context,
280
+ iframe: event.value,
281
+ }
282
+ }),
283
+ resolveData: assign((context, event) => {
284
+ if (context.iframe) {
285
+ context.iframe?.contentWindow?.postMessage({
286
+ type: 'updateData',
287
+ id: context.id,
288
+ data: event.data.data,
289
+ })
290
+ }
291
+ return {
292
+ ...context,
293
+ data: event.data.data,
294
+ }
295
+ }),
296
+ },
297
+ services: {
298
+ setter: async (context) => {
299
+ const walk = (obj: unknown, path: string[] = []) => {
300
+ const accum: Record<string, unknown> = {}
301
+ if (isScalar(obj)) {
302
+ return obj
303
+ }
304
+ Object.entries(obj as object).map(([key, value]) => {
305
+ if (Array.isArray(value)) {
306
+ accum[key] = value.map((item) => walk(item, [...path, key]))
307
+ } else {
308
+ const blueprint = context.blueprints.find(
309
+ (bp) => bp.path?.join('.') === [...path, key].join('.')
310
+ )
311
+ if (blueprint) {
312
+ accum[key] = setData(value, blueprint, context)
313
+ } else {
314
+ accum[key] = walk(value, [...path, key])
315
+ }
316
+ }
317
+ })
318
+ return accum
319
+ }
320
+ const accum = walk(context.data)
321
+ return { data: accum }
322
+ },
323
+ initializer: async (context, event) => {
324
+ const tina = context.cms.api.tina as Client
325
+ const schema = await tina.getSchema()
326
+ const documentNode = G.parse(event.value.query)
327
+ const optimizedQuery = await tina.getOptimizedQuery(documentNode)
328
+ if (!optimizedQuery) {
329
+ throw new Error(`Unable to optimize query`)
330
+ }
331
+ const { blueprints, formifiedQuery } = await formify({
332
+ schema,
333
+ optimizedDocumentNode: optimizedQuery,
334
+ })
335
+ const data = (await context.cms.api.tina.request(
336
+ G.print(formifiedQuery),
337
+ {
338
+ variables: event.value.variables,
339
+ }
340
+ )) as DataType
341
+ return {
342
+ data,
343
+ blueprints,
344
+ id: event.value.id,
345
+ }
346
+ },
347
+ onChangeCallback: (context) => (callback, _onReceive) => {
348
+ const schema = context.cms.api.tina.schema as TinaSchema
349
+ Object.values(context.documentMap).forEach((documentMachine) => {
350
+ if (documentMachine.skipFormRegister) {
351
+ return
352
+ }
353
+ const documentContext = documentMachine.ref.getSnapshot()?.context
354
+ const collectionName =
355
+ documentContext?.data?._internalSys.collection.name
356
+
357
+ const collection = schema.getCollection(
358
+ collectionName || ''
359
+ ) as TinaCollection
360
+ if (documentContext?.form) {
361
+ if (collection.ui?.global) {
362
+ context.cms.plugins.add(
363
+ new GlobalFormPlugin(documentContext.form)
364
+ )
365
+ } else {
366
+ context.cms.forms.add(documentContext.form)
367
+ }
368
+ }
369
+ })
370
+ if (context.cms) {
371
+ context.cms.events.subscribe(`forms:fields:onChange`, () => {
372
+ callback({ type: 'FIELD_CHANGE' })
373
+ })
374
+ context.cms.events.subscribe(`forms:reset`, () => {
375
+ callback({ type: 'FIELD_CHANGE' })
376
+ })
377
+ }
378
+ },
379
+ },
380
+ }
381
+ )
382
+ class QueryError extends Error {
383
+ public id: string
384
+ public skipFormRegister: boolean
385
+ constructor(message: string, id: string, skipFormRegister: boolean) {
386
+ super(message) // (1)
387
+ this.name = 'QueryError' // (2)
388
+ this.id = id
389
+ this.skipFormRegister = skipFormRegister
390
+ }
391
+ }
392
+ let count = 0
393
+
394
+ // https://github.com/oleics/node-is-scalar/blob/master/index.js
395
+ const withSymbol = typeof Symbol !== 'undefined'
396
+ function isScalar(value: unknown) {
397
+ const type = typeof value
398
+ if (type === 'string') return true
399
+ if (type === 'number') return true
400
+ if (type === 'boolean') return true
401
+ if (withSymbol === true && type === 'symbol') return true
402
+
403
+ if (value == null) return true
404
+ if (withSymbol === true && value instanceof Symbol) return true
405
+ if (value instanceof String) return true
406
+ if (value instanceof Number) return true
407
+ if (value instanceof Boolean) return true
408
+
409
+ return false
410
+ }
411
+
412
+ const setData = (
413
+ data: { [key: string]: unknown },
414
+ blueprint: Blueprint2,
415
+ context: ContextFrom<typeof queryMachine>
416
+ ) => {
417
+ if (data?._internalSys) {
418
+ const id = data._internalSys?.path
419
+ const doc = context.documentMap[id]
420
+ const docContext = doc?.ref?.getSnapshot()?.context
421
+ const form = docContext?.form
422
+ if (!form) {
423
+ const skipFormRegiester = (blueprint.path?.length || 0) > 2
424
+ throw new QueryError(
425
+ `Unable to resolve form for initial document`,
426
+ id,
427
+ skipFormRegiester
428
+ )
429
+ }
430
+ const _internalSys = docContext.data?._internalSys
431
+ if (!_internalSys) {
432
+ throw new Error(`No system information found for document ${id}`)
433
+ }
434
+
435
+ const fields = form.fields
436
+ const result = resolveForm({
437
+ id,
438
+ fields,
439
+ sys: _internalSys,
440
+ values: form.values,
441
+ fieldsToInclude: blueprint.fields,
442
+ context,
443
+ })
444
+ return { ...docContext.data, ...result }
445
+ } else {
446
+ // this isn't a node
447
+ }
448
+ return data
449
+ }
450
+
451
+ const resolveForm = ({
452
+ id,
453
+ fields,
454
+ sys,
455
+ values,
456
+ fieldsToInclude,
457
+ context,
458
+ }: {
459
+ id: string
460
+ fields: FieldType[]
461
+ sys: Record<string, unknown>
462
+ values: FormValues | undefined
463
+ fieldsToInclude: Blueprint2['fields']
464
+ context: ContextFrom<typeof queryMachine>
465
+ }) => {
466
+ const accum: Record<string, unknown> = {}
467
+ if (!values) {
468
+ return accum
469
+ }
470
+
471
+ fieldsToInclude?.forEach((fieldToInclude) => {
472
+ const field = fields.find((field) => fieldToInclude.name === field.name)
473
+ if (!field) {
474
+ if (fieldToInclude.name === 'id') {
475
+ accum[fieldToInclude.alias] = id
476
+ } else if (fieldToInclude.name === '_sys') {
477
+ if (fieldToInclude.alias !== '_internalSys') {
478
+ const sysAccum: Record<string, unknown> = {}
479
+ // TODO: loop through these and actually use their alias values
480
+ fieldToInclude.fields?.forEach((field) => {
481
+ sysAccum[field.alias] = sys[field.name]
482
+ })
483
+ accum[fieldToInclude.alias] = sysAccum
484
+ }
485
+ } else if (fieldToInclude.name === '__typename') {
486
+ // field namespaces are one level deeper than what we need, so grab the first
487
+ // one and remove the last string on the namespace
488
+ accum[fieldToInclude.alias] = NAMER.dataTypeName(
489
+ fields[0].namespace.slice(0, fields[0].namespace.length - 1)
490
+ )
491
+ } else if (fieldToInclude.name === '_values') {
492
+ if (fieldToInclude.alias !== '_internalValues') {
493
+ accum[fieldToInclude.alias] = values
494
+ }
495
+ } else {
496
+ }
497
+ } else {
498
+ const result = resolveField({
499
+ id,
500
+ field,
501
+ sys,
502
+ value: values[field.name],
503
+ fieldsToInclude: fieldsToInclude.find(({ name }) => name === field.name)
504
+ ?.fields,
505
+ context,
506
+ })
507
+ if (result) {
508
+ accum[fieldToInclude.alias] = result
509
+ }
510
+ }
511
+ })
512
+
513
+ return accum
514
+ }
515
+ const resolveField = ({
516
+ id,
517
+ field,
518
+ sys,
519
+ value,
520
+ fieldsToInclude,
521
+ context,
522
+ }: {
523
+ id: string
524
+ field: TinaFieldEnriched
525
+ sys: Record<string, unknown>
526
+ value: unknown
527
+ fieldsToInclude: Blueprint2['fields']
528
+ context: ContextFrom<typeof queryMachine>
529
+ }) => {
530
+ switch (field.type) {
531
+ case 'reference':
532
+ if (!value) {
533
+ return
534
+ }
535
+ if (typeof value === 'string') {
536
+ const doc = context.documentMap[value]
537
+ const docContext = doc?.ref?.getSnapshot()?.context
538
+ const form = docContext?.form
539
+ if (!form) {
540
+ throw new QueryError(
541
+ `Unable to resolve form for document`,
542
+ value,
543
+ true
544
+ )
545
+ }
546
+ const _internalSys = docContext.data?._internalSys
547
+ if (!_internalSys) {
548
+ throw new Error(`No system information found for document ${id}`)
549
+ }
550
+ return resolveForm({
551
+ id: value,
552
+ fields: form.fields,
553
+ sys: _internalSys,
554
+ values: form.values,
555
+ fieldsToInclude,
556
+ context,
557
+ })
558
+ }
559
+ throw new Error(`Unexpected value for type "reference"`)
560
+ case 'object':
561
+ if (field.fields) {
562
+ if (typeof field.fields === 'string') {
563
+ throw new Error('Global templates not supported')
564
+ }
565
+ field.fields
566
+ if (field.list) {
567
+ if (Array.isArray(value)) {
568
+ return value.map((item) => {
569
+ if (typeof field.fields === 'string') {
570
+ throw new Error('Global templates not supported')
571
+ }
572
+ return resolveForm({
573
+ id,
574
+ fields: field.fields,
575
+ sys,
576
+ values: item,
577
+ fieldsToInclude,
578
+ context,
579
+ })
580
+ })
581
+ }
582
+ } else {
583
+ return resolveForm({
584
+ id,
585
+ fields: field.fields,
586
+ sys,
587
+ values: value,
588
+ fieldsToInclude,
589
+ context,
590
+ })
591
+ }
592
+ }
593
+ if (field.templates) {
594
+ if (field.list) {
595
+ if (!value) {
596
+ return
597
+ }
598
+ if (!Array.isArray(value)) {
599
+ return
600
+ }
601
+ return value.map((item) => {
602
+ let t: Template<true>
603
+ Object.entries(field.templates).forEach(([name, template]) => {
604
+ if (name === item._template) {
605
+ if (typeof template === 'string') {
606
+ throw new Error('Global templates not supported')
607
+ }
608
+ t = template
609
+ }
610
+ })
611
+ return {
612
+ _template: item._template,
613
+ ...resolveForm({
614
+ id,
615
+ fields: t.fields,
616
+ sys,
617
+ values: item,
618
+ fieldsToInclude,
619
+ context,
620
+ }),
621
+ }
622
+ })
623
+ } else {
624
+ // not supported yet
625
+ }
626
+ }
627
+ default:
628
+ return value
629
+ }
630
+ }