@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,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
+ }