@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.
@@ -1,62 +1,339 @@
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
+ 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 DataTableColumn<T extends object> {
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
- items: T[] | (() => Async<T[]>);
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>(props: DataTableProps<T>) => {
22
- const [items, setItems] = useState<object[]>(
23
- typeof props.items === "function" ? [] : props.items,
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 key={key}>
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}>{col.value(item as T)}</Table.Td>
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
- <Table {...props.tableProps}>
55
- <Table.Thead>
56
- <Table.Tr>{head}</Table.Tr>
57
- </Table.Thead>
58
- <Table.Tbody>{rows}</Table.Tbody>
59
- </Table>
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
 
@@ -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
@@ -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 { ModalProps } from "@mantine/core";
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
- // Implementation to be added
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
+ };
@@ -1,3 +0,0 @@
1
- import { t as AlephaMantineProvider_default } from "./AlephaMantineProvider-Be0DAazb.js";
2
-
3
- export { AlephaMantineProvider_default as default };