slice-machine-ui 2.16.2-alpha.xru-improve-mcp-code-placeholder.1 → 2.16.2-alpha.xru-allow-only-one-ct-in-cr.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 (62) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/87tXF0WZlw5dKkWm9rMCx/_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-59bae5309574bc38.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/{main-1c0e53194ff2e726.js → main-c46f4dcf6e3174bd.js} +1 -1
  11. package/out/_next/static/chunks/pages/_app-03bcaf562c309de9.js +708 -0
  12. package/out/_next/static/chunks/pages/changelog-063c5e11dfc8fd55.js +1 -0
  13. package/out/_next/static/chunks/pages/changes-564336edb0ed18b0.js +1 -0
  14. package/out/_next/static/chunks/pages/custom-types/{[customTypeId]-389d1b7a492fb3e7.js → [customTypeId]-a408f5a660e096a6.js} +1 -1
  15. package/out/_next/static/chunks/pages/{custom-types-2a5fd94ee42ba593.js → custom-types-5acd56959b60346f.js} +1 -1
  16. package/out/_next/static/chunks/pages/{index-02dd147957c8b40f.js → index-0d8cb369de720a35.js} +1 -1
  17. package/out/_next/static/chunks/pages/labs-9630bfb1005be02b.js +1 -0
  18. package/out/_next/static/chunks/pages/page-types/{[pageTypeId]-3589bd1f9138a97b.js → [pageTypeId]-f5e851ebe35049a8.js} +1 -1
  19. package/out/_next/static/chunks/pages/settings-01f4aeb9112a1f87.js +1 -0
  20. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]/simulator-5008e29008aa04f4.js +1 -0
  21. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]-94c9cad9da2c3089.js +1 -0
  22. package/out/_next/static/chunks/pages/slices-4a60cd5f2c71327e.js +1 -0
  23. package/out/_next/static/chunks/webpack-b3522fdebabf510a.js +1 -0
  24. package/out/_next/static/css/cc9b10286400c2b9.css +1 -0
  25. package/out/changelog.html +1 -1
  26. package/out/changes.html +1 -1
  27. package/out/custom-types/[customTypeId].html +1 -1
  28. package/out/custom-types.html +1 -1
  29. package/out/index.html +1 -1
  30. package/out/labs.html +1 -1
  31. package/out/page-types/[pageTypeId].html +1 -1
  32. package/out/settings.html +1 -1
  33. package/out/slices/[lib]/[sliceName]/[variation]/simulator.html +1 -1
  34. package/out/slices/[lib]/[sliceName]/[variation].html +1 -1
  35. package/out/slices.html +1 -1
  36. package/package.json +8 -8
  37. package/src/features/builder/fields/ContentRelationshipFieldPicker.tsx +1203 -0
  38. package/src/features/customTypes/customTypesTable/CustomTypesTable.tsx +2 -4
  39. package/src/features/customTypes/customTypesTable/{useCustomTypes.ts → useCustomTypesAutoRevalidation.tsx} +1 -34
  40. package/src/features/customTypes/useCustomTypes.ts +48 -0
  41. package/src/legacy/lib/models/common/widgets/ContentRelationship/Form.tsx +35 -77
  42. package/src/legacy/lib/models/common/widgets/ContentRelationship/index.ts +44 -14
  43. package/src/legacy/lib/models/common/widgets/Link/index.ts +1 -1
  44. package/src/utils/isValidObject.ts +32 -0
  45. package/out/_next/static/OwlinLC1Rd2lfu9OXRePI/_buildManifest.js +0 -1
  46. package/out/_next/static/chunks/268-6d5fc7642a1b87c8.js +0 -1
  47. package/out/_next/static/chunks/34-2911c905c8a6e9d9.js +0 -1
  48. package/out/_next/static/chunks/630-93339694ef30b82d.js +0 -1
  49. package/out/_next/static/chunks/867-8164160c810122c6.js +0 -1
  50. package/out/_next/static/chunks/882-28837678beff7e51.js +0 -1
  51. package/out/_next/static/chunks/895-8c214ba470a4e23c.js +0 -1
  52. package/out/_next/static/chunks/pages/_app-3322e0a575e40a69.js +0 -657
  53. package/out/_next/static/chunks/pages/changelog-80a618708f44f25f.js +0 -1
  54. package/out/_next/static/chunks/pages/changes-d40c17939854b984.js +0 -1
  55. package/out/_next/static/chunks/pages/labs-c6df252ea5d8fb6f.js +0 -1
  56. package/out/_next/static/chunks/pages/settings-170379902605f38a.js +0 -1
  57. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]/simulator-063fa88ba75f483e.js +0 -1
  58. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/[variation]-6de02b8ed13b680d.js +0 -1
  59. package/out/_next/static/chunks/pages/slices-071bb494907adf0f.js +0 -1
  60. package/out/_next/static/chunks/webpack-2f903acb0cccbf9e.js +0 -1
  61. package/out/_next/static/css/9c9a7de81f9ac811.css +0 -1
  62. /package/out/_next/static/{OwlinLC1Rd2lfu9OXRePI → 87tXF0WZlw5dKkWm9rMCx}/_ssgManifest.js +0 -0
