@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.
- package/.env +2 -0
- package/.turbo/turbo-build.log +18 -0
- package/README.md +78 -0
- package/app/App.css +38 -0
- package/app/App.tsx +61 -0
- package/app/index.css +18 -0
- package/app/main.tsx +10 -0
- package/dist/components/StyleMe.d.ts +15 -0
- package/dist/components/StyleMe.d.ts.map +1 -0
- package/dist/components/error-boundary.d.ts +17 -0
- package/dist/components/error-boundary.d.ts.map +1 -0
- package/dist/components/explorer/edit-namespace-dialog.d.ts +14 -0
- package/dist/components/explorer/edit-namespace-dialog.d.ts.map +1 -0
- package/dist/components/explorer/edit-row-dialog.d.ts +10 -0
- package/dist/components/explorer/edit-row-dialog.d.ts.map +1 -0
- package/dist/components/explorer/expandable-deleted-attr.d.ts +15 -0
- package/dist/components/explorer/expandable-deleted-attr.d.ts.map +1 -0
- package/dist/components/explorer/explorer-layout.d.ts +8 -0
- package/dist/components/explorer/explorer-layout.d.ts.map +1 -0
- package/dist/components/explorer/index.d.ts +44 -0
- package/dist/components/explorer/index.d.ts.map +1 -0
- package/dist/components/explorer/inner-explorer.d.ts +16 -0
- package/dist/components/explorer/inner-explorer.d.ts.map +1 -0
- package/dist/components/explorer/new-namespace-dialog.d.ts +10 -0
- package/dist/components/explorer/new-namespace-dialog.d.ts.map +1 -0
- package/dist/components/explorer/query-inspector.d.ts +11 -0
- package/dist/components/explorer/query-inspector.d.ts.map +1 -0
- package/dist/components/explorer/recently-deleted.d.ts +36 -0
- package/dist/components/explorer/recently-deleted.d.ts.map +1 -0
- package/dist/components/explorer/search-input.d.ts +9 -0
- package/dist/components/explorer/search-input.d.ts.map +1 -0
- package/dist/components/explorer/table-components.d.ts +16 -0
- package/dist/components/explorer/table-components.d.ts.map +1 -0
- package/dist/components/explorer/view-settings.d.ts +10 -0
- package/dist/components/explorer/view-settings.d.ts.map +1 -0
- package/dist/components/rosePineDawnTheme.d.ts +13 -0
- package/dist/components/rosePineDawnTheme.d.ts.map +1 -0
- package/dist/components/select.d.ts +16 -0
- package/dist/components/select.d.ts.map +1 -0
- package/dist/components/toast.d.ts +4 -0
- package/dist/components/toast.d.ts.map +1 -0
- package/dist/components/ui.d.ts +336 -0
- package/dist/components/ui.d.ts.map +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/hooks/explorer.d.ts +29 -0
- package/dist/hooks/explorer.d.ts.map +1 -0
- package/dist/hooks/useAttrNotes.d.ts +10 -0
- package/dist/hooks/useAttrNotes.d.ts.map +1 -0
- package/dist/hooks/useClickOutside.d.ts +3 -0
- package/dist/hooks/useClickOutside.d.ts.map +1 -0
- package/dist/hooks/useColumnVisibility.d.ts +12 -0
- package/dist/hooks/useColumnVisibility.d.ts.map +1 -0
- package/dist/hooks/useEditBlobConstraints.d.ts +32 -0
- package/dist/hooks/useEditBlobConstraints.d.ts.map +1 -0
- package/dist/hooks/useExplorerHistory.d.ts +1 -0
- package/dist/hooks/useExplorerHistory.d.ts.map +1 -0
- package/dist/hooks/useIsOverflow.d.ts +6 -0
- package/dist/hooks/useIsOverflow.d.ts.map +1 -0
- package/dist/hooks/useLocalStorage.d.ts +2 -0
- package/dist/hooks/useLocalStorage.d.ts.map +1 -0
- package/dist/hooks/useMonacoJSONSchema.d.ts +3 -0
- package/dist/hooks/useMonacoJSONSchema.d.ts.map +1 -0
- package/dist/hooks/useStableDB.d.ts +7 -0
- package/dist/hooks/useStableDB.d.ts.map +1 -0
- package/dist/index.cjs +15 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9270 -0
- package/dist/schema.d.ts +5 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/style.css +1 -0
- package/dist/types.d.ts +241 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/utils/format.d.ts +2 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/indexingJobs.d.ts +24 -0
- package/dist/utils/indexingJobs.d.ts.map +1 -0
- package/dist/utils/parsePermsJSON.d.ts +11 -0
- package/dist/utils/parsePermsJSON.d.ts.map +1 -0
- package/dist/utils/renames.d.ts +3 -0
- package/dist/utils/renames.d.ts.map +1 -0
- package/dist/utils/tableWidthSize.d.ts +9 -0
- package/dist/utils/tableWidthSize.d.ts.map +1 -0
- package/index.html +13 -0
- package/package.json +109 -0
- package/src/components/StyleMe.tsx +97 -0
- package/src/components/error-boundary.tsx +76 -0
- package/src/components/explorer/edit-namespace-dialog.tsx +1886 -0
- package/src/components/explorer/edit-row-dialog.tsx +1151 -0
- package/src/components/explorer/expandable-deleted-attr.tsx +170 -0
- package/src/components/explorer/explorer-layout.tsx +156 -0
- package/src/components/explorer/index.tsx +217 -0
- package/src/components/explorer/inner-explorer.tsx +1341 -0
- package/src/components/explorer/new-namespace-dialog.tsx +54 -0
- package/src/components/explorer/query-inspector.tsx +394 -0
- package/src/components/explorer/recently-deleted.tsx +344 -0
- package/src/components/explorer/search-input.tsx +358 -0
- package/src/components/explorer/table-components.tsx +341 -0
- package/src/components/explorer/view-settings.tsx +75 -0
- package/src/components/rosePineDawnTheme.ts +45 -0
- package/src/components/select.tsx +198 -0
- package/src/components/toast.tsx +18 -0
- package/src/components/ui.tsx +1561 -0
- package/src/config.ts +61 -0
- package/src/hooks/explorer.tsx +125 -0
- package/src/hooks/useAttrNotes.ts +27 -0
- package/src/hooks/useClickOutside.ts +23 -0
- package/src/hooks/useColumnVisibility.ts +39 -0
- package/src/hooks/useEditBlobConstraints.ts +185 -0
- package/src/hooks/useExplorerHistory.ts +0 -0
- package/src/hooks/useIsOverflow.ts +24 -0
- package/src/hooks/useLocalStorage.ts +51 -0
- package/src/hooks/useMonacoJSONSchema.ts +41 -0
- package/src/hooks/useStableDB.ts +30 -0
- package/src/index.tsx +8 -0
- package/src/schema.ts +285 -0
- package/src/style.css +5 -0
- package/src/types.ts +359 -0
- package/src/utils/format.ts +13 -0
- package/src/utils/indexingJobs.ts +126 -0
- package/src/utils/parsePermsJSON.ts +35 -0
- package/src/utils/renames.ts +42 -0
- package/src/utils/tableWidthSize.ts +62 -0
- package/tailwind.config.cjs +42 -0
- package/tsconfig.json +22 -0
- package/vite-env.d.ts +1 -0
- 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
|
+
}
|