@tinacms/app 0.0.12 → 0.0.14

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.
@@ -1,728 +0,0 @@
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 { DocumentBlueprint } from '../formify/types'
15
- import { Form, GlobalFormPlugin, TinaCMS, TinaField } from 'tinacms'
16
- import { setIn } from 'final-form'
17
- import * as G from 'graphql'
18
- import * as util from './util'
19
- import { formify } from '../formify'
20
- import { spliceLocation } from '../formify/util'
21
- import { documentMachine } from './document-machine'
22
- import type { ActorRefFrom } from 'xstate'
23
- import { NAMER } from '@tinacms/schema-tools'
24
-
25
- export type DataType = Record<string, unknown>
26
- type DocumentInfo = {
27
- ref: ActorRefFrom<typeof documentMachine>
28
- }
29
- type DocumentMap = {
30
- [documentId: string]: DocumentInfo
31
- }
32
-
33
- type ContextType = {
34
- id: null | string
35
- data: null | DataType
36
- cms: TinaCMS
37
- selectedDocument: string | null
38
- url: string
39
- inputURL: null | string
40
- displayURL: null | string
41
- iframe: null | HTMLIFrameElement
42
- iframeWidth: string
43
- formifyCallback: (args: any) => Form
44
- documentMap: DocumentMap
45
- blueprints: DocumentBlueprint[]
46
- documentsToResolve: { id: string; location: string }[]
47
- }
48
- export const initialContext: ContextType = {
49
- id: null,
50
- data: null,
51
- selectedDocument: null,
52
- blueprints: [],
53
- // @ts-ignore
54
- cms: null,
55
- url: '/',
56
- iframeWidth: '200px',
57
- inputURL: null,
58
- displayURL: null,
59
- documentMap: {},
60
- // @ts-ignore
61
- formifyCallback: null,
62
- iframe: null,
63
- documentsToResolve: [],
64
- }
65
- export const queryMachine = createMachine(
66
- {
67
- tsTypes: {} as import('./query-machine.typegen').Typegen0,
68
- // Breaks stuff:
69
- // predictableActionArguments: true,
70
- schema: {
71
- context: {} as ContextType,
72
- services: {} as {
73
- initializer: {
74
- data: {
75
- data: DataType
76
- blueprints: DocumentBlueprint[]
77
- }
78
- }
79
- setter: {
80
- data: { data: DataType }
81
- }
82
- subDocumentResolver: {
83
- data: {
84
- id: string
85
- location: string
86
- }[]
87
- }
88
- onChangeCallback: {
89
- data: undefined
90
- }
91
- },
92
- events: {} as
93
- | {
94
- type: 'IFRAME_MOUNTED'
95
- value: HTMLIFrameElement
96
- }
97
- | {
98
- type: 'SET_URL'
99
- value: string
100
- }
101
- | {
102
- type: 'SET_INPUT_URL'
103
- value: string
104
- }
105
- | {
106
- type: 'SET_DISPLAY_URL'
107
- value: string
108
- }
109
- | {
110
- type: 'UPDATE_URL'
111
- }
112
- | {
113
- type: 'SELECT_DOCUMENT'
114
- value: string
115
- }
116
- | {
117
- type: 'DOCUMENT_READY'
118
- value: string
119
- }
120
- | {
121
- type: 'ADD_QUERY'
122
- value: {
123
- id: string
124
- type: 'open' | 'close'
125
- query: string
126
- data: object
127
- variables: object
128
- }
129
- }
130
- | {
131
- type: 'REMOVE_QUERY'
132
- value: string
133
- }
134
- | {
135
- type: 'SUBDOCUMENTS'
136
- value: { id: string; location: string }[]
137
- }
138
- | {
139
- type: 'FIELD_CHANGE'
140
- },
141
- },
142
- type: 'parallel',
143
- states: {
144
- url: {
145
- initial: 'idle',
146
- states: {
147
- idle: {
148
- on: {
149
- SET_URL: { actions: 'setUrl' },
150
- SET_INPUT_URL: { actions: 'setInputUrl' },
151
- SET_DISPLAY_URL: { actions: 'setDisplayUrl' },
152
- UPDATE_URL: { actions: 'updateUrl' },
153
- },
154
- },
155
- },
156
- },
157
- pipeline: {
158
- initial: 'idle',
159
- states: {
160
- idle: {
161
- entry: 'clear',
162
- on: {
163
- ADD_QUERY: 'initializing',
164
- SUBDOCUMENTS: 'pending',
165
- IFRAME_MOUNTED: {
166
- actions: 'setIframe',
167
- },
168
- },
169
- },
170
- initializing: {
171
- invoke: {
172
- src: 'initializer',
173
- onDone: {
174
- actions: ['storeInitialValues', 'scanForInitialDocuments'],
175
- target: 'pending',
176
- },
177
- onError: {
178
- target: 'error',
179
- actions: 'handleError',
180
- },
181
- },
182
- },
183
- waiting: {
184
- on: {
185
- DOCUMENT_READY: 'pending',
186
- },
187
- },
188
- pending: {
189
- invoke: {
190
- src: 'setter',
191
- onDone: 'ready',
192
- onError: {
193
- target: 'waiting',
194
- actions: 'handleMissingDocument',
195
- },
196
- },
197
- },
198
- ready: {
199
- entry: 'resolveData',
200
- invoke: {
201
- src: 'onChangeCallback',
202
- },
203
- on: {
204
- UPDATE_URL: 'idle',
205
- REMOVE_QUERY: 'idle',
206
- SELECT_DOCUMENT: {
207
- actions: 'selectDocument',
208
- },
209
- // TODO: for most _change_ events we could probably
210
- // optimize this and not go through the pending
211
- // process, but for now it keeps things simple and totally works
212
- // to just totally restart the process on each change
213
- FIELD_CHANGE: {
214
- target: 'pending',
215
- // actions: 'rescanForInitialDocuments',
216
- },
217
- },
218
- },
219
- error: {},
220
- },
221
- },
222
- },
223
- },
224
- {
225
- actions: {
226
- handleError: (_context, event) => console.error(event.data),
227
- handleMissingDocument: assign((context, event) => {
228
- count = count + 1
229
- if (count > 50) {
230
- throw new Error('infinite loop')
231
- }
232
- if (event.data instanceof QueryError) {
233
- if (context.documentMap[event.data.id]) {
234
- // Already exists
235
- return context
236
- }
237
- const doc = {
238
- ref: spawn(
239
- documentMachine.withContext({
240
- id: event.data.id,
241
- locations: [],
242
- cms: context.cms,
243
- formifyCallback: context.formifyCallback,
244
- form: null,
245
- data: null,
246
- subDocuments: [],
247
- allBlueprints: context.blueprints,
248
- })
249
- ),
250
- }
251
-
252
- return {
253
- ...context,
254
- documentMap: {
255
- ...context.documentMap,
256
- [event.data.id]: doc,
257
- },
258
- }
259
- } else {
260
- console.error(event.data)
261
- return context
262
- }
263
- }),
264
- clear: assign((context) => {
265
- Object.values(context.documentMap).forEach((doc) => {
266
- const form = doc.ref.getSnapshot()?.context?.form
267
- if (form) {
268
- context.cms.forms.remove(form.id)
269
- }
270
- })
271
- return {
272
- ...initialContext,
273
- formifyCallback: context.formifyCallback,
274
- cms: context.cms,
275
- // documentMap: context.documentMap, // to preserve docs across pages
276
- iframe: context.iframe,
277
- url: context.url,
278
- }
279
- }),
280
- setUrl: assign((context, event) => {
281
- return {
282
- ...context,
283
- url: event.value,
284
- }
285
- }),
286
- setDisplayUrl: assign((context, event) => {
287
- localStorage.setItem('tina-url', event.value)
288
- return {
289
- ...context,
290
- displayURL: event.value,
291
- }
292
- }),
293
- setInputUrl: assign((context, event) => {
294
- return {
295
- ...context,
296
- inputURL: event.value.startsWith('/')
297
- ? event.value
298
- : `/${event.value}`,
299
- }
300
- }),
301
- updateUrl: assign((context) => {
302
- if (context.inputURL) {
303
- Object.values(context.documentMap).forEach((doc) => {
304
- const form = doc.ref.getSnapshot()?.context?.form
305
- if (form) {
306
- context.cms.forms.remove(form.id)
307
- }
308
- })
309
- return {
310
- ...context,
311
- selectedDocument: initialContext.selectedDocument,
312
- documentMap: initialContext.documentMap,
313
- blueprints: initialContext.blueprints,
314
- data: initialContext.data,
315
- inputURL: null,
316
- displayURL: context.inputURL,
317
- url: context.inputURL,
318
- }
319
- } else {
320
- return context
321
- }
322
- }),
323
- storeInitialValues: assign((context, event) => {
324
- return {
325
- ...context,
326
- ...event.data,
327
- }
328
- }),
329
- selectDocument: assign((context, event) => {
330
- return {
331
- ...context,
332
- selectedDocument: event.value,
333
- }
334
- }),
335
- setIframe: assign((context, event) => {
336
- return {
337
- ...context,
338
- iframe: event.value,
339
- }
340
- }),
341
- resolveData: assign((context, event) => {
342
- if (context.iframe) {
343
- context.iframe?.contentWindow?.postMessage({
344
- id: context.id,
345
- data: event.data.data,
346
- })
347
- }
348
- return {
349
- ...context,
350
- }
351
- }),
352
- scanForInitialDocuments: assign((context, event) => {
353
- const blueprints = event.data.blueprints.filter(
354
- (blueprint) => blueprint.isTopLevel
355
- )
356
- const newDocuments: DocumentMap = {}
357
- blueprints.forEach((blueprint) => {
358
- const values = util.getAllIn(context.data, blueprint.id)
359
-
360
- values?.forEach((value) => {
361
- const location = spliceLocation(blueprint.id, value.location || [])
362
- const existing = context.documentMap[value.value.id]
363
- if (existing) {
364
- existing.ref.send({ type: 'ADD_LOCATION', value: location })
365
- } else {
366
- newDocuments[value.value.id] = {
367
- ref: spawn(
368
- documentMachine.withContext({
369
- id: value.value.id,
370
- form: null,
371
- cms: context.cms,
372
- formifyCallback: context.formifyCallback,
373
- data: null,
374
- locations: [location],
375
- subDocuments: [],
376
- allBlueprints: context.blueprints,
377
- })
378
- ),
379
- }
380
- }
381
- })
382
- })
383
- const nextDocumentMap = { ...context.documentMap, ...newDocuments }
384
- return {
385
- ...context,
386
- documentMap: nextDocumentMap,
387
- }
388
- }),
389
- },
390
- services: {
391
- setter: async (context) => {
392
- let newData = {}
393
- const initialBlueprints = context.blueprints.filter(
394
- (blueprint) => blueprint.isTopLevel
395
- )
396
- initialBlueprints.forEach((blueprint) => {
397
- const values = util.getAllInBlueprint(context.data, blueprint.id)
398
-
399
- values?.forEach((value) => {
400
- const location = spliceLocation(blueprint.id, value.location || [])
401
- if (!value.value) {
402
- return
403
- }
404
- const doc = context.documentMap[value.value._internalSys.path]
405
- const docContext = doc.ref.getSnapshot()?.context
406
- const form = docContext?.form
407
- if (!form) {
408
- throw new QueryError(
409
- `Unable to resolve form for initial document`,
410
- value.value._internalSys.path
411
- )
412
- }
413
-
414
- /**
415
- * This section can be removed when we support forms for list
416
- * and nested items.
417
- */
418
- if (blueprint.path.some((item) => item.list)) {
419
- // do nothing
420
- } else {
421
- if (form.global) {
422
- context.cms.plugins.add(
423
- new GlobalFormPlugin(
424
- form,
425
- form.global?.icon,
426
- form.global?.layout
427
- )
428
- )
429
- } else {
430
- context.cms.forms.add(form)
431
- }
432
- }
433
-
434
- if (docContext.data) {
435
- const nextData: Record<string, unknown> = setData({
436
- id: docContext.id,
437
- data: { ...docContext.data, ...form.values },
438
- // @ts-ignore form.fields is Field
439
- fields: form.fields,
440
- namespace: [docContext.data._internalSys.collection.name],
441
- path: [],
442
- blueprint,
443
- context,
444
- })
445
- newData = setIn(newData, location, nextData)
446
- }
447
- })
448
- })
449
- return { data: newData }
450
- },
451
- initializer: async (context, event) => {
452
- const schema = await context.cms.api.tina.getSchema()
453
- const documentNode = G.parse(event.value.query)
454
- const optimizedQuery = await context.cms.api.tina.getOptimizedQuery(
455
- documentNode
456
- )
457
- if (!optimizedQuery) {
458
- throw new Error(`Unable to optimize query`)
459
- }
460
- const { blueprints, formifiedQuery } = await formify({
461
- schema,
462
- optimizedDocumentNode: optimizedQuery,
463
- })
464
- const data = await context.cms.api.tina.request<DataType>(
465
- G.print(formifiedQuery),
466
- {
467
- variables: event.value.variables,
468
- }
469
- )
470
- return { data, blueprints, id: event.value.id }
471
- },
472
- onChangeCallback: (context) => (callback, _onReceive) => {
473
- if (context.cms) {
474
- context.cms.events.subscribe(`forms:fields:onChange`, () => {
475
- callback({ type: 'FIELD_CHANGE' })
476
- })
477
- context.cms.events.subscribe(`forms:reset`, () => {
478
- callback({ type: 'FIELD_CHANGE' })
479
- })
480
- }
481
- },
482
- },
483
- }
484
- )
485
- class QueryError extends Error {
486
- public id: string
487
- constructor(message: string, id: string) {
488
- super(message) // (1)
489
- this.name = 'QueryError' // (2)
490
- this.id = id
491
- }
492
- }
493
- let count = 0
494
-
495
- // https://github.com/oleics/node-is-scalar/blob/master/index.js
496
- const withSymbol = typeof Symbol !== 'undefined'
497
- function isScalar(value) {
498
- const type = typeof value
499
- if (type === 'string') return true
500
- if (type === 'number') return true
501
- if (type === 'boolean') return true
502
- if (withSymbol === true && type === 'symbol') return true
503
-
504
- if (value == null) return true
505
- if (withSymbol === true && value instanceof Symbol) return true
506
- if (value instanceof String) return true
507
- if (value instanceof Number) return true
508
- if (value instanceof Boolean) return true
509
-
510
- return false
511
- }
512
-
513
- const excludedValues = ['_internalValues', '_collection', '_template']
514
- const excludedTinaFieldValues = [
515
- '_sys',
516
- '_internalSys',
517
- '_internalValues',
518
- '_values',
519
- '_collection',
520
- '_template',
521
- ]
522
-
523
- const setData = ({
524
- id,
525
- data,
526
- path,
527
- fields,
528
- namespace,
529
- blueprint,
530
- context,
531
- }: {
532
- id: string
533
- data: Record<string, unknown>
534
- path: (string | number)[]
535
- fields: TinaField[]
536
- namespace: string[]
537
- blueprint: DocumentBlueprint
538
- context: ContextFrom<typeof queryMachine>
539
- }) => {
540
- const nextData: Record<string, unknown> = {}
541
- nextData['__typename'] = NAMER.dataTypeName(namespace)
542
- nextData['_tinaField'] = {
543
- id,
544
- keys: Object.keys(data)
545
- .filter((key) => !excludedTinaFieldValues.includes(key))
546
- .map((item) => [...path, item].join('.')),
547
- }
548
- Object.entries(data).forEach(([key, value]) => {
549
- const field = fields.find((field) => field.name === key)
550
- const nextPath = [...path, key]
551
- if (!value) {
552
- nextData[key] = null
553
- }
554
- // This is a property not controlled by the form, so it
555
- // cannot change. Eg. _sys
556
- if (!field) {
557
- if (!excludedValues.includes(key)) {
558
- nextData[key] = value
559
- }
560
- return
561
- }
562
- if (Array.isArray(value)) {
563
- if (field) {
564
- if (!field.list) {
565
- throw new Error(
566
- `Expected field for array value to be have property list: true`
567
- )
568
- } else {
569
- if (field.type === 'object') {
570
- if (field.templates) {
571
- nextData[key] = value.map((item, index) => {
572
- const template = Object.values(field.templates).find(
573
- (template) => {
574
- // @ts-ignore FIXME: template is transformed to an
575
- // object that the `blocks` field plugin expects
576
- return template.key === item._template
577
- }
578
- )
579
- if (!template) {
580
- throw new Error(
581
- `Unable to find template for field ${field.name}`
582
- )
583
- }
584
- return setData({
585
- id,
586
- data: item,
587
- path: [...nextPath, index],
588
- // @ts-ignore form.fields is Field
589
- fields: template.fields,
590
- namespace: template.namespace,
591
- blueprint,
592
- context,
593
- })
594
- })
595
- } else {
596
- if (typeof field?.fields === 'string') {
597
- throw new Error('Global templates not supported')
598
- }
599
- nextData[key] = value.map((item, index) =>
600
- setData({
601
- id,
602
- data: item,
603
- path: [...nextPath, index],
604
- // @ts-ignore form.fields is Field
605
- fields: field.fields,
606
- namespace: field.namespace,
607
- blueprint,
608
- context,
609
- })
610
- )
611
- }
612
- } else {
613
- nextData[key] = value
614
- }
615
- }
616
- } else {
617
- nextData[key] = value.map((item, index) =>
618
- isScalar(item)
619
- ? item
620
- : setData({
621
- id,
622
- data: item,
623
- path: [...nextPath, index],
624
- namespace: field.namespace,
625
- fields: [],
626
- blueprint,
627
- context,
628
- })
629
- )
630
- }
631
- } else {
632
- const fieldBlueprintPath = `${blueprint.id}.${nextPath
633
- .map((item) => (isNaN(Number(item)) ? item : '[]'))
634
- .join('.')}`
635
-
636
- const childBlueprint = context.blueprints.find(
637
- ({ id }) => id === fieldBlueprintPath
638
- )
639
- const blueprintField = blueprint.fields.find(
640
- ({ id }) => id === fieldBlueprintPath
641
- )
642
- // If the query isn't requesting this data, don't populate it
643
- if (!blueprintField && !childBlueprint) {
644
- return
645
- }
646
- if (isScalar(value)) {
647
- // This value is a reference (eg. "content/authors/pedro.md")
648
- if (childBlueprint) {
649
- if (typeof value === 'string') {
650
- if (!value) {
651
- nextData[key] = null
652
- return
653
- }
654
- const doc = context.documentMap[value]
655
- const docContext = doc?.ref?.getSnapshot()?.context
656
- const form = docContext?.form
657
- if (!form) {
658
- throw new QueryError(`Unable to resolve form for document`, value)
659
- }
660
- nextData[key] = {
661
- id: docContext.id,
662
- ...setData({
663
- id: docContext.id,
664
- data: { ...docContext.data, ...form.values },
665
- // @ts-ignore form.fields is Field
666
- fields: form.fields,
667
- namespace: [],
668
- path: [],
669
- blueprint: childBlueprint,
670
- context,
671
- }),
672
- }
673
- } else {
674
- // The reference value is null
675
- nextData[key] = null
676
- }
677
- } else {
678
- // This is a reference that's not formified.
679
- // That is - we don't generate form for it because the query didn't ask us to
680
- if (field && field.type === 'reference') {
681
- if (value) {
682
- nextData[key] = { id: value }
683
- } else {
684
- nextData[key] = null
685
- }
686
- } else {
687
- nextData[key] = value
688
- }
689
- }
690
- } else {
691
- // TODO: when rich-text is {json: {}, embeds: {}[]} we'll need to resolve the embeds
692
- if (field.type === 'rich-text') {
693
- nextData[key] = value
694
- return
695
- }
696
- if (field.type !== 'object') {
697
- throw new Error(
698
- `Expected field for object values to be of type "object", but got ${field.type}`
699
- )
700
- }
701
- if (field?.templates) {
702
- throw new Error(`Unexpected path ${field.name}`)
703
- } else {
704
- if (typeof field?.fields === 'string') {
705
- throw new Error('Global templates not supported')
706
- }
707
- const nextValue = value as Record<string, unknown>
708
- const nextDataResult = setData({
709
- id,
710
- data: nextValue,
711
- path: nextPath,
712
- namespace: field.namespace,
713
- fields: field.fields,
714
- blueprint,
715
- context,
716
- })
717
- // Don't populate an empty key {someValue: {}}
718
- if (Object.keys(nextDataResult).length > 0) {
719
- nextData[key] = nextDataResult
720
- } else {
721
- nextData[key] = null
722
- }
723
- }
724
- }
725
- }
726
- })
727
- return nextData
728
- }