@@ -0,0 +1,1203 @@
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
+ Restrict the selection to a specific type your content editors
318
+ can link to in the Page Builder.
319
+ <br />
320
+ For the selected type, choose which fields to expose in the API
321
+ response.
322
+ </Text>
323
+ {pickedCustomTypes.length > 1 && (
324
+ <Box margin={{ block: 12 }}>
325
+ <Alert
326
+ color="warn"
327
+ subtitle="This field is using the legacy types selection feature. We recommend having only one type selected in order to switch to the new type selection feature."
328
+ />
329
+ </Box>
330
+ )}
331
+ </Box>
332
+ {pickedCustomTypes.map((customType) => (
333
+ <Box
334
+ key={customType.id}
335
+ gap={4}
336
+ padding={8}
337
+ border
338
+ borderRadius={6}
339
+ borderColor="grey6"
340
+ backgroundColor="white"
341
+ justifyContent="space-between"
342
+ >
343
+ {pickedCustomTypes.length > 1 ? (
344
+ <Text>{customType.id}</Text>
345
+ ) : (
346
+ <TreeView>
347
+ <TreeViewCustomType
348
+ customType={customType}
349
+ onChange={(value) =>
350
+ onCustomTypesChange(customType.id, value)
351
+ }
352
+ fieldCheckMap={fieldCheckMap[customType.id] ?? {}}
353
+ allCustomTypes={allCustomTypes}
354
+ />
355
+ </TreeView>
356
+ )}
357
+
358
+ <IconButton
359
+ icon="close"
360
+ size="small"
361
+ onClick={() => removeCustomType(customType.id)}
362
+ sx={{ height: 24, width: 24 }}
363
+ hiddenLabel="Remove type"
364
+ />
365
+ </Box>
366
+ ))}
367
+ </>
368
+ ) : (
369
+ <EmptyView onSelect={addCustomType} allCustomTypes={allCustomTypes} />
370
+ )}
371
+ </Box>
372
+ <Box backgroundColor="white" flexDirection="column" padding={12}>
373
+ <Text variant="normal" color="grey11">
374
+ Have ideas for improving this field?{" "}
375
+ <a
376
+ // TODO: Add real URL: https://linear.app/prismic/issue/DT-2693
377
+ href="https://community.prismic.io/t/TODO"
378
+ target="_blank"
379
+ rel="noopener noreferrer"
380
+ style={{ color: "inherit", textDecoration: "underline" }}
381
+ >
382
+ Please provide your feedback here.
383
+ </a>
384
+ </Text>
385
+ </Box>
386
+ </Box>
387
+ );
388
+ }
389
+
390
+ type EmptyViewProps = {
391
+ onSelect: (customTypeId: string) => void;
392
+ allCustomTypes: CustomType[];
393
+ };
394
+
395
+ function EmptyView(props: EmptyViewProps) {
396
+ const { allCustomTypes, onSelect } = props;
397
+
398
+ return (
399
+ <Box
400
+ flexDirection="column"
401
+ gap={8}
402
+ alignItems="center"
403
+ padding={{ block: 24 }}
404
+ >
405
+ <Box flexDirection="column" alignItems="center" gap={4}>
406
+ <Text variant="h5" color="grey12">
407
+ No types selected yet.
408
+ </Text>
409
+ <Text color="grey11" component="p" align="center">
410
+ Add one type your content editors can link to.
411
+ <br />
412
+ For the selected type, select the fields to include in the API
413
+ response (used in your frontend queries).
414
+ </Text>
415
+ </Box>
416
+ <Box>
417
+ <AddTypeButton allCustomTypes={allCustomTypes} onSelect={onSelect} />
418
+ </Box>
419
+ </Box>
420
+ );
421
+ }
422
+
423
+ type AddTypeButtonProps = {
424
+ onSelect: (customTypeId: string) => void;
425
+ allCustomTypes: CustomType[];
426
+ };
427
+
428
+ function AddTypeButton(props: AddTypeButtonProps) {
429
+ const { allCustomTypes, onSelect } = props;
430
+
431
+ const triggerButton = (
432
+ <Button startIcon="add" color="grey" disabled={allCustomTypes.length === 0}>
433
+ Add type
434
+ </Button>
435
+ );
436
+
437
+ const disabledButton = (
438
+ <Box>
439
+ <Tooltip
440
+ content="No custom type available"
441
+ side="bottom"
442
+ align="start"
443
+ disableHoverableContent
444
+ >
445
+ {triggerButton}
446
+ </Tooltip>
447
+ </Box>
448
+ );
449
+
450
+ if (allCustomTypes.length === 0) return disabledButton;
451
+
452
+ return (
453
+ <Box>
454
+ <DropdownMenu>
455
+ <DropdownMenuTrigger>{triggerButton}</DropdownMenuTrigger>
456
+ <DropdownMenuContent maxHeight={400} minWidth={256} align="center">
457
+ <DropdownMenuLabel>
458
+ <Text color="grey11">Types</Text>
459
+ </DropdownMenuLabel>
460
+ {allCustomTypes.map((customType) => (
461
+ <DropdownMenuItem
462
+ key={customType.id}
463
+ onSelect={() => onSelect(customType.id)}
464
+ >
465
+ <Box alignItems="center" justifyContent="space-between" gap={8}>
466
+ <TextOverflow>
467
+ <Text>{customType.id}</Text>
468
+ </TextOverflow>
469
+ <Badge
470
+ title={
471
+ <Text variant="extraSmall" color="purple11">
472
+ {getTypeFormatLabel(customType.format)}
473
+ </Text>
474
+ }
475
+ color="purple"
476
+ size="small"
477
+ />
478
+ </Box>
479
+ </DropdownMenuItem>
480
+ ))}
481
+ </DropdownMenuContent>
482
+ </DropdownMenu>
483
+ </Box>
484
+ );
485
+ }
486
+
487
+ interface TreeViewCustomTypeProps {
488
+ customType: CustomType;
489
+ fieldCheckMap: PickerCustomType;
490
+ onChange: (newValue: PickerCustomType) => void;
491
+ allCustomTypes: CustomType[];
492
+ }
493
+
494
+ function TreeViewCustomType(props: TreeViewCustomTypeProps) {
495
+ const {
496
+ customType,
497
+ fieldCheckMap: customTypeFieldsCheckMap,
498
+ onChange: onCustomTypeChange,
499
+ allCustomTypes,
500
+ } = props;
501
+
502
+ const renderedFields = getCustomTypeStaticFields(customType).map(
503
+ ({ fieldId, field }) => {
504
+ // Group field
505
+
506
+ if (isGroupField(field)) {
507
+ const onGroupFieldChange = (
508
+ newGroupFields: PickerFirstLevelGroupFieldValue,
509
+ ) => {
510
+ onCustomTypeChange({
511
+ ...customTypeFieldsCheckMap,
512
+ [fieldId]: { type: "group", value: newGroupFields },
513
+ });
514
+ };
515
+
516
+ const groupFieldCheckMap = customTypeFieldsCheckMap[fieldId] ?? {};
517
+
518
+ return (
519
+ <TreeViewFirstLevelGroupField
520
+ key={fieldId}
521
+ group={field}
522
+ groupId={fieldId}
523
+ onChange={onGroupFieldChange}
524
+ fieldCheckMap={
525
+ groupFieldCheckMap.type === "group"
526
+ ? groupFieldCheckMap.value
527
+ : {}
528
+ }
529
+ allCustomTypes={allCustomTypes}
530
+ />
531
+ );
532
+ }
533
+
534
+ // Content relationship field
535
+
536
+ if (isContentRelationshipField(field)) {
537
+ const onContentRelationshipFieldChange = (
538
+ newCrFields: PickerContentRelationshipFieldValue,
539
+ ) => {
540
+ onCustomTypeChange({
541
+ ...customTypeFieldsCheckMap,
542
+ [fieldId]: {
543
+ type: "contentRelationship",
544
+ value: newCrFields,
545
+ },
546
+ });
547
+ };
548
+
549
+ const crFieldCheckMap = customTypeFieldsCheckMap[fieldId] ?? {};
550
+
551
+ return (
552
+ <TreeViewContentRelationshipField
553
+ key={fieldId}
554
+ field={field}
555
+ fieldId={fieldId}
556
+ onChange={onContentRelationshipFieldChange}
557
+ fieldCheckMap={
558
+ crFieldCheckMap.type === "contentRelationship"
559
+ ? crFieldCheckMap.value
560
+ : {}
561
+ }
562
+ allCustomTypes={allCustomTypes}
563
+ />
564
+ );
565
+ }
566
+
567
+ // Regular field
568
+
569
+ const onCheckedChange = (newValue: boolean) => {
570
+ onCustomTypeChange({
571
+ ...customTypeFieldsCheckMap,
572
+ [fieldId]: { type: "checkbox", value: newValue },
573
+ });
574
+ };
575
+
576
+ return (
577
+ <TreeViewCheckbox
578
+ key={fieldId}
579
+ title={fieldId}
580
+ checked={customTypeFieldsCheckMap[fieldId]?.value === true}
581
+ onCheckedChange={onCheckedChange}
582
+ />
583
+ );
584
+ },
585
+ );
586
+
587
+ const exposedFieldsCount = countPickedFields(customTypeFieldsCheckMap);
588
+ return (
589
+ <TreeViewSection
590
+ key={customType.id}
591
+ title={customType.id}
592
+ subtitle={
593
+ exposedFieldsCount > 0
594
+ ? getExposedFieldsLabel(exposedFieldsCount)
595
+ : "(No fields returned in the API)"
596
+ }
597
+ badge={getTypeFormatLabel(customType.format)}
598
+ defaultOpen
599
+ >
600
+ {renderedFields.length > 0 ? (
601
+ renderedFields
602
+ ) : (
603
+ <Text color="grey11">No available fields to select</Text>
604
+ )}
605
+ </TreeViewSection>
606
+ );
607
+ }
608
+
609
+ interface TreeViewContentRelationshipFieldProps {
610
+ fieldId: string;
611
+ field: Link;
612
+ fieldCheckMap: PickerContentRelationshipFieldValue;
613
+ onChange: (newValue: PickerContentRelationshipFieldValue) => void;
614
+ allCustomTypes: CustomType[];
615
+ }
616
+
617
+ function TreeViewContentRelationshipField(
618
+ props: TreeViewContentRelationshipFieldProps,
619
+ ) {
620
+ const {
621
+ field,
622
+ fieldId,
623
+ fieldCheckMap: crFieldsCheckMap,
624
+ onChange: onCrFieldChange,
625
+ allCustomTypes,
626
+ } = props;
627
+
628
+ if (!field.config?.customtypes) return null;
629
+
630
+ const resolvedCustomTypes = resolveContentRelationshipCustomTypes(
631
+ field.config.customtypes,
632
+ allCustomTypes,
633
+ );
634
+
635
+ if (resolvedCustomTypes.length === 0) return null;
636
+
637
+ return (
638
+ <TreeViewSection
639
+ title={fieldId}
640
+ subtitle={getExposedFieldsLabel(countPickedFields(crFieldsCheckMap))}
641
+ >
642
+ {resolvedCustomTypes.map((customType) => {
643
+ if (typeof customType === "string") return null;
644
+
645
+ const onNestedCustomTypeChange = (
646
+ newNestedCustomTypeFields: PickerNestedCustomTypeValue,
647
+ ) => {
648
+ onCrFieldChange({
649
+ ...crFieldsCheckMap,
650
+ [customType.id]: newNestedCustomTypeFields,
651
+ });
652
+ };
653
+
654
+ const nestedCtFieldsCheckMap = crFieldsCheckMap[customType.id] ?? {};
655
+
656
+ const renderedFields = getCustomTypeStaticFields(customType).map(
657
+ ({ fieldId, field }) => {
658
+ // Group field
659
+
660
+ if (isGroupField(field)) {
661
+ const onGroupFieldsChange = (
662
+ newGroupFields: PickerLeafGroupFieldValue,
663
+ ) => {
664
+ onNestedCustomTypeChange({
665
+ ...nestedCtFieldsCheckMap,
666
+ [fieldId]: { type: "group", value: newGroupFields },
667
+ });
668
+ };
669
+
670
+ const groupFieldCheckMap = nestedCtFieldsCheckMap[fieldId] ?? {};
671
+
672
+ return (
673
+ <TreeViewLeafGroupField
674
+ key={fieldId}
675
+ group={field}
676
+ groupId={fieldId}
677
+ onChange={onGroupFieldsChange}
678
+ fieldCheckMap={
679
+ groupFieldCheckMap.type === "group"
680
+ ? groupFieldCheckMap.value
681
+ : {}
682
+ }
683
+ />
684
+ );
685
+ }
686
+
687
+ // Regular field
688
+
689
+ const onCheckedChange = (newChecked: boolean) => {
690
+ onNestedCustomTypeChange({
691
+ ...nestedCtFieldsCheckMap,
692
+ [fieldId]: { type: "checkbox", value: newChecked },
693
+ });
694
+ };
695
+
696
+ return (
697
+ <TreeViewCheckbox
698
+ key={fieldId}
699
+ title={fieldId}
700
+ checked={nestedCtFieldsCheckMap[fieldId]?.value === true}
701
+ onCheckedChange={onCheckedChange}
702
+ />
703
+ );
704
+ },
705
+ );
706
+
707
+ if (renderedFields.length === 0) return null;
708
+
709
+ return (
710
+ <TreeViewSection
711
+ key={customType.id}
712
+ title={customType.id}
713
+ subtitle={getExposedFieldsLabel(
714
+ countPickedFields(nestedCtFieldsCheckMap),
715
+ )}
716
+ badge={getTypeFormatLabel(customType.format)}
717
+ >
718
+ {renderedFields}
719
+ </TreeViewSection>
720
+ );
721
+ })}
722
+ </TreeViewSection>
723
+ );
724
+ }
725
+
726
+ interface TreeViewLeafGroupFieldProps {
727
+ group: Group;
728
+ groupId: string;
729
+ fieldCheckMap: PickerLeafGroupFieldValue;
730
+ onChange: (newValue: PickerLeafGroupFieldValue) => void;
731
+ }
732
+
733
+ function TreeViewLeafGroupField(props: TreeViewLeafGroupFieldProps) {
734
+ const {
735
+ group,
736
+ groupId,
737
+ fieldCheckMap: groupFieldsCheckMap,
738
+ onChange: onGroupFieldChange,
739
+ } = props;
740
+
741
+ if (!group.config?.fields) return null;
742
+
743
+ const renderedFields = getGroupFields(group).map(({ fieldId }) => {
744
+ const onCheckedChange = (newChecked: boolean) => {
745
+ onGroupFieldChange({
746
+ ...groupFieldsCheckMap,
747
+ [fieldId]: { type: "checkbox", value: newChecked },
748
+ });
749
+ };
750
+
751
+ return (
752
+ <TreeViewCheckbox
753
+ key={fieldId}
754
+ title={fieldId}
755
+ checked={groupFieldsCheckMap[fieldId]?.value === true}
756
+ onCheckedChange={onCheckedChange}
757
+ />
758
+ );
759
+ });
760
+
761
+ if (renderedFields.length === 0) return null;
762
+
763
+ return (
764
+ <TreeViewSection
765
+ key={groupId}
766
+ title={groupId}
767
+ subtitle={getExposedFieldsLabel(countPickedFields(groupFieldsCheckMap))}
768
+ badge="Group"
769
+ >
770
+ {renderedFields}
771
+ </TreeViewSection>
772
+ );
773
+ }
774
+
775
+ interface TreeViewFirstLevelGroupFieldProps {
776
+ group: Group;
777
+ groupId: string;
778
+ fieldCheckMap: PickerFirstLevelGroupFieldValue;
779
+ onChange: (newValue: PickerFirstLevelGroupFieldValue) => void;
780
+ allCustomTypes: CustomType[];
781
+ }
782
+
783
+ function TreeViewFirstLevelGroupField(
784
+ props: TreeViewFirstLevelGroupFieldProps,
785
+ ) {
786
+ const {
787
+ group,
788
+ groupId,
789
+ fieldCheckMap: groupFieldsCheckMap,
790
+ onChange: onGroupFieldChange,
791
+ allCustomTypes,
792
+ } = props;
793
+
794
+ if (!group.config?.fields) return null;
795
+
796
+ return (
797
+ <TreeViewSection
798
+ key={groupId}
799
+ title={groupId}
800
+ subtitle={getExposedFieldsLabel(countPickedFields(groupFieldsCheckMap))}
801
+ badge="Group"
802
+ >
803
+ {getGroupFields(group).map(({ fieldId, field }) => {
804
+ if (isContentRelationshipField(field)) {
805
+ const onContentRelationshipFieldChange = (
806
+ newCrFields: PickerContentRelationshipFieldValue,
807
+ ) => {
808
+ onGroupFieldChange({
809
+ ...groupFieldsCheckMap,
810
+ [fieldId]: {
811
+ type: "contentRelationship",
812
+ value: newCrFields,
813
+ },
814
+ });
815
+ };
816
+
817
+ const crFieldCheckMap = groupFieldsCheckMap[fieldId] ?? {};
818
+
819
+ return (
820
+ <TreeViewContentRelationshipField
821
+ key={fieldId}
822
+ field={field}
823
+ fieldId={fieldId}
824
+ fieldCheckMap={
825
+ crFieldCheckMap.type === "contentRelationship"
826
+ ? crFieldCheckMap.value
827
+ : {}
828
+ }
829
+ onChange={onContentRelationshipFieldChange}
830
+ allCustomTypes={allCustomTypes}
831
+ />
832
+ );
833
+ }
834
+
835
+ const onCheckedChange = (newChecked: boolean) => {
836
+ onGroupFieldChange({
837
+ ...groupFieldsCheckMap,
838
+ [fieldId]: { type: "checkbox", value: newChecked },
839
+ });
840
+ };
841
+
842
+ return (
843
+ <TreeViewCheckbox
844
+ key={fieldId}
845
+ title={fieldId}
846
+ checked={groupFieldsCheckMap[fieldId]?.value === true}
847
+ onCheckedChange={onCheckedChange}
848
+ />
849
+ );
850
+ })}
851
+ </TreeViewSection>
852
+ );
853
+ }
854
+
855
+ function getExposedFieldsLabel(count: number) {
856
+ if (count === 0) return undefined;
857
+ return `(${count} ${pluralize(
858
+ count,
859
+ "field",
860
+ "fields",
861
+ )} returned in the API)`;
862
+ }
863
+
864
+ function getTypeFormatLabel(format: CustomType["format"]) {
865
+ return format === "page" ? "Page type" : "Custom type";
866
+ }
867
+
868
+ /** Retrieves all existing page & custom types. */
869
+ function useCustomTypes(value: LinkCustomtypes | undefined): {
870
+ /** Every existing custom type, used to discover nested custom types down the tree and the add type dropdown. */
871
+ allCustomTypes: CustomType[];
872
+ /** The custom types that are already picked. */
873
+ pickedCustomTypes: CustomType[];
874
+ } {
875
+ const { customTypes: allCustomTypes } = useCustomTypesRequest();
876
+
877
+ useEffect(() => {
878
+ void revalidateGetCustomTypes();
879
+ }, []);
880
+
881
+ if (!value) {
882
+ return {
883
+ allCustomTypes,
884
+ pickedCustomTypes: [],
885
+ };
886
+ }
887
+
888
+ const pickedCustomTypes = value.flatMap(
889
+ (pickedCt) => allCustomTypes.find((ct) => ct.id === getId(pickedCt)) ?? [],
890
+ );
891
+
892
+ return {
893
+ allCustomTypes,
894
+ pickedCustomTypes,
895
+ };
896
+ }
897
+
898
+ function resolveContentRelationshipCustomTypes(
899
+ customTypes: LinkCustomtypes,
900
+ localCustomTypes: CustomType[],
901
+ ): CustomType[] {
902
+ const fields = customTypes.flatMap<CustomType>((customType) => {
903
+ if (typeof customType === "string") return [];
904
+ return localCustomTypes.find((ct) => ct.id === customType.id) ?? [];
905
+ });
906
+
907
+ return fields;
908
+ }
909
+
910
+ /**
911
+ * Converts a Link config `customtypes` ({@link LinkCustomtypes}) structure into
912
+ * picker fields check map ({@link PickerCustomTypes}).
913
+ */
914
+ function convertLinkCustomtypesToFieldCheckMap(
915
+ customTypes: LinkCustomtypes,
916
+ ): PickerCustomTypes {
917
+ return customTypes.reduce<PickerCustomTypes>((customTypes, customType) => {
918
+ if (typeof customType === "string") return customTypes;
919
+
920
+ customTypes[customType.id] = customType.fields.reduce<PickerCustomType>(
921
+ (customTypeFields, field) => {
922
+ if (typeof field === "string") {
923
+ // Regular field
924
+ customTypeFields[field] = { type: "checkbox", value: true };
925
+ } else if ("fields" in field && field.fields !== undefined) {
926
+ // Group field
927
+ customTypeFields[field.id] = createGroupFieldCheckMap(field);
928
+ } else if ("customtypes" in field && field.customtypes !== undefined) {
929
+ // Content relationship field
930
+ customTypeFields[field.id] =
931
+ createContentRelationshipFieldCheckMap(field);
932
+ }
933
+
934
+ return customTypeFields;
935
+ },
936
+ {},
937
+ );
938
+ return customTypes;
939
+ }, {});
940
+ }
941
+
942
+ function createGroupFieldCheckMap(
943
+ group: LinkCustomtypesGroupFieldValue,
944
+ ): PickerFirstLevelGroupField {
945
+ return {
946
+ type: "group",
947
+ value: group.fields.reduce<PickerFirstLevelGroupFieldValue>(
948
+ (fields, field) => {
949
+ if (typeof field === "string") {
950
+ // Regular field
951
+ fields[field] = { type: "checkbox", value: true };
952
+ } else if ("customtypes" in field && field.customtypes !== undefined) {
953
+ // Content relationship field
954
+ fields[field.id] = createContentRelationshipFieldCheckMap(field);
955
+ }
956
+
957
+ return fields;
958
+ },
959
+ {},
960
+ ),
961
+ };
962
+ }
963
+
964
+ function createContentRelationshipFieldCheckMap(
965
+ field: LinkCustomtypesContentRelationshipFieldValue,
966
+ ): PickerContentRelationshipField {
967
+ const crField: PickerContentRelationshipField = {
968
+ type: "contentRelationship",
969
+ value: {},
970
+ };
971
+ const crFieldCustomTypes = crField.value;
972
+
973
+ for (const customType of field.customtypes) {
974
+ if (typeof customType === "string") continue;
975
+
976
+ crFieldCustomTypes[customType.id] ??= {};
977
+ const customTypeFields = crFieldCustomTypes[customType.id];
978
+
979
+ for (const nestedField of customType.fields) {
980
+ if (typeof nestedField === "string") {
981
+ // Regular field
982
+ customTypeFields[nestedField] = { type: "checkbox", value: true };
983
+ } else {
984
+ // Group field
985
+ const groupFieldsEntries = nestedField.fields.map(
986
+ (field) => [field, { type: "checkbox", value: true }] as const,
987
+ );
988
+
989
+ if (groupFieldsEntries.length > 0) {
990
+ customTypeFields[nestedField.id] = {
991
+ type: "group",
992
+ value: Object.fromEntries(groupFieldsEntries),
993
+ };
994
+ }
995
+ }
996
+ }
997
+ }
998
+
999
+ return crField;
1000
+ }
1001
+
1002
+ /**
1003
+ * Merges the existing Link `customtypes` array with the picker state, ensuring
1004
+ * that conversions from to string (custom type id) to object and vice versa are
1005
+ * made correctly and that the order is preserved.
1006
+ */
1007
+ function mergeAndConvertCheckMapToLinkCustomtypes(args: {
1008
+ existingLinkCustomtypes: LinkCustomtypes | undefined;
1009
+ previousPickerCustomtypes: PickerCustomTypes;
1010
+ newCustomType: PickerCustomType;
1011
+ customTypeId: string;
1012
+ }): LinkCustomtypes {
1013
+ const {
1014
+ existingLinkCustomtypes,
1015
+ previousPickerCustomtypes,
1016
+ newCustomType,
1017
+ customTypeId,
1018
+ } = args;
1019
+
1020
+ const result: NonReadonly<LinkCustomtypes> = [];
1021
+ const pickerLinkCustomtypes = convertFieldCheckMapToLinkCustomtypes({
1022
+ ...previousPickerCustomtypes,
1023
+ [customTypeId]: newCustomType,
1024
+ });
1025
+
1026
+ if (!existingLinkCustomtypes) return pickerLinkCustomtypes;
1027
+
1028
+ for (const existingLinkCt of existingLinkCustomtypes) {
1029
+ const existingPickerLinkCt = pickerLinkCustomtypes.find((ct) => {
1030
+ return getId(ct) === getId(existingLinkCt);
1031
+ });
1032
+
1033
+ if (existingPickerLinkCt !== undefined) {
1034
+ // Custom type with exposed fields, keep the customtypes object
1035
+ result.push(existingPickerLinkCt);
1036
+ } else if (getId(existingLinkCt) === customTypeId) {
1037
+ // Custom type that had exposed fields, but now has none, change to string
1038
+ result.push(getId(existingLinkCt));
1039
+ } else {
1040
+ // Custom type without exposed fields, keep the string
1041
+ result.push(existingLinkCt);
1042
+ }
1043
+ }
1044
+
1045
+ return result;
1046
+ }
1047
+
1048
+ /**
1049
+ * Converts a picker fields check map structure ({@link PickerCustomTypes}) into
1050
+ * Link config `customtypes` ({@link LinkCustomtypes}) and filter out empty Custom
1051
+ * types.
1052
+ */
1053
+ function convertFieldCheckMapToLinkCustomtypes(
1054
+ checkMap: PickerCustomTypes,
1055
+ ): LinkCustomtypes {
1056
+ return Object.entries(checkMap).flatMap<LinkCustomtypes[number]>(
1057
+ ([ctId, ctFields]) => {
1058
+ const fields = Object.entries(ctFields).flatMap<
1059
+ | string
1060
+ | LinkCustomtypesContentRelationshipFieldValue
1061
+ | LinkCustomtypesGroupFieldValue
1062
+ >(([fieldId, fieldValue]) => {
1063
+ // First level group field
1064
+ if (fieldValue.type === "group") {
1065
+ const fields = Object.entries(fieldValue.value).flatMap<
1066
+ string | LinkCustomtypesContentRelationshipFieldValue
1067
+ >(([fieldId, fieldValue]) => {
1068
+ if (fieldValue.type === "checkbox") {
1069
+ return fieldValue.value ? fieldId : [];
1070
+ }
1071
+
1072
+ const customTypes = createContentRelationshipLinkCustomtypes(
1073
+ fieldValue.value,
1074
+ );
1075
+
1076
+ return customTypes.length > 0
1077
+ ? { id: fieldId, customtypes: customTypes }
1078
+ : [];
1079
+ });
1080
+
1081
+ return fields.length > 0 ? { id: fieldId, fields } : [];
1082
+ }
1083
+
1084
+ // Content relationship field
1085
+ if (fieldValue.type === "contentRelationship") {
1086
+ const customTypes = createContentRelationshipLinkCustomtypes(
1087
+ fieldValue.value,
1088
+ );
1089
+
1090
+ return customTypes.length > 0
1091
+ ? { id: fieldId, customtypes: customTypes }
1092
+ : [];
1093
+ }
1094
+
1095
+ // Regular field
1096
+ return fieldValue.value ? fieldId : [];
1097
+ });
1098
+
1099
+ return fields.length > 0 ? { id: ctId, fields } : [];
1100
+ },
1101
+ );
1102
+ }
1103
+
1104
+ function createContentRelationshipLinkCustomtypes(
1105
+ value: PickerContentRelationshipFieldValue,
1106
+ ): LinkCustomtypesContentRelationshipFieldValue["customtypes"] {
1107
+ return Object.entries(value).flatMap(
1108
+ ([nestedCustomTypeId, nestedCustomTypeFields]) => {
1109
+ const fields = Object.entries(nestedCustomTypeFields).flatMap(
1110
+ ([nestedFieldId, nestedFieldValue]) => {
1111
+ // Leaf group field
1112
+ if (nestedFieldValue.type === "group") {
1113
+ const nestedGroupFields = Object.entries(
1114
+ nestedFieldValue.value,
1115
+ ).flatMap<string>(([fieldId, fieldValue]) => {
1116
+ // Regular field
1117
+ return fieldValue.type === "checkbox" && fieldValue.value
1118
+ ? fieldId
1119
+ : [];
1120
+ });
1121
+
1122
+ return nestedGroupFields.length > 0
1123
+ ? { id: nestedFieldId, fields: nestedGroupFields }
1124
+ : [];
1125
+ }
1126
+
1127
+ return nestedFieldValue.value ? nestedFieldId : [];
1128
+ },
1129
+ );
1130
+
1131
+ return fields.length > 0 ? { id: nestedCustomTypeId, fields } : [];
1132
+ },
1133
+ );
1134
+ }
1135
+
1136
+ /**
1137
+ * Generic recursive function that goes down the fields check map and counts all
1138
+ * the properties that are set to true, which correspond to selected fields.
1139
+ *
1140
+ * It's not type safe, but checks the type of the values at runtime so that
1141
+ * it only recurses into valid objects, and only counts checkbox fields.
1142
+ */
1143
+ function countPickedFields(
1144
+ fields: Record<string, unknown> | undefined,
1145
+ ): number {
1146
+ if (!fields) return 0;
1147
+ return Object.values(fields).reduce<number>((count, value) => {
1148
+ if (!isValidObject(value)) return count;
1149
+ if (isCheckboxValue(value)) return count + (value.value ? 1 : 0);
1150
+ return count + countPickedFields(value);
1151
+ }, 0);
1152
+ }
1153
+ function isCheckboxValue(value: unknown): value is PickerCheckboxField {
1154
+ if (!isValidObject(value)) return false;
1155
+ return "type" in value && value.type === "checkbox";
1156
+ }
1157
+
1158
+ function isGroupField(field: NestableWidget | Group): field is Group {
1159
+ return field.type === "Group";
1160
+ }
1161
+
1162
+ function isContentRelationshipField(
1163
+ field: NestableWidget | Group,
1164
+ ): field is Link {
1165
+ return (
1166
+ field.type === "Link" &&
1167
+ field.config?.select === "document" &&
1168
+ field.config?.customtypes !== undefined
1169
+ );
1170
+ }
1171
+
1172
+ function getCustomTypeStaticFields(customType: CustomType) {
1173
+ return Object.values(customType.json).flatMap((tabFields) => {
1174
+ return Object.entries(tabFields).flatMap(([fieldId, field]) => {
1175
+ if (
1176
+ field.type !== "Slices" &&
1177
+ field.type !== "Choice" &&
1178
+ // Filter out uid fields because it's a special field returned by the
1179
+ // API and is not part of the data object in the document.
1180
+ // We also filter by key "uid", because (as of the time of writing
1181
+ // this), creating any field with that API id will result in it being
1182
+ // used for metadata.
1183
+ (field.type !== "UID" || fieldId !== "uid")
1184
+ ) {
1185
+ return { fieldId, field: field as NestableWidget | Group };
1186
+ }
1187
+
1188
+ return [];
1189
+ });
1190
+ });
1191
+ }
1192
+
1193
+ function getGroupFields(group: Group) {
1194
+ if (!group.config?.fields) return [];
1195
+ return Object.entries(group.config.fields).map(([fieldId, field]) => {
1196
+ return { fieldId, field: field as NestableWidget };
1197
+ });
1198
+ }
1199
+ /** If it's a string, return it, otherwise return the `id` property. */
1200
+ function getId<T extends string | { id: string }>(customType: T): string {
1201
+ if (typeof customType === "string") return customType;
1202
+ return customType.id;
1203
+ }