@instantdb/components 0.0.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 (128) hide show
  1. package/.env +2 -0
  2. package/.turbo/turbo-build.log +18 -0
  3. package/README.md +78 -0
  4. package/app/App.css +38 -0
  5. package/app/App.tsx +61 -0
  6. package/app/index.css +18 -0
  7. package/app/main.tsx +10 -0
  8. package/dist/components/StyleMe.d.ts +15 -0
  9. package/dist/components/StyleMe.d.ts.map +1 -0
  10. package/dist/components/error-boundary.d.ts +17 -0
  11. package/dist/components/error-boundary.d.ts.map +1 -0
  12. package/dist/components/explorer/edit-namespace-dialog.d.ts +14 -0
  13. package/dist/components/explorer/edit-namespace-dialog.d.ts.map +1 -0
  14. package/dist/components/explorer/edit-row-dialog.d.ts +10 -0
  15. package/dist/components/explorer/edit-row-dialog.d.ts.map +1 -0
  16. package/dist/components/explorer/expandable-deleted-attr.d.ts +15 -0
  17. package/dist/components/explorer/expandable-deleted-attr.d.ts.map +1 -0
  18. package/dist/components/explorer/explorer-layout.d.ts +8 -0
  19. package/dist/components/explorer/explorer-layout.d.ts.map +1 -0
  20. package/dist/components/explorer/index.d.ts +44 -0
  21. package/dist/components/explorer/index.d.ts.map +1 -0
  22. package/dist/components/explorer/inner-explorer.d.ts +16 -0
  23. package/dist/components/explorer/inner-explorer.d.ts.map +1 -0
  24. package/dist/components/explorer/new-namespace-dialog.d.ts +10 -0
  25. package/dist/components/explorer/new-namespace-dialog.d.ts.map +1 -0
  26. package/dist/components/explorer/query-inspector.d.ts +11 -0
  27. package/dist/components/explorer/query-inspector.d.ts.map +1 -0
  28. package/dist/components/explorer/recently-deleted.d.ts +36 -0
  29. package/dist/components/explorer/recently-deleted.d.ts.map +1 -0
  30. package/dist/components/explorer/search-input.d.ts +9 -0
  31. package/dist/components/explorer/search-input.d.ts.map +1 -0
  32. package/dist/components/explorer/table-components.d.ts +16 -0
  33. package/dist/components/explorer/table-components.d.ts.map +1 -0
  34. package/dist/components/explorer/view-settings.d.ts +10 -0
  35. package/dist/components/explorer/view-settings.d.ts.map +1 -0
  36. package/dist/components/rosePineDawnTheme.d.ts +13 -0
  37. package/dist/components/rosePineDawnTheme.d.ts.map +1 -0
  38. package/dist/components/select.d.ts +16 -0
  39. package/dist/components/select.d.ts.map +1 -0
  40. package/dist/components/toast.d.ts +4 -0
  41. package/dist/components/toast.d.ts.map +1 -0
  42. package/dist/components/ui.d.ts +336 -0
  43. package/dist/components/ui.d.ts.map +1 -0
  44. package/dist/config.d.ts +14 -0
  45. package/dist/config.d.ts.map +1 -0
  46. package/dist/hooks/explorer.d.ts +29 -0
  47. package/dist/hooks/explorer.d.ts.map +1 -0
  48. package/dist/hooks/useAttrNotes.d.ts +10 -0
  49. package/dist/hooks/useAttrNotes.d.ts.map +1 -0
  50. package/dist/hooks/useClickOutside.d.ts +3 -0
  51. package/dist/hooks/useClickOutside.d.ts.map +1 -0
  52. package/dist/hooks/useColumnVisibility.d.ts +12 -0
  53. package/dist/hooks/useColumnVisibility.d.ts.map +1 -0
  54. package/dist/hooks/useEditBlobConstraints.d.ts +32 -0
  55. package/dist/hooks/useEditBlobConstraints.d.ts.map +1 -0
  56. package/dist/hooks/useExplorerHistory.d.ts +1 -0
  57. package/dist/hooks/useExplorerHistory.d.ts.map +1 -0
  58. package/dist/hooks/useIsOverflow.d.ts +6 -0
  59. package/dist/hooks/useIsOverflow.d.ts.map +1 -0
  60. package/dist/hooks/useLocalStorage.d.ts +2 -0
  61. package/dist/hooks/useLocalStorage.d.ts.map +1 -0
  62. package/dist/hooks/useMonacoJSONSchema.d.ts +3 -0
  63. package/dist/hooks/useMonacoJSONSchema.d.ts.map +1 -0
  64. package/dist/hooks/useStableDB.d.ts +7 -0
  65. package/dist/hooks/useStableDB.d.ts.map +1 -0
  66. package/dist/index.cjs +15 -0
  67. package/dist/index.d.ts +7 -0
  68. package/dist/index.d.ts.map +1 -0
  69. package/dist/index.js +9270 -0
  70. package/dist/schema.d.ts +5 -0
  71. package/dist/schema.d.ts.map +1 -0
  72. package/dist/style.css +1 -0
  73. package/dist/types.d.ts +241 -0
  74. package/dist/types.d.ts.map +1 -0
  75. package/dist/utils/format.d.ts +2 -0
  76. package/dist/utils/format.d.ts.map +1 -0
  77. package/dist/utils/indexingJobs.d.ts +24 -0
  78. package/dist/utils/indexingJobs.d.ts.map +1 -0
  79. package/dist/utils/parsePermsJSON.d.ts +11 -0
  80. package/dist/utils/parsePermsJSON.d.ts.map +1 -0
  81. package/dist/utils/renames.d.ts +3 -0
  82. package/dist/utils/renames.d.ts.map +1 -0
  83. package/dist/utils/tableWidthSize.d.ts +9 -0
  84. package/dist/utils/tableWidthSize.d.ts.map +1 -0
  85. package/index.html +13 -0
  86. package/package.json +109 -0
  87. package/src/components/StyleMe.tsx +97 -0
  88. package/src/components/error-boundary.tsx +76 -0
  89. package/src/components/explorer/edit-namespace-dialog.tsx +1886 -0
  90. package/src/components/explorer/edit-row-dialog.tsx +1151 -0
  91. package/src/components/explorer/expandable-deleted-attr.tsx +170 -0
  92. package/src/components/explorer/explorer-layout.tsx +156 -0
  93. package/src/components/explorer/index.tsx +217 -0
  94. package/src/components/explorer/inner-explorer.tsx +1341 -0
  95. package/src/components/explorer/new-namespace-dialog.tsx +54 -0
  96. package/src/components/explorer/query-inspector.tsx +394 -0
  97. package/src/components/explorer/recently-deleted.tsx +344 -0
  98. package/src/components/explorer/search-input.tsx +358 -0
  99. package/src/components/explorer/table-components.tsx +341 -0
  100. package/src/components/explorer/view-settings.tsx +75 -0
  101. package/src/components/rosePineDawnTheme.ts +45 -0
  102. package/src/components/select.tsx +198 -0
  103. package/src/components/toast.tsx +18 -0
  104. package/src/components/ui.tsx +1561 -0
  105. package/src/config.ts +61 -0
  106. package/src/hooks/explorer.tsx +125 -0
  107. package/src/hooks/useAttrNotes.ts +27 -0
  108. package/src/hooks/useClickOutside.ts +23 -0
  109. package/src/hooks/useColumnVisibility.ts +39 -0
  110. package/src/hooks/useEditBlobConstraints.ts +185 -0
  111. package/src/hooks/useExplorerHistory.ts +0 -0
  112. package/src/hooks/useIsOverflow.ts +24 -0
  113. package/src/hooks/useLocalStorage.ts +51 -0
  114. package/src/hooks/useMonacoJSONSchema.ts +41 -0
  115. package/src/hooks/useStableDB.ts +30 -0
  116. package/src/index.tsx +8 -0
  117. package/src/schema.ts +285 -0
  118. package/src/style.css +5 -0
  119. package/src/types.ts +359 -0
  120. package/src/utils/format.ts +13 -0
  121. package/src/utils/indexingJobs.ts +126 -0
  122. package/src/utils/parsePermsJSON.ts +35 -0
  123. package/src/utils/renames.ts +42 -0
  124. package/src/utils/tableWidthSize.ts +62 -0
  125. package/tailwind.config.cjs +42 -0
  126. package/tsconfig.json +22 -0
  127. package/vite-env.d.ts +1 -0
  128. package/vite.config.ts +49 -0
