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