slice-machine-ui 2.16.2-beta.9 → 2.17.1-alpha.jp-cr-ui-nested-ct-remove-section.1

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.
Files changed (67) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/0SPBnBOIdcPusg2ceVdAq/_buildManifest.js +1 -0
  3. package/out/_next/static/chunks/248-03446cd9e9f13730.js +1 -0
  4. package/out/_next/static/chunks/268-6a9214b97195af9c.js +1 -0
  5. package/out/_next/static/chunks/33641354.3864aefb6106ae71.js +28 -0
  6. package/out/_next/static/chunks/34-e684c5fd75cc9dd0.js +1 -0
  7. package/out/_next/static/chunks/630-4a8f19a6a113700f.js +1 -0
  8. package/out/_next/static/chunks/647-7b9b5aa9468f9e4b.js +1 -0
  9. package/out/_next/static/chunks/882-151468121d542ed6.js +1 -0
  10. package/out/_next/static/chunks/pages/_app-15912b10c760cd68.js +708 -0
  11. package/out/_next/static/chunks/pages/changelog-063c5e11dfc8fd55.js +1 -0
  12. package/out/_next/static/chunks/pages/changes-564336edb0ed18b0.js +1 -0
  13. package/out/_next/static/chunks/pages/custom-types/{[customTypeId]-389d1b7a492fb3e7.js → [customTypeId]-a408f5a660e096a6.js} +1 -1
  14. package/out/_next/static/chunks/pages/{custom-types-2a5fd94ee42ba593.js → custom-types-5acd56959b60346f.js} +1 -1
  15. package/out/_next/static/chunks/pages/{index-02dd147957c8b40f.js → index-0d8cb369de720a35.js} +1 -1
  16. package/out/_next/static/chunks/pages/labs-9630bfb1005be02b.js +1 -0
  17. package/out/_next/static/chunks/pages/page-types/{[pageTypeId]-3589bd1f9138a97b.js → [pageTypeId]-f5e851ebe35049a8.js} +1 -1
  18. package/out/_next/static/chunks/pages/settings-01f4aeb9112a1f87.js +1 -0
  19. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]/simulator-5008e29008aa04f4.js +1 -0
  20. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]-bd5e45632c419567.js +1 -0
  21. package/out/_next/static/chunks/pages/slices-4a60cd5f2c71327e.js +1 -0
  22. package/out/_next/static/chunks/webpack-b3522fdebabf510a.js +1 -0
  23. package/out/_next/static/css/cc9b10286400c2b9.css +1 -0
  24. package/out/changelog.html +1 -1
  25. package/out/changes.html +1 -1
  26. package/out/custom-types/[customTypeId].html +1 -1
  27. package/out/custom-types.html +1 -1
  28. package/out/index.html +1 -1
  29. package/out/labs.html +1 -1
  30. package/out/page-types/[pageTypeId].html +1 -1
  31. package/out/settings.html +1 -1
  32. package/out/slices/[lib]/[sliceName]/[variation]/simulator.html +1 -1
  33. package/out/slices/[lib]/[sliceName]/[variation].html +1 -1
  34. package/out/slices.html +1 -1
  35. package/package.json +8 -8
  36. package/src/features/builder/fields/contentRelationship/ContentRelationshipFieldPicker.tsx +1498 -0
  37. package/src/features/builder/fields/contentRelationship/Hint.tsx +28 -0
  38. package/src/features/builder/fields/contentRelationship/__tests__/ContentRelationshipFieldPicker.test.ts +1323 -0
  39. package/src/features/customTypes/customTypesTable/CustomTypesTable.tsx +2 -4
  40. package/src/features/customTypes/customTypesTable/{useCustomTypes.ts → useCustomTypesAutoRevalidation.tsx} +1 -34
  41. package/src/features/customTypes/useCustomTypes.ts +48 -0
  42. package/src/legacy/lib/builders/common/Zone/Card/components/Hints/index.tsx +12 -1
  43. package/src/legacy/lib/models/common/widgets/ContentRelationship/Form.tsx +35 -77
  44. package/src/legacy/lib/models/common/widgets/ContentRelationship/index.ts +46 -15
  45. package/src/legacy/lib/models/common/widgets/Link/index.ts +1 -1
  46. package/src/utils/isValidObject.ts +32 -0
  47. package/src/utils/tracking/getLinkTrackingProperties.ts +28 -0
  48. package/src/utils/tracking/trackFieldAdded.ts +2 -5
  49. package/src/utils/tracking/trackFieldUpdated.ts +2 -5
  50. package/out/_next/static/atP3j_itxjBtvIysQoCxK/_buildManifest.js +0 -1
  51. package/out/_next/static/chunks/268-6d5fc7642a1b87c8.js +0 -1
  52. package/out/_next/static/chunks/34-2911c905c8a6e9d9.js +0 -1
  53. package/out/_next/static/chunks/630-93339694ef30b82d.js +0 -1
  54. package/out/_next/static/chunks/867-8164160c810122c6.js +0 -1
  55. package/out/_next/static/chunks/882-28837678beff7e51.js +0 -1
  56. package/out/_next/static/chunks/895-8c214ba470a4e23c.js +0 -1
  57. package/out/_next/static/chunks/pages/_app-2400eb8f7c5ef3cb.js +0 -657
  58. package/out/_next/static/chunks/pages/changelog-80a618708f44f25f.js +0 -1
  59. package/out/_next/static/chunks/pages/changes-d40c17939854b984.js +0 -1
  60. package/out/_next/static/chunks/pages/labs-c6df252ea5d8fb6f.js +0 -1
  61. package/out/_next/static/chunks/pages/settings-170379902605f38a.js +0 -1
  62. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]/simulator-063fa88ba75f483e.js +0 -1
  63. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]-6de02b8ed13b680d.js +0 -1
  64. package/out/_next/static/chunks/pages/slices-071bb494907adf0f.js +0 -1
  65. package/out/_next/static/chunks/webpack-2f903acb0cccbf9e.js +0 -1
  66. package/out/_next/static/css/9c9a7de81f9ac811.css +0 -1
  67. /package/out/_next/static/{atP3j_itxjBtvIysQoCxK → 0SPBnBOIdcPusg2ceVdAq}/_ssgManifest.js +0 -0
