slice-machine-ui 2.16.2-beta.10 → 2.16.2-beta.12

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