@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,344 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { ActionForm, Button, Divider, useDialog } from '@lib/components/ui';
|
|
3
|
+
import { errorToast } from '../toast';
|
|
4
|
+
import { SchemaNamespace, DBAttr } from '@lib/types';
|
|
5
|
+
import useSWR from 'swr';
|
|
6
|
+
import { InstantReactWebDatabase } from '@instantdb/react';
|
|
7
|
+
import { useEffect, useState } from 'react';
|
|
8
|
+
import { ArrowPathIcon, ClockIcon } from '@heroicons/react/24/outline';
|
|
9
|
+
import { InstantAPIError } from '@instantdb/core';
|
|
10
|
+
import { ExpandableDeletedAttr } from './expandable-deleted-attr';
|
|
11
|
+
import { useAttrNotes } from '@lib/hooks/useAttrNotes';
|
|
12
|
+
import { useMemo } from 'react';
|
|
13
|
+
import { add, formatDistanceToNow, format } from 'date-fns';
|
|
14
|
+
import { useExplorerProps } from '.';
|
|
15
|
+
|
|
16
|
+
// -----
|
|
17
|
+
// Types
|
|
18
|
+
|
|
19
|
+
export type SoftDeletedAttr = Omit<DBAttr, 'metadata'> & {
|
|
20
|
+
'deletion-marked-at': string;
|
|
21
|
+
metadata: {
|
|
22
|
+
soft_delete_snapshot?: {
|
|
23
|
+
is_indexed: boolean;
|
|
24
|
+
is_required: boolean;
|
|
25
|
+
id_attr_id: string;
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type DeletedNamespace = {
|
|
31
|
+
idAttr: SoftDeletedAttr;
|
|
32
|
+
remainingCols: SoftDeletedAttr[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
// -----
|
|
36
|
+
// Hooks
|
|
37
|
+
|
|
38
|
+
export const useRecentlyDeletedAttrs = (appId: string) => {
|
|
39
|
+
const explorerProps = useExplorerProps();
|
|
40
|
+
|
|
41
|
+
const token = explorerProps.adminToken;
|
|
42
|
+
const result = useSWR(['recently-deleted', appId], async () => {
|
|
43
|
+
const response = await fetch(
|
|
44
|
+
`${explorerProps.apiURI}/dash/apps/${appId}/soft_deleted_attrs`,
|
|
45
|
+
{
|
|
46
|
+
method: 'GET',
|
|
47
|
+
headers: {
|
|
48
|
+
Authorization: `Bearer ${token}`,
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
);
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
console.error('Failed to fetch recently deleted attrs', data);
|
|
55
|
+
throw new Error(
|
|
56
|
+
'Failed to fetch recently deleted attrs' + JSON.stringify(data),
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const transformedData = {
|
|
60
|
+
...data,
|
|
61
|
+
attrs: data.attrs.map(withoutDeletionMarkers),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const successfulData = transformedData as {
|
|
65
|
+
attrs: SoftDeletedAttr[];
|
|
66
|
+
'grace-period-days': number;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return successfulData;
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const useRecentlyDeletedNamespaces = (
|
|
76
|
+
appId: string,
|
|
77
|
+
): DeletedNamespace[] => {
|
|
78
|
+
const { data } = useRecentlyDeletedAttrs(appId);
|
|
79
|
+
const deletedNamespaces = useMemo(() => {
|
|
80
|
+
const attrs = data?.attrs || [];
|
|
81
|
+
const idAttrs = attrs.filter((a) => {
|
|
82
|
+
return a['forward-identity'][2] === 'id';
|
|
83
|
+
});
|
|
84
|
+
const mapping = idAttrs.map((a) => {
|
|
85
|
+
const cols = attrs.filter(
|
|
86
|
+
(x) => x.metadata.soft_delete_snapshot?.id_attr_id === a.id,
|
|
87
|
+
);
|
|
88
|
+
return { idAttr: a, remainingCols: cols.filter((c) => a.id !== c.id) };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return mapping;
|
|
92
|
+
}, [data?.attrs]);
|
|
93
|
+
|
|
94
|
+
return deletedNamespaces;
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// -------
|
|
98
|
+
// RecentlyDeletedNamespaces
|
|
99
|
+
|
|
100
|
+
export function RecentlyDeletedNamespaces({
|
|
101
|
+
appId,
|
|
102
|
+
db,
|
|
103
|
+
}: {
|
|
104
|
+
db: InstantReactWebDatabase<any>;
|
|
105
|
+
appId: string;
|
|
106
|
+
}) {
|
|
107
|
+
const { data, mutate } = useRecentlyDeletedAttrs(appId);
|
|
108
|
+
const deletedNamespaces = useRecentlyDeletedNamespaces(appId);
|
|
109
|
+
const gracePeriodDays = data?.['grace-period-days'] || 2;
|
|
110
|
+
|
|
111
|
+
const onRestore = async ({ idAttr, remainingCols }: DeletedNamespace) => {
|
|
112
|
+
if (!db) return;
|
|
113
|
+
if (!data) return;
|
|
114
|
+
const ids = [idAttr, ...remainingCols].map((a) => a.id);
|
|
115
|
+
await db.core._reactor.pushOps(
|
|
116
|
+
ids.map((attrId) => ['restore-attr', attrId]),
|
|
117
|
+
);
|
|
118
|
+
const idSet = new Set(ids);
|
|
119
|
+
mutate({
|
|
120
|
+
...data,
|
|
121
|
+
attrs: data.attrs.filter((attr) => !idSet.has(attr.id)),
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<ActionForm className="flex max-w-2xl flex-col gap-4">
|
|
127
|
+
<h5 className="flex items-center gap-2 text-lg font-bold">
|
|
128
|
+
Recently Deleted Namespaces
|
|
129
|
+
</h5>
|
|
130
|
+
{deletedNamespaces.length ? (
|
|
131
|
+
<div className="flex flex-col gap-2">
|
|
132
|
+
{deletedNamespaces
|
|
133
|
+
.toSorted((a, b) => {
|
|
134
|
+
return (
|
|
135
|
+
+new Date(b.idAttr['deletion-marked-at']) -
|
|
136
|
+
+new Date(a.idAttr['deletion-marked-at'])
|
|
137
|
+
);
|
|
138
|
+
})
|
|
139
|
+
.map((ns) => {
|
|
140
|
+
const deletionMarkedAt = new Date(
|
|
141
|
+
ns.idAttr['deletion-marked-at'],
|
|
142
|
+
);
|
|
143
|
+
const expiresAt = add(deletionMarkedAt, {
|
|
144
|
+
days: gracePeriodDays,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div
|
|
149
|
+
key={ns.idAttr.id}
|
|
150
|
+
className="flex items-start justify-between gap-4 border-b py-3 last:border-b-0 dark:border-neutral-700"
|
|
151
|
+
>
|
|
152
|
+
<div className="min-w-0 flex-1">
|
|
153
|
+
<div className="font-semibold dark:text-white">
|
|
154
|
+
{ns.idAttr['forward-identity'][1]}
|
|
155
|
+
</div>
|
|
156
|
+
<div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
|
157
|
+
Deleted {format(deletionMarkedAt, 'MMM d, h:mm a')} ·{' '}
|
|
158
|
+
expires{' '}
|
|
159
|
+
{formatDistanceToNow(expiresAt, {
|
|
160
|
+
includeSeconds: false,
|
|
161
|
+
})}
|
|
162
|
+
</div>
|
|
163
|
+
{ns.remainingCols.length > 0 ? (
|
|
164
|
+
<div className="mt-1 truncate text-xs text-neutral-500 dark:text-neutral-400">
|
|
165
|
+
Columns:{' '}
|
|
166
|
+
{ns.remainingCols
|
|
167
|
+
.map((attr) => attr['forward-identity'][2])
|
|
168
|
+
.join(', ')}
|
|
169
|
+
</div>
|
|
170
|
+
) : (
|
|
171
|
+
<div className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
|
172
|
+
No columns
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
<div className="flex shrink-0 items-center">
|
|
177
|
+
<Button
|
|
178
|
+
size="mini"
|
|
179
|
+
variant="secondary"
|
|
180
|
+
onClick={() => onRestore(ns)}
|
|
181
|
+
>
|
|
182
|
+
<ArrowPathIcon className="h-3.5 w-3.5" />
|
|
183
|
+
Restore
|
|
184
|
+
</Button>
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
);
|
|
188
|
+
})}
|
|
189
|
+
</div>
|
|
190
|
+
) : (
|
|
191
|
+
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
|
192
|
+
No recently deleted namespaces.
|
|
193
|
+
</p>
|
|
194
|
+
)}
|
|
195
|
+
</ActionForm>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// -------
|
|
200
|
+
// RecentlyDeletedAttrs
|
|
201
|
+
|
|
202
|
+
export const RecentlyDeletedAttrs: React.FC<{
|
|
203
|
+
namespace: SchemaNamespace;
|
|
204
|
+
appId: string;
|
|
205
|
+
db: InstantReactWebDatabase<any>;
|
|
206
|
+
notes: ReturnType<typeof useAttrNotes>;
|
|
207
|
+
}> = ({ namespace, appId, db, notes }) => {
|
|
208
|
+
const { data, mutate, error } = useRecentlyDeletedAttrs(appId);
|
|
209
|
+
|
|
210
|
+
const [expandedAttr, setExpandedAttr] = useState<string | null>(null);
|
|
211
|
+
|
|
212
|
+
const dialog = useDialog();
|
|
213
|
+
|
|
214
|
+
const restoreAttr = async (attrId: string) => {
|
|
215
|
+
if (!db) return;
|
|
216
|
+
if (!data) return;
|
|
217
|
+
try {
|
|
218
|
+
await db.core._reactor.pushOps([['restore-attr', attrId]]);
|
|
219
|
+
mutate({
|
|
220
|
+
attrs: data.attrs.filter((attr) => attr.id !== attrId) ?? [],
|
|
221
|
+
'grace-period-days': data['grace-period-days'],
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
const attr = data.attrs.find((attr) => attr.id === attrId);
|
|
225
|
+
if (attr) {
|
|
226
|
+
const possibleMessage = getConstraintMessage(attr);
|
|
227
|
+
if (possibleMessage) {
|
|
228
|
+
notes.setNote(attrId, possibleMessage);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
} catch (error) {
|
|
232
|
+
console.error(error);
|
|
233
|
+
if (error instanceof InstantAPIError) {
|
|
234
|
+
if (error.body?.type === 'record-not-unique') {
|
|
235
|
+
errorToast(
|
|
236
|
+
'Attribute already exists. Rename existing attribute first and then try again to restore.',
|
|
237
|
+
);
|
|
238
|
+
} else {
|
|
239
|
+
errorToast(error.message);
|
|
240
|
+
}
|
|
241
|
+
} else {
|
|
242
|
+
errorToast('Failed to restore attr');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const idAttrId = namespace.attrs.find((a) => a.name === 'id')?.id || 'unk';
|
|
248
|
+
|
|
249
|
+
const filtered = data?.attrs?.filter(
|
|
250
|
+
(attr) => attr.metadata?.soft_delete_snapshot?.id_attr_id === idAttrId,
|
|
251
|
+
);
|
|
252
|
+
|
|
253
|
+
useEffect(() => {
|
|
254
|
+
if (filtered?.length === 0) {
|
|
255
|
+
dialog.onClose();
|
|
256
|
+
}
|
|
257
|
+
}, [filtered]);
|
|
258
|
+
|
|
259
|
+
if (error || !filtered || filtered.length === 0) {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return (
|
|
264
|
+
<div className="pb-2">
|
|
265
|
+
<Divider className="pb-2">
|
|
266
|
+
<div className="flex w-full grow items-center justify-center gap-2 text-center opacity-60">
|
|
267
|
+
<ClockIcon width={16} />
|
|
268
|
+
Recently Deleted
|
|
269
|
+
</div>
|
|
270
|
+
</Divider>
|
|
271
|
+
<div className="flex flex-col gap-2">
|
|
272
|
+
{filtered?.map((attr) => (
|
|
273
|
+
<ExpandableDeletedAttr
|
|
274
|
+
isExpanded={expandedAttr === attr.id}
|
|
275
|
+
setIsExpanded={(isExpanded) => {
|
|
276
|
+
if (isExpanded) {
|
|
277
|
+
setExpandedAttr(attr.id);
|
|
278
|
+
} else {
|
|
279
|
+
setExpandedAttr(null);
|
|
280
|
+
}
|
|
281
|
+
}}
|
|
282
|
+
key={attr.id}
|
|
283
|
+
attr={attr}
|
|
284
|
+
gracePeriodDays={data?.['grace-period-days'] || 2}
|
|
285
|
+
onRestore={restoreAttr}
|
|
286
|
+
/>
|
|
287
|
+
))}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// -------
|
|
294
|
+
// RecentlyDeletedNamespaces
|
|
295
|
+
|
|
296
|
+
// --------
|
|
297
|
+
// Helpers
|
|
298
|
+
|
|
299
|
+
const deletedMarker = '_deleted$';
|
|
300
|
+
|
|
301
|
+
const withoutDeletionMarkers = (attr: SoftDeletedAttr): SoftDeletedAttr => {
|
|
302
|
+
const newAttr = { ...attr };
|
|
303
|
+
const [fwdId, fwdEtype, fwdLabel] = attr['forward-identity'];
|
|
304
|
+
newAttr['forward-identity'] = [
|
|
305
|
+
fwdId,
|
|
306
|
+
removeDeletedMarker(fwdEtype),
|
|
307
|
+
removeDeletedMarker(fwdLabel),
|
|
308
|
+
];
|
|
309
|
+
if (attr['reverse-identity']) {
|
|
310
|
+
const [revId, revEtype, revLabel] = attr['reverse-identity']!;
|
|
311
|
+
newAttr['reverse-identity'] = [
|
|
312
|
+
revId,
|
|
313
|
+
removeDeletedMarker(revEtype),
|
|
314
|
+
removeDeletedMarker(revLabel),
|
|
315
|
+
];
|
|
316
|
+
}
|
|
317
|
+
return newAttr;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export const removeDeletedMarker = (s: string): string => {
|
|
321
|
+
const idx = s.indexOf(deletedMarker);
|
|
322
|
+
if (idx === -1) return s;
|
|
323
|
+
return s.slice(idx + deletedMarker.length);
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const getConstraintMessage = (attr: SoftDeletedAttr): string | null => {
|
|
327
|
+
if (attr && attr?.metadata?.soft_delete_snapshot) {
|
|
328
|
+
if (
|
|
329
|
+
attr.metadata.soft_delete_snapshot.is_indexed &&
|
|
330
|
+
attr.metadata.soft_delete_snapshot.is_required
|
|
331
|
+
) {
|
|
332
|
+
return 'Index and required constraints were dropped after restoring';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (attr.metadata.soft_delete_snapshot.is_indexed) {
|
|
336
|
+
return 'Indexed constraint was dropped after restoring';
|
|
337
|
+
}
|
|
338
|
+
if (attr.metadata.soft_delete_snapshot.is_required) {
|
|
339
|
+
return 'Required constraint was dropped after restoring';
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
return null;
|
|
344
|
+
};
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Combobox,
|
|
3
|
+
ComboboxInput,
|
|
4
|
+
ComboboxOption,
|
|
5
|
+
ComboboxOptions,
|
|
6
|
+
} from '@headlessui/react';
|
|
7
|
+
import { SearchFilter } from '@lib/hooks/explorer';
|
|
8
|
+
import { SchemaAttr } from '@lib/types';
|
|
9
|
+
import { debounce, last } from 'lodash';
|
|
10
|
+
import React, {
|
|
11
|
+
useCallback,
|
|
12
|
+
useEffect,
|
|
13
|
+
useMemo,
|
|
14
|
+
useRef,
|
|
15
|
+
useState,
|
|
16
|
+
} from 'react';
|
|
17
|
+
import { cn } from '../ui';
|
|
18
|
+
|
|
19
|
+
const excludedSearchAttrs: [string, string][] = [
|
|
20
|
+
// Exclude computed fields
|
|
21
|
+
['$files', 'url'],
|
|
22
|
+
];
|
|
23
|
+
const OPERATORS = [':', '>', '<'] as const;
|
|
24
|
+
type ParsedQueryPart = {
|
|
25
|
+
field: string;
|
|
26
|
+
operator: (typeof OPERATORS)[number];
|
|
27
|
+
value: string;
|
|
28
|
+
};
|
|
29
|
+
function parseSearchQuery(s: string): ParsedQueryPart[] {
|
|
30
|
+
let fieldStart = 0;
|
|
31
|
+
let currentPart: ParsedQueryPart | undefined;
|
|
32
|
+
let valueStart;
|
|
33
|
+
const parts: ParsedQueryPart[] = [];
|
|
34
|
+
let i = -1;
|
|
35
|
+
for (const c of s) {
|
|
36
|
+
i++;
|
|
37
|
+
|
|
38
|
+
if (c === ' ' && !(OPERATORS as readonly string[]).includes(s[i + 1])) {
|
|
39
|
+
fieldStart = i + 1;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if ((OPERATORS as readonly string[]).includes(c)) {
|
|
43
|
+
if (currentPart && valueStart != null) {
|
|
44
|
+
currentPart.value = s.substring(valueStart, fieldStart).trim();
|
|
45
|
+
parts.push(currentPart);
|
|
46
|
+
}
|
|
47
|
+
currentPart = {
|
|
48
|
+
field: s.substring(fieldStart, i).trim(),
|
|
49
|
+
operator: c as (typeof OPERATORS)[number],
|
|
50
|
+
value: '',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
valueStart = i + 1;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (currentPart && valueStart != null) {
|
|
58
|
+
currentPart.value = s.substring(valueStart).trim();
|
|
59
|
+
// Might push twice here...
|
|
60
|
+
parts.push(currentPart);
|
|
61
|
+
}
|
|
62
|
+
return parts;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function opToInstaqlOp(op: ':' | '<' | '>'): '=' | '$gt' | '$lt' {
|
|
66
|
+
switch (op) {
|
|
67
|
+
case ':':
|
|
68
|
+
// Not really an instaql op, but we have special handling in
|
|
69
|
+
// explorer.tsx to turn `=` into {k: v}
|
|
70
|
+
return '=';
|
|
71
|
+
case '<':
|
|
72
|
+
return '$lt';
|
|
73
|
+
case '>':
|
|
74
|
+
return '$gt';
|
|
75
|
+
default:
|
|
76
|
+
throw new Error('what kind of op is this? ' + op);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function queryToFilters({
|
|
81
|
+
query,
|
|
82
|
+
attrsByName,
|
|
83
|
+
stringIndexed,
|
|
84
|
+
}: {
|
|
85
|
+
query: string;
|
|
86
|
+
attrsByName: { [key: string]: SchemaAttr };
|
|
87
|
+
stringIndexed: SchemaAttr[];
|
|
88
|
+
}): SearchFilter[] {
|
|
89
|
+
if (!query.trim()) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const parsed = parseSearchQuery(query);
|
|
93
|
+
const parts: SearchFilter[] = parsed.flatMap(
|
|
94
|
+
(part: ParsedQueryPart): SearchFilter[] => {
|
|
95
|
+
const attr = attrsByName[part.field];
|
|
96
|
+
if (!attr || !part.value) {
|
|
97
|
+
return [];
|
|
98
|
+
}
|
|
99
|
+
if (
|
|
100
|
+
part.value.toLowerCase() === 'null' &&
|
|
101
|
+
part.operator === ':' &&
|
|
102
|
+
!attr.isRequired
|
|
103
|
+
) {
|
|
104
|
+
return [[part.field, '$isNull', null]];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const res: SearchFilter[] = [];
|
|
108
|
+
if (attr.checkedDataType && attr.isIndex) {
|
|
109
|
+
if (attr.checkedDataType === 'string') {
|
|
110
|
+
const val = part.value;
|
|
111
|
+
return [
|
|
112
|
+
[
|
|
113
|
+
part.field,
|
|
114
|
+
val === val.toLowerCase() ? '$ilike' : '$like',
|
|
115
|
+
`%${part.value}%`,
|
|
116
|
+
],
|
|
117
|
+
];
|
|
118
|
+
}
|
|
119
|
+
if (attr.checkedDataType === 'number') {
|
|
120
|
+
try {
|
|
121
|
+
return [
|
|
122
|
+
[
|
|
123
|
+
part.field,
|
|
124
|
+
opToInstaqlOp(part.operator),
|
|
125
|
+
JSON.parse(part.value),
|
|
126
|
+
],
|
|
127
|
+
];
|
|
128
|
+
} catch (e) {}
|
|
129
|
+
}
|
|
130
|
+
if (attr.checkedDataType === 'date') {
|
|
131
|
+
try {
|
|
132
|
+
return [
|
|
133
|
+
[
|
|
134
|
+
part.field,
|
|
135
|
+
opToInstaqlOp(part.operator),
|
|
136
|
+
JSON.parse(part.value),
|
|
137
|
+
],
|
|
138
|
+
];
|
|
139
|
+
} catch (e) {
|
|
140
|
+
// Might be a string date
|
|
141
|
+
return [[part.field, opToInstaqlOp(part.operator), part.value]];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
for (const inferredType of attr.inferredTypes || ['json']) {
|
|
146
|
+
switch (inferredType) {
|
|
147
|
+
case 'boolean':
|
|
148
|
+
case 'number': {
|
|
149
|
+
try {
|
|
150
|
+
res.push([
|
|
151
|
+
part.field,
|
|
152
|
+
opToInstaqlOp(part.operator),
|
|
153
|
+
JSON.parse(part.value),
|
|
154
|
+
]);
|
|
155
|
+
} catch (e) {}
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
default: {
|
|
159
|
+
res.push([part.field, opToInstaqlOp(part.operator), part.value]);
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return res;
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
if (!parsed.length && query.trim() && stringIndexed.length) {
|
|
169
|
+
for (const a of stringIndexed) {
|
|
170
|
+
parts.push([
|
|
171
|
+
a.name,
|
|
172
|
+
query.toLowerCase() === query ? '$ilike' : '$like',
|
|
173
|
+
`%${query.trim()}%`,
|
|
174
|
+
]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return parts;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function sameFilters(
|
|
181
|
+
oldFilters: [string, string, string][],
|
|
182
|
+
newFilters: [string, string, string][],
|
|
183
|
+
): boolean {
|
|
184
|
+
if (newFilters.length === oldFilters.length) {
|
|
185
|
+
for (let i = 0; i < newFilters.length; i++) {
|
|
186
|
+
for (let j = 0; j < 3; j++) {
|
|
187
|
+
if (newFilters[i][j] !== oldFilters[i][j]) {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return true;
|
|
193
|
+
}
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function SearchInput({
|
|
198
|
+
onSearchChange,
|
|
199
|
+
attrs,
|
|
200
|
+
initialFilters = [],
|
|
201
|
+
}: {
|
|
202
|
+
onSearchChange: (filters: SearchFilter[]) => void;
|
|
203
|
+
attrs?: SchemaAttr[];
|
|
204
|
+
initialFilters?: SearchFilter[];
|
|
205
|
+
}) {
|
|
206
|
+
const [query, setQuery] = useState('');
|
|
207
|
+
const lastFilters = useRef<SearchFilter[]>(initialFilters);
|
|
208
|
+
|
|
209
|
+
const { attrsByName, stringIndexed } = useMemo(() => {
|
|
210
|
+
const byName: { [key: string]: SchemaAttr } = {};
|
|
211
|
+
const stringIndexed = [];
|
|
212
|
+
for (const attr of attrs || []) {
|
|
213
|
+
byName[attr.name] = attr;
|
|
214
|
+
if (attr.isIndex && attr.checkedDataType === 'string') {
|
|
215
|
+
stringIndexed.push(attr);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return { attrsByName: byName, stringIndexed };
|
|
219
|
+
}, [attrs]);
|
|
220
|
+
|
|
221
|
+
const searchDebounce = useCallback(
|
|
222
|
+
debounce((query) => {
|
|
223
|
+
const filters = queryToFilters({ query, attrsByName, stringIndexed });
|
|
224
|
+
if (!sameFilters(lastFilters.current, filters)) {
|
|
225
|
+
lastFilters.current = filters;
|
|
226
|
+
onSearchChange(filters);
|
|
227
|
+
}
|
|
228
|
+
}, 80),
|
|
229
|
+
[attrsByName, stringIndexed, lastFilters],
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
const lastQuerySegment =
|
|
233
|
+
query.indexOf(':') !== -1 ? last(query.split(' ')) : query;
|
|
234
|
+
|
|
235
|
+
const comboOptions: { field: string; operator: string; display: string }[] = (
|
|
236
|
+
attrs || []
|
|
237
|
+
).flatMap((a) => {
|
|
238
|
+
const isExcluded = excludedSearchAttrs.some(
|
|
239
|
+
([ns, name]) => ns === a.namespace && name === a.name,
|
|
240
|
+
);
|
|
241
|
+
if (a.type === 'ref' || isExcluded) {
|
|
242
|
+
return [];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const ops = [];
|
|
246
|
+
|
|
247
|
+
const opCandidates = [];
|
|
248
|
+
opCandidates.push({
|
|
249
|
+
field: a.name,
|
|
250
|
+
operator: ':',
|
|
251
|
+
display: `${a.name}:`,
|
|
252
|
+
});
|
|
253
|
+
if (
|
|
254
|
+
a.isIndex &&
|
|
255
|
+
(a.checkedDataType === 'number' || a.checkedDataType === 'date')
|
|
256
|
+
) {
|
|
257
|
+
const base = {
|
|
258
|
+
field: a.name,
|
|
259
|
+
query: null,
|
|
260
|
+
};
|
|
261
|
+
opCandidates.push({ ...base, operator: '<', display: `${a.name}<` });
|
|
262
|
+
opCandidates.push({ ...base, operator: '>', display: `${a.name}>` });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
for (const op of opCandidates) {
|
|
266
|
+
if (
|
|
267
|
+
!lastQuerySegment ||
|
|
268
|
+
(op.display.startsWith(lastQuerySegment) &&
|
|
269
|
+
op.display !== lastQuerySegment)
|
|
270
|
+
) {
|
|
271
|
+
ops.push(op);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return ops;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const activeOption = useRef<(typeof comboOptions)[0] | null>(null);
|
|
278
|
+
|
|
279
|
+
function completeQuery(optionDisplay: string) {
|
|
280
|
+
let q;
|
|
281
|
+
if (lastQuerySegment && optionDisplay.startsWith(lastQuerySegment)) {
|
|
282
|
+
q = `${query}${optionDisplay.substring(lastQuerySegment.length)}`;
|
|
283
|
+
} else {
|
|
284
|
+
q = `${query.trim()} ${optionDisplay}`;
|
|
285
|
+
}
|
|
286
|
+
setQuery(q);
|
|
287
|
+
searchDebounce(q);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Set initial search query based on filters
|
|
291
|
+
useEffect(() => {
|
|
292
|
+
if (initialFilters.length > 0 && !query) {
|
|
293
|
+
// Simple conversion - this could be improved
|
|
294
|
+
setQuery(initialFilters.map((f) => `${f[0]}:${f[2]}`).join(' '));
|
|
295
|
+
}
|
|
296
|
+
}, [initialFilters]);
|
|
297
|
+
|
|
298
|
+
return (
|
|
299
|
+
<Combobox
|
|
300
|
+
value={query}
|
|
301
|
+
onChange={(option) => {
|
|
302
|
+
if (option) {
|
|
303
|
+
completeQuery(option);
|
|
304
|
+
}
|
|
305
|
+
}}
|
|
306
|
+
immediate={true}
|
|
307
|
+
>
|
|
308
|
+
<ComboboxInput
|
|
309
|
+
size={32}
|
|
310
|
+
className="rounded-md border border-neutral-300 px-3 py-2 text-sm dark:border-neutral-700 dark:bg-neutral-800 dark:text-white dark:placeholder:text-neutral-500"
|
|
311
|
+
value={query}
|
|
312
|
+
onChange={(e) => {
|
|
313
|
+
setQuery(e.target.value);
|
|
314
|
+
searchDebounce(e.target.value);
|
|
315
|
+
}}
|
|
316
|
+
onKeyDown={(e) => {
|
|
317
|
+
// Prevent the combobox's default action that inserts
|
|
318
|
+
// the active option and tabs out of the input.
|
|
319
|
+
// Inserting the option doesn't work in our case, because
|
|
320
|
+
// it's just the start of a query, you still need to add
|
|
321
|
+
// the value
|
|
322
|
+
if (e.key === 'Tab' && comboOptions.length) {
|
|
323
|
+
e.preventDefault();
|
|
324
|
+
|
|
325
|
+
const active = activeOption.current || comboOptions[0];
|
|
326
|
+
if (active) {
|
|
327
|
+
completeQuery(active.display);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}}
|
|
331
|
+
placeholder="Filter..."
|
|
332
|
+
/>
|
|
333
|
+
<ComboboxOptions
|
|
334
|
+
anchor="bottom start"
|
|
335
|
+
modal={false}
|
|
336
|
+
className="z-10 mt-1 w-(--input-width) divide-y overflow-auto rounded-md border border-neutral-300 bg-white shadow-lg dark:divide-neutral-700 dark:border-neutral-700 dark:bg-neutral-800"
|
|
337
|
+
>
|
|
338
|
+
{comboOptions.map((o, i) => (
|
|
339
|
+
<ComboboxOption
|
|
340
|
+
key={i}
|
|
341
|
+
value={o.display}
|
|
342
|
+
className={cn(
|
|
343
|
+
'px-3 py-1 data-focus:bg-blue-100 dark:text-white dark:data-focus:bg-neutral-700',
|
|
344
|
+
{},
|
|
345
|
+
)}
|
|
346
|
+
>
|
|
347
|
+
{({ focus }) => {
|
|
348
|
+
if (focus) {
|
|
349
|
+
activeOption.current = o;
|
|
350
|
+
}
|
|
351
|
+
return <span>{o.display}</span>;
|
|
352
|
+
}}
|
|
353
|
+
</ComboboxOption>
|
|
354
|
+
))}
|
|
355
|
+
</ComboboxOptions>
|
|
356
|
+
</Combobox>
|
|
357
|
+
);
|
|
358
|
+
}
|