@@ -0,0 +1,1498 @@
1
+ import { pluralize } from "@prismicio/editor-support/String";
2
+ import {
3
+ Alert,
4
+ AnimatedSuspense,
5
+ Badge,
6
+ Box,
7
+ Button,
8
+ DropdownMenu,
9
+ DropdownMenuContent,
10
+ DropdownMenuItem,
11
+ DropdownMenuLabel,
12
+ DropdownMenuTrigger,
13
+ Icon,
14
+ IconButton,
15
+ Skeleton,
16
+ Text,
17
+ TextOverflow,
18
+ Tooltip,
19
+ TreeView,
20
+ TreeViewCheckbox,
21
+ TreeViewSection,
22
+ } from "@prismicio/editor-ui";
23
+ import {
24
+ CustomType,
25
+ DynamicWidget,
26
+ Group,
27
+ Link,
28
+ LinkConfig,
29
+ NestableWidget,
30
+ } from "@prismicio/types-internal/lib/customtypes";
31
+ import { useEffect } from "react";
32
+
33
+ import { ErrorBoundary } from "@/ErrorBoundary";
34
+ import {
35
+ revalidateGetCustomTypes,
36
+ useCustomTypes as useCustomTypesRequest,
37
+ } from "@/features/customTypes/useCustomTypes";
38
+ import { isValidObject } from "@/utils/isValidObject";
39
+
40
+ type NonReadonly<T> = { -readonly [P in keyof T]: T[P] };
41
+
42
+ /**
43
+ * Picker fields check map types. Used internally to keep track of the checked
44
+ * fields in the TreeView, as it's easier to handle objects than arrays and
45
+ * also ensures field uniqueness.
46
+ *
47
+ * @example
48
+ * {
49
+ * author: {
50
+ * fullName: {
51
+ * type: "checkbox",
52
+ * value: true,
53
+ * },
54
+ * awards: {
55
+ * type: "group",
56
+ * value: {
57
+ * date: {
58
+ * type: "checkbox",
59
+ * value: true,
60
+ * },
61
+ * awardsCr: {
62
+ * type: "contentRelationship",
63
+ * value: {
64
+ * award: {
65
+ * title: {
66
+ * type: "checkbox",
67
+ * value: true,
68
+ * },
69
+ * issuer: {
70
+ * type: "group",
71
+ * value: {
72
+ * name: {
73
+ * type: "checkbox",
74
+ * value: true,
75
+ * },
76
+ * },
77
+ * },
78
+ * },
79
+ * },
80
+ * },
81
+ * },
82
+ * },
83
+ * professionCr: {
84
+ * type: "contentRelationship",
85
+ * value: {
86
+ * profession: {
87
+ * name: {
88
+ * type: "checkbox",
89
+ * value: true,
90
+ * },
91
+ * areas: {
92
+ * type: "group",
93
+ * value: {
94
+ * name: {
95
+ * type: "checkbox",
96
+ * value: true,
97
+ * },
98
+ * },
99
+ * },
100
+ * },
101
+ * },
102
+ * },
103
+ * },
104
+ * }
105
+ **/
106
+ interface PickerCustomTypes {
107
+ [customTypeId: string]: PickerCustomType;
108
+ }
109
+
110
+ interface PickerCustomType {
111
+ [fieldId: string]: PickerCustomTypeValue;
112
+ }
113
+
114
+ type PickerCustomTypeValue =
115
+ | PickerCheckboxField
116
+ | PickerFirstLevelGroupField
117
+ | PickerContentRelationshipField;
118
+
119
+ interface PickerCheckboxField {
120
+ type: "checkbox";
121
+ value: boolean;
122
+ }
123
+
124
+ interface PickerFirstLevelGroupField {
125
+ type: "group";
126
+ value: PickerFirstLevelGroupFieldValue;
127
+ }
128
+
129
+ interface PickerLeafGroupField {
130
+ type: "group";
131
+ value: PickerLeafGroupFieldValue;
132
+ }
133
+
134
+ interface PickerLeafGroupFieldValue {
135
+ [fieldId: string]: PickerCheckboxField;
136
+ }
137
+
138
+ interface PickerFirstLevelGroupFieldValue {
139
+ [fieldId: string]: PickerCheckboxField | PickerContentRelationshipField;
140
+ }
141
+
142
+ interface PickerContentRelationshipField {
143
+ type: "contentRelationship";
144
+ value: PickerContentRelationshipFieldValue;
145
+ }
146
+
147
+ interface PickerContentRelationshipFieldValue {
148
+ [customTypeId: string]: PickerNestedCustomTypeValue;
149
+ }
150
+ interface PickerNestedCustomTypeValue {
151
+ [fieldId: string]: PickerCheckboxField | PickerLeafGroupField;
152
+ }
153
+
154
+ /**
155
+ * Content relationship Link customtypes property structure.
156
+ *
157
+ * @example
158
+ * [
159
+ * {
160
+ * id: "author",
161
+ * fields: [
162
+ * "fullName",
163
+ * {
164
+ * id: "awards",
165
+ * fields: [
166
+ * "date",
167
+ * {
168
+ * id: "awardsCr",
169
+ * customtypes: [
170
+ * {
171
+ * id: "award",
172
+ * fields: [
173
+ * "title",
174
+ * {
175
+ * id: "issuer",
176
+ * fields: ["name"],
177
+ * },
178
+ * ],
179
+ * },
180
+ * ],
181
+ * },
182
+ * ],
183
+ * },
184
+ * {
185
+ * id: "professionCr",
186
+ * customtypes: [
187
+ * {
188
+ * id: "profession",
189
+ * fields: [
190
+ * "name",
191
+ * {
192
+ * id: "areas",
193
+ * fields: ["name"],
194
+ * },
195
+ * ],
196
+ * },
197
+ * ],
198
+ * },
199
+ * ],
200
+ * },
201
+ * ]
202
+ */
203
+ type LinkCustomtypes = NonNullable<LinkConfig["customtypes"]>;
204
+
205
+ type LinkCustomtypesFields = Exclude<
206
+ LinkCustomtypes[number],
207
+ string
208
+ >["fields"][number];
209
+
210
+ type LinkCustomtypesContentRelationshipFieldValue = Exclude<
211
+ LinkCustomtypesFields,
212
+ string | { fields: unknown }
213
+ >;
214
+
215
+ type LinkCustomtypesGroupFieldValue = Exclude<
216
+ LinkCustomtypesFields,
217
+ string | { customtypes: unknown }
218
+ >;
219
+
220
+ interface ContentRelationshipFieldPickerProps {
221
+ value: LinkCustomtypes | undefined;
222
+ onChange: (fields: LinkCustomtypes) => void;
223
+ }
224
+
225
+ export function ContentRelationshipFieldPicker(
226
+ props: ContentRelationshipFieldPickerProps,
227
+ ) {
228
+ return (
229
+ <ErrorBoundary
230
+ renderError={() => (
231
+ <Box alignItems="center" gap={8}>
232
+ <Icon name="alert" size="small" color="tomato10" />
233
+ <Text color="tomato10">Error loading your types</Text>
234
+ </Box>
235
+ )}
236
+ >
237
+ <AnimatedSuspense
238
+ fallback={
239
+ <Box flexDirection="column" position="relative">
240
+ <Skeleton height={240} />
241
+ <Box
242
+ position="absolute"
243
+ top="50%"
244
+ left="50%"
245
+ transform="translate(-50%, -50%)"
246
+ alignItems="center"
247
+ gap={8}
248
+ >
249
+ <Icon name="autorenew" size="small" color="grey11" />
250
+ <Text color="grey11">Loading your types...</Text>
251
+ </Box>
252
+ </Box>
253
+ }
254
+ >
255
+ <ContentRelationshipFieldPickerContent {...props} />
256
+ </AnimatedSuspense>
257
+ </ErrorBoundary>
258
+ );
259
+ }
260
+
261
+ function ContentRelationshipFieldPickerContent(
262
+ props: ContentRelationshipFieldPickerProps,
263
+ ) {
264
+ const { value: linkCustomtypes, onChange } = props;
265
+ const { allCustomTypes, pickedCustomTypes } = useCustomTypes(linkCustomtypes);
266
+
267
+ const fieldCheckMap = linkCustomtypes
268
+ ? convertLinkCustomtypesToFieldCheckMap({ linkCustomtypes, allCustomTypes })
269
+ : {};
270
+
271
+ function onCustomTypesChange(id: string, newCustomType: PickerCustomType) {
272
+ // The picker does not handle strings (custom type ids), as it's only meant
273
+ // to pick fields from custom types (objects). So we need to merge it with
274
+ // the existing value, which can have strings in the first level that
275
+ // represent new types added without any picked fields.
276
+ onChange(
277
+ mergeAndConvertCheckMapToLinkCustomtypes({
278
+ fieldCheckMap,
279
+ newCustomType,
280
+ linkCustomtypes,
281
+ customTypeId: id,
282
+ }),
283
+ );
284
+ }
285
+
286
+ function addCustomType(id: string) {
287
+ const newFields = linkCustomtypes ? [...linkCustomtypes, id] : [id];
288
+ onChange(newFields);
289
+ }
290
+
291
+ function removeCustomType(id: string) {
292
+ if (linkCustomtypes) {
293
+ onChange(
294
+ linkCustomtypes.filter((existingCt) => getId(existingCt) !== id),
295
+ );
296
+ }
297
+ }
298
+
299
+ return (
300
+ <Box
301
+ overflow="hidden"
302
+ flexDirection="column"
303
+ border
304
+ borderRadius={6}
305
+ width="100%"
306
+ >
307
+ <Box
308
+ border={{ bottom: true }}
309
+ padding={{ inline: 16, bottom: 16, top: 12 }}
310
+ flexDirection="column"
311
+ gap={8}
312
+ >
313
+ {pickedCustomTypes.length > 0 ? (
314
+ <>
315
+ <Box flexDirection="column">
316
+ <Text variant="h4" color="grey12">
317
+ Allowed type
318
+ </Text>
319
+ <Text color="grey11">
320
+ Select a single type that editors can link to in the Page
321
+ Builder.
322
+ <br />
323
+ For the selected type, choose which fields to include in the API
324
+ response.
325
+ </Text>
326
+ {pickedCustomTypes.length > 1 && (
327
+ <Box margin={{ block: 12 }}>
328
+ <Alert
329
+ color="warn"
330
+ icon="alert"
331
+ subtitle={
332
+ <>
333
+ <Text color="inherit" variant="bold">
334
+ Legacy mode. Keep only one type to enable the improved
335
+ Content Relationship feature.
336
+ </Text>
337
+ <br />
338
+ <a
339
+ href="https://prismic.io/docs/fields/content-relationship"
340
+ target="_blank"
341
+ rel="noopener noreferrer"
342
+ style={{
343
+ color: "inherit",
344
+ textDecoration: "none",
345
+ fontWeight: "bold",
346
+ display: "flex",
347
+ alignItems: "center",
348
+ gap: 4,
349
+ }}
350
+ >
351
+ <Text color="inherit" variant="bold">
352
+ See documentation
353
+ </Text>
354
+ <Icon
355
+ name="arrowForward"
356
+ size="small"
357
+ color="inherit"
358
+ />
359
+ </a>
360
+ </>
361
+ }
362
+ />
363
+ </Box>
364
+ )}
365
+ </Box>
366
+ {pickedCustomTypes.map((customType) => (
367
+ <Box
368
+ key={customType.id}
369
+ gap={4}
370
+ padding={8}
371
+ border
372
+ borderRadius={6}
373
+ borderColor="grey6"
374
+ backgroundColor="white"
375
+ justifyContent="space-between"
376
+ >
377
+ {pickedCustomTypes.length > 1 ? (
378
+ <Text>{customType.id}</Text>
379
+ ) : (
380
+ <TreeView>
381
+ <TreeViewCustomType
382
+ customType={customType}
383
+ onChange={(value) =>
384
+ onCustomTypesChange(customType.id, value)
385
+ }
386
+ fieldCheckMap={fieldCheckMap[customType.id] ?? {}}
387
+ allCustomTypes={allCustomTypes}
388
+ />
389
+ </TreeView>
390
+ )}
391
+
392
+ <IconButton
393
+ icon="close"
394
+ size="small"
395
+ onClick={() => removeCustomType(customType.id)}
396
+ sx={{ height: 24, width: 24 }}
397
+ hiddenLabel="Remove type"
398
+ />
399
+ </Box>
400
+ ))}
401
+ </>
402
+ ) : (
403
+ <EmptyView onSelect={addCustomType} allCustomTypes={allCustomTypes} />
404
+ )}
405
+ </Box>
406
+ <Box backgroundColor="white" flexDirection="column" padding={12}>
407
+ <Text variant="normal" color="grey11">
408
+ Have ideas for improving this field?{" "}
409
+ <a
410
+ href="https://community.prismic.io/t/content-relationship-share-your-requests-and-feedback/19843"
411
+ target="_blank"
412
+ rel="noopener noreferrer"
413
+ style={{ color: "inherit", textDecoration: "underline" }}
414
+ >
415
+ Please provide your feedback here
416
+ </a>
417
+ .
418
+ </Text>
419
+ </Box>
420
+ </Box>
421
+ );
422
+ }
423
+
424
+ type EmptyViewProps = {
425
+ onSelect: (customTypeId: string) => void;
426
+ allCustomTypes: CustomType[];
427
+ };
428
+
429
+ function EmptyView(props: EmptyViewProps) {
430
+ const { allCustomTypes, onSelect } = props;
431
+
432
+ return (
433
+ <Box
434
+ flexDirection="column"
435
+ gap={8}
436
+ alignItems="center"
437
+ padding={{ block: 24 }}
438
+ >
439
+ <Box flexDirection="column" alignItems="center" gap={4}>
440
+ <Text variant="h5" color="grey12">
441
+ No type selected
442
+ </Text>
443
+ <Text color="grey11" component="p" align="center">
444
+ Select the type editors can link to.
445
+ <br />
446
+ Then, choose which fields to return in the API.
447
+ </Text>
448
+ </Box>
449
+ <Box>
450
+ <AddTypeButton allCustomTypes={allCustomTypes} onSelect={onSelect} />
451
+ </Box>
452
+ </Box>
453
+ );
454
+ }
455
+
456
+ type AddTypeButtonProps = {
457
+ onSelect: (customTypeId: string) => void;
458
+ allCustomTypes: CustomType[];
459
+ };
460
+
461
+ function AddTypeButton(props: AddTypeButtonProps) {
462
+ const { allCustomTypes, onSelect } = props;
463
+
464
+ const triggerButton = (
465
+ <Button startIcon="add" color="grey" disabled={allCustomTypes.length === 0}>
466
+ Add type
467
+ </Button>
468
+ );
469
+
470
+ if (allCustomTypes.length === 0) {
471
+ return (
472
+ <Box>
473
+ <Tooltip
474
+ content="No type available"
475
+ side="bottom"
476
+ align="start"
477
+ disableHoverableContent
478
+ >
479
+ {triggerButton}
480
+ </Tooltip>
481
+ </Box>
482
+ );
483
+ }
484
+
485
+ return (
486
+ <Box>
487
+ <DropdownMenu>
488
+ <DropdownMenuTrigger>{triggerButton}</DropdownMenuTrigger>
489
+ <DropdownMenuContent maxHeight={400} minWidth={256} align="center">
490
+ <DropdownMenuLabel>
491
+ <Text color="grey11">Types</Text>
492
+ </DropdownMenuLabel>
493
+ {allCustomTypes.map((customType) => (
494
+ <DropdownMenuItem
495
+ key={customType.id}
496
+ onSelect={() => onSelect(customType.id)}
497
+ >
498
+ <Box alignItems="center" justifyContent="space-between" gap={8}>
499
+ <TextOverflow>
500
+ <Text>{customType.id}</Text>
501
+ </TextOverflow>
502
+ <Badge
503
+ title={
504
+ <Text variant="extraSmall" color="purple11">
505
+ {getTypeFormatLabel(customType.format)}
506
+ </Text>
507
+ }
508
+ color="purple"
509
+ size="small"
510
+ />
511
+ </Box>
512
+ </DropdownMenuItem>
513
+ ))}
514
+ </DropdownMenuContent>
515
+ </DropdownMenu>
516
+ </Box>
517
+ );
518
+ }
519
+
520
+ interface TreeViewCustomTypeProps {
521
+ customType: CustomType;
522
+ fieldCheckMap: PickerCustomType;
523
+ onChange: (newValue: PickerCustomType) => void;
524
+ allCustomTypes: CustomType[];
525
+ }
526
+
527
+ function TreeViewCustomType(props: TreeViewCustomTypeProps) {
528
+ const {
529
+ customType,
530
+ fieldCheckMap: customTypeFieldsCheckMap,
531
+ onChange: onCustomTypeChange,
532
+ allCustomTypes,
533
+ } = props;
534
+
535
+ const renderedFields = getCustomTypeStaticFields(customType).map(
536
+ ([fieldId, field]) => {
537
+ // Group field
538
+
539
+ if (field.type === "Group") {
540
+ const onGroupFieldChange = (
541
+ newGroupFields: PickerFirstLevelGroupFieldValue,
542
+ ) => {
543
+ onCustomTypeChange({
544
+ ...customTypeFieldsCheckMap,
545
+ [fieldId]: { type: "group", value: newGroupFields },
546
+ });
547
+ };
548
+
549
+ const groupFieldCheckMap = customTypeFieldsCheckMap[fieldId] ?? {};
550
+
551
+ return (
552
+ <TreeViewFirstLevelGroupField
553
+ key={fieldId}
554
+ group={field}
555
+ groupId={fieldId}
556
+ onChange={onGroupFieldChange}
557
+ fieldCheckMap={
558
+ groupFieldCheckMap.type === "group"
559
+ ? groupFieldCheckMap.value
560
+ : {}
561
+ }
562
+ allCustomTypes={allCustomTypes}
563
+ />
564
+ );
565
+ }
566
+
567
+ // Content relationship field with custom types
568
+
569
+ if (isContentRelationshipFieldWithSingleCustomtype(field)) {
570
+ const onContentRelationshipFieldChange = (
571
+ newCrFields: PickerContentRelationshipFieldValue,
572
+ ) => {
573
+ onCustomTypeChange({
574
+ ...customTypeFieldsCheckMap,
575
+ [fieldId]: {
576
+ type: "contentRelationship",
577
+ value: newCrFields,
578
+ },
579
+ });
580
+ };
581
+
582
+ const crFieldCheckMap = customTypeFieldsCheckMap[fieldId] ?? {};
583
+
584
+ return (
585
+ <TreeViewContentRelationshipField
586
+ key={fieldId}
587
+ field={field}
588
+ fieldId={fieldId}
589
+ onChange={onContentRelationshipFieldChange}
590
+ fieldCheckMap={
591
+ crFieldCheckMap.type === "contentRelationship"
592
+ ? crFieldCheckMap.value
593
+ : {}
594
+ }
595
+ allCustomTypes={allCustomTypes}
596
+ />
597
+ );
598
+ }
599
+
600
+ // Regular field
601
+
602
+ const onCheckedChange = (newValue: boolean) => {
603
+ onCustomTypeChange({
604
+ ...customTypeFieldsCheckMap,
605
+ [fieldId]: { type: "checkbox", value: newValue },
606
+ });
607
+ };
608
+
609
+ return (
610
+ <TreeViewCheckbox
611
+ key={fieldId}
612
+ title={fieldId}
613
+ checked={customTypeFieldsCheckMap[fieldId]?.value === true}
614
+ onCheckedChange={onCheckedChange}
615
+ />
616
+ );
617
+ },
618
+ );
619
+
620
+ const exposedFieldsCount = countPickedFields(customTypeFieldsCheckMap);
621
+
622
+ return (
623
+ <TreeViewSection
624
+ key={customType.id}
625
+ title={customType.id}
626
+ subtitle={
627
+ exposedFieldsCount.pickedFields > 0
628
+ ? getPickedFieldsLabel(
629
+ exposedFieldsCount.pickedFields,
630
+ "returned in the API",
631
+ )
632
+ : "(No fields returned in the API)"
633
+ }
634
+ badge={getTypeFormatLabel(customType.format)}
635
+ defaultOpen
636
+ >
637
+ {renderedFields.length > 0 ? renderedFields : <NoFieldsAvailable />}
638
+ </TreeViewSection>
639
+ );
640
+ }
641
+
642
+ interface TreeViewContentRelationshipFieldProps {
643
+ fieldId: string;
644
+ field: Link;
645
+ fieldCheckMap: PickerContentRelationshipFieldValue;
646
+ onChange: (newValue: PickerContentRelationshipFieldValue) => void;
647
+ allCustomTypes: CustomType[];
648
+ }
649
+
650
+ function TreeViewContentRelationshipField(
651
+ props: TreeViewContentRelationshipFieldProps,
652
+ ) {
653
+ const {
654
+ field,
655
+ fieldId,
656
+ fieldCheckMap: crFieldsCheckMap,
657
+ onChange: onCrFieldChange,
658
+ allCustomTypes,
659
+ } = props;
660
+
661
+ if (!field.config?.customtypes) return null;
662
+
663
+ const resolvedCustomTypes = resolveContentRelationshipCustomTypes(
664
+ field.config.customtypes,
665
+ allCustomTypes,
666
+ );
667
+
668
+ if (resolvedCustomTypes.length !== 1) return null;
669
+
670
+ const [customType] = resolvedCustomTypes;
671
+
672
+ if (typeof customType === "string") return null;
673
+
674
+ const onNestedCustomTypeChange = (
675
+ newNestedCustomTypeFields: PickerNestedCustomTypeValue,
676
+ ) => {
677
+ onCrFieldChange({
678
+ ...crFieldsCheckMap,
679
+ [customType.id]: newNestedCustomTypeFields,
680
+ });
681
+ };
682
+
683
+ const nestedCtFieldsCheckMap = crFieldsCheckMap[customType.id] ?? {};
684
+
685
+ const renderedFields = getCustomTypeStaticFields(customType).map(
686
+ ([fieldId, field]) => {
687
+ // Group field
688
+
689
+ if (field.type === "Group") {
690
+ const onGroupFieldsChange = (
691
+ newGroupFields: PickerLeafGroupFieldValue,
692
+ ) => {
693
+ onNestedCustomTypeChange({
694
+ ...nestedCtFieldsCheckMap,
695
+ [fieldId]: { type: "group", value: newGroupFields },
696
+ });
697
+ };
698
+
699
+ const groupFieldCheckMap = nestedCtFieldsCheckMap[fieldId] ?? {};
700
+
701
+ return (
702
+ <TreeViewLeafGroupField
703
+ key={fieldId}
704
+ group={field}
705
+ groupId={fieldId}
706
+ onChange={onGroupFieldsChange}
707
+ fieldCheckMap={
708
+ groupFieldCheckMap.type === "group"
709
+ ? groupFieldCheckMap.value
710
+ : {}
711
+ }
712
+ />
713
+ );
714
+ }
715
+
716
+ // Regular field
717
+
718
+ const onCheckedChange = (newChecked: boolean) => {
719
+ onNestedCustomTypeChange({
720
+ ...nestedCtFieldsCheckMap,
721
+ [fieldId]: { type: "checkbox", value: newChecked },
722
+ });
723
+ };
724
+
725
+ return (
726
+ <TreeViewCheckbox
727
+ key={fieldId}
728
+ title={fieldId}
729
+ checked={nestedCtFieldsCheckMap[fieldId]?.value === true}
730
+ onCheckedChange={onCheckedChange}
731
+ />
732
+ );
733
+ },
734
+ );
735
+
736
+ return (
737
+ <TreeViewSection
738
+ key={customType.id}
739
+ // @ts-expect-error - TODO: Fix this when we are able to release editor packages
740
+ title={
741
+ <Text>
742
+ {fieldId} <Text color="grey11">→ {customType.id}</Text>
743
+ </Text>
744
+ }
745
+ subtitle={getPickedFieldsLabel(
746
+ countPickedFields(nestedCtFieldsCheckMap).pickedFields,
747
+ )}
748
+ badge={getTypeFormatLabel(customType.format)}
749
+ >
750
+ {renderedFields.length > 0 ? renderedFields : <NoFieldsAvailable />}
751
+ </TreeViewSection>
752
+ );
753
+ }
754
+
755
+ function NoFieldsAvailable() {
756
+ return <Text color="grey11">No available fields to select</Text>;
757
+ }
758
+
759
+ interface TreeViewLeafGroupFieldProps {
760
+ group: Group;
761
+ groupId: string;
762
+ fieldCheckMap: PickerLeafGroupFieldValue;
763
+ onChange: (newValue: PickerLeafGroupFieldValue) => void;
764
+ }
765
+
766
+ function TreeViewLeafGroupField(props: TreeViewLeafGroupFieldProps) {
767
+ const {
768
+ group,
769
+ groupId,
770
+ fieldCheckMap: groupFieldsCheckMap,
771
+ onChange: onGroupFieldChange,
772
+ } = props;
773
+
774
+ if (!group.config?.fields) return null;
775
+
776
+ const renderedFields = getGroupFields(group).map(({ fieldId }) => {
777
+ const onCheckedChange = (newChecked: boolean) => {
778
+ onGroupFieldChange({
779
+ ...groupFieldsCheckMap,
780
+ [fieldId]: { type: "checkbox", value: newChecked },
781
+ });
782
+ };
783
+
784
+ return (
785
+ <TreeViewCheckbox
786
+ key={fieldId}
787
+ title={fieldId}
788
+ checked={groupFieldsCheckMap[fieldId]?.value === true}
789
+ onCheckedChange={onCheckedChange}
790
+ />
791
+ );
792
+ });
793
+
794
+ return (
795
+ <TreeViewSection
796
+ key={groupId}
797
+ title={groupId}
798
+ subtitle={getPickedFieldsLabel(
799
+ countPickedFields(groupFieldsCheckMap).pickedFields,
800
+ )}
801
+ badge="Group"
802
+ >
803
+ {renderedFields.length > 0 ? renderedFields : <NoFieldsAvailable />}
804
+ </TreeViewSection>
805
+ );
806
+ }
807
+
808
+ interface TreeViewFirstLevelGroupFieldProps {
809
+ group: Group;
810
+ groupId: string;
811
+ fieldCheckMap: PickerFirstLevelGroupFieldValue;
812
+ onChange: (newValue: PickerFirstLevelGroupFieldValue) => void;
813
+ allCustomTypes: CustomType[];
814
+ }
815
+
816
+ function TreeViewFirstLevelGroupField(
817
+ props: TreeViewFirstLevelGroupFieldProps,
818
+ ) {
819
+ const {
820
+ group,
821
+ groupId,
822
+ fieldCheckMap: groupFieldsCheckMap,
823
+ onChange: onGroupFieldChange,
824
+ allCustomTypes,
825
+ } = props;
826
+
827
+ const renderedFields = getGroupFields(group).map(({ fieldId, field }) => {
828
+ // Content relationship field with custom types
829
+
830
+ if (isContentRelationshipFieldWithSingleCustomtype(field)) {
831
+ const onContentRelationshipFieldChange = (
832
+ newCrFields: PickerContentRelationshipFieldValue,
833
+ ) => {
834
+ onGroupFieldChange({
835
+ ...groupFieldsCheckMap,
836
+ [fieldId]: {
837
+ type: "contentRelationship",
838
+ value: newCrFields,
839
+ },
840
+ });
841
+ };
842
+
843
+ const crFieldCheckMap = groupFieldsCheckMap[fieldId] ?? {};
844
+
845
+ return (
846
+ <TreeViewContentRelationshipField
847
+ key={fieldId}
848
+ field={field}
849
+ fieldId={fieldId}
850
+ fieldCheckMap={
851
+ crFieldCheckMap.type === "contentRelationship"
852
+ ? crFieldCheckMap.value
853
+ : {}
854
+ }
855
+ onChange={onContentRelationshipFieldChange}
856
+ allCustomTypes={allCustomTypes}
857
+ />
858
+ );
859
+ }
860
+
861
+ // Regular field
862
+
863
+ const onCheckedChange = (newChecked: boolean) => {
864
+ onGroupFieldChange({
865
+ ...groupFieldsCheckMap,
866
+ [fieldId]: { type: "checkbox", value: newChecked },
867
+ });
868
+ };
869
+
870
+ return (
871
+ <TreeViewCheckbox
872
+ key={fieldId}
873
+ title={fieldId}
874
+ checked={groupFieldsCheckMap[fieldId]?.value === true}
875
+ onCheckedChange={onCheckedChange}
876
+ />
877
+ );
878
+ });
879
+
880
+ return (
881
+ <TreeViewSection
882
+ key={groupId}
883
+ title={groupId}
884
+ subtitle={getPickedFieldsLabel(
885
+ countPickedFields(groupFieldsCheckMap).pickedFields,
886
+ )}
887
+ badge="Group"
888
+ >
889
+ {renderedFields.length > 0 ? renderedFields : <NoFieldsAvailable />}
890
+ </TreeViewSection>
891
+ );
892
+ }
893
+
894
+ function getPickedFieldsLabel(count: number, suffix = "selected") {
895
+ if (count === 0) return undefined;
896
+ return `(${count} ${pluralize(count, "field", "fields")} ${suffix})`;
897
+ }
898
+
899
+ function getTypeFormatLabel(format: CustomType["format"]) {
900
+ return format === "page" ? "Page type" : "Custom type";
901
+ }
902
+
903
+ /** Retrieves all existing page & custom types. */
904
+ function useCustomTypes(linkCustomtypes: LinkCustomtypes | undefined): {
905
+ /** Every existing custom type, used to discover nested custom types down the tree and the add type dropdown. */
906
+ allCustomTypes: CustomType[];
907
+ /** The custom types that are already picked. */
908
+ pickedCustomTypes: CustomType[];
909
+ } {
910
+ const { customTypes: allCustomTypes } = useCustomTypesRequest();
911
+
912
+ useEffect(() => {
913
+ void revalidateGetCustomTypes();
914
+ }, []);
915
+
916
+ if (!linkCustomtypes) {
917
+ return {
918
+ allCustomTypes,
919
+ pickedCustomTypes: [],
920
+ };
921
+ }
922
+
923
+ const pickedCustomTypes = linkCustomtypes.flatMap(
924
+ (pickedCt) => allCustomTypes.find((ct) => ct.id === getId(pickedCt)) ?? [],
925
+ );
926
+
927
+ return {
928
+ allCustomTypes,
929
+ pickedCustomTypes,
930
+ };
931
+ }
932
+
933
+ function resolveContentRelationshipCustomTypes(
934
+ linkCustomtypes: LinkCustomtypes,
935
+ allCustomTypes: CustomType[],
936
+ ): CustomType[] {
937
+ return linkCustomtypes.flatMap((linkCustomtype) => {
938
+ return allCustomTypes.find((ct) => ct.id === getId(linkCustomtype)) ?? [];
939
+ });
940
+ }
941
+
942
+ /**
943
+ * Converts a Link config `customtypes` ({@link LinkCustomtypes}) structure into
944
+ * picker fields check map ({@link PickerCustomTypes}).
945
+ */
946
+ export function convertLinkCustomtypesToFieldCheckMap(args: {
947
+ linkCustomtypes: LinkCustomtypes;
948
+ allCustomTypes?: CustomType[];
949
+ }): PickerCustomTypes {
950
+ const { linkCustomtypes, allCustomTypes } = args;
951
+
952
+ // If allCustomTypes is undefined, avoid checking if the fields exist.
953
+ const shouldValidate = allCustomTypes !== undefined;
954
+
955
+ const checkMap = linkCustomtypes.reduce<PickerCustomTypes>(
956
+ (customTypes, customType) => {
957
+ if (typeof customType === "string") return customTypes;
958
+
959
+ let ctFlatFieldMap: Record<string, NestableWidget | Group> = {};
960
+
961
+ if (shouldValidate) {
962
+ const existingCt = allCustomTypes.find((c) => c.id === customType.id);
963
+ // Exit early if the custom type doesn't exist
964
+ if (!existingCt) return customTypes;
965
+
966
+ ctFlatFieldMap = getCustomTypeStaticFieldsMap(existingCt);
967
+ }
968
+
969
+ const customTypeFields = customType.fields.reduce<PickerCustomType>(
970
+ (fields, field) => {
971
+ // Check if the field exists (only if validating)
972
+ const existingField = ctFlatFieldMap[getId(field)];
973
+ if (shouldValidate && existingField === undefined) return fields;
974
+
975
+ // Regular field
976
+ if (typeof field === "string") {
977
+ // Check if the field matched the existing one in the custom type (only if validating)
978
+ if (
979
+ shouldValidate &&
980
+ existingField !== undefined &&
981
+ existingField.type === "Group"
982
+ ) {
983
+ return fields;
984
+ }
985
+
986
+ fields[field] = { type: "checkbox", value: true };
987
+ return fields;
988
+ }
989
+
990
+ // Group field
991
+ if ("fields" in field && field.fields !== undefined) {
992
+ // Check if the field matched the existing one in the custom type (only if validating)
993
+ if (
994
+ shouldValidate &&
995
+ existingField !== undefined &&
996
+ existingField.type !== "Group"
997
+ ) {
998
+ return fields;
999
+ }
1000
+
1001
+ const groupFieldCheckMap = createGroupFieldCheckMap({
1002
+ group: field,
1003
+ allCustomTypes,
1004
+ ctFlatFieldMap,
1005
+ });
1006
+
1007
+ if (groupFieldCheckMap) {
1008
+ fields[field.id] = groupFieldCheckMap;
1009
+ }
1010
+
1011
+ return fields;
1012
+ }
1013
+
1014
+ // Content relationship field
1015
+ if ("customtypes" in field && field.customtypes !== undefined) {
1016
+ // Check if the field matched the existing one in the custom type (only if validating)
1017
+ if (
1018
+ shouldValidate &&
1019
+ existingField !== undefined &&
1020
+ !isContentRelationshipField(existingField)
1021
+ ) {
1022
+ return fields;
1023
+ }
1024
+
1025
+ const crFieldCheckMap = createContentRelationshipFieldCheckMap({
1026
+ field,
1027
+ allCustomTypes,
1028
+ });
1029
+
1030
+ if (crFieldCheckMap) {
1031
+ fields[field.id] = crFieldCheckMap;
1032
+ }
1033
+
1034
+ return fields;
1035
+ }
1036
+
1037
+ return fields;
1038
+ },
1039
+ {},
1040
+ );
1041
+
1042
+ if (Object.keys(customTypeFields).length > 0) {
1043
+ customTypes[customType.id] = customTypeFields;
1044
+ }
1045
+
1046
+ return customTypes;
1047
+ },
1048
+ {},
1049
+ );
1050
+
1051
+ return checkMap;
1052
+ }
1053
+
1054
+ function createGroupFieldCheckMap(args: {
1055
+ group: LinkCustomtypesGroupFieldValue;
1056
+ allCustomTypes?: CustomType[];
1057
+ ctFlatFieldMap: Record<string, NestableWidget | Group>;
1058
+ }): PickerFirstLevelGroupField | undefined {
1059
+ const { group, ctFlatFieldMap, allCustomTypes } = args;
1060
+
1061
+ // If allCustomTypes is undefined, avoid checking if the fields exist.
1062
+ const shouldValidate = allCustomTypes !== undefined;
1063
+
1064
+ const fieldEntries = group.fields.reduce<PickerFirstLevelGroupFieldValue>(
1065
+ (fields, field) => {
1066
+ // Check if the field exists (only if validating)
1067
+ const existingField = getGroupFieldFromMap(
1068
+ ctFlatFieldMap,
1069
+ group.id,
1070
+ getId(field),
1071
+ );
1072
+ if (shouldValidate && !existingField) return fields;
1073
+
1074
+ // Regular field
1075
+ if (typeof field === "string") {
1076
+ // Check if the field matched the existing one in the custom type (only if validating)
1077
+ if (
1078
+ shouldValidate &&
1079
+ existingField !== undefined &&
1080
+ existingField.type === "Group"
1081
+ ) {
1082
+ return fields;
1083
+ }
1084
+
1085
+ fields[field] = { type: "checkbox", value: true };
1086
+ return fields;
1087
+ }
1088
+
1089
+ // Content relationship field
1090
+ if ("customtypes" in field && field.customtypes !== undefined) {
1091
+ // Check if the field matched the existing one in the custom type (only if validating)
1092
+ if (
1093
+ shouldValidate &&
1094
+ existingField !== undefined &&
1095
+ !isContentRelationshipField(existingField)
1096
+ ) {
1097
+ return fields;
1098
+ }
1099
+
1100
+ const crFieldCheckMap = createContentRelationshipFieldCheckMap({
1101
+ field,
1102
+ allCustomTypes,
1103
+ });
1104
+
1105
+ if (crFieldCheckMap) {
1106
+ fields[field.id] = crFieldCheckMap;
1107
+ }
1108
+
1109
+ return fields;
1110
+ }
1111
+
1112
+ return fields;
1113
+ },
1114
+ {},
1115
+ );
1116
+
1117
+ if (Object.keys(fieldEntries).length === 0) return undefined;
1118
+
1119
+ return {
1120
+ type: "group",
1121
+ value: fieldEntries,
1122
+ };
1123
+ }
1124
+
1125
+ function createContentRelationshipFieldCheckMap(args: {
1126
+ field: LinkCustomtypesContentRelationshipFieldValue;
1127
+ allCustomTypes?: CustomType[];
1128
+ }): PickerContentRelationshipField | undefined {
1129
+ const { field, allCustomTypes } = args;
1130
+
1131
+ // If allCustomTypes is undefined, avoid checking if the fields exists.
1132
+ const shouldValidate = allCustomTypes !== undefined;
1133
+
1134
+ const fieldEntries =
1135
+ field.customtypes.reduce<PickerContentRelationshipFieldValue>(
1136
+ (customTypes, customType) => {
1137
+ if (typeof customType === "string") return customTypes;
1138
+
1139
+ let ctFlatFieldMap: Record<string, NestableWidget | Group> = {};
1140
+
1141
+ if (shouldValidate) {
1142
+ const existingCt = allCustomTypes.find((c) => c.id === customType.id);
1143
+ // Exit early if the custom type doesn't exist
1144
+ if (!existingCt) return customTypes;
1145
+
1146
+ ctFlatFieldMap = getCustomTypeStaticFieldsMap(existingCt);
1147
+ }
1148
+
1149
+ const ctFields = customType.fields.reduce<PickerNestedCustomTypeValue>(
1150
+ (nestedFields, nestedField) => {
1151
+ // Regular field
1152
+ if (typeof nestedField === "string") {
1153
+ const existingField = ctFlatFieldMap[nestedField];
1154
+
1155
+ // Check if the field matched the existing one in the custom type (only if validating)
1156
+ if (
1157
+ shouldValidate &&
1158
+ (existingField === undefined || existingField.type === "Group")
1159
+ ) {
1160
+ return nestedFields;
1161
+ }
1162
+
1163
+ nestedFields[nestedField] = { type: "checkbox", value: true };
1164
+ return nestedFields;
1165
+ }
1166
+
1167
+ if ("fields" in nestedField && nestedField.fields !== undefined) {
1168
+ // Group field
1169
+ const groupFields =
1170
+ nestedField.fields.reduce<PickerLeafGroupFieldValue>(
1171
+ (groupFields, groupField) => {
1172
+ const existingField = getGroupFieldFromMap(
1173
+ ctFlatFieldMap,
1174
+ nestedField.id,
1175
+ groupField,
1176
+ );
1177
+
1178
+ // Check if the field matched the existing one in the custom type (only if validating)
1179
+ if (
1180
+ shouldValidate &&
1181
+ (existingField === undefined ||
1182
+ existingField.type === "Group")
1183
+ ) {
1184
+ return groupFields;
1185
+ }
1186
+
1187
+ groupFields[groupField] = { type: "checkbox", value: true };
1188
+ return groupFields;
1189
+ },
1190
+ {},
1191
+ );
1192
+
1193
+ if (Object.keys(groupFields).length > 0) {
1194
+ nestedFields[nestedField.id] = {
1195
+ type: "group",
1196
+ value: groupFields,
1197
+ };
1198
+ }
1199
+ }
1200
+
1201
+ return nestedFields;
1202
+ },
1203
+ {},
1204
+ );
1205
+
1206
+ if (Object.keys(ctFields).length > 0) {
1207
+ customTypes[customType.id] = ctFields;
1208
+ }
1209
+
1210
+ return customTypes;
1211
+ },
1212
+ {},
1213
+ );
1214
+
1215
+ if (Object.keys(fieldEntries).length === 0) return undefined;
1216
+
1217
+ return {
1218
+ type: "contentRelationship",
1219
+ value: fieldEntries,
1220
+ };
1221
+ }
1222
+
1223
+ /**
1224
+ * Merges the existing Link `customtypes` array with the picker state, ensuring
1225
+ * that conversions from to string (custom type id) to object and vice versa are
1226
+ * made correctly and that the order is preserved.
1227
+ */
1228
+ function mergeAndConvertCheckMapToLinkCustomtypes(args: {
1229
+ linkCustomtypes: LinkCustomtypes | undefined;
1230
+ fieldCheckMap: PickerCustomTypes;
1231
+ newCustomType: PickerCustomType;
1232
+ customTypeId: string;
1233
+ }): LinkCustomtypes {
1234
+ const { linkCustomtypes, fieldCheckMap, newCustomType, customTypeId } = args;
1235
+
1236
+ const result: NonReadonly<LinkCustomtypes> = [];
1237
+ const pickerLinkCustomtypes = convertFieldCheckMapToLinkCustomtypes({
1238
+ ...fieldCheckMap,
1239
+ [customTypeId]: newCustomType,
1240
+ });
1241
+
1242
+ if (!linkCustomtypes) return pickerLinkCustomtypes;
1243
+
1244
+ for (const existingLinkCt of linkCustomtypes) {
1245
+ const existingPickerLinkCt = pickerLinkCustomtypes.find((ct) => {
1246
+ return getId(ct) === getId(existingLinkCt);
1247
+ });
1248
+
1249
+ if (existingPickerLinkCt !== undefined) {
1250
+ // Custom type with exposed fields, keep the customtypes object
1251
+ result.push(existingPickerLinkCt);
1252
+ } else if (getId(existingLinkCt) === customTypeId) {
1253
+ // Custom type that had exposed fields, but now has none, change to string
1254
+ result.push(getId(existingLinkCt));
1255
+ } else {
1256
+ // Custom type without exposed fields, keep the string
1257
+ result.push(existingLinkCt);
1258
+ }
1259
+ }
1260
+
1261
+ return result;
1262
+ }
1263
+
1264
+ /**
1265
+ * Converts a picker fields check map structure ({@link PickerCustomTypes}) into
1266
+ * Link config `customtypes` ({@link LinkCustomtypes}) and filter out empty Custom
1267
+ * types.
1268
+ */
1269
+ function convertFieldCheckMapToLinkCustomtypes(
1270
+ checkMap: PickerCustomTypes,
1271
+ ): LinkCustomtypes {
1272
+ return Object.entries(checkMap).flatMap<LinkCustomtypes[number]>(
1273
+ ([ctId, ctFields]) => {
1274
+ const fields = Object.entries(ctFields).flatMap<
1275
+ | string
1276
+ | LinkCustomtypesContentRelationshipFieldValue
1277
+ | LinkCustomtypesGroupFieldValue
1278
+ >(([fieldId, fieldValue]) => {
1279
+ // First level group field
1280
+ if (fieldValue.type === "group") {
1281
+ const fields = Object.entries(fieldValue.value).flatMap<
1282
+ string | LinkCustomtypesContentRelationshipFieldValue
1283
+ >(([fieldId, fieldValue]) => {
1284
+ if (fieldValue.type === "checkbox") {
1285
+ return fieldValue.value ? fieldId : [];
1286
+ }
1287
+
1288
+ const customTypes = createContentRelationshipLinkCustomtypes(
1289
+ fieldValue.value,
1290
+ );
1291
+
1292
+ return customTypes.length > 0
1293
+ ? { id: fieldId, customtypes: customTypes }
1294
+ : [];
1295
+ });
1296
+
1297
+ return fields.length > 0 ? { id: fieldId, fields } : [];
1298
+ }
1299
+
1300
+ // Content relationship field
1301
+ if (fieldValue.type === "contentRelationship") {
1302
+ const customTypes = createContentRelationshipLinkCustomtypes(
1303
+ fieldValue.value,
1304
+ );
1305
+
1306
+ return customTypes.length > 0
1307
+ ? { id: fieldId, customtypes: customTypes }
1308
+ : [];
1309
+ }
1310
+
1311
+ // Regular field
1312
+ return fieldValue.value ? fieldId : [];
1313
+ });
1314
+
1315
+ return fields.length > 0 ? { id: ctId, fields } : [];
1316
+ },
1317
+ );
1318
+ }
1319
+
1320
+ function createContentRelationshipLinkCustomtypes(
1321
+ value: PickerContentRelationshipFieldValue,
1322
+ ): LinkCustomtypesContentRelationshipFieldValue["customtypes"] {
1323
+ return Object.entries(value).flatMap(
1324
+ ([nestedCustomTypeId, nestedCustomTypeFields]) => {
1325
+ const fields = Object.entries(nestedCustomTypeFields).flatMap(
1326
+ ([nestedFieldId, nestedFieldValue]) => {
1327
+ // Leaf group field
1328
+ if (nestedFieldValue.type === "group") {
1329
+ const nestedGroupFields = Object.entries(
1330
+ nestedFieldValue.value,
1331
+ ).flatMap<string>(([fieldId, fieldValue]) => {
1332
+ // Regular field
1333
+ return fieldValue.type === "checkbox" && fieldValue.value
1334
+ ? fieldId
1335
+ : [];
1336
+ });
1337
+
1338
+ return nestedGroupFields.length > 0
1339
+ ? { id: nestedFieldId, fields: nestedGroupFields }
1340
+ : [];
1341
+ }
1342
+
1343
+ return nestedFieldValue.value ? nestedFieldId : [];
1344
+ },
1345
+ );
1346
+
1347
+ return fields.length > 0 ? { id: nestedCustomTypeId, fields } : [];
1348
+ },
1349
+ );
1350
+ }
1351
+
1352
+ type CountPickedFieldsResult = {
1353
+ pickedFields: number;
1354
+ nestedPickedFields: number;
1355
+ };
1356
+
1357
+ /**
1358
+ * Generic recursive function that goes down the fields check map and counts all
1359
+ * the properties that are set to true, which correspond to selected fields.
1360
+ *
1361
+ * Distinguishes between all picked fields and nested picked fields within a
1362
+ * content relationship field.
1363
+ *
1364
+ * It's not type safe, but checks the type of the values at runtime so that
1365
+ * it only recurses into valid objects, and only counts checkbox fields.
1366
+ */
1367
+ export function countPickedFields(
1368
+ fields: Record<string, unknown> | undefined,
1369
+ isNested = false,
1370
+ ): CountPickedFieldsResult {
1371
+ if (!fields) return { pickedFields: 0, nestedPickedFields: 0 };
1372
+
1373
+ return Object.values(fields).reduce<CountPickedFieldsResult>(
1374
+ (result, value) => {
1375
+ if (!isValidObject(value)) return result;
1376
+
1377
+ if ("type" in value && value.type === "checkbox") {
1378
+ const isChecked = Boolean(value.value);
1379
+ if (!isChecked) return result;
1380
+
1381
+ return {
1382
+ pickedFields: result.pickedFields + 1,
1383
+ nestedPickedFields: result.nestedPickedFields + (isNested ? 1 : 0),
1384
+ };
1385
+ }
1386
+
1387
+ if ("type" in value && value.type === "contentRelationship") {
1388
+ const { pickedFields, nestedPickedFields } = countPickedFields(
1389
+ value,
1390
+ true,
1391
+ );
1392
+
1393
+ return {
1394
+ pickedFields: result.pickedFields + pickedFields,
1395
+ nestedPickedFields: result.nestedPickedFields + nestedPickedFields,
1396
+ };
1397
+ }
1398
+
1399
+ const { pickedFields, nestedPickedFields } = countPickedFields(
1400
+ value,
1401
+ isNested,
1402
+ );
1403
+
1404
+ return {
1405
+ pickedFields: result.pickedFields + pickedFields,
1406
+ nestedPickedFields: result.nestedPickedFields + nestedPickedFields,
1407
+ };
1408
+ },
1409
+ {
1410
+ pickedFields: 0,
1411
+ nestedPickedFields: 0,
1412
+ },
1413
+ );
1414
+ }
1415
+
1416
+ function isContentRelationshipField(field: DynamicWidget): field is Link {
1417
+ return field.type === "Link" && field.config?.select === "document";
1418
+ }
1419
+
1420
+ /**
1421
+ * Check if the field is a Content Relationship Link with a **single** custom
1422
+ * type. CRs with multiple custom types are not currently supported (legacy).
1423
+ */
1424
+ function isContentRelationshipFieldWithSingleCustomtype(
1425
+ field: NestableWidget | Group,
1426
+ ): field is Link {
1427
+ return !!(
1428
+ isContentRelationshipField(field) &&
1429
+ field.config?.customtypes &&
1430
+ field.config.customtypes.length === 1
1431
+ );
1432
+ }
1433
+
1434
+ /**
1435
+ * Flattens all custom type tabs and fields into an array of [fieldId, field] tuples.
1436
+ * Also filters out invalid fields.
1437
+ */
1438
+ function getCustomTypeStaticFields(
1439
+ customType: CustomType,
1440
+ ): [fieldId: string, field: NestableWidget | Group][] {
1441
+ return Object.values(customType.json).flatMap((tabFields) => {
1442
+ return Object.entries(tabFields).flatMap<[string, NestableWidget | Group]>(
1443
+ ([fieldId, field]) => {
1444
+ return isValidField(fieldId, field) ? [[fieldId, field]] : [];
1445
+ },
1446
+ );
1447
+ });
1448
+ }
1449
+
1450
+ /**
1451
+ * Flattens all custom type tabs and fields into a map of field ids to fields.
1452
+ * Also filters out invalid fields.
1453
+ */
1454
+ function getCustomTypeStaticFieldsMap(
1455
+ customType: CustomType,
1456
+ ): Record<string, NestableWidget | Group> {
1457
+ return Object.fromEntries(getCustomTypeStaticFields(customType));
1458
+ }
1459
+
1460
+ function getGroupFieldFromMap(
1461
+ flattenFields: Record<string, NestableWidget | Group>,
1462
+ groupId: string,
1463
+ fieldId: string,
1464
+ ) {
1465
+ const group = flattenFields[groupId];
1466
+ if (group === undefined || group.type !== "Group") return undefined;
1467
+ return group.config?.fields?.[fieldId];
1468
+ }
1469
+
1470
+ function isValidField(
1471
+ fieldId: string,
1472
+ field: DynamicWidget,
1473
+ ): field is NestableWidget | Group {
1474
+ return (
1475
+ field.type !== "Slices" &&
1476
+ field.type !== "Choice" &&
1477
+ // We don't display uid fields because they're a special field returned by
1478
+ // the API and they're not included in the document data object.
1479
+ // We also filter by key "uid", because (as of the time of writing this)
1480
+ // creating any field with that API id will result in it being used for
1481
+ // metadata, regardless of its type.
1482
+ field.type !== "UID" &&
1483
+ fieldId !== "uid"
1484
+ );
1485
+ }
1486
+
1487
+ function getGroupFields(group: Group) {
1488
+ if (!group.config?.fields) return [];
1489
+ return Object.entries(group.config.fields).map(([fieldId, field]) => {
1490
+ return { fieldId, field: field as NestableWidget };
1491
+ });
1492
+ }
1493
+
1494
+ /** If it's a string, return it, otherwise return the `id` property. */
1495
+ function getId<T extends string | { id: string }>(customType: T): string {
1496
+ if (typeof customType === "string") return customType;
1497
+ return customType.id;
1498
+ }