@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,1151 @@
1
+ import React from 'react';
2
+ import { id, InstantReactWebDatabase, tx } from '@instantdb/react';
3
+ import {
4
+ TextareaHTMLAttributes,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from 'react';
10
+
11
+ import {
12
+ ActionButton,
13
+ ActionForm,
14
+ Button,
15
+ CodeEditor,
16
+ Label,
17
+ Select,
18
+ Checkbox,
19
+ } from '@lib/components/ui';
20
+ import { SchemaAttr, SchemaNamespace, SchemaNamespaceMap } from '@lib/types';
21
+ import { errorToast, successToast } from '@lib/components/toast';
22
+ import * as Tooltip from '@radix-ui/react-tooltip';
23
+ import {
24
+ Combobox,
25
+ ComboboxInput,
26
+ ComboboxOption,
27
+ ComboboxOptions,
28
+ } from '@headlessui/react';
29
+ import {
30
+ ArrowUturnLeftIcon,
31
+ ArrowPathIcon,
32
+ Cog8ToothIcon,
33
+ TrashIcon,
34
+ InformationCircleIcon,
35
+ } from '@heroicons/react/24/solid';
36
+ import { validate } from 'uuid';
37
+ import clsx from 'clsx';
38
+ import { ClockIcon } from '@heroicons/react/24/outline';
39
+ import { useExplorerProps } from '.';
40
+
41
+ type FieldType = 'string' | 'number' | 'boolean' | 'json';
42
+ type FieldTypeOption = { value: FieldType; label: string };
43
+
44
+ const fieldTypeOptions: FieldTypeOption[] = [
45
+ { value: 'string', label: 'string' },
46
+ { value: 'number', label: 'number' },
47
+ { value: 'boolean', label: 'boolean' },
48
+ { value: 'json', label: 'json' },
49
+ ];
50
+
51
+ interface ResizingTextAreaProps
52
+ extends TextareaHTMLAttributes<HTMLTextAreaElement> {
53
+ onSave?: () => void;
54
+ }
55
+
56
+ function ResizingTextArea({
57
+ onSave,
58
+ onChange,
59
+ onKeyDown,
60
+ ...props
61
+ }: ResizingTextAreaProps) {
62
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
63
+
64
+ const autoResize = (element: HTMLTextAreaElement) => {
65
+ // Capture the scroll position before resizing
66
+ const scrollContainer = element.closest('.overflow-y-auto');
67
+ const scrollTop = scrollContainer?.scrollTop || 0;
68
+
69
+ // Resize the textarea
70
+ element.style.height = 'auto';
71
+ element.style.height = element.scrollHeight + 'px';
72
+
73
+ // Restore scroll position
74
+ if (scrollContainer) {
75
+ scrollContainer.scrollTop = scrollTop;
76
+ }
77
+ };
78
+
79
+ useEffect(() => {
80
+ if (textareaRef.current) {
81
+ autoResize(textareaRef.current);
82
+ if (textareaRef.current) {
83
+ autoResize(textareaRef.current);
84
+ }
85
+ }
86
+ }, [props.value]);
87
+
88
+ return (
89
+ <textarea
90
+ ref={textareaRef}
91
+ className="flex min-h-[34px] w-full flex-1 resize-none overflow-hidden rounded-xs border-gray-200 bg-white px-3 py-1 placeholder:text-gray-400 dark:border-neutral-700 dark:bg-neutral-800 dark:placeholder:text-neutral-500"
92
+ rows={1}
93
+ placeholder="hello world (Shift+Enter for new line)"
94
+ {...props}
95
+ onChange={(e) => {
96
+ onChange?.(e);
97
+ autoResize(e.target);
98
+ }}
99
+ onKeyDown={(e) => {
100
+ if (e.key === 'Enter' && !e.shiftKey && onSave) {
101
+ e.preventDefault();
102
+ onSave();
103
+ }
104
+ onKeyDown?.(e);
105
+ }}
106
+ />
107
+ );
108
+ }
109
+
110
+ const defaultValueByType: Record<FieldType, any> = {
111
+ string: '',
112
+ number: 0,
113
+ boolean: false,
114
+ json: {},
115
+ };
116
+
117
+ function validFieldTypeOptions(checkedDataType?: string): FieldTypeOption[] {
118
+ if (!checkedDataType) {
119
+ return fieldTypeOptions;
120
+ }
121
+
122
+ if (checkedDataType === 'date') {
123
+ return fieldTypeOptions.filter(
124
+ (opt) => opt.value === 'string' || opt.value === 'number',
125
+ );
126
+ }
127
+
128
+ return fieldTypeOptions.filter((opt) => opt.value === checkedDataType);
129
+ }
130
+
131
+ // returns true if value is an object or array (but not null)
132
+ const isJsonObject = (value: any) => !!value && typeof value === 'object';
133
+
134
+ function isValidJson(value: any) {
135
+ try {
136
+ JSON.parse(value);
137
+ return true;
138
+ } catch (e) {
139
+ return false;
140
+ }
141
+ }
142
+
143
+ function tryJsonParse(value: any) {
144
+ try {
145
+ return JSON.parse(value);
146
+ } catch (e) {
147
+ return value;
148
+ }
149
+ }
150
+
151
+ function getAppropriateFieldType(attr: SchemaAttr, value: any): FieldType {
152
+ // Use the checkedDatatype if it's set
153
+ if (attr.checkedDataType) {
154
+ if (attr.checkedDataType === 'date') {
155
+ return 'string';
156
+ }
157
+ return attr.checkedDataType;
158
+ }
159
+
160
+ if (value != null) {
161
+ // if object or array, label as "json" for now
162
+ const t = isJsonObject(value) ? 'json' : typeof value;
163
+ // defaults to 'string' type (fieldTypeOptions[0])
164
+ const option = fieldTypeOptions.find((opt) => opt.value === t);
165
+
166
+ if (option) {
167
+ return option.value;
168
+ }
169
+ }
170
+
171
+ // For nulls we guess the based on what we could infer from previous values
172
+ // for this attribute
173
+ if (attr.inferredTypes?.length) {
174
+ return attr.inferredTypes[0];
175
+ }
176
+
177
+ // Fallback to the first option
178
+ return fieldTypeOptions[0].value;
179
+ }
180
+
181
+ function parseFieldValue(value: any, type: FieldType) {
182
+ // Preserve null regardless of type
183
+ if (value === null) {
184
+ return null;
185
+ }
186
+
187
+ if (type === 'number') {
188
+ const cleaned = String(value).replace(/[^\d.-]/g, '');
189
+ if (
190
+ cleaned === '' ||
191
+ cleaned === '-' ||
192
+ cleaned === '.' ||
193
+ cleaned === '-.'
194
+ ) {
195
+ return cleaned;
196
+ }
197
+ const match = cleaned.match(/^(-?\d*\.?\d*)\.?$/);
198
+ return match ? Number(match[0]) : '';
199
+ } else if (type === 'boolean') {
200
+ return value === 'true';
201
+ } else if (type === 'string') {
202
+ return isJsonObject(value) ? JSON.stringify(value) : String(value);
203
+ } else if (type === 'json') {
204
+ return tryJsonParse(value);
205
+ }
206
+
207
+ return value;
208
+ }
209
+
210
+ function uuidValidate(uuid: string): string | null {
211
+ return validate(uuid) ? null : 'Invalid UUID.';
212
+ }
213
+
214
+ function RefItemTooltip({
215
+ db,
216
+ namespace,
217
+ item,
218
+ }: {
219
+ db: InstantReactWebDatabase<any>;
220
+ namespace: SchemaNamespaceMap;
221
+ item: Record<string, any>;
222
+ }) {
223
+ const [open, setOpen] = useState(false);
224
+ const [loadObject, setLoadObject] = useState(false);
225
+
226
+ const { data, isLoading } = db.useQuery(
227
+ open || loadObject
228
+ ? { [namespace.name]: { $: { where: { id: item.id } } } }
229
+ : null,
230
+ );
231
+
232
+ return (
233
+ <Tooltip.Provider>
234
+ <Tooltip.Root delayDuration={0} open={open}>
235
+ <Tooltip.Trigger
236
+ asChild={true}
237
+ onMouseEnter={() => setLoadObject(true)}
238
+ onTouchStart={() => setLoadObject(true)}
239
+ >
240
+ <span>
241
+ <Button
242
+ size="mini"
243
+ variant="subtle"
244
+ onClick={() => setOpen((v) => !v)}
245
+ >
246
+ <InformationCircleIcon height={14} />
247
+ </Button>
248
+ </span>
249
+ </Tooltip.Trigger>
250
+ <Tooltip.Content collisionPadding={10} side="bottom">
251
+ <div className="relative">
252
+ <div
253
+ className="bg-opacity-90 max-w-md overflow-auto border bg-white p-2 font-mono text-xs whitespace-pre shadow-md backdrop-blur-xs dark:border-neutral-700 dark:bg-neutral-800"
254
+ style={{
255
+ maxHeight: `var(--radix-popper-available-height)`,
256
+ }}
257
+ >
258
+ {JSON.stringify(data?.[namespace.name]?.[0] || item, null, 2)}
259
+ </div>
260
+ {isLoading ? (
261
+ <div className="absolute top-0 right-0 animate-spin p-2 opacity-50">
262
+ <Cog8ToothIcon width={12} />
263
+ </div>
264
+ ) : null}
265
+ </div>
266
+ </Tooltip.Content>
267
+ </Tooltip.Root>
268
+ </Tooltip.Provider>
269
+ );
270
+ }
271
+
272
+ function LinkComboboxItem({
273
+ q,
274
+ option,
275
+ uniqueAttrs,
276
+ filterableAttrs,
277
+ }: {
278
+ q: string;
279
+ option: any;
280
+ uniqueAttrs: SchemaAttr[];
281
+ filterableAttrs: SchemaAttr[];
282
+ }) {
283
+ const [open, setOpen] = useState(false);
284
+ return (
285
+ <ComboboxOption
286
+ key={option.id}
287
+ value={option}
288
+ className={clsx(
289
+ 'cursor-pointer px-3 py-1 data-focus:bg-blue-100 dark:border-neutral-700 dark:bg-neutral-800',
290
+ {},
291
+ )}
292
+ >
293
+ <Tooltip.Provider>
294
+ <Tooltip.Root delayDuration={0} open={open}>
295
+ <Tooltip.Trigger
296
+ asChild={true}
297
+ onMouseEnter={() => setOpen(true)}
298
+ onMouseLeave={() => setOpen(false)}
299
+ >
300
+ <div>
301
+ <div>
302
+ <code>{option.id}</code>
303
+ </div>
304
+ <div className="truncate">
305
+ {filterableAttrs
306
+ .filter(
307
+ (a) =>
308
+ option.hasOwnProperty(a.name) &&
309
+ !a.isUniq &&
310
+ q &&
311
+ JSON.stringify(option[a.name])
312
+ .toLowerCase()
313
+ .indexOf(q.toLowerCase()) !== -1,
314
+ )
315
+ .slice(0, 3)
316
+ .map((a) => (
317
+ <div key={a.id}>
318
+ <span className="font-medium">{a.name}</span>:{' '}
319
+ {JSON.stringify(option[a.name])}
320
+ </div>
321
+ ))}
322
+ {uniqueAttrs
323
+ .filter((a) => option.hasOwnProperty(a.name))
324
+ .slice(0, 3)
325
+ .map((a) => (
326
+ <div key={a.id}>
327
+ <span className="font-medium">{a.name}</span>:{' '}
328
+ {JSON.stringify(option[a.name])}
329
+ </div>
330
+ ))}
331
+ </div>
332
+ </div>
333
+ </Tooltip.Trigger>
334
+ <Tooltip.Content collisionPadding={10}>
335
+ <div
336
+ className="bg-opacity-90 max-w-md overflow-auto border bg-white p-2 font-mono text-xs whitespace-pre shadow-md backdrop-blur-xs dark:border-neutral-700 dark:bg-neutral-800"
337
+ style={{
338
+ maxHeight: `var(--radix-popper-available-height)`,
339
+ }}
340
+ >
341
+ {JSON.stringify(option, null, 2)}
342
+ </div>
343
+ </Tooltip.Content>
344
+ </Tooltip.Root>
345
+ </Tooltip.Provider>
346
+ </ComboboxOption>
347
+ );
348
+ }
349
+
350
+ function LinkCombobox({
351
+ db,
352
+ namespace,
353
+ onLinkRef,
354
+ ignoreIds,
355
+ onClose,
356
+ }: {
357
+ db: InstantReactWebDatabase<any>;
358
+ namespace: SchemaNamespaceMap;
359
+ onLinkRef: (item: any) => void;
360
+ ignoreIds: Set<string>;
361
+ onClose: () => void;
362
+ }) {
363
+ const [q, setq] = useState('');
364
+
365
+ const inputRef = useRef<HTMLInputElement | null>(null);
366
+
367
+ const { uniqueAttrs, filterableAttrs } = useMemo(() => {
368
+ const uniqueAttrs: SchemaAttr[] = [];
369
+ const filterableAttrs: SchemaAttr[] = [];
370
+ for (const [_k, attr] of Object.entries(namespace.attrs)) {
371
+ if (attr.isUniq && attr.name !== 'id' && attr.type === 'blob') {
372
+ uniqueAttrs.push(attr);
373
+ }
374
+ if (
375
+ attr.isIndex &&
376
+ (attr.checkedDataType === 'string' || attr.checkedDataType === 'number')
377
+ ) {
378
+ filterableAttrs.push(attr);
379
+ }
380
+ }
381
+ return { uniqueAttrs, filterableAttrs };
382
+ }, [namespace.attrs]);
383
+
384
+ const query = useMemo(() => {
385
+ const clauses: any[] = [{ $entityIdStartsWith: q }];
386
+ let numVal;
387
+ try {
388
+ const num = JSON.parse(q);
389
+ if (typeof num === 'number') {
390
+ numVal = num;
391
+ }
392
+ } catch (e) {}
393
+
394
+ for (const attr of filterableAttrs) {
395
+ if (attr.checkedDataType === 'string' && q.trim()) {
396
+ clauses.push({ [attr.name]: { $ilike: `%${q.trim()}%` } });
397
+ }
398
+ if (attr.checkedDataType === 'number' && numVal != null) {
399
+ clauses.push({ [attr.name]: numVal });
400
+ }
401
+ }
402
+ for (const attr of uniqueAttrs) {
403
+ if (!attr.checkedDataType) {
404
+ clauses.push({ [attr.name]: q });
405
+ if (numVal != null) {
406
+ clauses.push({ [attr.name]: numVal });
407
+ }
408
+ }
409
+ }
410
+ return {
411
+ [namespace.name]: {
412
+ $: {
413
+ where: {
414
+ or: clauses,
415
+ },
416
+ limit: 20,
417
+ },
418
+ },
419
+ };
420
+ }, [namespace.name, filterableAttrs, q]);
421
+
422
+ const { data, isLoading } = db.useQuery(query);
423
+
424
+ const options = data?.[namespace.name]?.filter((o) => !ignoreIds.has(o.id));
425
+
426
+ return (
427
+ <div className="relative mt-1 w-full">
428
+ <Combobox
429
+ key={isLoading ? 'query-loading' : 'query-loaded'}
430
+ onChange={(option: any) => {
431
+ if (option) {
432
+ onLinkRef(option);
433
+ setq('');
434
+ onClose();
435
+ }
436
+ }}
437
+ onClose={onClose}
438
+ immediate={true}
439
+ >
440
+ <ComboboxInput
441
+ ref={inputRef}
442
+ autoFocus={true}
443
+ size={32}
444
+ className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-800 dark:placeholder:text-neutral-500"
445
+ value={q}
446
+ onChange={(e) => {
447
+ setq(e.target.value);
448
+ }}
449
+ placeholder={`Search ${namespace.name}...`}
450
+ />
451
+
452
+ <ComboboxOptions
453
+ portal={false}
454
+ unmount={false}
455
+ static={true}
456
+ className="absolute left-0 z-10 mt-1 max-h-[25vh] w-full divide-y overflow-scroll rounded-md border border-gray-300 bg-white shadow-lg empty:invisible dark:border-neutral-700 dark:bg-neutral-800"
457
+ >
458
+ {(options || []).map((o) => (
459
+ <LinkComboboxItem
460
+ key={o.id}
461
+ q={q}
462
+ option={o}
463
+ uniqueAttrs={uniqueAttrs}
464
+ filterableAttrs={filterableAttrs}
465
+ />
466
+ ))}
467
+ </ComboboxOptions>
468
+ {options?.length || isLoading ? null : (
469
+ <div className="absolute left-0 z-10 mt-1 w-full divide-y overflow-scroll rounded-md border border-gray-300 bg-white p-2 shadow-lg dark:border-neutral-700 dark:bg-neutral-800">
470
+ No matching rows in <code>{namespace.name}</code>
471
+ </div>
472
+ )}
473
+ </Combobox>
474
+ </div>
475
+ );
476
+ }
477
+
478
+ function RefItem({
479
+ db,
480
+ item,
481
+ attr,
482
+ namespace,
483
+ refUpdates,
484
+ handleLinkRef,
485
+ handleUnlinkRef,
486
+ }: {
487
+ db: InstantReactWebDatabase<any>;
488
+ item: Record<string, any>;
489
+ attr: SchemaAttr;
490
+ namespace: SchemaNamespaceMap;
491
+ refUpdates: null | Record<string, { action: 'link' | 'unlink'; item: any }>;
492
+ handleLinkRef: (attr: SchemaAttr, item: any) => void;
493
+ handleUnlinkRef: (attr: SchemaAttr, id: string) => void;
494
+ }) {
495
+ const [showAddLink, setShowAddLink] = useState(false);
496
+ const searchIgnoreIds = useMemo(() => {
497
+ const res: Set<string> = new Set();
498
+ for (const [k] of Object.entries(refUpdates || {})) {
499
+ res.add(k);
500
+ }
501
+ for (const linkItem of item[attr.name] || []) {
502
+ res.add(linkItem.id);
503
+ }
504
+ return res;
505
+ }, [item[attr.name], refUpdates]);
506
+
507
+ const cardinality = attr.cardinality;
508
+ const hasLink = item[attr.name]?.length > 0;
509
+
510
+ return (
511
+ <>
512
+ {item[attr.name]?.map((x: any) => {
513
+ const markedForUnlink = refUpdates?.[x.id]?.action === 'unlink';
514
+ return (
515
+ <div key={x.id}>
516
+ <code className={markedForUnlink ? 'line-through' : ''}>
517
+ {x.id}
518
+ </code>
519
+ <RefItemTooltip db={db} namespace={namespace} item={x} />
520
+ <Button
521
+ title={markedForUnlink ? 'Undo' : 'Unlink'}
522
+ type="link"
523
+ size="mini"
524
+ variant={markedForUnlink ? 'subtle' : 'destructive'}
525
+ className="border-none"
526
+ onClick={() =>
527
+ markedForUnlink
528
+ ? handleLinkRef(attr, x)
529
+ : handleUnlinkRef(attr, x.id)
530
+ }
531
+ >
532
+ {markedForUnlink ? (
533
+ <ArrowUturnLeftIcon height={14} />
534
+ ) : (
535
+ <TrashIcon height={14} />
536
+ )}
537
+ </Button>
538
+ </div>
539
+ );
540
+ })}
541
+ {Object.entries(refUpdates || {}).map(([id, { action, item }]) => {
542
+ if (action !== 'link') {
543
+ return;
544
+ }
545
+
546
+ return (
547
+ <div key={id}>
548
+ <code>{id}</code>
549
+ <RefItemTooltip db={db} namespace={namespace} item={item} />
550
+ <Button
551
+ title={'Remove'}
552
+ type="link"
553
+ size="mini"
554
+ variant={'destructive'}
555
+ className="border-none"
556
+ onClick={() => handleUnlinkRef(attr, id)}
557
+ >
558
+ <TrashIcon height={14} />
559
+ </Button>
560
+ </div>
561
+ );
562
+ })}
563
+ {showAddLink ? (
564
+ <LinkCombobox
565
+ namespace={namespace}
566
+ onLinkRef={(item) => handleLinkRef(attr, item)}
567
+ db={db}
568
+ ignoreIds={searchIgnoreIds}
569
+ onClose={() => setShowAddLink(false)}
570
+ />
571
+ ) : (
572
+ <Button variant="secondary" onClick={() => setShowAddLink(true)}>
573
+ {cardinality === 'many' || !hasLink ? 'Add link' : 'Replace link'}
574
+ </Button>
575
+ )}
576
+ </>
577
+ );
578
+ }
579
+
580
+ const isEditableBlobAttr = (namespace: SchemaNamespace, attr: SchemaAttr) => {
581
+ return (
582
+ (attr.type === 'blob' && namespace.name !== '$files') ||
583
+ (namespace.name === '$files' &&
584
+ attr.type === 'blob' &&
585
+ attr.name === 'path')
586
+ );
587
+ };
588
+
589
+ export function EditRowDialog({
590
+ db,
591
+ namespace,
592
+ item,
593
+ onClose,
594
+ }: {
595
+ db: InstantReactWebDatabase<any>;
596
+ namespace: SchemaNamespace;
597
+ item: Record<string, any>;
598
+ onClose: () => void;
599
+ }) {
600
+ const op: 'edit' | 'add' = item.id ? 'edit' : 'add';
601
+ const explorerProps = useExplorerProps();
602
+
603
+ const editableBlobAttrs: SchemaAttr[] = [];
604
+ const editableRefAttrs: SchemaAttr[] = [];
605
+
606
+ for (const a of namespace.attrs) {
607
+ if (a.name !== 'id') {
608
+ if (isEditableBlobAttr(namespace, a)) {
609
+ editableBlobAttrs.push(a);
610
+ }
611
+ if (a.type === 'ref') {
612
+ editableRefAttrs.push(a);
613
+ }
614
+ }
615
+ }
616
+
617
+ const currentBlobs = editableBlobAttrs.reduce(
618
+ (acc, attr) => {
619
+ const val = item[attr.name];
620
+ const t = getAppropriateFieldType(attr, val);
621
+
622
+ const defaultValue =
623
+ op === 'add' ? (attr.isRequired ? defaultValueByType[t] : null) : val;
624
+
625
+ return {
626
+ ...acc,
627
+ [attr.name]: {
628
+ type: t,
629
+ value: defaultValue,
630
+ error: null,
631
+ },
632
+ };
633
+ },
634
+ {} as Record<string, { type: FieldType; value: any; error: string | null }>,
635
+ );
636
+
637
+ const [blobUpdates, setUpdatedBlobValues] = useState<Record<string, any>>({
638
+ ...currentBlobs,
639
+ ...(op === 'add' ? { id: { type: 'string', value: id() } } : {}),
640
+ });
641
+
642
+ const [refUpdates, setRefUpdates] = useState<
643
+ // Map of attr-name -> id -> add or remove
644
+ Record<string, Record<string, { item: any; action: 'link' | 'unlink' }>>
645
+ >({});
646
+
647
+ const [jsonUpdates, setJsonUpdates] = useState<Record<string, any>>({});
648
+ const [nullFields, setNullFields] = useState<Record<string, boolean>>(
649
+ editableBlobAttrs.reduce((acc, attr) => {
650
+ // Don't set nullFields for new rows
651
+ return {
652
+ ...acc,
653
+ [attr.name]:
654
+ op === 'edit'
655
+ ? item[attr.name] === null || item[attr.name] === undefined
656
+ : blobUpdates[attr.name].value === null,
657
+ };
658
+ }, {}),
659
+ );
660
+
661
+ const hasFormErrors = Object.values(blobUpdates).some((u) => !!u.error);
662
+ const [shouldDisplayErrors, setShouldDisplayErrors] = useState(false);
663
+
664
+ const handleResetForm = () => {
665
+ setRefUpdates({});
666
+
667
+ // Reset the blobUpdates to the original values
668
+ setUpdatedBlobValues({ ...currentBlobs });
669
+
670
+ // Reset the nullFields state based on the original item
671
+ setNullFields(
672
+ editableBlobAttrs.reduce((acc, attr) => {
673
+ return {
674
+ ...acc,
675
+ [attr.name]:
676
+ op === 'edit'
677
+ ? item[attr.name] === null || item[attr.name] === undefined
678
+ : false,
679
+ };
680
+ }, {}),
681
+ );
682
+
683
+ // Also reset any JSON updates to match the original values
684
+ const resetJsonUpdates: Record<string, any> = {};
685
+ editableBlobAttrs.forEach((attr) => {
686
+ if (
687
+ currentBlobs[attr.name]?.type === 'json' &&
688
+ item[attr.name] !== undefined
689
+ ) {
690
+ resetJsonUpdates[attr.name] =
691
+ item[attr.name] === null
692
+ ? 'null'
693
+ : JSON.stringify(item[attr.name], null, 2);
694
+ }
695
+ });
696
+ setJsonUpdates(resetJsonUpdates);
697
+
698
+ // Reset shouldDisplayErrors to clean state
699
+ setShouldDisplayErrors(false);
700
+ };
701
+
702
+ const handleChangeFieldType = (field: string, type: FieldType) => {
703
+ setUpdatedBlobValues((prev) => {
704
+ const value = prev[field]?.value;
705
+
706
+ return {
707
+ ...prev,
708
+ [field]: { type, value: parseFieldValue(value, type) },
709
+ };
710
+ });
711
+ };
712
+
713
+ const handleUpdateFieldValue = (
714
+ field: string,
715
+ value: any,
716
+ validate?: (value: any) => string | null,
717
+ ) => {
718
+ const error = validate ? validate(value) : null;
719
+ setUpdatedBlobValues((prev) => {
720
+ const type = prev[field]?.type || 'string';
721
+
722
+ return {
723
+ ...prev,
724
+ [field]: {
725
+ type,
726
+ value: parseFieldValue(value, type),
727
+ error,
728
+ },
729
+ };
730
+ });
731
+ };
732
+
733
+ const handleUpdateJson = (field: string, value: any) => {
734
+ setJsonUpdates((prev) => ({ ...prev, [field]: value }));
735
+
736
+ setUpdatedBlobValues((prev) => {
737
+ const current = prev[field] || {};
738
+
739
+ if (value === '') {
740
+ return {
741
+ ...prev,
742
+ [field]: { type: 'json', value: undefined, error: 'Invalid JSON' },
743
+ };
744
+ }
745
+
746
+ return {
747
+ ...prev,
748
+ [field]: isValidJson(value)
749
+ ? { type: 'json', value: JSON.parse(value), error: null }
750
+ : { ...current, type: 'json', error: 'Invalid JSON' },
751
+ };
752
+ });
753
+ };
754
+
755
+ const handleNullToggle = (field: string, checked: boolean) => {
756
+ setNullFields((prev) => ({ ...prev, [field]: checked }));
757
+ const currentType = blobUpdates[field]?.type || 'string';
758
+
759
+ if (checked) {
760
+ setUpdatedBlobValues((prev) => ({
761
+ ...prev,
762
+ [field]: {
763
+ type: currentType,
764
+ value: null, // set field to null
765
+ error: null,
766
+ },
767
+ }));
768
+
769
+ if (currentType === 'json') {
770
+ setJsonUpdates((prev) => ({ ...prev, [field]: 'null' }));
771
+ }
772
+ } else {
773
+ setUpdatedBlobValues((prev) => ({
774
+ ...prev,
775
+ [field]: {
776
+ type: currentType,
777
+ value: defaultValueByType[currentType as FieldType], // set to default
778
+ error: null,
779
+ },
780
+ }));
781
+
782
+ if (currentType === 'json') {
783
+ setJsonUpdates((prev) => ({ ...prev, [field]: '{}' }));
784
+ }
785
+ }
786
+ };
787
+
788
+ const handleUnlinkRef = (attr: SchemaAttr, id: string) => {
789
+ setRefUpdates((v) => {
790
+ const existing = item[attr.name]?.find((x: any) => x.id === id);
791
+ if (existing) {
792
+ return {
793
+ ...v,
794
+ [attr.name]: {
795
+ ...(v[attr.name] || {}),
796
+ [id]: { action: 'unlink', item: null },
797
+ },
798
+ };
799
+ }
800
+ const { [id]: _, ...withoutId } = v[attr.name] || {};
801
+
802
+ return {
803
+ ...v,
804
+ [attr.name]: withoutId,
805
+ };
806
+ });
807
+ };
808
+
809
+ const handleLinkRef = (attr: SchemaAttr, linkItem: any) => {
810
+ setRefUpdates((v) => {
811
+ const id = linkItem.id;
812
+ const existing = v[attr.name]?.[id];
813
+ // This is a undo
814
+ if (existing && existing.action === 'unlink') {
815
+ if (attr.cardinality === 'one') {
816
+ const { [attr.name]: _, ...rest } = v;
817
+ return rest;
818
+ }
819
+ const { [id]: _, ...withoutId } = v[attr.name];
820
+ return {
821
+ ...v,
822
+ [attr.name]: withoutId,
823
+ };
824
+ }
825
+
826
+ // Replace an existing link
827
+ // Need to unlink the old one
828
+ if (attr.cardinality === 'one' && item[attr.name]?.length) {
829
+ const existingLink = item[attr.name][0];
830
+ return {
831
+ ...v,
832
+ [attr.name]: {
833
+ [id]: { action: 'link', item: linkItem },
834
+ [existingLink.id]: { action: 'unlink', item: null },
835
+ },
836
+ };
837
+ }
838
+
839
+ // Add a new link
840
+ return {
841
+ ...v,
842
+ [attr.name]: {
843
+ ...(v[attr.name] || {}),
844
+ [id]: { action: 'link', item: linkItem },
845
+ },
846
+ };
847
+ });
848
+ };
849
+
850
+ const focusElementAtTabIndex = (index: number) => {
851
+ // Use requestAnimationFrame to wait for the next render cycle
852
+ // so that the input is shown to focus
853
+ requestAnimationFrame(() => {
854
+ const element = document.querySelector(`[tabindex="${index}"]`);
855
+ if (element && element instanceof HTMLElement) {
856
+ element.focus();
857
+ }
858
+ });
859
+ };
860
+
861
+ const handleSaveRow = async () => {
862
+ if (hasFormErrors) {
863
+ setShouldDisplayErrors(true);
864
+ return;
865
+ }
866
+
867
+ const params = Object.fromEntries(
868
+ Object.entries(blobUpdates).map(([field, { value }]) => {
869
+ return [field, value];
870
+ }),
871
+ );
872
+ const itemId = item.id || params.id || id();
873
+ delete params.id;
874
+ try {
875
+ let chunks = tx[namespace.name][itemId];
876
+ const unlinks = [];
877
+ const links = [];
878
+ for (const [attrName, v] of Object.entries(refUpdates)) {
879
+ for (const [id, { action }] of Object.entries(v)) {
880
+ if (action === 'link') {
881
+ links.push({ [attrName]: id });
882
+ }
883
+ if (action === 'unlink') {
884
+ unlinks.push({ [attrName]: id });
885
+ }
886
+ }
887
+ }
888
+
889
+ // Do unlinks first
890
+ for (const unlink of unlinks) {
891
+ chunks = chunks.unlink(unlink);
892
+ }
893
+ chunks = chunks.update(params);
894
+ for (const link of links) {
895
+ chunks = chunks.link(link);
896
+ }
897
+
898
+ await db.transact(chunks);
899
+
900
+ onClose();
901
+ successToast('Successfully updated row!');
902
+ } catch (e: any) {
903
+ const message = e.message;
904
+ if (message) {
905
+ errorToast(`Failed to save row: ${message}`);
906
+ } else {
907
+ throw e;
908
+ }
909
+ }
910
+ };
911
+
912
+ return (
913
+ <ActionForm className="p-4">
914
+ <h5 className="flex text-lg font-bold">
915
+ {op == 'edit' ? 'Edit row' : 'Add row'}
916
+ </h5>
917
+ <code className="font-mono text-sm font-medium text-gray-500 dark:text-neutral-500">
918
+ {op == 'edit' ? (
919
+ <>
920
+ {namespace.name}['{item.id}']
921
+ </>
922
+ ) : (
923
+ <>{namespace.name}</>
924
+ )}
925
+ </code>
926
+ <div className="mt-4 flex flex-col gap-4">
927
+ {op === 'add' ? (
928
+ <div key="id" className="flex flex-col gap-1">
929
+ <div className="flex items-center justify-between">
930
+ <Label className="font-mono">
931
+ <div className="flex gap-1">
932
+ id{' '}
933
+ <Button
934
+ type="link"
935
+ size="mini"
936
+ variant="subtle"
937
+ onClick={() => handleUpdateFieldValue('id', id())}
938
+ >
939
+ <ArrowPathIcon height={14} />
940
+ </Button>
941
+ </div>
942
+ </Label>
943
+ </div>
944
+ <div className="flex flex-col gap-1">
945
+ <input
946
+ className="flex w-full flex-1 rounded-xs border-gray-200 bg-white px-3 py-1 placeholder:text-gray-400 dark:border-neutral-700 dark:bg-neutral-800"
947
+ value={blobUpdates.id?.value ?? ''}
948
+ onChange={(e) =>
949
+ handleUpdateFieldValue('id', e.target.value, uuidValidate)
950
+ }
951
+ />
952
+ </div>{' '}
953
+ {blobUpdates.id?.error && shouldDisplayErrors && (
954
+ <span className="text-sm font-medium text-red-500">
955
+ {blobUpdates.id.error}
956
+ </span>
957
+ )}
958
+ </div>
959
+ ) : null}
960
+
961
+ {editableBlobAttrs.map((attr, i) => {
962
+ const tabIndex = i + 1;
963
+ const { type, value, error } = blobUpdates[attr.name] || {
964
+ type: 'string',
965
+ value: defaultValueByType['string'],
966
+ };
967
+ const json =
968
+ jsonUpdates[attr.name] ||
969
+ (value !== null ? JSON.stringify(value, null, 2) : 'null');
970
+ const isNullField = nullFields[attr.name];
971
+
972
+ return (
973
+ <div key={attr.name} className="flex flex-col gap-1">
974
+ <div className="flex items-center justify-between">
975
+ <Label className="font-mono">{attr.name}</Label>
976
+ <div className="flex items-center gap-2">
977
+ <div className="flex items-center">
978
+ {!attr.isRequired && (
979
+ <Checkbox
980
+ checked={isNullField}
981
+ onChange={(checked) =>
982
+ handleNullToggle(attr.name, checked)
983
+ }
984
+ label={
985
+ <span className="text-[10px] text-gray-600 uppercase dark:text-neutral-600">
986
+ null
987
+ </span>
988
+ }
989
+ />
990
+ )}
991
+ </div>
992
+ <Select
993
+ className="w-24 rounded-sm px-2 py-0.5 text-sm"
994
+ value={type}
995
+ options={validFieldTypeOptions(attr.checkedDataType)}
996
+ onChange={(option) =>
997
+ handleChangeFieldType(
998
+ attr.name,
999
+ option!.value as FieldType,
1000
+ )
1001
+ }
1002
+ />
1003
+ </div>
1004
+ </div>
1005
+ <div className="flex flex-col gap-1">
1006
+ {!isNullField ? (
1007
+ <div className="flex space-x-1">
1008
+ <div className="flex-1">
1009
+ {type === 'json' ? (
1010
+ <div className="h-32 w-full rounded-sm border">
1011
+ <CodeEditor
1012
+ darkMode={explorerProps.darkMode}
1013
+ tabIndex={tabIndex}
1014
+ language="json"
1015
+ value={json}
1016
+ onChange={(code) =>
1017
+ handleUpdateJson(attr.name, code)
1018
+ }
1019
+ />
1020
+ </div>
1021
+ ) : type === 'boolean' ? (
1022
+ <Select
1023
+ tabIndex={tabIndex}
1024
+ value={value}
1025
+ options={[
1026
+ { value: 'false', label: 'false' },
1027
+ { value: 'true', label: 'true' },
1028
+ ]}
1029
+ onChange={(option) =>
1030
+ handleUpdateFieldValue(attr.name, option!.value)
1031
+ }
1032
+ />
1033
+ ) : type === 'number' ? (
1034
+ <input
1035
+ tabIndex={tabIndex}
1036
+ type="number"
1037
+ className="flex w-full flex-1 rounded-xs border-gray-200 bg-white px-3 py-1 placeholder:text-gray-400 dark:border-neutral-700 dark:bg-neutral-800"
1038
+ value={value ?? ''}
1039
+ onChange={(num) =>
1040
+ handleUpdateFieldValue(attr.name, num.target.value)
1041
+ }
1042
+ />
1043
+ ) : (
1044
+ <ResizingTextArea
1045
+ tabIndex={tabIndex}
1046
+ value={value ?? ''}
1047
+ onChange={(e) =>
1048
+ handleUpdateFieldValue(attr.name, e.target.value)
1049
+ }
1050
+ onSave={handleSaveRow}
1051
+ />
1052
+ )}
1053
+ </div>
1054
+ {attr.checkedDataType === 'date' && (
1055
+ <Button
1056
+ variant="subtle"
1057
+ size="mini"
1058
+ onClick={() => {
1059
+ handleUpdateFieldValue(
1060
+ attr.name,
1061
+ type === 'number'
1062
+ ? Date.now()
1063
+ : new Date().toISOString(),
1064
+ );
1065
+ }}
1066
+ >
1067
+ <ClockIcon height={14} />
1068
+ now
1069
+ </Button>
1070
+ )}
1071
+ </div>
1072
+ ) : (
1073
+ <button
1074
+ onClick={() => {
1075
+ handleNullToggle(attr.name, false);
1076
+ focusElementAtTabIndex(tabIndex);
1077
+ }}
1078
+ className="flex-1 rounded-xs border border-gray-200 bg-gray-50 px-3 py-1 text-left text-gray-500 italic dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400"
1079
+ >
1080
+ null
1081
+ </button>
1082
+ )}
1083
+ {error && shouldDisplayErrors && (
1084
+ <span className="text-sm font-medium text-red-500">
1085
+ {error}
1086
+ </span>
1087
+ )}
1088
+ </div>
1089
+ </div>
1090
+ );
1091
+ })}
1092
+
1093
+ {editableRefAttrs.map((attr, i) => {
1094
+ const namespace = attr.isForward
1095
+ ? attr.linkConfig.reverse!.nsMap
1096
+ : attr.linkConfig.forward.nsMap;
1097
+
1098
+ if (!namespace) {
1099
+ // Sometimes we get links to namespaces that don't exist
1100
+ return null;
1101
+ }
1102
+
1103
+ return (
1104
+ <div key={attr.name} className="flex flex-col gap-1">
1105
+ <div className="flex items-center justify-between">
1106
+ <Label className="font-mono">{attr.name}</Label>
1107
+ <span className="rounded-sm px-2 py-0.5 text-sm">
1108
+ Link to <code>{namespace.name}</code>
1109
+ </span>
1110
+ </div>
1111
+ <div className="flex flex-col gap-1">
1112
+ <RefItem
1113
+ db={db}
1114
+ item={item}
1115
+ namespace={namespace}
1116
+ attr={attr}
1117
+ refUpdates={refUpdates[attr.name]}
1118
+ handleLinkRef={handleLinkRef}
1119
+ handleUnlinkRef={handleUnlinkRef}
1120
+ />
1121
+ </div>
1122
+ </div>
1123
+ );
1124
+ })}
1125
+ </div>
1126
+ <div className="mt-8 flex flex-row items-center justify-between gap-1">
1127
+ {shouldDisplayErrors && hasFormErrors ? (
1128
+ <span className="text-sm font-medium text-red-500">
1129
+ Failed to save. Please check above for errors.
1130
+ </span>
1131
+ ) : (
1132
+ <span />
1133
+ )}
1134
+ <div className="flex flex-row items-center gap-1">
1135
+ <Button type="button" variant="secondary" onClick={handleResetForm}>
1136
+ Reset
1137
+ </Button>
1138
+ <ActionButton
1139
+ tabIndex={editableBlobAttrs.length + 1}
1140
+ type="submit"
1141
+ variant="primary"
1142
+ label="Save"
1143
+ submitLabel="Saving..."
1144
+ errorMessage="Failed to save row."
1145
+ onClick={handleSaveRow}
1146
+ />
1147
+ </div>
1148
+ </div>
1149
+ </ActionForm>
1150
+ );
1151
+ }