@alepha/ui 0.11.5 → 0.11.6
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/dist/index.d.ts +127 -24
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +644 -57
- package/dist/index.js.map +1 -1
- package/package.json +21 -20
- package/src/components/buttons/ActionButton.tsx +29 -3
- package/src/components/buttons/ToggleSidebarButton.tsx +4 -1
- package/src/components/form/Control.tsx +28 -11
- package/src/components/form/ControlNumber.tsx +96 -0
- package/src/components/form/ControlQueryBuilder.tsx +248 -0
- 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 +238 -15
- package/src/constants/ui.ts +1 -0
- package/src/index.ts +2 -0
- package/src/utils/extractSchemaFields.ts +171 -0
|
@@ -56,18 +56,20 @@ export const Sidebar = (props: SidebarProps) => {
|
|
|
56
56
|
if (item.type === "section") {
|
|
57
57
|
if (props.collapsed) return;
|
|
58
58
|
return (
|
|
59
|
-
<
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
59
|
+
<Flex mt={"md"} mb={"xs"} align={"center"} gap={"xs"}>
|
|
60
|
+
<ThemeIcon c={"dimmed"} size={"xs"} variant={"transparent"}>
|
|
61
|
+
{item.icon}
|
|
62
|
+
</ThemeIcon>
|
|
63
|
+
<Text
|
|
64
|
+
key={key}
|
|
65
|
+
size={"xs"}
|
|
66
|
+
c={"dimmed"}
|
|
67
|
+
tt={"uppercase"}
|
|
68
|
+
fw={"bold"}
|
|
69
|
+
>
|
|
70
|
+
{item.label}
|
|
71
|
+
</Text>
|
|
72
|
+
</Flex>
|
|
71
73
|
);
|
|
72
74
|
}
|
|
73
75
|
}
|
|
@@ -191,7 +193,9 @@ export const SidebarItem = (props: SidebarItemProps) => {
|
|
|
191
193
|
if (level > maxLevel) return null;
|
|
192
194
|
|
|
193
195
|
const handleItemClick = (e: MouseEvent) => {
|
|
194
|
-
|
|
196
|
+
if (!props.item.target) {
|
|
197
|
+
e.preventDefault();
|
|
198
|
+
}
|
|
195
199
|
if (item.children && item.children.length > 0) {
|
|
196
200
|
setIsOpen(!isOpen);
|
|
197
201
|
} else {
|
|
@@ -206,6 +210,7 @@ export const SidebarItem = (props: SidebarItemProps) => {
|
|
|
206
210
|
w={"100%"}
|
|
207
211
|
justify="space-between"
|
|
208
212
|
href={props.item.href}
|
|
213
|
+
target={props.item.target}
|
|
209
214
|
variant={"subtle"}
|
|
210
215
|
size={
|
|
211
216
|
props.item.theme?.size ??
|
|
@@ -310,7 +315,9 @@ const SidebarCollapsedItem = (props: SidebarItemProps) => {
|
|
|
310
315
|
const [isOpen, setIsOpen] = useState<boolean>(isActive(item));
|
|
311
316
|
|
|
312
317
|
const handleItemClick = (e: MouseEvent) => {
|
|
313
|
-
|
|
318
|
+
if (!props.item.target) {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
}
|
|
314
321
|
if (item.children && item.children.length > 0) {
|
|
315
322
|
setIsOpen(!isOpen);
|
|
316
323
|
} else {
|
|
@@ -332,6 +339,7 @@ const SidebarCollapsedItem = (props: SidebarItemProps) => {
|
|
|
332
339
|
onClick={handleItemClick}
|
|
333
340
|
icon={item.icon ?? <IconSquareRounded />}
|
|
334
341
|
href={props.item.href}
|
|
342
|
+
target={props.item.target}
|
|
335
343
|
menu={
|
|
336
344
|
item.children
|
|
337
345
|
? {
|
|
@@ -384,6 +392,7 @@ export interface SidebarSearch extends SidebarAbstractItem {
|
|
|
384
392
|
export interface SidebarSection extends SidebarAbstractItem {
|
|
385
393
|
type: "section";
|
|
386
394
|
label: string;
|
|
395
|
+
icon?: ReactNode;
|
|
387
396
|
}
|
|
388
397
|
|
|
389
398
|
export interface SidebarMenuItem extends SidebarAbstractItem {
|
|
@@ -391,6 +400,7 @@ export interface SidebarMenuItem extends SidebarAbstractItem {
|
|
|
391
400
|
description?: string;
|
|
392
401
|
icon?: ReactNode;
|
|
393
402
|
href?: string;
|
|
403
|
+
target?: "_blank" | "_self" | "_parent" | "_top";
|
|
394
404
|
activeStartsWith?: boolean; // Use startWith matching for active state
|
|
395
405
|
onClick?: () => void;
|
|
396
406
|
children?: SidebarMenuItem[];
|
|
@@ -1,34 +1,222 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import {
|
|
2
|
+
type Async,
|
|
3
|
+
type Page,
|
|
4
|
+
type PageMetadata,
|
|
5
|
+
type TObject,
|
|
6
|
+
t,
|
|
7
|
+
} from "@alepha/core";
|
|
8
|
+
import { DateTimeProvider, type DurationLike } from "@alepha/datetime";
|
|
9
|
+
import { useInject } from "@alepha/react";
|
|
10
|
+
import { useForm } from "@alepha/react-form";
|
|
11
|
+
import {
|
|
12
|
+
Flex,
|
|
13
|
+
Pagination,
|
|
14
|
+
Paper,
|
|
15
|
+
Select,
|
|
16
|
+
Table,
|
|
17
|
+
type TableProps,
|
|
18
|
+
type TableTrProps,
|
|
19
|
+
} from "@mantine/core";
|
|
20
|
+
import { useDebouncedCallback } from "@mantine/hooks";
|
|
4
21
|
import { type ReactNode, useEffect, useState } from "react";
|
|
5
22
|
import ActionButton from "../buttons/ActionButton.tsx";
|
|
23
|
+
import TypeForm from "../form/TypeForm.tsx";
|
|
6
24
|
|
|
7
25
|
export interface DataTableColumn<T extends object> {
|
|
8
26
|
label: string;
|
|
9
|
-
value: (item: T) => ReactNode;
|
|
27
|
+
value: (item: T, index: number) => ReactNode;
|
|
10
28
|
}
|
|
11
29
|
|
|
30
|
+
export type MaybePage<T> = Omit<Page<T>, "page"> & {
|
|
31
|
+
page?: Partial<PageMetadata>;
|
|
32
|
+
};
|
|
33
|
+
|
|
12
34
|
export interface DataTableProps<T extends object> {
|
|
13
|
-
|
|
35
|
+
/**
|
|
36
|
+
* 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.
|
|
37
|
+
*/
|
|
38
|
+
items:
|
|
39
|
+
| MaybePage<T>
|
|
40
|
+
| ((
|
|
41
|
+
filters: Record<string, string> & {
|
|
42
|
+
page: number;
|
|
43
|
+
size: number;
|
|
44
|
+
sort?: string;
|
|
45
|
+
},
|
|
46
|
+
) => Async<MaybePage<T>>);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The columns to display in the table. Each column is defined by a key and a DataTableColumn object.
|
|
50
|
+
*/
|
|
14
51
|
columns: {
|
|
15
52
|
[key: string]: DataTableColumn<T>;
|
|
16
53
|
};
|
|
54
|
+
|
|
55
|
+
defaultSize?: number;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Optional filters to apply to the data.
|
|
59
|
+
*/
|
|
60
|
+
filters?: TObject;
|
|
61
|
+
|
|
62
|
+
panel?: (item: T) => ReactNode;
|
|
63
|
+
canPanel?: (item: T) => boolean;
|
|
64
|
+
|
|
65
|
+
submitOnInit?: boolean;
|
|
66
|
+
submitEvery?: DurationLike;
|
|
67
|
+
|
|
68
|
+
withLineNumbers?: boolean;
|
|
69
|
+
withCheckbox?: boolean;
|
|
70
|
+
checkboxActions?: any[];
|
|
71
|
+
|
|
72
|
+
actions?: any[];
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Enable infinity scroll mode. When true, pagination controls are hidden and new items are loaded automatically when scrolling to the bottom.
|
|
76
|
+
*/
|
|
77
|
+
infinityScroll?: boolean;
|
|
78
|
+
|
|
79
|
+
// -------------------------------------------------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Props to pass to the Mantine Table component.
|
|
83
|
+
*/
|
|
17
84
|
tableProps?: TableProps;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Function to generate props for each table row based on the item.
|
|
88
|
+
*/
|
|
18
89
|
tableTrProps?: (item: T) => TableTrProps;
|
|
19
90
|
}
|
|
20
91
|
|
|
21
92
|
const DataTable = <T extends object>(props: DataTableProps<T>) => {
|
|
22
|
-
const [items, setItems] = useState<
|
|
23
|
-
typeof props.items === "function"
|
|
93
|
+
const [items, setItems] = useState<MaybePage<T>>(
|
|
94
|
+
typeof props.items === "function"
|
|
95
|
+
? {
|
|
96
|
+
content: [],
|
|
97
|
+
}
|
|
98
|
+
: props.items,
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
const defaultSize = props.infinityScroll ? 50 : props.defaultSize || 10;
|
|
102
|
+
const [page, setPage] = useState(1);
|
|
103
|
+
const [size, setSize] = useState(String(defaultSize));
|
|
104
|
+
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
|
105
|
+
const [currentPage, setCurrentPage] = useState(0);
|
|
106
|
+
|
|
107
|
+
const form = useForm(
|
|
108
|
+
{
|
|
109
|
+
schema: t.object({
|
|
110
|
+
...(props.filters ? props.filters.properties : {}),
|
|
111
|
+
page: t.number({ default: 0 }),
|
|
112
|
+
size: t.number({ default: defaultSize }),
|
|
113
|
+
sort: t.optional(t.string()),
|
|
114
|
+
}),
|
|
115
|
+
handler: async (values, args) => {
|
|
116
|
+
if (typeof props.items === "function") {
|
|
117
|
+
const response = await props.items(
|
|
118
|
+
values as Record<string, string> & {
|
|
119
|
+
page: number;
|
|
120
|
+
size: number;
|
|
121
|
+
sort?: string;
|
|
122
|
+
},
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (props.infinityScroll && values.page > 0) {
|
|
126
|
+
// Append new items to existing ones for infinity scroll
|
|
127
|
+
setItems((prev) => ({
|
|
128
|
+
...response,
|
|
129
|
+
content: [...prev.content, ...response.content],
|
|
130
|
+
}));
|
|
131
|
+
} else {
|
|
132
|
+
setItems(response);
|
|
133
|
+
}
|
|
134
|
+
setCurrentPage(values.page);
|
|
135
|
+
setIsLoadingMore(false);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
onReset: async () => {
|
|
139
|
+
setPage(1);
|
|
140
|
+
setSize("10");
|
|
141
|
+
await form.submit();
|
|
142
|
+
},
|
|
143
|
+
onChange: async (key, value) => {
|
|
144
|
+
if (key === "page") {
|
|
145
|
+
setPage(value + 1);
|
|
146
|
+
await form.submit();
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (key === "size") {
|
|
151
|
+
setSize(String(value));
|
|
152
|
+
form.input.page.set(0);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
//submitDebounce();
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
[],
|
|
24
160
|
);
|
|
25
161
|
|
|
162
|
+
const submitDebounce = useDebouncedCallback(() => form.submit(), {
|
|
163
|
+
delay: 1000,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const dt = useInject(DateTimeProvider);
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
if (props.submitOnInit) {
|
|
170
|
+
console.log("submitting");
|
|
171
|
+
form.submit();
|
|
172
|
+
}
|
|
173
|
+
if (props.submitEvery) {
|
|
174
|
+
const it = dt.createInterval(() => {
|
|
175
|
+
form.submit();
|
|
176
|
+
}, props.submitEvery);
|
|
177
|
+
return () => dt.clearInterval(it);
|
|
178
|
+
}
|
|
179
|
+
}, []);
|
|
180
|
+
|
|
26
181
|
useEffect(() => {
|
|
27
182
|
if (typeof props.items !== "function") {
|
|
28
183
|
setItems(props.items);
|
|
29
184
|
}
|
|
30
185
|
}, [props.items]);
|
|
31
186
|
|
|
187
|
+
// Infinity scroll detection
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
if (!props.infinityScroll || typeof props.items !== "function") return;
|
|
190
|
+
|
|
191
|
+
const handleScroll = () => {
|
|
192
|
+
if (isLoadingMore) return;
|
|
193
|
+
|
|
194
|
+
const scrollTop = window.scrollY;
|
|
195
|
+
const windowHeight = window.innerHeight;
|
|
196
|
+
const docHeight = document.documentElement.scrollHeight;
|
|
197
|
+
|
|
198
|
+
const isNearBottom = scrollTop + windowHeight >= docHeight - 200;
|
|
199
|
+
|
|
200
|
+
if (isNearBottom) {
|
|
201
|
+
const totalPages = items.page?.totalPages ?? 1;
|
|
202
|
+
|
|
203
|
+
if (currentPage + 1 < totalPages) {
|
|
204
|
+
setIsLoadingMore(true);
|
|
205
|
+
form.input.page.set(currentPage + 1);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
window.addEventListener("scroll", handleScroll);
|
|
211
|
+
return () => window.removeEventListener("scroll", handleScroll);
|
|
212
|
+
}, [
|
|
213
|
+
props.infinityScroll,
|
|
214
|
+
isLoadingMore,
|
|
215
|
+
items.page?.totalPages,
|
|
216
|
+
currentPage,
|
|
217
|
+
form,
|
|
218
|
+
]);
|
|
219
|
+
|
|
32
220
|
const head = Object.entries(props.columns).map(([key, col]) => (
|
|
33
221
|
<Table.Th key={key}>
|
|
34
222
|
<ActionButton justify={"space-between"} radius={0} fullWidth size={"xs"}>
|
|
@@ -37,26 +225,61 @@ const DataTable = <T extends object>(props: DataTableProps<T>) => {
|
|
|
37
225
|
</Table.Th>
|
|
38
226
|
));
|
|
39
227
|
|
|
40
|
-
const rows = items.map((item, index) => {
|
|
228
|
+
const rows = items.content.map((item, index) => {
|
|
41
229
|
const trProps = props.tableTrProps
|
|
42
230
|
? props.tableTrProps(item as T)
|
|
43
231
|
: ({} as TableTrProps);
|
|
44
232
|
return (
|
|
45
233
|
<Table.Tr key={JSON.stringify(item)} {...trProps}>
|
|
46
234
|
{Object.entries(props.columns).map(([key, col]) => (
|
|
47
|
-
<Table.Td key={key}>{col.value(item as T)}</Table.Td>
|
|
235
|
+
<Table.Td key={key}>{col.value(item as T, index)}</Table.Td>
|
|
48
236
|
))}
|
|
49
237
|
</Table.Tr>
|
|
50
238
|
);
|
|
51
239
|
});
|
|
52
240
|
|
|
241
|
+
const schema = t.omit(form.options.schema, ["page", "size", "sort"]);
|
|
242
|
+
|
|
53
243
|
return (
|
|
54
|
-
<
|
|
55
|
-
<
|
|
56
|
-
<
|
|
57
|
-
</
|
|
58
|
-
<Table
|
|
59
|
-
|
|
244
|
+
<Flex direction={"column"} gap={"sm"} flex={1}>
|
|
245
|
+
<Paper withBorder p={"sm"}>
|
|
246
|
+
{props.filters ? <TypeForm form={form} schema={schema} /> : null}
|
|
247
|
+
</Paper>
|
|
248
|
+
<Table striped stripedColor={""} {...props.tableProps}>
|
|
249
|
+
<Table.Thead>
|
|
250
|
+
<Table.Tr>{head}</Table.Tr>
|
|
251
|
+
</Table.Thead>
|
|
252
|
+
<Table.Tbody>{rows}</Table.Tbody>
|
|
253
|
+
</Table>
|
|
254
|
+
|
|
255
|
+
{!props.infinityScroll && (
|
|
256
|
+
<Flex justify={"space-between"} align={"center"}>
|
|
257
|
+
<Pagination
|
|
258
|
+
withEdges
|
|
259
|
+
total={items.page?.totalPages ?? 1}
|
|
260
|
+
value={page}
|
|
261
|
+
onChange={(value) => {
|
|
262
|
+
form.input.page.set(value - 1);
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
<Flex>
|
|
266
|
+
<Select
|
|
267
|
+
value={size}
|
|
268
|
+
onChange={(value) => {
|
|
269
|
+
form.input.size.set(Number(value));
|
|
270
|
+
}}
|
|
271
|
+
data={[
|
|
272
|
+
{ value: "5", label: "5" },
|
|
273
|
+
{ value: "10", label: "10" },
|
|
274
|
+
{ value: "25", label: "25" },
|
|
275
|
+
{ value: "50", label: "50" },
|
|
276
|
+
{ value: "100", label: "100" },
|
|
277
|
+
]}
|
|
278
|
+
/>
|
|
279
|
+
</Flex>
|
|
280
|
+
</Flex>
|
|
281
|
+
)}
|
|
282
|
+
</Flex>
|
|
60
283
|
);
|
|
61
284
|
};
|
|
62
285
|
|
package/src/constants/ui.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export { default as ConfirmDialog } from "./components/dialogs/ConfirmDialog.tsx
|
|
|
28
28
|
export { default as PromptDialog } from "./components/dialogs/PromptDialog.tsx";
|
|
29
29
|
export { default as Control } from "./components/form/Control.tsx";
|
|
30
30
|
export { default as ControlDate } from "./components/form/ControlDate.tsx";
|
|
31
|
+
export { default as ControlQueryBuilder } from "./components/form/ControlQueryBuilder.tsx";
|
|
31
32
|
export { default as ControlSelect } from "./components/form/ControlSelect.tsx";
|
|
32
33
|
export { default as TypeForm } from "./components/form/TypeForm.tsx";
|
|
33
34
|
export { default as AdminShell } from "./components/layout/AdminShell.tsx";
|
|
@@ -80,6 +81,7 @@ export type {
|
|
|
80
81
|
} from "./services/DialogService.tsx";
|
|
81
82
|
export { DialogService } from "./services/DialogService.tsx";
|
|
82
83
|
export { ToastService } from "./services/ToastService.tsx";
|
|
84
|
+
export * from "./utils/extractSchemaFields.ts";
|
|
83
85
|
export * from "./utils/icons.tsx";
|
|
84
86
|
export * from "./utils/string.ts";
|
|
85
87
|
|
|
@@ -0,0 +1,171 @@
|
|
|
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) : "unknown";
|
|
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
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle nested objects
|
|
82
|
+
if (
|
|
83
|
+
"type" in fieldSchema &&
|
|
84
|
+
fieldSchema.type === "object" &&
|
|
85
|
+
"properties" in fieldSchema &&
|
|
86
|
+
typeof fieldSchema.properties === "object"
|
|
87
|
+
) {
|
|
88
|
+
field.nested = extractSchemaFields(
|
|
89
|
+
fieldSchema.properties as TProperties,
|
|
90
|
+
path,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
fields.push(field);
|
|
95
|
+
|
|
96
|
+
// Also add nested fields to the flat list for autocomplete
|
|
97
|
+
if (field.nested) {
|
|
98
|
+
fields.push(...field.nested);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return fields;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get suggested operators based on field type
|
|
107
|
+
*/
|
|
108
|
+
export function getOperatorsForField(field: SchemaField): string[] {
|
|
109
|
+
const allOperators = ["=", "!="];
|
|
110
|
+
|
|
111
|
+
if (field.enum) {
|
|
112
|
+
// Enum fields: equality and IN array
|
|
113
|
+
return [...allOperators, "in"];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
switch (field.type) {
|
|
117
|
+
case "string":
|
|
118
|
+
case "text":
|
|
119
|
+
// String fields: equality, like, and null checks
|
|
120
|
+
return [...allOperators, "~", "~*", "null"];
|
|
121
|
+
|
|
122
|
+
case "number":
|
|
123
|
+
case "integer":
|
|
124
|
+
// Numeric fields: all comparison operators
|
|
125
|
+
return [...allOperators, ">", ">=", "<", "<="];
|
|
126
|
+
|
|
127
|
+
case "boolean":
|
|
128
|
+
// Boolean fields: only equality
|
|
129
|
+
return allOperators;
|
|
130
|
+
|
|
131
|
+
case "datetime":
|
|
132
|
+
case "date":
|
|
133
|
+
// Date fields: all comparison operators
|
|
134
|
+
return [...allOperators, ">", ">=", "<", "<="];
|
|
135
|
+
|
|
136
|
+
default:
|
|
137
|
+
return [...allOperators, "null"];
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get operator symbol and description
|
|
143
|
+
*/
|
|
144
|
+
export const OPERATOR_INFO: Record<
|
|
145
|
+
string,
|
|
146
|
+
{ symbol: string; label: string; example: string }
|
|
147
|
+
> = {
|
|
148
|
+
eq: { symbol: "=", label: "equals", example: "name=John" },
|
|
149
|
+
ne: { symbol: "!=", label: "not equals", example: "status!=archived" },
|
|
150
|
+
gt: { symbol: ">", label: "greater than", example: "age>18" },
|
|
151
|
+
gte: { symbol: ">=", label: "greater or equal", example: "age>=18" },
|
|
152
|
+
lt: { symbol: "<", label: "less than", example: "age<65" },
|
|
153
|
+
lte: { symbol: "<=", label: "less or equal", example: "age<=65" },
|
|
154
|
+
like: { symbol: "~", label: "like (case-sensitive)", example: "name~John" },
|
|
155
|
+
ilike: {
|
|
156
|
+
symbol: "~*",
|
|
157
|
+
label: "like (case-insensitive)",
|
|
158
|
+
example: "name~*john",
|
|
159
|
+
},
|
|
160
|
+
null: { symbol: "=null", label: "is null", example: "deletedAt=null" },
|
|
161
|
+
notNull: {
|
|
162
|
+
symbol: "!=null",
|
|
163
|
+
label: "is not null",
|
|
164
|
+
example: "email!=null",
|
|
165
|
+
},
|
|
166
|
+
in: {
|
|
167
|
+
symbol: "[...]",
|
|
168
|
+
label: "in array",
|
|
169
|
+
example: "status=[active,pending]",
|
|
170
|
+
},
|
|
171
|
+
};
|