decent
This commit is contained in:
382
web-components/src/components/DataTable/DataTable.tsx
Normal file
382
web-components/src/components/DataTable/DataTable.tsx
Normal file
@ -0,0 +1,382 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Paper,
|
||||
Group,
|
||||
Button,
|
||||
TextInput,
|
||||
Select,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Text,
|
||||
Badge,
|
||||
Box,
|
||||
Stack,
|
||||
Pagination,
|
||||
LoadingOverlay,
|
||||
Center,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconSearch,
|
||||
IconPlus,
|
||||
IconDots,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconRefresh,
|
||||
IconFilter,
|
||||
} from '@tabler/icons-react';
|
||||
import { modals } from '@mantine/modals';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { ListItem, FilterOptions } from '../../types';
|
||||
|
||||
export interface TableColumn {
|
||||
key: string;
|
||||
label: string;
|
||||
sortable?: boolean;
|
||||
filterable?: boolean;
|
||||
width?: string | number;
|
||||
render?: (value: any, item: ListItem) => React.ReactNode;
|
||||
}
|
||||
|
||||
export interface TableAction {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
color?: string;
|
||||
onClick: (item: ListItem) => void;
|
||||
show?: (item: ListItem) => boolean;
|
||||
}
|
||||
|
||||
export interface DataTableProps {
|
||||
data: ListItem[];
|
||||
columns: TableColumn[];
|
||||
loading?: boolean;
|
||||
error?: string | null;
|
||||
title?: string;
|
||||
|
||||
// Pagination
|
||||
total?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
onPageChange?: (page: number) => void;
|
||||
|
||||
// Actions
|
||||
onAdd?: () => void;
|
||||
onEdit?: (item: ListItem) => void;
|
||||
onDelete?: (item: ListItem) => Promise<void>;
|
||||
onRefresh?: () => void;
|
||||
customActions?: TableAction[];
|
||||
|
||||
// Filtering & Search
|
||||
searchable?: boolean;
|
||||
filterable?: boolean;
|
||||
filters?: FilterOptions;
|
||||
onFiltersChange?: (filters: FilterOptions) => void;
|
||||
|
||||
// Styling
|
||||
withBorder?: boolean;
|
||||
withColumnBorders?: boolean;
|
||||
striped?: boolean;
|
||||
highlightOnHover?: boolean;
|
||||
|
||||
// Empty state
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
const DataTable: React.FC<DataTableProps> = ({
|
||||
data,
|
||||
columns,
|
||||
loading = false,
|
||||
error = null,
|
||||
title,
|
||||
total = 0,
|
||||
page = 1,
|
||||
pageSize = 10,
|
||||
onPageChange,
|
||||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRefresh,
|
||||
customActions = [],
|
||||
searchable = true,
|
||||
filterable = false,
|
||||
filters = {},
|
||||
onFiltersChange,
|
||||
withBorder = true,
|
||||
withColumnBorders = false,
|
||||
striped = true,
|
||||
highlightOnHover = true,
|
||||
emptyMessage = 'No data available',
|
||||
}) => {
|
||||
const [localFilters, setLocalFilters] = useState<FilterOptions>(filters);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalFilters(filters);
|
||||
}, [filters]);
|
||||
|
||||
const handleFilterChange = (key: string, value: string) => {
|
||||
const newFilters = { ...localFilters, [key]: value };
|
||||
setLocalFilters(newFilters);
|
||||
onFiltersChange?.(newFilters);
|
||||
};
|
||||
|
||||
const handleSearchChange = (value: string) => {
|
||||
handleFilterChange('search', value);
|
||||
};
|
||||
|
||||
const handleDeleteConfirm = (item: ListItem) => {
|
||||
modals.openConfirmModal({
|
||||
title: 'Confirm Delete',
|
||||
children: (
|
||||
<Text size="sm">
|
||||
Are you sure you want to delete this item? This action cannot be undone.
|
||||
</Text>
|
||||
),
|
||||
labels: { confirm: 'Delete', cancel: 'Cancel' },
|
||||
confirmProps: { color: 'red' },
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
if (onDelete) {
|
||||
await onDelete(item);
|
||||
notifications.show({
|
||||
title: 'Success',
|
||||
message: 'Item deleted successfully',
|
||||
color: 'green',
|
||||
});
|
||||
}
|
||||
} catch (error: any) {
|
||||
notifications.show({
|
||||
title: 'Error',
|
||||
message: error.message || 'Failed to delete item',
|
||||
color: 'red',
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderCellValue = (column: TableColumn, item: ListItem) => {
|
||||
const value = item[column.key];
|
||||
|
||||
if (column.render) {
|
||||
return column.render(value, item);
|
||||
}
|
||||
|
||||
// Default rendering for common data types
|
||||
if (value === null || value === undefined) {
|
||||
return <Text c="dimmed">-</Text>;
|
||||
}
|
||||
|
||||
if (typeof value === 'boolean') {
|
||||
return (
|
||||
<Badge color={value ? 'green' : 'gray'} size="sm">
|
||||
{value ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
if (column.key === 'status') {
|
||||
const statusColors: Record<string, string> = {
|
||||
active: 'green',
|
||||
inactive: 'gray',
|
||||
pending: 'yellow',
|
||||
suspended: 'red',
|
||||
success: 'green',
|
||||
error: 'red',
|
||||
warning: 'yellow',
|
||||
};
|
||||
return (
|
||||
<Badge color={statusColors[value] || 'blue'} size="sm">
|
||||
{value}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
return <Text>{value.toString()}</Text>;
|
||||
};
|
||||
|
||||
const renderActionMenu = (item: ListItem) => {
|
||||
const actions: TableAction[] = [];
|
||||
|
||||
if (onEdit) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: 'Edit',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: onEdit,
|
||||
});
|
||||
}
|
||||
|
||||
if (onDelete) {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
icon: <IconTrash size={14} />,
|
||||
color: 'red',
|
||||
onClick: () => handleDeleteConfirm(item),
|
||||
});
|
||||
}
|
||||
|
||||
actions.push(...customActions);
|
||||
|
||||
const visibleActions = actions.filter(action =>
|
||||
!action.show || action.show(item)
|
||||
);
|
||||
|
||||
if (visibleActions.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu position="bottom-end">
|
||||
<Menu.Target>
|
||||
<ActionIcon variant="subtle" color="gray">
|
||||
<IconDots size={16} />
|
||||
</ActionIcon>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown>
|
||||
{visibleActions.map((action) => (
|
||||
<Menu.Item
|
||||
key={action.key}
|
||||
leftSection={action.icon}
|
||||
color={action.color}
|
||||
onClick={() => action.onClick(item)}
|
||||
>
|
||||
{action.label}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* Header with title and actions */}
|
||||
<Group justify="space-between">
|
||||
<Group>
|
||||
{title && <Text size="xl" fw={600}>{title}</Text>}
|
||||
</Group>
|
||||
|
||||
<Group>
|
||||
{onRefresh && (
|
||||
<ActionIcon variant="light" onClick={onRefresh}>
|
||||
<IconRefresh size={16} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{onAdd && (
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={onAdd}>
|
||||
Add New
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Filters and Search */}
|
||||
{(searchable || filterable) && (
|
||||
<Group>
|
||||
{searchable && (
|
||||
<TextInput
|
||||
placeholder="Search..."
|
||||
leftSection={<IconSearch size={16} />}
|
||||
value={localFilters.search || ''}
|
||||
onChange={(e) => handleSearchChange(e.currentTarget.value)}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{filterable && (
|
||||
<Group>
|
||||
<ActionIcon variant="light">
|
||||
<IconFilter size={16} />
|
||||
</ActionIcon>
|
||||
{/* Add specific filter components as needed */}
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<Paper withBorder={withBorder} pos="relative">
|
||||
<LoadingOverlay visible={loading} />
|
||||
|
||||
{error ? (
|
||||
<Center p="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="red" fw={500}>Error loading data</Text>
|
||||
<Text c="dimmed" size="sm">{error}</Text>
|
||||
{onRefresh && (
|
||||
<Button variant="light" size="sm" onClick={onRefresh}>
|
||||
Try Again
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : data.length === 0 ? (
|
||||
<Center p="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="dimmed">{emptyMessage}</Text>
|
||||
{onAdd && (
|
||||
<Button variant="light" size="sm" onClick={onAdd}>
|
||||
Add First Item
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
</Center>
|
||||
) : (
|
||||
<Table
|
||||
striped={striped}
|
||||
highlightOnHover={highlightOnHover}
|
||||
withColumnBorders={withColumnBorders}
|
||||
>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
{columns.map((column) => (
|
||||
<Table.Th key={column.key} style={{ width: column.width }}>
|
||||
{column.label}
|
||||
</Table.Th>
|
||||
))}
|
||||
{(onEdit || onDelete || customActions.length > 0) && (
|
||||
<Table.Th style={{ width: 50 }}>Actions</Table.Th>
|
||||
)}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{data.map((item) => (
|
||||
<Table.Tr key={item.id}>
|
||||
{columns.map((column) => (
|
||||
<Table.Td key={`${item.id}-${column.key}`}>
|
||||
{renderCellValue(column, item)}
|
||||
</Table.Td>
|
||||
))}
|
||||
{(onEdit || onDelete || customActions.length > 0) && (
|
||||
<Table.Td>
|
||||
{renderActionMenu(item)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<Group justify="center">
|
||||
<Pagination
|
||||
total={totalPages}
|
||||
value={page}
|
||||
onChange={onPageChange}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default DataTable;
|
||||
247
web-components/src/components/FormSidebar/FormSidebar.tsx
Normal file
247
web-components/src/components/FormSidebar/FormSidebar.tsx
Normal file
@ -0,0 +1,247 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
Paper,
|
||||
TextInput,
|
||||
Select,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
JsonInput,
|
||||
Button,
|
||||
Group,
|
||||
Stack,
|
||||
Title,
|
||||
ActionIcon,
|
||||
ScrollArea,
|
||||
Box,
|
||||
Text,
|
||||
} from '@mantine/core';
|
||||
import { IconX } from '@tabler/icons-react';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { FormField, NotificationConfig } from '../../types';
|
||||
|
||||
export interface FormSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
title: string;
|
||||
editMode?: boolean;
|
||||
editItem?: any;
|
||||
fields: FormField[];
|
||||
onSubmit: (values: any) => Promise<void>;
|
||||
width?: number;
|
||||
initialValues?: Record<string, any>;
|
||||
validateOnSubmit?: boolean;
|
||||
}
|
||||
|
||||
const FormSidebar: React.FC<FormSidebarProps> = ({
|
||||
opened,
|
||||
onClose,
|
||||
onSuccess,
|
||||
title,
|
||||
editMode = false,
|
||||
editItem,
|
||||
fields,
|
||||
onSubmit,
|
||||
width = 450,
|
||||
initialValues = {},
|
||||
validateOnSubmit = true,
|
||||
}) => {
|
||||
const isEditing = editMode && !!editItem;
|
||||
|
||||
// Build initial form values from fields
|
||||
const buildInitialValues = () => {
|
||||
const values: Record<string, any> = {};
|
||||
fields.forEach(field => {
|
||||
values[field.name] = field.defaultValue ?? (field.type === 'multiselect' ? [] : '');
|
||||
});
|
||||
return { ...values, ...initialValues };
|
||||
};
|
||||
|
||||
// Build validation rules from fields
|
||||
const buildValidation = () => {
|
||||
const validation: Record<string, (value: any) => string | null> = {};
|
||||
fields.forEach(field => {
|
||||
validation[field.name] = (value: any) => {
|
||||
if (field.required && (!value || (typeof value === 'string' && value.trim() === ''))) {
|
||||
return `${field.label} is required`;
|
||||
}
|
||||
if (field.validation?.email && value && !/^\S+@\S+$/.test(value)) {
|
||||
return 'Invalid email format';
|
||||
}
|
||||
if (field.validation?.url && value && !/^https?:\/\/.+/.test(value)) {
|
||||
return 'Invalid URL format';
|
||||
}
|
||||
if (field.validation?.minLength && value && value.length < field.validation.minLength) {
|
||||
return `${field.label} must be at least ${field.validation.minLength} characters`;
|
||||
}
|
||||
if (field.validation?.maxLength && value && value.length > field.validation.maxLength) {
|
||||
return `${field.label} must be no more than ${field.validation.maxLength} characters`;
|
||||
}
|
||||
if (field.validation?.pattern && value && !field.validation.pattern.test(value)) {
|
||||
return `${field.label} format is invalid`;
|
||||
}
|
||||
if (field.validation?.custom) {
|
||||
return field.validation.custom(value);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
});
|
||||
return validation;
|
||||
};
|
||||
|
||||
const form = useForm({
|
||||
initialValues: buildInitialValues(),
|
||||
validate: buildValidation(),
|
||||
});
|
||||
|
||||
// Update form values when editItem changes
|
||||
useEffect(() => {
|
||||
if (isEditing && editItem) {
|
||||
const updatedValues: Record<string, any> = {};
|
||||
fields.forEach(field => {
|
||||
updatedValues[field.name] = editItem[field.name] ?? field.defaultValue ?? '';
|
||||
});
|
||||
form.setValues(updatedValues);
|
||||
} else if (!isEditing) {
|
||||
form.setValues(buildInitialValues());
|
||||
}
|
||||
}, [editItem, opened, isEditing]);
|
||||
|
||||
const handleSubmit = async (values: typeof form.values) => {
|
||||
try {
|
||||
await onSubmit(values);
|
||||
|
||||
const successNotification: NotificationConfig = {
|
||||
title: 'Success',
|
||||
message: `${title} ${isEditing ? 'updated' : 'created'} successfully`,
|
||||
color: 'green',
|
||||
};
|
||||
|
||||
notifications.show(successNotification);
|
||||
onSuccess();
|
||||
onClose();
|
||||
form.reset();
|
||||
} catch (error: any) {
|
||||
console.error(`Error ${isEditing ? 'updating' : 'creating'} ${title.toLowerCase()}:`, error);
|
||||
|
||||
const errorNotification: NotificationConfig = {
|
||||
title: 'Error',
|
||||
message: error.message || `Failed to ${isEditing ? 'update' : 'create'} ${title.toLowerCase()}`,
|
||||
color: 'red',
|
||||
};
|
||||
|
||||
notifications.show(errorNotification);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (field: FormField) => {
|
||||
const inputProps = form.getInputProps(field.name);
|
||||
const commonProps = {
|
||||
key: field.name,
|
||||
label: field.label,
|
||||
placeholder: field.placeholder,
|
||||
description: field.description,
|
||||
required: field.required,
|
||||
disabled: field.disabled || (isEditing && field.name === 'id'),
|
||||
...inputProps,
|
||||
};
|
||||
|
||||
switch (field.type) {
|
||||
case 'email':
|
||||
return <TextInput {...commonProps} type="email" />;
|
||||
|
||||
case 'number':
|
||||
return <NumberInput {...commonProps} />;
|
||||
|
||||
case 'textarea':
|
||||
return <Textarea {...commonProps} autosize minRows={3} maxRows={6} />;
|
||||
|
||||
case 'select':
|
||||
return (
|
||||
<Select
|
||||
{...commonProps}
|
||||
data={field.options || []}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
return (
|
||||
<MultiSelect
|
||||
{...commonProps}
|
||||
data={field.options || []}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'json':
|
||||
return (
|
||||
<JsonInput
|
||||
{...commonProps}
|
||||
validationError="Invalid JSON format"
|
||||
formatOnBlur
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return <TextInput {...commonProps} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 60,
|
||||
right: opened ? 0 : `-${width}px`,
|
||||
bottom: 0,
|
||||
width: `${width}px`,
|
||||
zIndex: 1000,
|
||||
borderRadius: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
borderLeft: '1px solid var(--mantine-color-gray-3)',
|
||||
backgroundColor: 'var(--mantine-color-body)',
|
||||
transition: 'right 0.3s ease',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<Group justify="space-between" p="md" style={{ borderBottom: '1px solid var(--mantine-color-gray-3)' }}>
|
||||
<Title order={4}>
|
||||
{isEditing ? `Edit ${title}` : `Create New ${title}`}
|
||||
</Title>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={onClose}
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
|
||||
{/* Content */}
|
||||
<ScrollArea style={{ flex: 1 }}>
|
||||
<Box p="md">
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Stack gap="md">
|
||||
{fields.map(renderField)}
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="light" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{isEditing ? 'Update' : 'Create'} {title}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
</Box>
|
||||
</ScrollArea>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default FormSidebar;
|
||||
223
web-components/src/hooks/useApiService.ts
Normal file
223
web-components/src/hooks/useApiService.ts
Normal file
@ -0,0 +1,223 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import axios, { AxiosInstance, AxiosRequestConfig } from 'axios';
|
||||
import { ApiResponse, PaginatedResponse, FilterOptions } from '../types';
|
||||
|
||||
export interface ApiServiceConfig {
|
||||
baseURL: string;
|
||||
defaultHeaders?: Record<string, string>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface UseApiServiceReturn<T> {
|
||||
data: T[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
total: number;
|
||||
hasMore: boolean;
|
||||
client: AxiosInstance;
|
||||
|
||||
// CRUD operations
|
||||
getAll: (filters?: FilterOptions) => Promise<T[]>;
|
||||
getById: (id: string) => Promise<T>;
|
||||
create: (data: Partial<T>) => Promise<T>;
|
||||
update: (id: string, data: Partial<T>) => Promise<T>;
|
||||
delete: (id: string) => Promise<void>;
|
||||
|
||||
// Utility methods
|
||||
clearError: () => void;
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useApiService = <T extends { id: string }>(
|
||||
config: ApiServiceConfig,
|
||||
endpoint: string
|
||||
): UseApiServiceReturn<T> => {
|
||||
const [data, setData] = useState<T[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
|
||||
// Create axios instance
|
||||
const client = axios.create({
|
||||
baseURL: config.baseURL,
|
||||
timeout: config.timeout || 10000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...config.defaultHeaders,
|
||||
},
|
||||
});
|
||||
|
||||
// Add request interceptor for common headers
|
||||
client.interceptors.request.use(
|
||||
(config) => {
|
||||
// Add user email header if available (common pattern in the codebase)
|
||||
const userEmail = 'admin@example.com'; // This could come from a context or config
|
||||
if (userEmail) {
|
||||
config.headers['X-User-Email'] = userEmail;
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => Promise.reject(error)
|
||||
);
|
||||
|
||||
// Add response interceptor for error handling
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
const errorMessage = error.response?.data?.message || error.message || 'An error occurred';
|
||||
setError(errorMessage);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const getAll = useCallback(async (filters: FilterOptions = {}): Promise<T[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params.append(key, value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const response = await client.get<PaginatedResponse<T> | ApiResponse<T[]>>(`${endpoint}?${params.toString()}`);
|
||||
|
||||
// Handle both paginated and simple array responses
|
||||
if ('data' in response.data && Array.isArray(response.data.data)) {
|
||||
// Paginated response
|
||||
const paginatedData = response.data as PaginatedResponse<T>;
|
||||
setData(paginatedData.data);
|
||||
setTotal(paginatedData.total);
|
||||
setHasMore(paginatedData.has_more || false);
|
||||
return paginatedData.data;
|
||||
} else if ('data' in response.data && Array.isArray(response.data.data)) {
|
||||
// Simple array response wrapped in ApiResponse
|
||||
const apiData = response.data as ApiResponse<T[]>;
|
||||
setData(apiData.data);
|
||||
setTotal(apiData.data.length);
|
||||
setHasMore(false);
|
||||
return apiData.data;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
// Direct array response
|
||||
setData(response.data);
|
||||
setTotal(response.data.length);
|
||||
setHasMore(false);
|
||||
return response.data;
|
||||
} else {
|
||||
throw new Error('Invalid response format');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to fetch data';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, endpoint]);
|
||||
|
||||
const getById = useCallback(async (id: string): Promise<T> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await client.get<ApiResponse<T> | T>(`${endpoint}/${id}`);
|
||||
const item = 'data' in response.data ? response.data.data : response.data;
|
||||
return item;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to fetch item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, endpoint]);
|
||||
|
||||
const create = useCallback(async (itemData: Partial<T>): Promise<T> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await client.post<ApiResponse<T> | T>(endpoint, itemData);
|
||||
const newItem = 'data' in response.data ? response.data.data : response.data;
|
||||
|
||||
// Update local data
|
||||
setData(prev => [...prev, newItem]);
|
||||
setTotal(prev => prev + 1);
|
||||
|
||||
return newItem;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to create item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, endpoint]);
|
||||
|
||||
const update = useCallback(async (id: string, itemData: Partial<T>): Promise<T> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const response = await client.put<ApiResponse<T> | T>(`${endpoint}/${id}`, itemData);
|
||||
const updatedItem = 'data' in response.data ? response.data.data : response.data;
|
||||
|
||||
// Update local data
|
||||
setData(prev => prev.map(item => item.id === id ? updatedItem : item));
|
||||
|
||||
return updatedItem;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to update item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, endpoint]);
|
||||
|
||||
const deleteItem = useCallback(async (id: string): Promise<void> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
await client.delete(`${endpoint}/${id}`);
|
||||
|
||||
// Update local data
|
||||
setData(prev => prev.filter(item => item.id !== id));
|
||||
setTotal(prev => prev - 1);
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.message || err.message || 'Failed to delete item';
|
||||
setError(errorMessage);
|
||||
throw err;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [client, endpoint]);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
await getAll();
|
||||
}, [getAll]);
|
||||
|
||||
return {
|
||||
data,
|
||||
loading,
|
||||
error,
|
||||
total,
|
||||
hasMore,
|
||||
client,
|
||||
getAll,
|
||||
getById,
|
||||
create,
|
||||
update,
|
||||
delete: deleteItem,
|
||||
clearError,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
108
web-components/src/hooks/useDataFilter.ts
Normal file
108
web-components/src/hooks/useDataFilter.ts
Normal file
@ -0,0 +1,108 @@
|
||||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { FilterOptions, ListItem } from '../types';
|
||||
|
||||
export interface UseDataFilterOptions {
|
||||
searchFields?: string[];
|
||||
defaultFilters?: FilterOptions;
|
||||
debounceMs?: number;
|
||||
}
|
||||
|
||||
export interface UseDataFilterReturn {
|
||||
filteredData: ListItem[];
|
||||
filters: FilterOptions;
|
||||
setFilter: (key: string, value: any) => void;
|
||||
clearFilters: () => void;
|
||||
resetFilters: () => void;
|
||||
searchTerm: string;
|
||||
setSearchTerm: (term: string) => void;
|
||||
}
|
||||
|
||||
export const useDataFilter = (
|
||||
data: ListItem[],
|
||||
options: UseDataFilterOptions = {}
|
||||
): UseDataFilterReturn => {
|
||||
const {
|
||||
searchFields = ['name', 'title', 'email', 'description'],
|
||||
defaultFilters = {},
|
||||
} = options;
|
||||
|
||||
const [filters, setFilters] = useState<FilterOptions>(defaultFilters);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const setFilter = useCallback((key: string, value: any) => {
|
||||
if (key === 'search') {
|
||||
setSearchTerm(value);
|
||||
setFilters(prev => ({ ...prev, search: value }));
|
||||
} else {
|
||||
setFilters(prev => ({ ...prev, [key]: value }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setFilters({});
|
||||
setSearchTerm('');
|
||||
}, []);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
setFilters(defaultFilters);
|
||||
setSearchTerm('');
|
||||
}, [defaultFilters]);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = [...data];
|
||||
|
||||
// Apply search filter
|
||||
if (searchTerm.trim()) {
|
||||
const term = searchTerm.toLowerCase().trim();
|
||||
result = result.filter(item => {
|
||||
return searchFields.some(field => {
|
||||
const value = item[field];
|
||||
if (!value) return false;
|
||||
return value.toString().toLowerCase().includes(term);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Apply other filters
|
||||
Object.entries(filters).forEach(([key, value]) => {
|
||||
if (key === 'search') return; // Already handled above
|
||||
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (Array.isArray(value) && value.length > 0) {
|
||||
// Array filter (e.g., multiple status selection)
|
||||
result = result.filter(item => value.includes(item[key]));
|
||||
} else {
|
||||
// Single value filter
|
||||
result = result.filter(item => {
|
||||
const itemValue = item[key];
|
||||
if (itemValue == null) return false;
|
||||
|
||||
// Exact match for most cases
|
||||
if (itemValue.toString().toLowerCase() === value.toString().toLowerCase()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Partial match for string fields
|
||||
if (typeof itemValue === 'string' && typeof value === 'string') {
|
||||
return itemValue.toLowerCase().includes(value.toLowerCase());
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [data, searchTerm, filters, searchFields]);
|
||||
|
||||
return {
|
||||
filteredData,
|
||||
filters,
|
||||
setFilter,
|
||||
clearFilters,
|
||||
resetFilters,
|
||||
searchTerm,
|
||||
setSearchTerm,
|
||||
};
|
||||
};
|
||||
83
web-components/src/index.ts
Normal file
83
web-components/src/index.ts
Normal file
@ -0,0 +1,83 @@
|
||||
// Components
|
||||
export { default as FormSidebar } from './components/FormSidebar/FormSidebar';
|
||||
export { default as DataTable } from './components/DataTable/DataTable';
|
||||
|
||||
// Types
|
||||
export * from './types';
|
||||
|
||||
// Hooks
|
||||
export { useApiService } from './hooks/useApiService';
|
||||
export { useDataFilter } from './hooks/useDataFilter';
|
||||
|
||||
// Utils
|
||||
export * from './utils/notifications';
|
||||
export * from './utils/validation';
|
||||
|
||||
// Re-export common Mantine components and utilities for consistency
|
||||
export {
|
||||
// Core components that are commonly used
|
||||
Paper,
|
||||
Stack,
|
||||
Group,
|
||||
Button,
|
||||
TextInput,
|
||||
Select,
|
||||
MultiSelect,
|
||||
NumberInput,
|
||||
Textarea,
|
||||
JsonInput,
|
||||
ActionIcon,
|
||||
Menu,
|
||||
Text,
|
||||
Title,
|
||||
Badge,
|
||||
Table,
|
||||
Pagination,
|
||||
LoadingOverlay,
|
||||
Center,
|
||||
Box,
|
||||
ScrollArea,
|
||||
Divider,
|
||||
|
||||
|
||||
} from '@mantine/core';
|
||||
|
||||
// Re-export common hooks
|
||||
export {
|
||||
useDisclosure,
|
||||
useToggle,
|
||||
useLocalStorage,
|
||||
} from '@mantine/hooks';
|
||||
|
||||
// Re-export form utilities
|
||||
export { useForm } from '@mantine/form';
|
||||
|
||||
// Re-export notifications
|
||||
export { notifications } from '@mantine/notifications';
|
||||
|
||||
// Re-export modals
|
||||
export { modals } from '@mantine/modals';
|
||||
|
||||
// Re-export common icons
|
||||
export {
|
||||
IconPlus,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconSearch,
|
||||
IconFilter,
|
||||
IconRefresh,
|
||||
IconX,
|
||||
IconDots,
|
||||
IconChevronDown,
|
||||
IconChevronRight,
|
||||
IconUser,
|
||||
IconUsers,
|
||||
IconKey,
|
||||
IconSettings,
|
||||
IconEye,
|
||||
IconEyeOff,
|
||||
IconCopy,
|
||||
IconCheck,
|
||||
IconAlertCircle,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
87
web-components/src/types/index.ts
Normal file
87
web-components/src/types/index.ts
Normal file
@ -0,0 +1,87 @@
|
||||
// Common types used across all microfrontends
|
||||
|
||||
export interface BaseEntity {
|
||||
id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
created_by?: string;
|
||||
updated_by?: string;
|
||||
}
|
||||
|
||||
export interface Owner {
|
||||
type: 'individual' | 'team';
|
||||
name: string;
|
||||
owner: string;
|
||||
}
|
||||
|
||||
export interface FormSidebarProps {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
editItem?: any;
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
id: string;
|
||||
name?: string;
|
||||
title?: string;
|
||||
email?: string;
|
||||
status?: string;
|
||||
role?: string;
|
||||
type?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T;
|
||||
message?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page: number;
|
||||
limit: number;
|
||||
has_more: boolean;
|
||||
}
|
||||
|
||||
export interface FilterOptions {
|
||||
search?: string;
|
||||
status?: string;
|
||||
type?: string;
|
||||
role?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface NotificationConfig {
|
||||
title: string;
|
||||
message: string;
|
||||
color: 'red' | 'green' | 'blue' | 'yellow' | 'gray';
|
||||
[key: string]: any; // Allow additional properties for Mantine compatibility
|
||||
}
|
||||
|
||||
export interface ValidationRule {
|
||||
required?: boolean;
|
||||
minLength?: number;
|
||||
maxLength?: number;
|
||||
pattern?: RegExp;
|
||||
email?: boolean;
|
||||
url?: boolean;
|
||||
custom?: (value: any) => string | null;
|
||||
}
|
||||
|
||||
export interface FormField {
|
||||
name: string;
|
||||
label: string;
|
||||
type: 'text' | 'email' | 'number' | 'select' | 'multiselect' | 'textarea' | 'date' | 'json';
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
required?: boolean;
|
||||
disabled?: boolean;
|
||||
options?: Array<{ value: string; label: string }>;
|
||||
validation?: ValidationRule;
|
||||
defaultValue?: any;
|
||||
}
|
||||
95
web-components/src/utils/notifications.ts
Normal file
95
web-components/src/utils/notifications.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { NotificationConfig } from '../types';
|
||||
|
||||
export const showSuccessNotification = (message: string, title = 'Success') => {
|
||||
const config: NotificationConfig = {
|
||||
title,
|
||||
message,
|
||||
color: 'green',
|
||||
};
|
||||
notifications.show(config);
|
||||
};
|
||||
|
||||
export const showErrorNotification = (message: string, title = 'Error') => {
|
||||
const config: NotificationConfig = {
|
||||
title,
|
||||
message,
|
||||
color: 'red',
|
||||
};
|
||||
notifications.show(config);
|
||||
};
|
||||
|
||||
export const showWarningNotification = (message: string, title = 'Warning') => {
|
||||
const config: NotificationConfig = {
|
||||
title,
|
||||
message,
|
||||
color: 'yellow',
|
||||
};
|
||||
notifications.show(config);
|
||||
};
|
||||
|
||||
export const showInfoNotification = (message: string, title = 'Info') => {
|
||||
const config: NotificationConfig = {
|
||||
title,
|
||||
message,
|
||||
color: 'blue',
|
||||
};
|
||||
notifications.show(config);
|
||||
};
|
||||
|
||||
// Common notification messages used across microfrontends
|
||||
export const NotificationMessages = {
|
||||
// Generic CRUD operations
|
||||
createSuccess: (entityName: string) => `${entityName} created successfully`,
|
||||
updateSuccess: (entityName: string) => `${entityName} updated successfully`,
|
||||
deleteSuccess: (entityName: string) => `${entityName} deleted successfully`,
|
||||
|
||||
createError: (entityName: string) => `Failed to create ${entityName}`,
|
||||
updateError: (entityName: string) => `Failed to update ${entityName}`,
|
||||
deleteError: (entityName: string) => `Failed to delete ${entityName}`,
|
||||
|
||||
loadError: (entityName: string) => `Failed to load ${entityName}`,
|
||||
networkError: 'Network error occurred. Please try again.',
|
||||
|
||||
// Validation
|
||||
validationError: 'Please check the form for errors',
|
||||
requiredFieldError: (fieldName: string) => `${fieldName} is required`,
|
||||
|
||||
// Authentication
|
||||
authRequired: 'Authentication required',
|
||||
permissionDenied: 'Permission denied',
|
||||
sessionExpired: 'Session expired. Please log in again.',
|
||||
|
||||
// Application-specific
|
||||
applicationCreated: 'Application created successfully',
|
||||
applicationUpdated: 'Application updated successfully',
|
||||
applicationDeleted: 'Application deleted successfully',
|
||||
|
||||
tokenCreated: 'Token created successfully',
|
||||
tokenRevoked: 'Token revoked successfully',
|
||||
|
||||
userCreated: 'User created successfully',
|
||||
userUpdated: 'User updated successfully',
|
||||
userDeleted: 'User deleted successfully',
|
||||
|
||||
functionCreated: 'Function created successfully',
|
||||
functionUpdated: 'Function updated successfully',
|
||||
functionDeleted: 'Function deleted successfully',
|
||||
|
||||
executionStarted: 'Function execution started',
|
||||
executionCompleted: 'Function execution completed',
|
||||
executionFailed: 'Function execution failed',
|
||||
};
|
||||
|
||||
// Utility to show notifications for common operations
|
||||
export const showCrudNotification = {
|
||||
success: (operation: 'create' | 'update' | 'delete', entityName: string) => {
|
||||
const message = NotificationMessages[`${operation}Success`](entityName);
|
||||
showSuccessNotification(message);
|
||||
},
|
||||
|
||||
error: (operation: 'create' | 'update' | 'delete' | 'load', entityName: string, customMessage?: string) => {
|
||||
const message = customMessage || NotificationMessages[`${operation}Error`](entityName);
|
||||
showErrorNotification(message);
|
||||
},
|
||||
};
|
||||
137
web-components/src/utils/validation.ts
Normal file
137
web-components/src/utils/validation.ts
Normal file
@ -0,0 +1,137 @@
|
||||
// Common validation patterns used across microfrontends
|
||||
|
||||
export const ValidationPatterns = {
|
||||
email: /^\S+@\S+\.\S+$/,
|
||||
url: /^https?:\/\/.+/,
|
||||
duration: /^\d+[smhd]$/, // e.g., "30s", "5m", "2h", "1d"
|
||||
token: /^[a-zA-Z0-9_-]+$/,
|
||||
appId: /^[a-zA-Z0-9-_]+$/,
|
||||
uuid: /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
};
|
||||
|
||||
export const ValidationMessages = {
|
||||
required: (fieldName: string) => `${fieldName} is required`,
|
||||
email: 'Please enter a valid email address',
|
||||
url: 'Please enter a valid URL (http:// or https://)',
|
||||
duration: 'Duration must be in format like 30s, 5m, 2h, 1d',
|
||||
minLength: (fieldName: string, minLength: number) =>
|
||||
`${fieldName} must be at least ${minLength} characters`,
|
||||
maxLength: (fieldName: string, maxLength: number) =>
|
||||
`${fieldName} must be no more than ${maxLength} characters`,
|
||||
pattern: (fieldName: string) => `${fieldName} format is invalid`,
|
||||
token: 'Token can only contain letters, numbers, underscores and hyphens',
|
||||
appId: 'App ID can only contain letters, numbers, hyphens and underscores',
|
||||
uuid: 'Please enter a valid UUID',
|
||||
positiveNumber: 'Must be a positive number',
|
||||
range: (fieldName: string, min: number, max: number) =>
|
||||
`${fieldName} must be between ${min} and ${max}`,
|
||||
};
|
||||
|
||||
// Common validation functions
|
||||
export const validateRequired = (value: any): string | null => {
|
||||
if (value === null || value === undefined || value === '' ||
|
||||
(Array.isArray(value) && value.length === 0)) {
|
||||
return 'This field is required';
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateEmail = (value: string): string | null => {
|
||||
if (!value) return null;
|
||||
return ValidationPatterns.email.test(value) ? null : ValidationMessages.email;
|
||||
};
|
||||
|
||||
export const validateUrl = (value: string): string | null => {
|
||||
if (!value) return null;
|
||||
return ValidationPatterns.url.test(value) ? null : ValidationMessages.url;
|
||||
};
|
||||
|
||||
export const validateDuration = (value: string): string | null => {
|
||||
if (!value) return null;
|
||||
return ValidationPatterns.duration.test(value) ? null : ValidationMessages.duration;
|
||||
};
|
||||
|
||||
export const validateMinLength = (value: string, minLength: number, fieldName = 'Field'): string | null => {
|
||||
if (!value) return null;
|
||||
return value.length >= minLength ? null : ValidationMessages.minLength(fieldName, minLength);
|
||||
};
|
||||
|
||||
export const validateMaxLength = (value: string, maxLength: number, fieldName = 'Field'): string | null => {
|
||||
if (!value) return null;
|
||||
return value.length <= maxLength ? null : ValidationMessages.maxLength(fieldName, maxLength);
|
||||
};
|
||||
|
||||
export const validatePattern = (value: string, pattern: RegExp, fieldName = 'Field'): string | null => {
|
||||
if (!value) return null;
|
||||
return pattern.test(value) ? null : ValidationMessages.pattern(fieldName);
|
||||
};
|
||||
|
||||
export const validateRange = (value: number, min: number, max: number, fieldName = 'Field'): string | null => {
|
||||
if (value == null) return null;
|
||||
if (value < min || value > max) {
|
||||
return ValidationMessages.range(fieldName, min, max);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const validateAppId = (value: string): string | null => {
|
||||
if (!value) return null;
|
||||
return ValidationPatterns.appId.test(value) ? null : ValidationMessages.appId;
|
||||
};
|
||||
|
||||
export const validateToken = (value: string): string | null => {
|
||||
if (!value) return null;
|
||||
return ValidationPatterns.token.test(value) ? null : ValidationMessages.token;
|
||||
};
|
||||
|
||||
export const validateUuid = (value: string): string | null => {
|
||||
if (!value) return null;
|
||||
return ValidationPatterns.uuid.test(value) ? null : ValidationMessages.uuid;
|
||||
};
|
||||
|
||||
export const validateJsonString = (value: string): string | null => {
|
||||
if (!value || value.trim() === '') return null;
|
||||
|
||||
try {
|
||||
JSON.parse(value);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return 'Invalid JSON format';
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to parse duration strings to seconds (common across KMS and FaaS)
|
||||
export const parseDuration = (duration: string): number => {
|
||||
const match = duration.match(/^(\d+)([smhd]?)$/);
|
||||
if (!match) return 86400; // Default to 24h in seconds
|
||||
|
||||
const value = parseInt(match[1]);
|
||||
const unit = match[2] || 'h';
|
||||
|
||||
switch (unit) {
|
||||
case 's': return value; // seconds
|
||||
case 'm': return value * 60; // minutes to seconds
|
||||
case 'h': return value * 3600; // hours to seconds
|
||||
case 'd': return value * 86400; // days to seconds
|
||||
default: return value * 3600; // default to hours
|
||||
}
|
||||
};
|
||||
|
||||
// Utility function to format duration from seconds to human readable
|
||||
export const formatDuration = (seconds: number): string => {
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
};
|
||||
|
||||
// Combine multiple validators
|
||||
export const combineValidators = (...validators: Array<(value: any) => string | null>) => {
|
||||
return (value: any): string | null => {
|
||||
for (const validator of validators) {
|
||||
const error = validator(value);
|
||||
if (error) return error;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user