@@ -0,0 +1,1886 @@
1
+ import React from 'react';
2
+ import { id } from '@instantdb/core';
3
+ import { InstantReactWebDatabase } from '@instantdb/react';
4
+ import {
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ ReactNode,
10
+ MutableRefObject,
11
+ } from 'react';
12
+ import {
13
+ ArrowLeftIcon,
14
+ PlusIcon,
15
+ TrashIcon,
16
+ ArrowUturnLeftIcon,
17
+ PencilSquareIcon,
18
+ } from '@heroicons/react/24/solid';
19
+ import { errorToast, successToast } from '@lib/components/toast';
20
+ import {
21
+ ActionButton,
22
+ ActionForm,
23
+ Button,
24
+ Checkbox,
25
+ cn,
26
+ Content,
27
+ Divider,
28
+ IconButton,
29
+ InfoTip,
30
+ Label,
31
+ ProgressButton,
32
+ Select,
33
+ TextInput,
34
+ ToggleGroup,
35
+ } from '@lib/components/ui';
36
+ import {
37
+ RelationshipKinds,
38
+ relationshipConstraints,
39
+ relationshipConstraintsInverse,
40
+ } from '@lib/types';
41
+ import {
42
+ CheckedDataType,
43
+ DBAttr,
44
+ InstantIndexingJob,
45
+ InstantIndexingJobInvalidTriple,
46
+ SchemaAttr,
47
+ SchemaNamespace,
48
+ } from '@lib/types';
49
+ import {
50
+ createJob,
51
+ jobFetchLoop,
52
+ jobIsCompleted,
53
+ jobIsErrored,
54
+ } from '@lib/utils/indexingJobs';
55
+ import { useExplorerProps, useExplorerState } from './index';
56
+ import { useClose } from '@headlessui/react';
57
+ import {
58
+ PendingJob,
59
+ useEditBlobConstraints,
60
+ } from '@lib/hooks/useEditBlobConstraints';
61
+ import { RecentlyDeletedAttrs } from './recently-deleted';
62
+ import { useAttrNotes } from '@lib/hooks/useAttrNotes';
63
+ import { createRenameNamespaceOps } from '@lib/utils/renames';
64
+ import { useSWRConfig } from 'swr';
65
+
66
+ export function EditNamespaceDialog({
67
+ db,
68
+ namespace,
69
+ namespaces,
70
+ onClose,
71
+ isSystemCatalogNs,
72
+ }: {
73
+ db: InstantReactWebDatabase<any>;
74
+ namespace: SchemaNamespace;
75
+ namespaces: SchemaNamespace[];
76
+ onClose: (p?: { ok: boolean }) => void;
77
+ readOnly: boolean;
78
+ isSystemCatalogNs: boolean;
79
+ }) {
80
+ const props = useExplorerProps();
81
+ const appId = props.appId;
82
+ const { history, explorerState } = useExplorerState();
83
+ const { mutate } = useSWRConfig();
84
+ const [screen, setScreen] = useState<
85
+ | { type: 'main' }
86
+ | { type: 'delete' }
87
+ | { type: 'rename' }
88
+ | { type: 'add' }
89
+ | { type: 'edit'; attrId: string; isForward: boolean }
90
+ >({ type: 'main' });
91
+
92
+ const [renameNsInput, setRenameNsInput] = useState(namespace.name);
93
+ const [renameNsErrorText, setRenameNsErrorText] = useState<string | null>(
94
+ null,
95
+ );
96
+
97
+ async function deleteNs() {
98
+ const ops = namespace.attrs.map((attr) => ['delete-attr', attr.id]);
99
+ await db.core._reactor.pushOps(ops);
100
+ // update the recently deleted attr cache
101
+ setTimeout(() => {
102
+ mutate(['recently-deleted', appId]);
103
+ }, 500);
104
+ onClose({ ok: true });
105
+ }
106
+
107
+ async function renameNs(newName: string) {
108
+ if (newName.startsWith('$')) {
109
+ setRenameNsErrorText('Namespace name cannot start with $');
110
+ return;
111
+ }
112
+
113
+ const ops = createRenameNamespaceOps(newName, namespace, namespaces);
114
+
115
+ await db.core._reactor.pushOps(ops);
116
+ history.push(
117
+ {
118
+ namespace: newName,
119
+ },
120
+ true,
121
+ );
122
+ successToast('Renamed namespace to ' + newName);
123
+ setRenameNsInput('');
124
+ setScreen({ type: 'main' });
125
+ }
126
+
127
+ const notes = useAttrNotes();
128
+
129
+ const screenAttr = useMemo(() => {
130
+ return (
131
+ screen.type === 'edit' &&
132
+ namespace.attrs.find(
133
+ (a) => a.id === screen.attrId && a.isForward === screen.isForward,
134
+ )
135
+ );
136
+ }, [
137
+ screen.type === 'edit' ? screen.attrId : null,
138
+ screen.type === 'edit' ? screen.isForward : null,
139
+ namespace.attrs,
140
+ ]);
141
+
142
+ return (
143
+ <>
144
+ {screen.type === 'rename' && (
145
+ <div className="px-2">
146
+ <button
147
+ onClick={() => {
148
+ setScreen({
149
+ type: 'main',
150
+ });
151
+ }}
152
+ className="mb-3"
153
+ >
154
+ <ArrowLeftIcon className="h-4 w-4 cursor-pointer" />
155
+ </button>
156
+ <h6 className="text-md pb-2 font-bold">Rename {namespace.name}</h6>
157
+ <form
158
+ onSubmit={(e) => {
159
+ e.preventDefault();
160
+ renameNs(renameNsInput);
161
+ }}
162
+ >
163
+ <Content className="pb-2 text-sm">
164
+ This will immediately rename the namespace. You'll need to{' '}
165
+ <strong className="dark:text-white">update your code</strong> to
166
+ the new name.
167
+ </Content>
168
+ <TextInput
169
+ disabled={isSystemCatalogNs}
170
+ value={renameNsInput}
171
+ onChange={(n) => setRenameNsInput(n)}
172
+ />
173
+ <div className="flex flex-col gap-2 rounded-sm py-2">
174
+ <Button
175
+ type="submit"
176
+ disabled={
177
+ renameNsInput.startsWith('$') || renameNsInput.length === 0
178
+ }
179
+ >
180
+ Rename {namespace.name} → {renameNsInput}
181
+ </Button>
182
+ </div>
183
+ </form>{' '}
184
+ </div>
185
+ )}
186
+
187
+ {screen.type === 'main' ? (
188
+ <div className="flex flex-col gap-4 px-2">
189
+ <div className="mr-8 flex gap-1">
190
+ <h5 className="flex items-center text-lg font-bold">
191
+ {namespace.name}
192
+ </h5>
193
+ <IconButton
194
+ variant="subtle"
195
+ onClick={() => {
196
+ setScreen({ type: 'rename' });
197
+ }}
198
+ icon={
199
+ <PencilSquareIcon className="h-4 w-4 opacity-50"></PencilSquareIcon>
200
+ }
201
+ label="Rename"
202
+ ></IconButton>
203
+
204
+ <Button
205
+ className="ml-4"
206
+ disabled={isSystemCatalogNs}
207
+ title={
208
+ isSystemCatalogNs
209
+ ? `The ${namespace.name} namespace can't be deleted.`
210
+ : undefined
211
+ }
212
+ size="mini"
213
+ variant="secondary"
214
+ onClick={() => setScreen({ type: 'delete' })}
215
+ >
216
+ <TrashIcon className="inline" height="1rem" />
217
+ Delete
218
+ </Button>
219
+ </div>
220
+
221
+ <div className="flex flex-col gap-2">
222
+ {namespace.attrs.map((attr) => (
223
+ <div
224
+ key={attr.id + '-' + attr.name}
225
+ className="flex justify-between"
226
+ >
227
+ <div className="flex items-center gap-3">
228
+ <span className="py-0.5 font-bold">{attr.name}</span>
229
+ {notes.notes[attr.id]?.message && (
230
+ <InfoTip>
231
+ <div className="px-2 text-xs text-gray-500 dark:text-neutral-400">
232
+ {notes.notes[attr.id].message}
233
+ </div>
234
+ </InfoTip>
235
+ )}
236
+ </div>
237
+ {attr.name !== 'id' ? (
238
+ <Button
239
+ className="px-2"
240
+ size="mini"
241
+ variant="subtle"
242
+ onClick={() => {
243
+ notes.removeNote(attr.id);
244
+ setScreen({
245
+ type: 'edit',
246
+ attrId: attr.id,
247
+ isForward: attr.isForward,
248
+ });
249
+ }}
250
+ >
251
+ Edit
252
+ </Button>
253
+ ) : null}
254
+ </div>
255
+ ))}
256
+ </div>
257
+
258
+ <div>
259
+ <Button
260
+ size="mini"
261
+ variant="secondary"
262
+ onClick={() => setScreen({ type: 'add' })}
263
+ >
264
+ <PlusIcon className="inline" height="12px" />
265
+ New attribute
266
+ </Button>
267
+ </div>
268
+ <RecentlyDeletedAttrs
269
+ notes={notes}
270
+ db={db}
271
+ appId={appId}
272
+ namespace={namespace}
273
+ />
274
+ </div>
275
+ ) : screen.type === 'add' ? (
276
+ <AddAttrForm
277
+ db={db}
278
+ namespace={namespace}
279
+ namespaces={namespaces}
280
+ onClose={() => setScreen({ type: 'main' })}
281
+ constraints={getSystemConstraints({
282
+ namespaceName: namespace.name,
283
+ isSystemCatalogNs,
284
+ })}
285
+ />
286
+ ) : screen.type === 'delete' ? (
287
+ <DeleteForm
288
+ name={namespace.name}
289
+ type="namespace"
290
+ onClose={onClose}
291
+ onConfirm={deleteNs}
292
+ />
293
+ ) : screen.type === 'edit' && screenAttr ? (
294
+ <EditAttrForm
295
+ db={db}
296
+ attr={screenAttr}
297
+ onClose={() => setScreen({ type: 'main' })}
298
+ constraints={getSystemConstraints({
299
+ namespaceName: namespace.name,
300
+ isSystemCatalogNs: isSystemCatalogNs,
301
+ attr: screenAttr,
302
+ })}
303
+ />
304
+ ) : null}
305
+ </>
306
+ );
307
+ }
308
+
309
+ function DeleteForm({
310
+ name,
311
+ type,
312
+ onClose,
313
+ onConfirm,
314
+ }: {
315
+ name: string;
316
+ type: 'namespace' | 'attribute';
317
+ onClose: () => void;
318
+ onConfirm: () => void;
319
+ }) {
320
+ return (
321
+ <ActionForm className="min flex flex-col gap-4">
322
+ <h5 className="flex items-center gap-2 text-lg font-bold">
323
+ <ArrowLeftIcon
324
+ height="1rem"
325
+ className="cursor-pointer"
326
+ onClick={() => onClose()}
327
+ />
328
+ Delete {name}
329
+ </h5>
330
+
331
+ <div className="flex flex-col gap-2">
332
+ <p className="pb-2">
333
+ Are you sure you want to delete the <strong>{name}</strong> {type}?
334
+ </p>
335
+ <ActionButton
336
+ variant="destructive"
337
+ label={`Delete ${name}`}
338
+ submitLabel="Deleting..."
339
+ errorMessage="Failed to delete"
340
+ onClick={onConfirm}
341
+ />
342
+ </div>
343
+ </ActionForm>
344
+ );
345
+ }
346
+
347
+ function AddAttrForm({
348
+ db,
349
+ namespace,
350
+ namespaces,
351
+ onClose,
352
+ constraints,
353
+ }: {
354
+ db: InstantReactWebDatabase<any>;
355
+ namespace: SchemaNamespace;
356
+ namespaces: SchemaNamespace[];
357
+ onClose: () => void;
358
+ constraints: SystemConstraints;
359
+ }) {
360
+ const [isRequired, setIsRequired] = useState(false);
361
+ const [isIndex, setIsIndex] = useState(false);
362
+ const [isUniq, setIsUniq] = useState(false);
363
+ const [isCascade, setIsCascade] = useState(false);
364
+ const [isCascadeReverse, setIsCascadeReverse] = useState(false);
365
+ const [checkedDataType, setCheckedDataType] =
366
+ useState<CheckedDataType | null>(null);
367
+ const [attrType, setAttrType] = useState<'blob' | 'ref'>('blob');
368
+ const [relationship, setRelationship] =
369
+ useState<RelationshipKinds>('many-many');
370
+
371
+ const [reverseNamespace, setReverseNamespace] = useState<
372
+ SchemaNamespace | undefined
373
+ >(() => namespaces.find((n) => n.name !== namespace.name) ?? namespaces[0]);
374
+ const [attrName, setAttrName] = useState('');
375
+ const [reverseAttrName, setReverseAttrName] = useState(namespace.name);
376
+
377
+ const isCascadeAllowed =
378
+ relationship === 'one-one' || relationship === 'one-many';
379
+ const isCascadeReverseAllowed =
380
+ relationship === 'one-one' || relationship === 'many-one';
381
+
382
+ const linkValidation = validateLink({
383
+ attrName,
384
+ reverseAttrName,
385
+ namespaceName: namespace.name,
386
+ reverseNamespaceName: reverseNamespace?.name,
387
+ });
388
+
389
+ const canSubmit = attrType === 'blob' ? attrName : linkValidation.isValidLink;
390
+
391
+ useEffect(() => {
392
+ if (attrType !== 'ref') return;
393
+ if (!reverseNamespace) return;
394
+
395
+ const isSelfLink = reverseNamespace.name === namespace.name;
396
+ setAttrName(isSelfLink ? 'parent' : reverseNamespace.name);
397
+ setReverseAttrName(isSelfLink ? 'children' : namespace.name);
398
+ if (isSelfLink) {
399
+ setRelationship('one-many');
400
+ }
401
+ }, [attrType, reverseNamespace]);
402
+
403
+ async function addAttr() {
404
+ if (attrType === 'blob') {
405
+ const attr: DBAttr = {
406
+ id: id(),
407
+ 'forward-identity': [id(), namespace.name, attrName],
408
+ 'value-type': 'blob',
409
+ cardinality: 'one',
410
+ 'unique?': isUniq,
411
+ 'index?': isIndex,
412
+ 'required?': isRequired,
413
+ 'checked-data-type': checkedDataType ?? undefined,
414
+ };
415
+
416
+ const ops = [['add-attr', attr]];
417
+ await db._core._reactor.pushOps(ops);
418
+ onClose();
419
+ } else {
420
+ // invariants
421
+ if (!reverseNamespace) throw new Error('No reverse namespace');
422
+
423
+ const attr: DBAttr = {
424
+ id: id(),
425
+ ...relationshipConstraints[relationship],
426
+ 'forward-identity': [id(), namespace.name, attrName],
427
+ 'reverse-identity': [id(), reverseNamespace.name, reverseAttrName],
428
+ 'value-type': 'ref',
429
+ 'index?': false,
430
+ 'required?': isRequired,
431
+ 'on-delete': isCascadeAllowed && isCascade ? 'cascade' : undefined,
432
+ 'on-delete-reverse':
433
+ isCascadeReverseAllowed && isCascadeReverse ? 'cascade' : undefined,
434
+ };
435
+
436
+ const ops = [['add-attr', attr]];
437
+ await db._core._reactor.pushOps(ops);
438
+ onClose();
439
+ }
440
+ }
441
+
442
+ return (
443
+ <ActionForm className="min flex flex-col gap-4">
444
+ <h5 className="flex items-center gap-2 text-lg font-bold">
445
+ <ArrowLeftIcon className="h-4 w-4 cursor-pointer" onClick={onClose} />
446
+ Add an attribute
447
+ </h5>
448
+
449
+ <div className="flex flex-col gap-1">
450
+ <h6 className="text-md font-bold">Type</h6>
451
+ <ToggleGroup
452
+ ariaLabel="Text alignment"
453
+ selectedId={attrType}
454
+ items={[
455
+ { id: 'blob', label: 'Data' },
456
+ { id: 'ref', label: 'Link' },
457
+ ]}
458
+ onChange={(item) => setAttrType(item.id as 'blob' | 'ref')}
459
+ />
460
+ </div>
461
+ {attrType === 'blob' ? (
462
+ <>
463
+ <div className="flex flex-1 flex-col gap-1">
464
+ <h6 className="text-md font-bold">Name</h6>
465
+ <TextInput value={attrName} onChange={(n) => setAttrName(n)} />
466
+ </div>
467
+ <div className="flex flex-col gap-2">
468
+ <h6 className="text-md font-bold">Constraints</h6>
469
+ <div className="flex gap-2">
470
+ <Checkbox
471
+ disabled={constraints.require.disabled}
472
+ checked={isRequired}
473
+ onChange={(enabled) => setIsRequired(enabled)}
474
+ label={
475
+ <span>
476
+ <strong>Require this attribute</strong> so all entities will
477
+ be guaranteed to have it
478
+ </span>
479
+ }
480
+ title={constraints.require.message}
481
+ />
482
+ </div>
483
+ <div className="flex gap-2">
484
+ <Checkbox
485
+ checked={isIndex}
486
+ onChange={(enabled) => setIsIndex(enabled)}
487
+ label={
488
+ <span>
489
+ <strong>Index this attribute</strong> to improve lookup
490
+ performance of values
491
+ </span>
492
+ }
493
+ />
494
+ </div>
495
+ <div className="flex gap-2">
496
+ <Checkbox
497
+ checked={isUniq}
498
+ onChange={(enabled) => setIsUniq(enabled)}
499
+ label={
500
+ <span>
501
+ <strong>Enforce uniqueness</strong> so no two entities can
502
+ have the same value for this attribute
503
+ </span>
504
+ }
505
+ />
506
+ </div>
507
+ </div>
508
+ <div className="flex flex-col gap-2">
509
+ <h6 className="text-md font-bold">Enforce type</h6>
510
+ <div className="flex gap-2">
511
+ <Select<CheckedDataType | 'none'>
512
+ value={checkedDataType || 'none'}
513
+ onChange={(v) => {
514
+ if (!v) {
515
+ return;
516
+ }
517
+ const { value } = v;
518
+ if (value === 'none') {
519
+ setCheckedDataType(null);
520
+ }
521
+ setCheckedDataType(value as CheckedDataType);
522
+ }}
523
+ options={[
524
+ {
525
+ label: 'Any (not enforced)',
526
+ value: 'none',
527
+ },
528
+ {
529
+ label: 'String',
530
+ value: 'string',
531
+ },
532
+ {
533
+ label: 'Number',
534
+ value: 'number',
535
+ },
536
+ {
537
+ label: 'Boolean',
538
+ value: 'boolean',
539
+ },
540
+ {
541
+ label: 'Date',
542
+ value: 'date',
543
+ },
544
+ ]}
545
+ />
546
+ </div>
547
+ </div>
548
+ </>
549
+ ) : attrType === 'ref' ? (
550
+ <>
551
+ <div className="flex flex-col gap-1">
552
+ <h6 className="text-md font-bold">Link to namespace</h6>
553
+ <Select
554
+ value={reverseNamespace?.id ?? undefined}
555
+ options={namespaces.map((ns) => {
556
+ const label =
557
+ ns.name + (ns.name === namespace.name ? ' (self-link)' : '');
558
+ return {
559
+ label,
560
+ value: ns.id,
561
+ };
562
+ })}
563
+ onChange={(item) => {
564
+ if (!item) return;
565
+ const ns = namespaces.find((n) => n.id === item.value);
566
+
567
+ setReverseNamespace(ns);
568
+ }}
569
+ />
570
+ </div>
571
+
572
+ <RelationshipConfigurator
573
+ relationship={relationship}
574
+ attrName={attrName}
575
+ reverseAttrName={reverseAttrName}
576
+ namespaceName={namespace.name}
577
+ reverseNamespaceName={reverseNamespace?.name}
578
+ setAttrName={setAttrName}
579
+ setReverseAttrName={setReverseAttrName}
580
+ setRelationship={setRelationship}
581
+ isCascadeAllowed={isCascadeAllowed}
582
+ isCascade={isCascade}
583
+ setIsCascade={setIsCascade}
584
+ isCascadeReverseAllowed={isCascadeReverseAllowed}
585
+ isCascadeReverse={isCascadeReverse}
586
+ setIsCascadeReverse={setIsCascadeReverse}
587
+ isRequired={isRequired}
588
+ setIsRequired={setIsRequired}
589
+ constraints={constraints}
590
+ />
591
+ </>
592
+ ) : null}
593
+
594
+ <div className="flex flex-col gap-2">
595
+ <ActionButton
596
+ type="submit"
597
+ label="Create attribute"
598
+ submitLabel="Creating attribute..."
599
+ errorMessage="Failed to create attribute"
600
+ disabled={!canSubmit}
601
+ className="border-gray-500 disabled:opacity-20"
602
+ onClick={addAttr}
603
+ />
604
+ {linkValidation.shouldShowSelfLinkNameError ? (
605
+ <span className="text-red-500">
606
+ Self-links must have different attribute names.
607
+ </span>
608
+ ) : null}
609
+ </div>
610
+ </ActionForm>
611
+ );
612
+ }
613
+
614
+ function jobWorkingStatus(job: InstantIndexingJob | null) {
615
+ if (
616
+ !job ||
617
+ (job.job_status !== 'processing' && job.job_status !== 'waiting')
618
+ ) {
619
+ return;
620
+ }
621
+
622
+ if (job.job_status === 'waiting') {
623
+ return 'Waiting for worker...';
624
+ }
625
+
626
+ if (!job.work_estimate) {
627
+ return 'Estimating work...';
628
+ }
629
+
630
+ const completed = Math.min(job.work_estimate, job.work_completed || 0);
631
+
632
+ const percent = Math.floor((completed / job.work_estimate) * 100);
633
+
634
+ return `${percent}% complete...`;
635
+ }
636
+
637
+ function InvalidTriplesSample({
638
+ indexingJob,
639
+ attr,
640
+ onClickSample,
641
+ }: {
642
+ indexingJob: InstantIndexingJob | null;
643
+ attr: SchemaAttr;
644
+ onClickSample: (triple: InstantIndexingJobInvalidTriple) => void;
645
+ }) {
646
+ if (!indexingJob?.invalid_triples_sample?.length) {
647
+ return;
648
+ }
649
+ return (
650
+ <div>
651
+ Here are the first few invalid entities we found:
652
+ <table className="dark:text-netural-500 mx-2 my-2 flex-1 text-left font-mono text-xs text-gray-500">
653
+ <thead className="bg-white text-gray-700 dark:bg-neutral-800 dark:text-white">
654
+ <tr>
655
+ <th className="pr-2">id</th>
656
+ <th className="max-w-fit pr-2">{attr.name}</th>
657
+ <th className="pr-2">type</th>
658
+ </tr>
659
+ </thead>
660
+ <tbody>
661
+ {indexingJob.invalid_triples_sample.slice(0, 3).map((t, i) => (
662
+ <tr
663
+ key={i}
664
+ className="cursor-pointer rounded-md px-2 whitespace-nowrap hover:bg-gray-200"
665
+ onClick={() => onClickSample(t)}
666
+ >
667
+ <td className="pr-2">
668
+ <pre>{t.entity_id}</pre>
669
+ </td>
670
+ <td className="truncate pr-2" style={{ maxWidth: '12rem' }}>
671
+ {JSON.stringify(t.value)}
672
+ </td>
673
+ <td className="pr-2">{t.json_type}</td>
674
+ </tr>
675
+ ))}
676
+ </tbody>
677
+ </table>
678
+ </div>
679
+ );
680
+ }
681
+
682
+ function IndexingJobError({
683
+ indexingJob,
684
+ attr,
685
+ onClose,
686
+ }: {
687
+ indexingJob?: InstantIndexingJob | null;
688
+ attr: SchemaAttr;
689
+ onClose: () => void;
690
+ }) {
691
+ const { history } = useExplorerState();
692
+ if (!indexingJob) return;
693
+ if (indexingJob.error === 'missing-required-error') {
694
+ return (
695
+ <div className="mt-2 mb-2 border-l-2 border-l-red-500 pl-2">
696
+ <div>
697
+ {indexingJob.error_data?.count} <code>{attr.namespace}</code>{' '}
698
+ {indexingJob.error_data?.count === 1 ? 'entity does' : 'entities do'}{' '}
699
+ not have <code>{attr.name}</code> set.
700
+ </div>
701
+ <InvalidTriplesSample
702
+ indexingJob={{
703
+ ...indexingJob,
704
+ invalid_triples_sample:
705
+ indexingJob.error_data &&
706
+ indexingJob.error_data['entity-ids']?.map((id) => ({
707
+ entity_id: String(id),
708
+ value: null,
709
+ json_type: attr.checkedDataType || 'null',
710
+ })),
711
+ }}
712
+ attr={attr}
713
+ onClickSample={(t) => {
714
+ history.push({
715
+ namespace: attr.namespace,
716
+ where: ['id', t.entity_id],
717
+ });
718
+ // It would be nice to have a way to minimize the dialog so you could go back
719
+ onClose();
720
+ }}
721
+ />
722
+ </div>
723
+ );
724
+ }
725
+
726
+ if (indexingJob.error === 'triple-too-large-error') {
727
+ return (
728
+ <div className="mt-2 mb-2 border-l-2 border-l-red-500 pl-2">
729
+ <div>Some of the existing data is too large to index. </div>
730
+ <InvalidTriplesSample
731
+ indexingJob={indexingJob}
732
+ attr={attr}
733
+ onClickSample={(t) => {
734
+ history.push({
735
+ namespace: attr.namespace,
736
+ where: ['id', t.entity_id],
737
+ });
738
+ // It would be nice to have a way to minimize the dialog so you could go back
739
+ onClose();
740
+ }}
741
+ />
742
+ </div>
743
+ );
744
+ }
745
+
746
+ if (indexingJob.error === 'invalid-triple-error') {
747
+ return (
748
+ <div className="mt-2 mb-2 border-l-2 border-l-red-500 pl-2">
749
+ <div>
750
+ The type can't be set to {indexingJob?.checked_data_type} because some
751
+ data is the wrong type.
752
+ </div>
753
+ <InvalidTriplesSample
754
+ indexingJob={indexingJob}
755
+ attr={attr}
756
+ onClickSample={(t) => {
757
+ history.push({
758
+ namespace: attr.namespace,
759
+ where: ['id', t.entity_id],
760
+ });
761
+ // It would be nice to have a way to minimize the dialog so you could go back
762
+ onClose();
763
+ }}
764
+ />
765
+ </div>
766
+ );
767
+ }
768
+
769
+ if (indexingJob.error === 'triple-not-unique-error') {
770
+ return (
771
+ <div className="mt-2 mb-2 border-l-2 border-l-red-500 pl-2">
772
+ <div>Some of the existing data is not unique. </div>
773
+ {indexingJob.invalid_unique_value != null ? (
774
+ <div>
775
+ Found{' '}
776
+ <span
777
+ className={
778
+ typeof indexingJob.invalid_unique_value === 'object'
779
+ ? ''
780
+ : 'cursor-pointer underline'
781
+ }
782
+ onClick={
783
+ typeof indexingJob.invalid_unique_value === 'object'
784
+ ? undefined
785
+ : () => {
786
+ history.push({
787
+ namespace: attr.namespace,
788
+ where: [attr.name, indexingJob.invalid_unique_value],
789
+ });
790
+ onClose();
791
+ }
792
+ }
793
+ >
794
+ multiple entities with value{' '}
795
+ <code>{JSON.stringify(indexingJob.invalid_unique_value)}</code>
796
+ </span>
797
+ .
798
+ </div>
799
+ ) : null}
800
+ <InvalidTriplesSample
801
+ indexingJob={indexingJob}
802
+ attr={attr}
803
+ onClickSample={(t) => {
804
+ history.push({
805
+ namespace: attr.namespace,
806
+ where: ['id', t.entity_id],
807
+ });
808
+ onClose();
809
+ }}
810
+ />
811
+ </div>
812
+ );
813
+ }
814
+ // Catchall for unexpected errors
815
+ if (indexingJob.error) {
816
+ return (
817
+ <div className="mt-2 mb-2 space-y-2 border-l-2 border-l-red-500 pl-2">
818
+ <div>
819
+ An unexpected error occured while changing constraints. Please share
820
+ these details with the Instant team:
821
+ </div>
822
+ <pre>id: "{indexingJob.id}"</pre>
823
+ </div>
824
+ );
825
+ }
826
+ }
827
+
828
+ function RelationshipConfigurator({
829
+ attrName,
830
+ reverseAttrName,
831
+ namespaceName,
832
+ reverseNamespaceName,
833
+ relationship,
834
+ setAttrName,
835
+ setReverseAttrName,
836
+ setRelationship,
837
+ isCascade,
838
+ setIsCascade,
839
+ isCascadeAllowed,
840
+ isCascadeReverse,
841
+ setIsCascadeReverse,
842
+ isCascadeReverseAllowed,
843
+ isRequired,
844
+ setIsRequired,
845
+ constraints,
846
+ }: {
847
+ relationship: RelationshipKinds;
848
+ reverseNamespaceName: string | undefined;
849
+ attrName: string;
850
+ reverseAttrName: string;
851
+ namespaceName: string;
852
+
853
+ setAttrName: (n: string) => void;
854
+ setReverseAttrName: (n: string) => void;
855
+ setRelationship: (n: RelationshipKinds) => void;
856
+
857
+ isCascadeAllowed: boolean;
858
+ isCascade: boolean;
859
+ setIsCascade: (n: boolean) => void;
860
+
861
+ isCascadeReverseAllowed: boolean;
862
+ isCascadeReverse: boolean;
863
+ setIsCascadeReverse: (n: boolean) => void;
864
+
865
+ isRequired: boolean;
866
+ setIsRequired: (n: boolean) => void;
867
+ constraints: SystemConstraints;
868
+ }) {
869
+ const isFullLink = attrName && reverseNamespaceName && reverseAttrName;
870
+
871
+ return (
872
+ <>
873
+ <div className="flex flex-col gap-4 md:flex-row md:gap-2">
874
+ <div className="flex flex-1 flex-col gap-1">
875
+ <h6 className="text-md font-bold">Forward attribute name</h6>
876
+ <TextInput
877
+ disabled={constraints.attr.disabled}
878
+ title={constraints.attr.message}
879
+ value={attrName}
880
+ onChange={(n) => setAttrName(n)}
881
+ />
882
+ <div className="rounded-xs py-0.5 text-xs text-gray-500 dark:text-neutral-400">
883
+ {isFullLink ? (
884
+ <>
885
+ <strong>
886
+ {namespaceName}.{attrName}
887
+ </strong>{' '}
888
+ will link to <strong>{reverseNamespaceName}</strong>
889
+ </>
890
+ ) : (
891
+ <>&nbsp;</>
892
+ )}
893
+ </div>
894
+ </div>
895
+
896
+ <div className="flex flex-1 flex-col gap-1">
897
+ <h6 className="text-md font-bold">Reverse attribute name</h6>
898
+ <TextInput
899
+ disabled={constraints.attr.disabled}
900
+ title={constraints.attr.message}
901
+ value={reverseAttrName}
902
+ onChange={(n) => setReverseAttrName(n)}
903
+ />
904
+ <div className="rounded-xs py-0.5 text-xs text-gray-500 dark:text-neutral-400">
905
+ {isFullLink ? (
906
+ <>
907
+ <strong>
908
+ {reverseNamespaceName}.{reverseAttrName}
909
+ </strong>{' '}
910
+ will link to <strong>{namespaceName}</strong>
911
+ </>
912
+ ) : (
913
+ <>&nbsp;</>
914
+ )}
915
+ </div>
916
+ </div>
917
+ </div>
918
+
919
+ <div className="flex flex-col gap-1">
920
+ <h6 className="text-md font-bold">Relationship</h6>
921
+ <RelationshipSelect
922
+ disabled={!isFullLink || constraints.attr.disabled}
923
+ value={relationship}
924
+ onChange={(v) => {
925
+ setRelationship(v.value);
926
+ }}
927
+ namespace={namespaceName}
928
+ reverseNamespace={reverseNamespaceName ?? ''}
929
+ attr={attrName}
930
+ reverseAttr={reverseAttrName}
931
+ title={
932
+ constraints.attr.disabled ? constraints.attr.message : undefined
933
+ }
934
+ />
935
+ <div
936
+ className={
937
+ 'text-xs wrap-break-word text-gray-500 dark:text-neutral-400'
938
+ }
939
+ >
940
+ {isFullLink ? (
941
+ relationshipDescriptions[relationship](
942
+ namespaceName,
943
+ reverseNamespaceName,
944
+ attrName,
945
+ reverseAttrName,
946
+ )
947
+ ) : (
948
+ <>&nbsp;</>
949
+ )}
950
+ </div>
951
+ </div>
952
+
953
+ <div className="flex gap-2">
954
+ <Checkbox
955
+ checked={isCascadeAllowed && isCascade}
956
+ disabled={!isCascadeAllowed || constraints.attr.disabled}
957
+ onChange={setIsCascade}
958
+ title={constraints.attr.message}
959
+ label={
960
+ <span className="dark:text-neutral-200">
961
+ <div>
962
+ <strong>
963
+ Cascade Delete {reverseNamespaceName} → {namespaceName}
964
+ </strong>
965
+ </div>
966
+ When a <strong>{reverseNamespaceName}</strong> entity is deleted,
967
+ all linked <strong>{namespaceName}</strong> will be deleted
968
+ automatically
969
+ </span>
970
+ }
971
+ />
972
+ </div>
973
+
974
+ <div className="flex gap-2">
975
+ <Checkbox
976
+ checked={isCascadeReverseAllowed && isCascadeReverse}
977
+ disabled={!isCascadeReverseAllowed || constraints.attr.disabled}
978
+ onChange={setIsCascadeReverse}
979
+ title={constraints.attr.message}
980
+ label={
981
+ <span className="dark:text-neutral-200">
982
+ <div>
983
+ <strong>
984
+ Cascade Delete {namespaceName} → {reverseNamespaceName}
985
+ </strong>
986
+ </div>
987
+ When a <strong>{namespaceName}</strong> entity is deleted, all
988
+ linked <strong>{reverseNamespaceName}</strong> will be deleted
989
+ automatically
990
+ </span>
991
+ }
992
+ />
993
+ </div>
994
+
995
+ <div className="flex flex-col gap-1">
996
+ <h6 className="text-md font-bold">Constraints</h6>
997
+ <div className="flex gap-2">
998
+ <Checkbox
999
+ disabled={constraints.require.disabled}
1000
+ title={constraints.require.message}
1001
+ checked={isRequired}
1002
+ onChange={(enabled) => setIsRequired(enabled)}
1003
+ label={
1004
+ <span>
1005
+ <strong>Require this attribute</strong> so all entities will be
1006
+ guaranteed to have it
1007
+ </span>
1008
+ }
1009
+ />
1010
+ </div>
1011
+ </div>
1012
+ </>
1013
+ );
1014
+ }
1015
+
1016
+ function RelationshipSelect({
1017
+ value,
1018
+ disabled,
1019
+ onChange,
1020
+ namespace,
1021
+ attr,
1022
+ reverseNamespace,
1023
+ reverseAttr,
1024
+ title,
1025
+ }: {
1026
+ disabled?: boolean;
1027
+ value: RelationshipKinds;
1028
+ onChange: (v: { value: RelationshipKinds; label: string }) => void;
1029
+ namespace: string;
1030
+ attr: string;
1031
+ reverseNamespace: string;
1032
+ reverseAttr: string;
1033
+ title?: string;
1034
+ }) {
1035
+ return (
1036
+ <Select
1037
+ disabled={disabled}
1038
+ value={value}
1039
+ onChange={(v) => {
1040
+ if (!v) return;
1041
+
1042
+ onChange(v as { value: RelationshipKinds; label: string });
1043
+ }}
1044
+ options={[
1045
+ {
1046
+ label: 'Many-to-many',
1047
+ value: 'many-many',
1048
+ },
1049
+ {
1050
+ label: 'One-to-one',
1051
+ value: 'one-one',
1052
+ },
1053
+ {
1054
+ label: `${namespace} has-many ${
1055
+ attr || '---'
1056
+ } / ${reverseNamespace} has-one ${reverseAttr || '---'}`,
1057
+ value: 'many-one',
1058
+ },
1059
+ {
1060
+ label: `${namespace} has-one ${
1061
+ attr || '---'
1062
+ } / ${reverseNamespace} has-many ${reverseAttr || '---'}`,
1063
+ value: 'one-many',
1064
+ },
1065
+ ]}
1066
+ title={title}
1067
+ />
1068
+ );
1069
+ }
1070
+
1071
+ const relationshipDescriptions: Record<
1072
+ RelationshipKinds,
1073
+ (f: string, r: string, fa: string, ra: string) => ReactNode
1074
+ > = {
1075
+ 'many-many': (fn, rn, fa, ra) => (
1076
+ <>
1077
+ <strong>{fn}</strong> can have many <strong>{fa}</strong>, and{' '}
1078
+ <strong>{rn}</strong> can be associated with more than one{' '}
1079
+ <strong>{ra}</strong>
1080
+ </>
1081
+ ),
1082
+ 'one-one': (fn, rn, fa, ra) => (
1083
+ <>
1084
+ <strong>{fn}</strong> can have only one <strong>{fa}</strong>, and a{' '}
1085
+ <strong>{rn}</strong> can only have one <strong>{ra}</strong>
1086
+ </>
1087
+ ),
1088
+ 'many-one': (fn, rn, fa, ra) => (
1089
+ <>
1090
+ <strong>{fn}</strong> can have many <strong>{fa}</strong>, but{' '}
1091
+ <strong>{rn}</strong> can only have one <strong>{ra}</strong>
1092
+ </>
1093
+ ),
1094
+ 'one-many': (fn, rn, fa, ra) => (
1095
+ <>
1096
+ <strong>{fn}</strong> can have only one <strong>{fa}</strong>, but{' '}
1097
+ <strong>{rn}</strong> can be associated with more than one{' '}
1098
+ <strong>{ra}</strong>
1099
+ </>
1100
+ ),
1101
+ };
1102
+
1103
+ async function updateRequired({
1104
+ appId,
1105
+ attr,
1106
+ isRequired,
1107
+ authToken,
1108
+ setIndexingJob,
1109
+ stopFetchLoop,
1110
+ apiURI,
1111
+ }: {
1112
+ appId: string;
1113
+ attr: SchemaAttr;
1114
+ isRequired: boolean;
1115
+ authToken: string | undefined;
1116
+ setIndexingJob: (job: InstantIndexingJob) => void;
1117
+ apiURI: string;
1118
+ stopFetchLoop: MutableRefObject<null | (() => void)>;
1119
+ }) {
1120
+ if (!authToken || isRequired === attr.isRequired) {
1121
+ return;
1122
+ }
1123
+ stopFetchLoop.current?.();
1124
+ const friendlyName = `${attr.namespace}.${attr.name}`;
1125
+ try {
1126
+ const job = await createJob(
1127
+ {
1128
+ appId,
1129
+ attrId: attr.id,
1130
+ jobType: isRequired ? 'required' : 'remove-required',
1131
+ apiURI,
1132
+ },
1133
+ authToken,
1134
+ );
1135
+ setIndexingJob(job);
1136
+ const fetchLoop = jobFetchLoop(appId, job.id, authToken, apiURI);
1137
+ stopFetchLoop.current = fetchLoop.stop;
1138
+ const finishedJob = await fetchLoop.start((data, error) => {
1139
+ if (error) {
1140
+ errorToast(`Error while marking ${friendlyName} as required.`);
1141
+ }
1142
+ if (data) {
1143
+ setIndexingJob(data);
1144
+ }
1145
+ });
1146
+ if (finishedJob) {
1147
+ if (finishedJob.job_status === 'completed') {
1148
+ successToast(
1149
+ isRequired
1150
+ ? `Marked ${friendlyName} as required.`
1151
+ : `Marked ${friendlyName} as optional.`,
1152
+ );
1153
+ return 'completed';
1154
+ }
1155
+ if (finishedJob.job_status === 'canceled') {
1156
+ errorToast('Marking required was canceled.');
1157
+ return 'canceled';
1158
+ }
1159
+ if (finishedJob.job_status === 'errored') {
1160
+ if (finishedJob.error === 'invalid-triple-error') {
1161
+ errorToast(`Found invalid data while updating ${friendlyName}.`);
1162
+ } else {
1163
+ errorToast(`Encountered an error while updating ${friendlyName}.`);
1164
+ }
1165
+ return 'errored';
1166
+ }
1167
+ }
1168
+ } catch (e) {
1169
+ console.error(e);
1170
+ errorToast(`Unexpected error while updating ${friendlyName}`);
1171
+ return 'errored';
1172
+ }
1173
+ }
1174
+
1175
+ type BlobConstraintControlComponent<V> = (props: {
1176
+ pendingJob?: PendingJob;
1177
+ runningJob?: InstantIndexingJob;
1178
+ value: V;
1179
+ setValue: (v: V) => void;
1180
+ disabled: boolean;
1181
+ disabledReason?: string;
1182
+ attr: SchemaAttr;
1183
+ }) => JSX.Element;
1184
+
1185
+ const EditCheckedDataTypeControl: BlobConstraintControlComponent<
1186
+ CheckedDataType | 'any'
1187
+ > = ({
1188
+ pendingJob,
1189
+ runningJob,
1190
+ value,
1191
+ setValue,
1192
+ disabled,
1193
+ disabledReason,
1194
+ attr,
1195
+ }) => {
1196
+ const notRunning = !runningJob || jobIsCompleted(runningJob);
1197
+ const closeDialog = useClose();
1198
+
1199
+ // Revert to previous value if job errored
1200
+ useEffect(() => {
1201
+ if (runningJob && jobIsErrored(runningJob)) {
1202
+ setValue(attr.checkedDataType || 'any');
1203
+ }
1204
+ }, [runningJob]);
1205
+
1206
+ return (
1207
+ <>
1208
+ <div className="flex flex-col gap-2">
1209
+ <h6 className="text-md font-bold">
1210
+ Enforce type{' '}
1211
+ <InfoTip>
1212
+ <div className="w-48 text-sm">
1213
+ Checks the type on all existing entities and enforces the type
1214
+ when entities are created or updated.
1215
+ </div>
1216
+ </InfoTip>
1217
+ </h6>
1218
+ </div>
1219
+ <div className="flex items-center gap-2">
1220
+ <Select
1221
+ className={cn(
1222
+ pendingJob &&
1223
+ 'border-[#606AF4] ring-1 ring-[#606AF4] ring-inset focus:ring-[#606AF4]',
1224
+ )}
1225
+ disabled={disabled || (runningJob && !jobIsCompleted(runningJob))}
1226
+ title={disabled ? disabledReason : undefined}
1227
+ value={value}
1228
+ onChange={(v) => {
1229
+ if (!v) {
1230
+ return;
1231
+ }
1232
+ setValue(v.value as CheckedDataType | 'any');
1233
+ }}
1234
+ options={[
1235
+ {
1236
+ label: 'Any (not enforced)',
1237
+ value: 'any',
1238
+ },
1239
+ {
1240
+ label: 'String',
1241
+ value: 'string',
1242
+ },
1243
+ {
1244
+ label: 'Number',
1245
+ value: 'number',
1246
+ },
1247
+ {
1248
+ label: 'Boolean',
1249
+ value: 'boolean',
1250
+ },
1251
+ {
1252
+ label: 'Date',
1253
+ value: 'date',
1254
+ },
1255
+ ]}
1256
+ />
1257
+ {pendingJob && notRunning && (
1258
+ <ArrowUturnLeftIcon
1259
+ onClick={() => {
1260
+ setValue(attr.checkedDataType || 'any');
1261
+ }}
1262
+ height="1.2rem"
1263
+ className="cursor-pointer pr-2 text-[#606AF4]"
1264
+ />
1265
+ )}
1266
+ </div>
1267
+ {runningJob && jobIsErrored(runningJob) && (
1268
+ <IndexingJobError
1269
+ indexingJob={runningJob}
1270
+ attr={attr}
1271
+ onClose={closeDialog}
1272
+ />
1273
+ )}
1274
+ </>
1275
+ );
1276
+ };
1277
+
1278
+ const EditRequiredControl: BlobConstraintControlComponent<boolean> = ({
1279
+ pendingJob,
1280
+ runningJob,
1281
+ value,
1282
+ setValue,
1283
+ disabled,
1284
+ disabledReason,
1285
+ attr,
1286
+ }) => {
1287
+ const closeDialog = useClose();
1288
+
1289
+ // If job is errored, revert the value
1290
+ useEffect(() => {
1291
+ if (runningJob && jobIsErrored(runningJob)) {
1292
+ setValue(attr.isRequired || false);
1293
+ }
1294
+ }, [runningJob]);
1295
+
1296
+ return (
1297
+ <>
1298
+ <div className="flex justify-between">
1299
+ <Checkbox
1300
+ disabled={disabled || (runningJob && !jobIsCompleted(runningJob))}
1301
+ title={disabled ? disabledReason : undefined}
1302
+ checked={value}
1303
+ onChange={(enabled) => setValue(enabled)}
1304
+ label={
1305
+ <span
1306
+ className={cn(
1307
+ disabled || (runningJob && !jobIsCompleted(runningJob))
1308
+ ? 'cursor-default'
1309
+ : 'cursor-pointer',
1310
+ pendingJob && 'text-[#606AF4]',
1311
+ )}
1312
+ >
1313
+ <strong>Require this attribute</strong> so all entities will be
1314
+ guaranteed to have it
1315
+ </span>
1316
+ }
1317
+ />
1318
+ {pendingJob && (
1319
+ <ArrowUturnLeftIcon
1320
+ onClick={() => {
1321
+ setValue(!value);
1322
+ }}
1323
+ height="1.2rem"
1324
+ className="cursor-pointer pr-2 text-[#606AF4]"
1325
+ />
1326
+ )}
1327
+ </div>
1328
+ {runningJob && jobIsErrored(runningJob) && (
1329
+ <IndexingJobError
1330
+ indexingJob={runningJob}
1331
+ attr={attr}
1332
+ onClose={closeDialog}
1333
+ />
1334
+ )}
1335
+ </>
1336
+ );
1337
+ };
1338
+
1339
+ const EditIndexedControl: BlobConstraintControlComponent<boolean> = ({
1340
+ pendingJob,
1341
+ runningJob,
1342
+ value,
1343
+ setValue,
1344
+ disabled,
1345
+ disabledReason,
1346
+ attr,
1347
+ }) => {
1348
+ const closeDialog = useClose();
1349
+
1350
+ // If job is errored, revert the value
1351
+ useEffect(() => {
1352
+ if (runningJob && jobIsErrored(runningJob)) {
1353
+ setValue(attr.isIndex);
1354
+ }
1355
+ }, [runningJob]);
1356
+
1357
+ return (
1358
+ <>
1359
+ <div className="flex justify-between">
1360
+ <Checkbox
1361
+ disabled={disabled || (runningJob && !jobIsCompleted(runningJob))}
1362
+ title={disabled ? disabledReason : undefined}
1363
+ checked={value}
1364
+ onChange={(enabled) => setValue(enabled)}
1365
+ label={
1366
+ <span
1367
+ className={cn(
1368
+ disabled || (runningJob && !jobIsCompleted(runningJob))
1369
+ ? 'cursor-default'
1370
+ : 'cursor-pointer',
1371
+ pendingJob && 'text-[#606AF4]',
1372
+ )}
1373
+ >
1374
+ <strong>Index this attribute</strong> to improve lookup
1375
+ performance of values
1376
+ </span>
1377
+ }
1378
+ />
1379
+ {pendingJob && (
1380
+ <ArrowUturnLeftIcon
1381
+ onClick={() => {
1382
+ setValue(!value);
1383
+ }}
1384
+ height="1.2rem"
1385
+ className="cursor-pointer pr-2 text-[#606AF4]"
1386
+ />
1387
+ )}
1388
+ </div>
1389
+ {runningJob && jobIsErrored(runningJob) && (
1390
+ <IndexingJobError
1391
+ indexingJob={runningJob}
1392
+ attr={attr}
1393
+ onClose={closeDialog}
1394
+ />
1395
+ )}
1396
+ </>
1397
+ );
1398
+ };
1399
+
1400
+ const EditUniqueControl: BlobConstraintControlComponent<boolean> = ({
1401
+ pendingJob,
1402
+ runningJob,
1403
+ value,
1404
+ setValue,
1405
+ disabled,
1406
+ disabledReason,
1407
+ attr,
1408
+ }) => {
1409
+ const closeDialog = useClose();
1410
+
1411
+ // If job is errored, revert the value
1412
+ useEffect(() => {
1413
+ if (runningJob && jobIsErrored(runningJob)) {
1414
+ setValue(attr.isUniq);
1415
+ }
1416
+ }, [runningJob]);
1417
+
1418
+ return (
1419
+ <>
1420
+ <div className="flex justify-between">
1421
+ <Checkbox
1422
+ disabled={disabled || (runningJob && !jobIsCompleted(runningJob))}
1423
+ title={disabled ? disabledReason : undefined}
1424
+ checked={value}
1425
+ onChange={(enabled) => setValue(enabled)}
1426
+ label={
1427
+ <span
1428
+ className={cn(
1429
+ disabled || (runningJob && !jobIsCompleted(runningJob))
1430
+ ? 'cursor-default'
1431
+ : 'cursor-pointer',
1432
+ pendingJob && 'text-[#606AF4]',
1433
+ )}
1434
+ >
1435
+ <strong>Enforce uniqueness</strong> so no two entities can have
1436
+ the same value for this attribute
1437
+ </span>
1438
+ }
1439
+ />
1440
+ {pendingJob && (
1441
+ <ArrowUturnLeftIcon
1442
+ onClick={() => {
1443
+ setValue(!value);
1444
+ }}
1445
+ height="1.2rem"
1446
+ className="cursor-pointer pr-2 text-[#606AF4]"
1447
+ />
1448
+ )}
1449
+ </div>
1450
+ {runningJob && jobIsErrored(runningJob) && (
1451
+ <IndexingJobError
1452
+ indexingJob={runningJob}
1453
+ attr={attr}
1454
+ onClose={closeDialog}
1455
+ />
1456
+ )}
1457
+ </>
1458
+ );
1459
+ };
1460
+
1461
+ const EditBlobConstraints = ({
1462
+ appId,
1463
+ attr,
1464
+ constraints,
1465
+ }: {
1466
+ appId: string;
1467
+ attr: SchemaAttr;
1468
+ constraints: SystemConstraints;
1469
+ }) => {
1470
+ const [requiredChecked, setRequiredChecked] = useState(
1471
+ attr.isRequired || false,
1472
+ );
1473
+
1474
+ const [indexedChecked, setIndexedChecked] = useState(attr.isIndex);
1475
+
1476
+ const [uniqueChecked, setUniqueChecked] = useState(attr.isUniq);
1477
+
1478
+ const [checkedDataType, setCheckedDataType] = useState<
1479
+ CheckedDataType | 'any'
1480
+ >(attr.checkedDataType || 'any');
1481
+
1482
+ const explorerProps = useExplorerProps();
1483
+
1484
+ const { isPending, pending, apply, isRunning, running, progress } =
1485
+ useEditBlobConstraints({
1486
+ attr,
1487
+ appId,
1488
+ token: explorerProps.adminToken,
1489
+ isRequired: requiredChecked,
1490
+ isIndexed: indexedChecked,
1491
+ isUnique: uniqueChecked,
1492
+ checkedDataType,
1493
+ });
1494
+
1495
+ return (
1496
+ <div>
1497
+ <div className="flex flex-col gap-2">
1498
+ <h6 className="text-md font-bold">Constraints</h6>
1499
+ <EditRequiredControl
1500
+ pendingJob={pending.require}
1501
+ runningJob={running.require}
1502
+ value={requiredChecked}
1503
+ setValue={setRequiredChecked}
1504
+ disabled={constraints.require.disabled}
1505
+ disabledReason={constraints.require.message}
1506
+ attr={attr}
1507
+ />
1508
+ <EditIndexedControl
1509
+ pendingJob={pending.index}
1510
+ runningJob={running.index}
1511
+ value={indexedChecked}
1512
+ setValue={setIndexedChecked}
1513
+ disabled={constraints.attr.disabled}
1514
+ disabledReason={constraints.attr.message}
1515
+ attr={attr}
1516
+ />
1517
+ <EditUniqueControl
1518
+ pendingJob={pending.unique}
1519
+ runningJob={running.unique}
1520
+ value={uniqueChecked}
1521
+ setValue={setUniqueChecked}
1522
+ disabled={constraints.attr.disabled}
1523
+ disabledReason={constraints.attr.message}
1524
+ attr={attr}
1525
+ />
1526
+ <EditCheckedDataTypeControl
1527
+ pendingJob={pending.type}
1528
+ runningJob={running.type}
1529
+ value={checkedDataType}
1530
+ setValue={setCheckedDataType}
1531
+ disabled={constraints.attr.disabled}
1532
+ disabledReason={constraints.attr.message}
1533
+ attr={attr}
1534
+ />
1535
+ <ProgressButton
1536
+ loading={!!progress}
1537
+ percentage={progress || 0}
1538
+ variant={isPending || isRunning ? 'primary' : 'secondary'}
1539
+ // Switching from primary <-> secondary changes height without this
1540
+ className="border"
1541
+ onClick={() => apply()}
1542
+ disabled={!isPending && !progress}
1543
+ >
1544
+ {isRunning ? 'Updating Constraints...' : 'Update Constraints'}
1545
+ </ProgressButton>
1546
+ </div>
1547
+ </div>
1548
+ );
1549
+ };
1550
+
1551
+ function EditAttrForm({
1552
+ db,
1553
+ attr,
1554
+ onClose,
1555
+ constraints,
1556
+ }: {
1557
+ db: InstantReactWebDatabase<any>;
1558
+ attr: SchemaAttr;
1559
+ onClose: () => void;
1560
+ constraints: SystemConstraints;
1561
+ }) {
1562
+ const props = useExplorerProps();
1563
+ const appId = props.appId;
1564
+ const { mutate } = useSWRConfig();
1565
+ const [screen, setScreen] = useState<{ type: 'main' } | { type: 'delete' }>({
1566
+ type: 'main',
1567
+ });
1568
+
1569
+ const [attrName, setAttrName] = useState(attr.linkConfig.forward.attr);
1570
+ const [reverseAttrName, setReverseAttrName] = useState(
1571
+ attr.linkConfig.reverse?.attr,
1572
+ );
1573
+ const [relationship, setRelationship] = useState<RelationshipKinds>(() => {
1574
+ const relKey = `${attr.cardinality}-${attr.isUniq}`;
1575
+ const relKind = relationshipConstraintsInverse[relKey];
1576
+ return relKind;
1577
+ });
1578
+
1579
+ const explorerProps = useExplorerProps();
1580
+
1581
+ const [isCascade, setIsCascade] = useState(() => attr.onDelete === 'cascade');
1582
+
1583
+ const [isCascadeReverse, setIsCascadeReverse] = useState(
1584
+ () => attr.onDeleteReverse === 'cascade',
1585
+ );
1586
+
1587
+ const [isRequired, setIsRequired] = useState(attr.isRequired || false);
1588
+ const [wasRequired, _] = useState(isRequired);
1589
+
1590
+ const [indexingJob, setIndexingJob] = useState<InstantIndexingJob | null>(
1591
+ null,
1592
+ );
1593
+
1594
+ const stopFetchLoop = useRef<null | (() => void)>(null);
1595
+ const closeDialog = useClose();
1596
+
1597
+ useEffect(() => {
1598
+ return () => stopFetchLoop.current?.();
1599
+ }, [stopFetchLoop]);
1600
+
1601
+ const isCascadeAllowed =
1602
+ relationship === 'one-one' || relationship === 'one-many';
1603
+ const isCascadeReverseAllowed =
1604
+ relationship === 'one-one' || relationship === 'many-one';
1605
+
1606
+ const linkValidation = validateLink({
1607
+ attrName,
1608
+ reverseAttrName,
1609
+ namespaceName: attr.linkConfig.forward.namespace,
1610
+ reverseNamespaceName: attr.linkConfig.reverse?.namespace,
1611
+ });
1612
+
1613
+ async function updateRef() {
1614
+ if (!attr.linkConfig.reverse) {
1615
+ throw new Error('No reverse link config');
1616
+ }
1617
+
1618
+ if (isRequired !== wasRequired) {
1619
+ const res = await updateRequired({
1620
+ appId,
1621
+ attr,
1622
+ isRequired,
1623
+ authToken: explorerProps.adminToken,
1624
+ setIndexingJob,
1625
+ stopFetchLoop,
1626
+ apiURI: explorerProps.apiURI,
1627
+ });
1628
+
1629
+ if (res != 'completed') return;
1630
+ }
1631
+
1632
+ const ops = [
1633
+ [
1634
+ 'update-attr',
1635
+ {
1636
+ id: attr.id,
1637
+ ...relationshipConstraints[relationship],
1638
+ 'forward-identity': [
1639
+ attr.linkConfig.forward.id,
1640
+ attr.linkConfig.forward.namespace,
1641
+ attrName,
1642
+ ],
1643
+ 'reverse-identity': [
1644
+ attr.linkConfig.reverse.id,
1645
+ attr.linkConfig.reverse.namespace,
1646
+ reverseAttrName,
1647
+ ],
1648
+ 'on-delete': isCascadeAllowed && isCascade ? 'cascade' : null,
1649
+ 'on-delete-reverse':
1650
+ isCascadeReverseAllowed && isCascadeReverse ? 'cascade' : null,
1651
+ },
1652
+ ],
1653
+ ];
1654
+
1655
+ await db.core._reactor.pushOps(ops);
1656
+
1657
+ successToast('Updated attribute');
1658
+ }
1659
+
1660
+ async function renameBlobAttr() {
1661
+ const ops = [
1662
+ [
1663
+ 'update-attr',
1664
+ {
1665
+ id: attr.id,
1666
+ 'forward-identity': [
1667
+ attr.linkConfig.forward.id,
1668
+ attr.linkConfig.forward.namespace,
1669
+ attrName,
1670
+ ],
1671
+ },
1672
+ ],
1673
+ ];
1674
+
1675
+ await db.core._reactor.pushOps(ops);
1676
+
1677
+ successToast('Renamed attribute');
1678
+ }
1679
+
1680
+ async function deleteAttr() {
1681
+ await db.core._reactor.pushOps([['delete-attr', attr.id]]);
1682
+ // update the recently deleted attr cache
1683
+ setTimeout(() => {
1684
+ mutate(['recently-deleted', appId]);
1685
+ }, 500);
1686
+ onClose();
1687
+ }
1688
+
1689
+ if (screen.type === 'delete') {
1690
+ return (
1691
+ <DeleteForm
1692
+ onConfirm={deleteAttr}
1693
+ onClose={onClose}
1694
+ name={attr.name}
1695
+ type="attribute"
1696
+ />
1697
+ );
1698
+ }
1699
+ return (
1700
+ <div className="flex flex-col gap-4">
1701
+ <div className="mr-8 flex gap-4">
1702
+ <div className="flex items-center gap-2">
1703
+ <ArrowLeftIcon className="h-4 w-4 cursor-pointer" onClick={onClose} />
1704
+ <h5 className="flex items-center text-lg font-bold">
1705
+ Edit {attr.namespace}.{attr.name}
1706
+ </h5>
1707
+ </div>
1708
+
1709
+ <Button
1710
+ disabled={constraints.attr.disabled}
1711
+ title={constraints.attr.message}
1712
+ variant="secondary"
1713
+ size="mini"
1714
+ onClick={() => setScreen({ type: 'delete' })}
1715
+ >
1716
+ <TrashIcon className="inline" height="1rem" />
1717
+ Delete
1718
+ </Button>
1719
+ </div>
1720
+
1721
+ {attr.type === 'blob' ? (
1722
+ <>
1723
+ <EditBlobConstraints
1724
+ appId={appId}
1725
+ attr={attr}
1726
+ constraints={constraints}
1727
+ />
1728
+
1729
+ <Divider />
1730
+
1731
+ <ActionForm className="flex flex-col gap-1">
1732
+ <h6 className="text-md font-bold">Rename</h6>
1733
+ <Content className="text-sm">
1734
+ This will immediately rename the attribute. You'll need to{' '}
1735
+ <strong className="dark:text-white">update your code</strong> to
1736
+ the new name.
1737
+ </Content>
1738
+ <TextInput
1739
+ disabled={constraints.attr.disabled}
1740
+ title={constraints.attr.message}
1741
+ value={attrName}
1742
+ onChange={(n) => setAttrName(n)}
1743
+ />
1744
+ <div className="flex flex-col gap-2 rounded-sm py-2">
1745
+ <ActionButton
1746
+ type="submit"
1747
+ label={`Rename ${attr.name} → ${attrName}`}
1748
+ submitLabel="Renaming attribute..."
1749
+ errorMessage="Failed to rename attribute"
1750
+ disabled={
1751
+ constraints.attr.disabled ||
1752
+ !attrName ||
1753
+ attrName === attr.name
1754
+ }
1755
+ title={constraints.attr.message}
1756
+ onClick={renameBlobAttr}
1757
+ />
1758
+ </div>
1759
+ </ActionForm>
1760
+ </>
1761
+ ) : (
1762
+ <ActionForm className="flex flex-col gap-6">
1763
+ <RelationshipConfigurator
1764
+ relationship={relationship}
1765
+ attrName={attrName}
1766
+ reverseAttrName={reverseAttrName ?? ''}
1767
+ namespaceName={attr.linkConfig.forward.namespace}
1768
+ reverseNamespaceName={attr.linkConfig.reverse!.namespace}
1769
+ setAttrName={setAttrName}
1770
+ setReverseAttrName={setReverseAttrName}
1771
+ setRelationship={setRelationship}
1772
+ isCascadeAllowed={isCascadeAllowed}
1773
+ isCascade={isCascade}
1774
+ setIsCascade={setIsCascade}
1775
+ isCascadeReverseAllowed={isCascadeReverseAllowed}
1776
+ isCascadeReverse={isCascadeReverse}
1777
+ setIsCascadeReverse={setIsCascadeReverse}
1778
+ isRequired={isRequired}
1779
+ setIsRequired={setIsRequired}
1780
+ constraints={constraints}
1781
+ />
1782
+
1783
+ <IndexingJobError
1784
+ indexingJob={indexingJob}
1785
+ attr={attr}
1786
+ onClose={() => {
1787
+ closeDialog();
1788
+ onClose();
1789
+ }}
1790
+ />
1791
+
1792
+ <div className="flex flex-col gap-6">
1793
+ <ActionButton
1794
+ disabled={
1795
+ constraints.attr.disabled || !linkValidation.isValidLink
1796
+ }
1797
+ type="submit"
1798
+ label="Update relationship"
1799
+ submitLabel="Updating relationship..."
1800
+ errorMessage="Failed to update relationship"
1801
+ onClick={updateRef}
1802
+ title={constraints.attr.message}
1803
+ />
1804
+ {linkValidation.shouldShowSelfLinkNameError ? (
1805
+ <span className="text-red-500">
1806
+ Self-links must have different attribute names.
1807
+ </span>
1808
+ ) : null}
1809
+ </div>
1810
+ </ActionForm>
1811
+ )}
1812
+ </div>
1813
+ );
1814
+ }
1815
+ function validateLink({
1816
+ reverseNamespaceName,
1817
+ namespaceName,
1818
+ attrName,
1819
+ reverseAttrName,
1820
+ }: {
1821
+ reverseNamespaceName: string | undefined;
1822
+ reverseAttrName: string | undefined;
1823
+ namespaceName: string;
1824
+ attrName: string;
1825
+ }) {
1826
+ const isSelfLink =
1827
+ reverseNamespaceName && reverseNamespaceName === namespaceName;
1828
+ const isNonEmptyLink =
1829
+ attrName && namespaceName && reverseNamespaceName && reverseAttrName;
1830
+ const isValidSelfLinkNames = attrName !== reverseAttrName;
1831
+ const isValidLink =
1832
+ isNonEmptyLink && (isSelfLink ? isValidSelfLinkNames : true);
1833
+ const shouldShowSelfLinkNameError =
1834
+ isNonEmptyLink && isSelfLink && !isValidSelfLinkNames;
1835
+
1836
+ return {
1837
+ isSelfLink,
1838
+ isNonEmptyLink,
1839
+ isValidLink,
1840
+ shouldShowSelfLinkNameError,
1841
+ };
1842
+ }
1843
+
1844
+ type SystemConstraints = {
1845
+ attr: {
1846
+ disabled: boolean;
1847
+ message?: string;
1848
+ };
1849
+ require: {
1850
+ disabled: boolean;
1851
+ message?: string;
1852
+ };
1853
+ };
1854
+
1855
+ function getSystemConstraints({
1856
+ namespaceName,
1857
+ isSystemCatalogNs: isSystemCatalogNs,
1858
+ attr: isSystemCatalogAttr,
1859
+ }: {
1860
+ namespaceName: string;
1861
+ isSystemCatalogNs: boolean;
1862
+ attr?: SchemaAttr;
1863
+ }): SystemConstraints {
1864
+ const isSystemAttr = isSystemCatalogAttr?.catalog === 'system';
1865
+
1866
+ const attrMessage = isSystemCatalogAttr
1867
+ ? `${isSystemCatalogAttr.namespace}.${isSystemCatalogAttr.name} is managed by the system and can't be edited`
1868
+ : undefined;
1869
+
1870
+ const requireMessage = isSystemAttr
1871
+ ? attrMessage
1872
+ : isSystemCatalogNs
1873
+ ? `The ${namespaceName} namespace is managed by the system and can't modify required constraints yet.`
1874
+ : undefined;
1875
+
1876
+ return {
1877
+ attr: {
1878
+ disabled: isSystemAttr || false,
1879
+ message: attrMessage,
1880
+ },
1881
+ require: {
1882
+ disabled: isSystemAttr || isSystemCatalogNs,
1883
+ message: requireMessage,
1884
+ },
1885
+ };
1886
+ }