@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.
- package/CHANGELOG.md +1203 -0
- package/LICENSE +176 -0
- package/index.html +69 -0
- package/package.json +32 -0
- package/src/App.tsx +79 -0
- package/src/Playground.tsx +161 -0
- package/src/dummy-client.ts +1 -0
- package/src/fields/rich-text/index.tsx +6 -0
- package/src/fields/rich-text/monaco/error-message.tsx +118 -0
- package/src/fields/rich-text/monaco/index.tsx +230 -0
- package/src/fields/rich-text/monaco/use-debounce.ts +25 -0
- package/src/global.css +120 -0
- package/src/index.css +388 -0
- package/src/lib/build-form.ts +49 -0
- package/src/lib/errors.tsx +24 -0
- package/src/lib/expand-query.ts +273 -0
- package/src/lib/graphql-reducer.ts +1014 -0
- package/src/lib/types.ts +53 -0
- package/src/lib/util.ts +129 -0
- package/src/main.tsx +12 -0
- package/src/preflight.css +233 -0
- package/src/preview.tsx +27 -0
- package/src/vite-env.d.ts +8 -0
- package/tsconfig.json +32 -0
- package/tsconfig.node.json +10 -0
|
@@ -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
|
+
};
|