@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.
@@ -0,0 +1,313 @@
1
+ import type { TObject } from "@alepha/core";
2
+ import { parseQueryString } from "@alepha/postgres";
3
+ import { useEvents } from "@alepha/react";
4
+ import {
5
+ ActionIcon,
6
+ Badge,
7
+ Divider,
8
+ Flex,
9
+ Group,
10
+ Popover,
11
+ Stack,
12
+ Text,
13
+ TextInput,
14
+ type TextInputProps,
15
+ } from "@mantine/core";
16
+ import { IconFilter, IconInfoTriangle, IconX } from "@tabler/icons-react";
17
+ import { useRef, useState } from "react";
18
+ import { ui } from "../../constants/ui.ts";
19
+ import {
20
+ extractSchemaFields,
21
+ OPERATOR_INFO,
22
+ type SchemaField,
23
+ } from "../../utils/extractSchemaFields.ts";
24
+ import ActionButton from "../buttons/ActionButton.tsx";
25
+
26
+ export interface ControlQueryBuilderProps
27
+ extends Omit<TextInputProps, "value" | "onChange"> {
28
+ schema?: TObject;
29
+ value?: string;
30
+ onChange?: (value: string) => void;
31
+ placeholder?: string;
32
+ }
33
+
34
+ /**
35
+ * Query builder with text input and help popover.
36
+ * Generates query strings for parseQueryString syntax.
37
+ */
38
+ const ControlQueryBuilder = ({
39
+ schema,
40
+ value = "",
41
+ onChange,
42
+ placeholder = "Enter query or click for assistance...",
43
+ ...textInputProps
44
+ }: ControlQueryBuilderProps) => {
45
+ const [helpOpened, setHelpOpened] = useState(false);
46
+ const [textValue, setTextValue] = useState(value);
47
+ const inputRef = useRef<HTMLInputElement>(null);
48
+ const fields = schema ? extractSchemaFields(schema) : [];
49
+ const [error, setError] = useState<string | null>(null);
50
+
51
+ const isValid = (value: string) => {
52
+ try {
53
+ parseQueryString(value.trim());
54
+ } catch (e) {
55
+ setError((e as Error).message);
56
+ return false;
57
+ }
58
+ setError(null);
59
+ return true;
60
+ };
61
+
62
+ const handleTextChange = (newValue: string) => {
63
+ setTextValue(newValue);
64
+ if (isValid(newValue)) {
65
+ onChange?.(newValue);
66
+ }
67
+ };
68
+
69
+ const handleClear = () => {
70
+ setTextValue("");
71
+ onChange?.("");
72
+ isValid("");
73
+ };
74
+
75
+ const handleInsert = (text: string) => {
76
+ const newValue = textValue ? `${textValue}${text} ` : `${text} `;
77
+ setTextValue(newValue);
78
+ if (isValid(newValue)) {
79
+ onChange?.(newValue);
80
+ }
81
+ // Refocus the input after inserting
82
+ setTimeout(() => {
83
+ inputRef.current?.focus();
84
+ // set cursor to end
85
+ const length = inputRef.current?.value.length || 0;
86
+ inputRef.current?.setSelectionRange(length, length);
87
+ }, 0);
88
+ };
89
+
90
+ useEvents(
91
+ {
92
+ "form:change": (event) => {
93
+ if (event.id === inputRef.current?.form?.id) {
94
+ if (event.path === (textInputProps as any)["data-path"]) {
95
+ setTextValue(event.value ?? "");
96
+ }
97
+ }
98
+ },
99
+ },
100
+ [],
101
+ );
102
+
103
+ return (
104
+ <Popover
105
+ width={800}
106
+ position="bottom-start"
107
+ shadow="md"
108
+ opened={helpOpened}
109
+ onChange={setHelpOpened}
110
+ closeOnClickOutside
111
+ closeOnEscape
112
+ transitionProps={{
113
+ transition: "fade-up",
114
+ duration: 200,
115
+ timingFunction: "ease",
116
+ }}
117
+ >
118
+ <Popover.Target>
119
+ <TextInput
120
+ ref={inputRef}
121
+ placeholder={placeholder}
122
+ value={textValue}
123
+ onChange={(e) => handleTextChange(e.currentTarget.value)}
124
+ onFocus={() => setHelpOpened(true)}
125
+ leftSection={
126
+ error ? <IconInfoTriangle size={16} /> : <IconFilter size={16} />
127
+ }
128
+ rightSection={
129
+ textValue && (
130
+ <ActionIcon
131
+ size="sm"
132
+ variant="subtle"
133
+ color="gray"
134
+ onClick={handleClear}
135
+ >
136
+ <IconX size={14} />
137
+ </ActionIcon>
138
+ )
139
+ }
140
+ {...textInputProps}
141
+ />
142
+ </Popover.Target>
143
+ <Popover.Dropdown
144
+ bg={"transparent"}
145
+ p={"xs"}
146
+ bd={`1px solid ${ui.colors.border}`}
147
+ style={{
148
+ backdropFilter: "blur(20px)",
149
+ }}
150
+ >
151
+ <QueryHelp fields={fields} onInsert={handleInsert} />
152
+ </Popover.Dropdown>
153
+ </Popover>
154
+ );
155
+ };
156
+
157
+ // ---------------------------------------------------------------------------------------------------------------------
158
+ // Query Help Component
159
+ // ---------------------------------------------------------------------------------------------------------------------
160
+
161
+ interface QueryHelpProps {
162
+ fields: SchemaField[];
163
+ onInsert: (text: string) => void;
164
+ }
165
+
166
+ function QueryHelp({ fields, onInsert }: QueryHelpProps) {
167
+ return (
168
+ <Group
169
+ gap="md"
170
+ align="flex-start"
171
+ wrap="nowrap"
172
+ bg={ui.colors.surface}
173
+ p={"sm"}
174
+ bdrs={"sm"}
175
+ >
176
+ {/* Left Column: Operators */}
177
+ <Stack gap="md" style={{ flex: 1 }}>
178
+ {/* Available Operators */}
179
+ <Stack gap="xs">
180
+ <Text size="sm" fw={600}>
181
+ Operators
182
+ </Text>
183
+ <Stack gap={4}>
184
+ {Object.entries(OPERATOR_INFO).map(([key, info]) => (
185
+ <Group key={key} gap="xs" wrap="nowrap">
186
+ <ActionButton
187
+ px={"xs"}
188
+ size={"xs"}
189
+ h={24}
190
+ variant={"default"}
191
+ justify={"center"}
192
+ miw={48}
193
+ onClick={() => onInsert(info.symbol)}
194
+ >
195
+ {info.symbol}
196
+ </ActionButton>
197
+ <Text size="xs" c="dimmed" style={{ flex: 1 }}>
198
+ {info.label}
199
+ </Text>
200
+ </Group>
201
+ ))}
202
+ </Stack>
203
+ </Stack>
204
+
205
+ <Divider />
206
+
207
+ {/* Logic Operators */}
208
+ <Stack gap="xs">
209
+ <Text size="sm" fw={600}>
210
+ Logic
211
+ </Text>
212
+ <Stack gap={4}>
213
+ <Group gap="xs" wrap="nowrap">
214
+ <ActionButton
215
+ px={"xs"}
216
+ size={"xs"}
217
+ h={24}
218
+ variant={"default"}
219
+ justify={"center"}
220
+ miw={48}
221
+ onClick={() => onInsert("&")}
222
+ >
223
+ &
224
+ </ActionButton>
225
+ <Text size="xs" c="dimmed">
226
+ AND
227
+ </Text>
228
+ </Group>
229
+ <Group gap="xs" wrap="nowrap">
230
+ <ActionButton
231
+ px={"xs"}
232
+ size={"xs"}
233
+ h={24}
234
+ variant={"default"}
235
+ justify={"center"}
236
+ miw={48}
237
+ onClick={() => onInsert("|")}
238
+ >
239
+ |
240
+ </ActionButton>
241
+ <Text size="xs" c="dimmed">
242
+ OR
243
+ </Text>
244
+ </Group>
245
+ </Stack>
246
+ </Stack>
247
+ </Stack>
248
+
249
+ {/* Divider */}
250
+ {fields.length > 0 && <Divider orientation="vertical" />}
251
+
252
+ {/* Right Column: Fields */}
253
+ {fields.length > 0 && (
254
+ <Flex direction={"column"} gap="xs" style={{ flex: 2 }}>
255
+ <Text size="sm" fw={600}>
256
+ Fields
257
+ </Text>
258
+ <Flex
259
+ direction={"column"}
260
+ gap={4}
261
+ style={{ maxHeight: 300, overflowY: "auto" }}
262
+ >
263
+ {fields.map((field) => (
264
+ <Flex key={field.path} gap="xs" wrap="nowrap" align="flex-start">
265
+ <ActionButton
266
+ px={"xs"}
267
+ size={"xs"}
268
+ h={24}
269
+ variant={"default"}
270
+ justify={"end"}
271
+ miw={120}
272
+ onClick={() => onInsert(field.path)}
273
+ >
274
+ {field.path}
275
+ </ActionButton>
276
+ <Flex
277
+ mt={3}
278
+ direction={"column"}
279
+ gap={2}
280
+ style={{ flex: 1, minWidth: 0 }}
281
+ >
282
+ <Text size="xs" c="dimmed" lineClamp={1}>
283
+ {field.description || field.type}
284
+ </Text>
285
+ {field.enum && (
286
+ <Group gap={0} wrap="wrap">
287
+ {field.enum.map((enumValue) => (
288
+ <ActionButton
289
+ px={"xs"}
290
+ size={"xs"}
291
+ h={24}
292
+ key={enumValue}
293
+ onClick={() => onInsert(enumValue)}
294
+ >
295
+ {enumValue}
296
+ </ActionButton>
297
+ ))}
298
+ </Group>
299
+ )}
300
+ </Flex>
301
+ <Badge size="xs" variant="light" style={{ flexShrink: 0 }}>
302
+ {field.type}
303
+ </Badge>
304
+ </Flex>
305
+ ))}
306
+ </Flex>
307
+ </Flex>
308
+ )}
309
+ </Group>
310
+ );
311
+ }
312
+
313
+ export default ControlQueryBuilder;
@@ -97,7 +97,7 @@ const ControlSelect = (props: ControlSelectProps) => {
97
97
 
98
98
  return (
99
99
  <Input.Wrapper {...inputProps}>
100
- <Flex mt={"calc(var(--mantine-spacing-xs) / 2)"}>
100
+ <Flex>
101
101
  <SegmentedControl
102
102
  disabled={inputProps.disabled}
103
103
  defaultValue={String(props.input.props.defaultValue)}
@@ -19,6 +19,7 @@ export interface TypeFormProps<T extends TObject> {
19
19
  lg?: number;
20
20
  xl?: number;
21
21
  };
22
+ schema?: TObject;
22
23
  children?: (input: FormModel<T>["input"]) => ReactNode;
23
24
  controlProps?: Partial<Omit<ControlProps, "input">>;
24
25
  skipFormElement?: boolean;
@@ -63,11 +64,12 @@ const TypeForm = <T extends TObject>(props: TypeFormProps<T>) => {
63
64
  submitButtonProps,
64
65
  } = props;
65
66
 
66
- if (!form.options?.schema?.properties) {
67
+ const schema = props.schema || form.options.schema;
68
+ if (!schema?.properties) {
67
69
  return null;
68
70
  }
69
71
 
70
- const fieldNames = Object.keys(form.options.schema.properties);
72
+ const fieldNames = Object.keys(schema.properties);
71
73
 
72
74
  // Filter out unsupported field types (objects only, arrays are now supported)
73
75
  const supportedFields = fieldNames.filter((fieldName) => {
@@ -140,11 +142,11 @@ const TypeForm = <T extends TObject>(props: TypeFormProps<T>) => {
140
142
  <Flex direction={"column"} gap={"sm"}>
141
143
  {renderFields()}
142
144
  {!skipSubmitButton && (
143
- <Flex>
145
+ <Flex gap={"sm"}>
144
146
  <ActionButton form={form} {...submitButtonProps}>
145
147
  {submitButtonProps?.children ?? "Submit"}
146
148
  </ActionButton>
147
- <button type={"reset"}>Reset</button>
149
+ <ActionButton type={"reset"}>Reset</ActionButton>
148
150
  </Flex>
149
151
  )}
150
152
  </Flex>
@@ -27,7 +27,14 @@ export interface AdminShellProps {
27
27
 
28
28
  declare module "@alepha/core" {
29
29
  interface State {
30
+ /**
31
+ * Whether the sidebar is opened or closed.
32
+ */
30
33
  "alepha.ui.sidebar.opened"?: boolean;
34
+
35
+ /**
36
+ * Whether the sidebar is collapsed (narrow) or expanded (wide).
37
+ */
31
38
  "alepha.ui.sidebar.collapsed"?: boolean;
32
39
  }
33
40
  }
@@ -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[];