@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.
@@ -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
- <Text
60
- key={key}
61
- size={"xs"}
62
- c={"dimmed"}
63
- mt={"md"}
64
- mb={"xs"}
65
- mx={"sm"}
66
- tt={"uppercase"}
67
- fw={"bold"}
68
- >
69
- {item.label}
70
- </Text>
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
- e.preventDefault();
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
- e.preventDefault();
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 type { Async } from "@alepha/core";
2
- import type { TableTrProps } from "@mantine/core";
3
- import { Table, type TableProps } from "@mantine/core";
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
- items: T[] | (() => Async<T[]>);
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<object[]>(
23
- typeof props.items === "function" ? [] : props.items,
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
- <Table {...props.tableProps}>
55
- <Table.Thead>
56
- <Table.Tr>{head}</Table.Tr>
57
- </Table.Thead>
58
- <Table.Tbody>{rows}</Table.Tbody>
59
- </Table>
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
 
@@ -4,5 +4,6 @@ export const ui = {
4
4
  background: "var(--alepha-background)",
5
5
  surface: "var(--alepha-surface)",
6
6
  elevated: "var(--alepha-elevated)",
7
+ border: "var(--alepha-border)",
7
8
  },
8
9
  };
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
+ };