@alepha/ui 0.11.5 → 0.11.7
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/LICENSE +1 -1
- package/dist/AlephaMantineProvider-B4TwQ4tY.js +3 -0
- package/dist/{AlephaMantineProvider-Be0DAazb.js → AlephaMantineProvider-CzMrw7V3.js} +4 -4
- package/dist/{AlephaMantineProvider-Be0DAazb.js.map → AlephaMantineProvider-CzMrw7V3.js.map} +1 -1
- package/dist/index.d.ts +142 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1026 -94
- package/dist/index.js.map +1 -1
- package/package.json +22 -20
- package/src/components/buttons/ActionButton.tsx +29 -3
- package/src/components/buttons/ToggleSidebarButton.tsx +4 -1
- package/src/components/data/JsonViewer.tsx +352 -0
- package/src/components/form/Control.tsx +28 -11
- package/src/components/form/ControlNumber.tsx +96 -0
- package/src/components/form/ControlQueryBuilder.tsx +313 -0
- package/src/components/form/ControlSelect.tsx +1 -1
- package/src/components/form/TypeForm.tsx +6 -4
- package/src/components/layout/AdminShell.tsx +7 -0
- package/src/components/layout/Sidebar.tsx +24 -14
- package/src/components/table/DataTable.tsx +297 -20
- package/src/constants/ui.ts +1 -0
- package/src/index.ts +3 -0
- package/src/services/DialogService.tsx +13 -2
- package/src/utils/extractSchemaFields.ts +172 -0
- package/dist/AlephaMantineProvider-Ba88lMeq.js +0 -3
|
@@ -1,62 +1,339 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
Alepha,
|
|
3
|
+
type Async,
|
|
4
|
+
type Page,
|
|
5
|
+
type PageMetadata,
|
|
6
|
+
type Static,
|
|
7
|
+
type TObject,
|
|
8
|
+
t,
|
|
9
|
+
} from "@alepha/core";
|
|
10
|
+
import { DateTimeProvider, type DurationLike } from "@alepha/datetime";
|
|
11
|
+
import { useInject } from "@alepha/react";
|
|
12
|
+
import { type FormModel, useForm } from "@alepha/react-form";
|
|
13
|
+
import {
|
|
14
|
+
Flex,
|
|
15
|
+
Pagination,
|
|
16
|
+
Select,
|
|
17
|
+
Table,
|
|
18
|
+
type TableProps,
|
|
19
|
+
type TableTrProps,
|
|
20
|
+
} from "@mantine/core";
|
|
21
|
+
import { useDebouncedCallback } from "@mantine/hooks";
|
|
4
22
|
import { type ReactNode, useEffect, useState } from "react";
|
|
5
23
|
import ActionButton from "../buttons/ActionButton.tsx";
|
|
24
|
+
import TypeForm, { type TypeFormProps } from "../form/TypeForm.tsx";
|
|
6
25
|
|
|
7
|
-
export interface
|
|
26
|
+
export interface DataTableColumnContext<Filters extends TObject> {
|
|
27
|
+
index: number;
|
|
28
|
+
form: FormModel<Filters>;
|
|
29
|
+
alepha: Alepha;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface DataTableColumn<T extends object, Filters extends TObject> {
|
|
8
33
|
label: string;
|
|
9
|
-
value: (item: T) => ReactNode;
|
|
34
|
+
value: (item: T, ctx: DataTableColumnContext<Filters>) => ReactNode;
|
|
35
|
+
fit?: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type MaybePage<T> = Omit<Page<T>, "page"> & {
|
|
39
|
+
page?: Partial<PageMetadata>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export interface DataTableSubmitContext<T extends object> {
|
|
43
|
+
items: T[];
|
|
10
44
|
}
|
|
11
45
|
|
|
12
|
-
export interface DataTableProps<T extends object> {
|
|
13
|
-
|
|
46
|
+
export interface DataTableProps<T extends object, Filters extends TObject> {
|
|
47
|
+
/**
|
|
48
|
+
* The items to display in the table. Can be a static page of items or a function that returns a promise resolving to a page of items.
|
|
49
|
+
*/
|
|
50
|
+
items:
|
|
51
|
+
| MaybePage<T>
|
|
52
|
+
| ((
|
|
53
|
+
filters: Static<Filters> & {
|
|
54
|
+
page: number;
|
|
55
|
+
size: number;
|
|
56
|
+
sort?: string;
|
|
57
|
+
},
|
|
58
|
+
ctx: DataTableSubmitContext<T>,
|
|
59
|
+
) => Async<MaybePage<T>>);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The columns to display in the table. Each column is defined by a key and a DataTableColumn object.
|
|
63
|
+
*/
|
|
14
64
|
columns: {
|
|
15
|
-
[key: string]: DataTableColumn<T>;
|
|
65
|
+
[key: string]: DataTableColumn<T, Filters>;
|
|
16
66
|
};
|
|
67
|
+
|
|
68
|
+
defaultSize?: number;
|
|
69
|
+
|
|
70
|
+
typeFormProps?: Partial<Omit<TypeFormProps<Filters>, "form">>;
|
|
71
|
+
|
|
72
|
+
onFilterChange?: (
|
|
73
|
+
key: string,
|
|
74
|
+
value: unknown,
|
|
75
|
+
form: FormModel<Filters>,
|
|
76
|
+
) => void;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Optional filters to apply to the data.
|
|
80
|
+
*/
|
|
81
|
+
filters?: TObject;
|
|
82
|
+
|
|
83
|
+
panel?: (item: T) => ReactNode;
|
|
84
|
+
canPanel?: (item: T) => boolean;
|
|
85
|
+
|
|
86
|
+
submitOnInit?: boolean;
|
|
87
|
+
submitEvery?: DurationLike;
|
|
88
|
+
|
|
89
|
+
withLineNumbers?: boolean;
|
|
90
|
+
withCheckbox?: boolean;
|
|
91
|
+
checkboxActions?: any[];
|
|
92
|
+
|
|
93
|
+
actions?: any[];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Enable infinity scroll mode. When true, pagination controls are hidden and new items are loaded automatically when scrolling to the bottom.
|
|
97
|
+
*/
|
|
98
|
+
infinityScroll?: boolean;
|
|
99
|
+
|
|
100
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Props to pass to the Mantine Table component.
|
|
104
|
+
*/
|
|
17
105
|
tableProps?: TableProps;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Function to generate props for each table row based on the item.
|
|
109
|
+
*/
|
|
18
110
|
tableTrProps?: (item: T) => TableTrProps;
|
|
19
111
|
}
|
|
20
112
|
|
|
21
|
-
const DataTable = <T extends object
|
|
22
|
-
|
|
23
|
-
|
|
113
|
+
const DataTable = <T extends object, Filters extends TObject>(
|
|
114
|
+
props: DataTableProps<T, Filters>,
|
|
115
|
+
) => {
|
|
116
|
+
const [items, setItems] = useState<MaybePage<T>>(
|
|
117
|
+
typeof props.items === "function"
|
|
118
|
+
? {
|
|
119
|
+
content: [],
|
|
120
|
+
}
|
|
121
|
+
: props.items,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
const defaultSize = props.infinityScroll ? 100 : props.defaultSize || 10;
|
|
125
|
+
const [page, setPage] = useState(1);
|
|
126
|
+
const [size, setSize] = useState(String(defaultSize));
|
|
127
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
128
|
+
const alepha = useInject(Alepha);
|
|
129
|
+
|
|
130
|
+
const form = useForm(
|
|
131
|
+
{
|
|
132
|
+
schema: t.object({
|
|
133
|
+
...(props.filters ? props.filters.properties : {}),
|
|
134
|
+
page: t.number({ default: 0 }),
|
|
135
|
+
size: t.number({ default: defaultSize }),
|
|
136
|
+
sort: t.optional(t.string()),
|
|
137
|
+
}),
|
|
138
|
+
handler: async (values, args) => {
|
|
139
|
+
if (typeof props.items === "function") {
|
|
140
|
+
const response = await props.items(
|
|
141
|
+
values as Static<Filters> & {
|
|
142
|
+
page: number;
|
|
143
|
+
size: number;
|
|
144
|
+
sort?: string;
|
|
145
|
+
},
|
|
146
|
+
{
|
|
147
|
+
items: items.content,
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
if (props.infinityScroll && values.page > 0) {
|
|
152
|
+
// Append new items to existing ones for infinity scroll
|
|
153
|
+
setItems((prev) => ({
|
|
154
|
+
...response,
|
|
155
|
+
content: [...prev.content, ...response.content],
|
|
156
|
+
}));
|
|
157
|
+
} else {
|
|
158
|
+
setItems(response);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
setCurrentPage(values.page);
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
onReset: async () => {
|
|
165
|
+
setPage(1);
|
|
166
|
+
setSize("10");
|
|
167
|
+
await form.submit();
|
|
168
|
+
},
|
|
169
|
+
onChange: async (key, value) => {
|
|
170
|
+
if (key === "page") {
|
|
171
|
+
setPage(value + 1);
|
|
172
|
+
await form.submit();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (key === "size") {
|
|
177
|
+
setSize(String(value));
|
|
178
|
+
form.input.page.set(0);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
props.onFilterChange?.(key, value, form as any);
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
[items],
|
|
24
186
|
);
|
|
25
187
|
|
|
188
|
+
const submitDebounce = useDebouncedCallback(() => form.submit(), {
|
|
189
|
+
delay: 800,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const dt = useInject(DateTimeProvider);
|
|
193
|
+
|
|
194
|
+
useEffect(() => {
|
|
195
|
+
if (props.submitOnInit) {
|
|
196
|
+
form.submit();
|
|
197
|
+
}
|
|
198
|
+
if (props.submitEvery) {
|
|
199
|
+
const it = dt.createInterval(() => {
|
|
200
|
+
form.submit();
|
|
201
|
+
}, props.submitEvery);
|
|
202
|
+
return () => dt.clearInterval(it);
|
|
203
|
+
}
|
|
204
|
+
}, []);
|
|
205
|
+
|
|
26
206
|
useEffect(() => {
|
|
27
207
|
if (typeof props.items !== "function") {
|
|
28
208
|
setItems(props.items);
|
|
29
209
|
}
|
|
30
210
|
}, [props.items]);
|
|
31
211
|
|
|
212
|
+
// Infinity scroll detection
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
if (!props.infinityScroll || typeof props.items !== "function") return;
|
|
215
|
+
|
|
216
|
+
const handleScroll = () => {
|
|
217
|
+
if (form.submitting) return;
|
|
218
|
+
|
|
219
|
+
const scrollTop = window.scrollY;
|
|
220
|
+
const windowHeight = window.innerHeight;
|
|
221
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
222
|
+
|
|
223
|
+
const isNearBottom = scrollTop + windowHeight >= docHeight - 300;
|
|
224
|
+
|
|
225
|
+
if (isNearBottom) {
|
|
226
|
+
const totalPages = items.page?.totalPages ?? 1;
|
|
227
|
+
|
|
228
|
+
if (currentPage + 1 < totalPages) {
|
|
229
|
+
form.input.page.set(currentPage + 1);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
window.addEventListener("scroll", handleScroll);
|
|
235
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
236
|
+
}, [
|
|
237
|
+
props.infinityScroll,
|
|
238
|
+
form.submitting,
|
|
239
|
+
items.page?.totalPages,
|
|
240
|
+
currentPage,
|
|
241
|
+
form,
|
|
242
|
+
]);
|
|
243
|
+
|
|
32
244
|
const head = Object.entries(props.columns).map(([key, col]) => (
|
|
33
|
-
<Table.Th
|
|
245
|
+
<Table.Th
|
|
246
|
+
key={key}
|
|
247
|
+
style={{
|
|
248
|
+
...(col.fit
|
|
249
|
+
? {
|
|
250
|
+
width: "1%",
|
|
251
|
+
whiteSpace: "nowrap",
|
|
252
|
+
}
|
|
253
|
+
: {}),
|
|
254
|
+
}}
|
|
255
|
+
>
|
|
34
256
|
<ActionButton justify={"space-between"} radius={0} fullWidth size={"xs"}>
|
|
35
257
|
{col.label}
|
|
36
258
|
</ActionButton>
|
|
37
259
|
</Table.Th>
|
|
38
260
|
));
|
|
39
261
|
|
|
40
|
-
const rows = items.map((item, index) => {
|
|
262
|
+
const rows = items.content.map((item, index) => {
|
|
41
263
|
const trProps = props.tableTrProps
|
|
42
264
|
? props.tableTrProps(item as T)
|
|
43
265
|
: ({} as TableTrProps);
|
|
44
266
|
return (
|
|
45
267
|
<Table.Tr key={JSON.stringify(item)} {...trProps}>
|
|
46
268
|
{Object.entries(props.columns).map(([key, col]) => (
|
|
47
|
-
<Table.Td key={key}>
|
|
269
|
+
<Table.Td key={key}>
|
|
270
|
+
{col.value(item as T, {
|
|
271
|
+
index,
|
|
272
|
+
form: form as unknown as FormModel<Filters>,
|
|
273
|
+
alepha,
|
|
274
|
+
})}
|
|
275
|
+
</Table.Td>
|
|
48
276
|
))}
|
|
49
277
|
</Table.Tr>
|
|
50
278
|
);
|
|
51
279
|
});
|
|
52
280
|
|
|
281
|
+
const schema = t.omit(form.options.schema, ["page", "size", "sort"]);
|
|
282
|
+
|
|
53
283
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
<
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
284
|
+
<Flex direction={"column"} gap={"sm"} flex={1}>
|
|
285
|
+
{props.filters ? (
|
|
286
|
+
<TypeForm
|
|
287
|
+
{...props.typeFormProps}
|
|
288
|
+
form={form as unknown as FormModel<Filters>}
|
|
289
|
+
schema={schema}
|
|
290
|
+
/>
|
|
291
|
+
) : null}
|
|
292
|
+
|
|
293
|
+
<Flex flex={1} className={"overflow-auto"}>
|
|
294
|
+
<Table
|
|
295
|
+
striped
|
|
296
|
+
withRowBorders
|
|
297
|
+
withColumnBorders
|
|
298
|
+
withTableBorder
|
|
299
|
+
stripedColor={""}
|
|
300
|
+
{...props.tableProps}
|
|
301
|
+
>
|
|
302
|
+
<Table.Thead>
|
|
303
|
+
<Table.Tr>{head}</Table.Tr>
|
|
304
|
+
</Table.Thead>
|
|
305
|
+
<Table.Tbody>{rows}</Table.Tbody>
|
|
306
|
+
</Table>
|
|
307
|
+
</Flex>
|
|
308
|
+
|
|
309
|
+
{!props.infinityScroll && (
|
|
310
|
+
<Flex justify={"space-between"} align={"center"}>
|
|
311
|
+
<Pagination
|
|
312
|
+
withEdges
|
|
313
|
+
total={items.page?.totalPages ?? 1}
|
|
314
|
+
value={page}
|
|
315
|
+
onChange={(value) => {
|
|
316
|
+
form.input.page.set(value - 1);
|
|
317
|
+
}}
|
|
318
|
+
/>
|
|
319
|
+
<Flex>
|
|
320
|
+
<Select
|
|
321
|
+
value={size}
|
|
322
|
+
onChange={(value) => {
|
|
323
|
+
form.input.size.set(Number(value));
|
|
324
|
+
}}
|
|
325
|
+
data={[
|
|
326
|
+
{ value: "5", label: "5" },
|
|
327
|
+
{ value: "10", label: "10" },
|
|
328
|
+
{ value: "25", label: "25" },
|
|
329
|
+
{ value: "50", label: "50" },
|
|
330
|
+
{ value: "100", label: "100" },
|
|
331
|
+
]}
|
|
332
|
+
/>
|
|
333
|
+
</Flex>
|
|
334
|
+
</Flex>
|
|
335
|
+
)}
|
|
336
|
+
</Flex>
|
|
60
337
|
);
|
|
61
338
|
};
|
|
62
339
|
|
package/src/constants/ui.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -23,11 +23,13 @@ export type {
|
|
|
23
23
|
export { default as ActionButton } from "./components/buttons/ActionButton.tsx";
|
|
24
24
|
export { default as DarkModeButton } from "./components/buttons/DarkModeButton.tsx";
|
|
25
25
|
export { default as OmnibarButton } from "./components/buttons/OmnibarButton.tsx";
|
|
26
|
+
export { default as JsonViewer } from "./components/data/JsonViewer.tsx";
|
|
26
27
|
export { default as AlertDialog } from "./components/dialogs/AlertDialog.tsx";
|
|
27
28
|
export { default as ConfirmDialog } from "./components/dialogs/ConfirmDialog.tsx";
|
|
28
29
|
export { default as PromptDialog } from "./components/dialogs/PromptDialog.tsx";
|
|
29
30
|
export { default as Control } from "./components/form/Control.tsx";
|
|
30
31
|
export { default as ControlDate } from "./components/form/ControlDate.tsx";
|
|
32
|
+
export { default as ControlQueryBuilder } from "./components/form/ControlQueryBuilder.tsx";
|
|
31
33
|
export { default as ControlSelect } from "./components/form/ControlSelect.tsx";
|
|
32
34
|
export { default as TypeForm } from "./components/form/TypeForm.tsx";
|
|
33
35
|
export { default as AdminShell } from "./components/layout/AdminShell.tsx";
|
|
@@ -80,6 +82,7 @@ export type {
|
|
|
80
82
|
} from "./services/DialogService.tsx";
|
|
81
83
|
export { DialogService } from "./services/DialogService.tsx";
|
|
82
84
|
export { ToastService } from "./services/ToastService.tsx";
|
|
85
|
+
export * from "./utils/extractSchemaFields.ts";
|
|
83
86
|
export * from "./utils/icons.tsx";
|
|
84
87
|
export * from "./utils/string.ts";
|
|
85
88
|
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { Flex, type ModalProps } from "@mantine/core";
|
|
2
2
|
import { modals } from "@mantine/modals";
|
|
3
3
|
import type { ReactNode } from "react";
|
|
4
|
+
import JsonViewer from "../components/data/JsonViewer.tsx";
|
|
4
5
|
import AlertDialog from "../components/dialogs/AlertDialog";
|
|
5
6
|
import ConfirmDialog from "../components/dialogs/ConfirmDialog";
|
|
6
7
|
import PromptDialog from "../components/dialogs/PromptDialog";
|
|
8
|
+
import { ui } from "../constants/ui.ts";
|
|
7
9
|
|
|
8
10
|
// Base interfaces
|
|
9
11
|
export interface BaseDialogOptions extends Partial<ModalProps> {
|
|
@@ -161,7 +163,16 @@ export class DialogService {
|
|
|
161
163
|
* Show a JSON editor/viewer dialog
|
|
162
164
|
*/
|
|
163
165
|
public json(data?: any, options?: BaseDialogOptions): void {
|
|
164
|
-
|
|
166
|
+
this.open({
|
|
167
|
+
size: "lg",
|
|
168
|
+
title: options?.title || "Json Viewer",
|
|
169
|
+
...options,
|
|
170
|
+
content: (
|
|
171
|
+
<Flex bdrs={"md"} w={"100%"} flex={1} p={"sm"} bg={ui.colors.surface}>
|
|
172
|
+
<JsonViewer size={"xs"} data={data} />
|
|
173
|
+
</Flex>
|
|
174
|
+
),
|
|
175
|
+
});
|
|
165
176
|
}
|
|
166
177
|
|
|
167
178
|
/**
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import type { TObject, TProperties, TSchema } from "@alepha/core";
|
|
2
|
+
|
|
3
|
+
export interface SchemaField {
|
|
4
|
+
name: string;
|
|
5
|
+
path: string;
|
|
6
|
+
type: string;
|
|
7
|
+
enum?: readonly any[];
|
|
8
|
+
format?: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
nested?: SchemaField[];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Extract field information from a TypeBox schema for query building.
|
|
15
|
+
* Supports nested objects and provides field metadata for autocomplete.
|
|
16
|
+
*/
|
|
17
|
+
export function extractSchemaFields(
|
|
18
|
+
schema: TObject | TProperties,
|
|
19
|
+
prefix = "",
|
|
20
|
+
): SchemaField[] {
|
|
21
|
+
const fields: SchemaField[] = [];
|
|
22
|
+
|
|
23
|
+
// Safety check
|
|
24
|
+
if (!schema || typeof schema !== "object") {
|
|
25
|
+
return fields;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Handle TObject wrapper
|
|
29
|
+
const properties =
|
|
30
|
+
"properties" in schema ? schema.properties : (schema as TProperties);
|
|
31
|
+
|
|
32
|
+
// Safety check for properties
|
|
33
|
+
if (!properties || typeof properties !== "object") {
|
|
34
|
+
return fields;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
38
|
+
// Skip if value is not an object (type guard)
|
|
39
|
+
if (typeof value !== "object" || value === null) {
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const fieldSchema = value as TSchema & {
|
|
44
|
+
format?: string;
|
|
45
|
+
enum?: readonly any[];
|
|
46
|
+
description?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
50
|
+
|
|
51
|
+
// Determine the display type - use format for datetime-related fields
|
|
52
|
+
const format = "format" in fieldSchema ? fieldSchema.format : undefined;
|
|
53
|
+
const baseType =
|
|
54
|
+
"type" in fieldSchema ? (fieldSchema.type as string) : "object";
|
|
55
|
+
|
|
56
|
+
let displayType = baseType;
|
|
57
|
+
if (format === "date-time") {
|
|
58
|
+
displayType = "datetime";
|
|
59
|
+
} else if (format === "date") {
|
|
60
|
+
displayType = "date";
|
|
61
|
+
} else if (format === "time") {
|
|
62
|
+
displayType = "time";
|
|
63
|
+
} else if (format === "duration") {
|
|
64
|
+
displayType = "duration";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const field: SchemaField = {
|
|
68
|
+
name: key,
|
|
69
|
+
path,
|
|
70
|
+
type: displayType,
|
|
71
|
+
format,
|
|
72
|
+
description:
|
|
73
|
+
"description" in fieldSchema ? fieldSchema.description : undefined,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
// Handle enum
|
|
77
|
+
if ("enum" in fieldSchema && fieldSchema.enum) {
|
|
78
|
+
field.enum = fieldSchema.enum;
|
|
79
|
+
field.type = "enum";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Handle nested objects
|
|
83
|
+
if (
|
|
84
|
+
"type" in fieldSchema &&
|
|
85
|
+
fieldSchema.type === "object" &&
|
|
86
|
+
"properties" in fieldSchema &&
|
|
87
|
+
typeof fieldSchema.properties === "object"
|
|
88
|
+
) {
|
|
89
|
+
field.nested = extractSchemaFields(
|
|
90
|
+
fieldSchema.properties as TProperties,
|
|
91
|
+
path,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
fields.push(field);
|
|
96
|
+
|
|
97
|
+
// Also add nested fields to the flat list for autocomplete
|
|
98
|
+
if (field.nested) {
|
|
99
|
+
fields.push(...field.nested);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return fields;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Get suggested operators based on field type
|
|
108
|
+
*/
|
|
109
|
+
export function getOperatorsForField(field: SchemaField): string[] {
|
|
110
|
+
const allOperators = ["=", "!="];
|
|
111
|
+
|
|
112
|
+
if (field.enum) {
|
|
113
|
+
// Enum fields: equality and IN array
|
|
114
|
+
return [...allOperators, "in"];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
switch (field.type) {
|
|
118
|
+
case "string":
|
|
119
|
+
case "text":
|
|
120
|
+
// String fields: equality, like, and null checks
|
|
121
|
+
return [...allOperators, "~", "~*", "null"];
|
|
122
|
+
|
|
123
|
+
case "number":
|
|
124
|
+
case "integer":
|
|
125
|
+
// Numeric fields: all comparison operators
|
|
126
|
+
return [...allOperators, ">", ">=", "<", "<="];
|
|
127
|
+
|
|
128
|
+
case "boolean":
|
|
129
|
+
// Boolean fields: only equality
|
|
130
|
+
return allOperators;
|
|
131
|
+
|
|
132
|
+
case "datetime":
|
|
133
|
+
case "date":
|
|
134
|
+
// Date fields: all comparison operators
|
|
135
|
+
return [...allOperators, ">", ">=", "<", "<="];
|
|
136
|
+
|
|
137
|
+
default:
|
|
138
|
+
return [...allOperators, "null"];
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Get operator symbol and description
|
|
144
|
+
*/
|
|
145
|
+
export const OPERATOR_INFO: Record<
|
|
146
|
+
string,
|
|
147
|
+
{ symbol: string; label: string; example: string }
|
|
148
|
+
> = {
|
|
149
|
+
eq: { symbol: "=", label: "equals", example: "name=John" },
|
|
150
|
+
ne: { symbol: "!=", label: "not equals", example: "status!=archived" },
|
|
151
|
+
gt: { symbol: ">", label: "greater than", example: "age>18" },
|
|
152
|
+
gte: { symbol: ">=", label: "greater or equal", example: "age>=18" },
|
|
153
|
+
lt: { symbol: "<", label: "less than", example: "age<65" },
|
|
154
|
+
lte: { symbol: "<=", label: "less or equal", example: "age<=65" },
|
|
155
|
+
like: { symbol: "~", label: "like (case-sensitive)", example: "name~John" },
|
|
156
|
+
ilike: {
|
|
157
|
+
symbol: "~*",
|
|
158
|
+
label: "like (case-insensitive)",
|
|
159
|
+
example: "name~*john",
|
|
160
|
+
},
|
|
161
|
+
null: { symbol: "=null", label: "is null", example: "deletedAt=null" },
|
|
162
|
+
notNull: {
|
|
163
|
+
symbol: "!=null",
|
|
164
|
+
label: "is not null",
|
|
165
|
+
example: "email!=null",
|
|
166
|
+
},
|
|
167
|
+
in: {
|
|
168
|
+
symbol: "[...]",
|
|
169
|
+
label: "in array",
|
|
170
|
+
example: "status=[active,pending]",
|
|
171
|
+
},
|
|
172
|
+
};
|