This commit is contained in:
2025-08-31 23:27:52 -04:00
parent 23dfc171b8
commit 40f8780dec
27 changed files with 24228 additions and 0 deletions

View 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;

View 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;

View 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,
};
};

View 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,
};
};

View 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';

View 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;
}

View 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);
},
};

View 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;
};
};