This commit is contained in:
2025-08-31 23:39:44 -04:00
parent 40f8780dec
commit d4f4747fde
20 changed files with 1919 additions and 13 deletions

View File

@ -0,0 +1,374 @@
import React from 'react';
import {
Menu,
ActionIcon,
Button,
Group,
Text,
Divider,
} from '@mantine/core';
import {
IconDots,
IconEdit,
IconTrash,
IconEye,
IconCopy,
IconDownload,
IconShare,
IconArchive,
IconRestore,
IconSettings,
IconPlayerPlay,
IconPlayerPause,
IconPlayerStop,
IconRefresh,
TablerIconsProps,
} from '@tabler/icons-react';
import { modals } from '@mantine/modals';
import { notifications } from '@mantine/notifications';
export interface ActionMenuItem {
key: string;
label: string;
icon?: React.ComponentType<TablerIconsProps>;
color?: string;
disabled?: boolean;
hidden?: boolean;
onClick: (item?: any) => void | Promise<void>;
// Confirmation dialog for destructive actions
confirm?: {
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
};
// Show/hide based on item properties
show?: (item: any) => boolean;
}
export interface ActionMenuProps {
item?: any; // The data item this menu is for
actions: ActionMenuItem[];
// Menu trigger customization
trigger?: 'dots' | 'button' | 'custom';
triggerLabel?: string;
triggerIcon?: React.ComponentType<TablerIconsProps>;
triggerProps?: any;
customTrigger?: React.ReactNode;
// Menu positioning
position?: 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start';
// Menu styling
withArrow?: boolean;
withinPortal?: boolean;
// Accessibility
'aria-label'?: string;
}
const ActionMenu: React.FC<ActionMenuProps> = ({
item,
actions,
trigger = 'dots',
triggerLabel = 'Actions',
triggerIcon: TriggerIcon = IconDots,
triggerProps = {},
customTrigger,
position = 'bottom-end',
withArrow = false,
withinPortal = true,
'aria-label': ariaLabel,
}) => {
// Filter actions based on show/hidden conditions
const visibleActions = actions.filter(action => {
if (action.hidden) return false;
if (action.show && !action.show(item)) return false;
return true;
});
// Group actions by type (with dividers)
const groupedActions = groupActionsByType(visibleActions);
// Don't render if no visible actions
if (visibleActions.length === 0) {
return null;
}
const handleActionClick = async (action: ActionMenuItem) => {
try {
// Show confirmation dialog for destructive actions
if (action.confirm) {
return new Promise<void>((resolve) => {
modals.openConfirmModal({
title: action.confirm!.title,
children: (
<Text size="sm">{action.confirm!.message}</Text>
),
labels: {
confirm: action.confirm!.confirmLabel || 'Confirm',
cancel: action.confirm!.cancelLabel || 'Cancel'
},
confirmProps: { color: action.color || 'red' },
onConfirm: async () => {
try {
await action.onClick(item);
resolve();
} catch (error) {
console.error(`Action ${action.key} failed:`, error);
notifications.show({
title: 'Action Failed',
message: `Failed to ${action.label.toLowerCase()}`,
color: 'red',
});
}
},
onCancel: () => resolve(),
});
});
} else {
await action.onClick(item);
}
} catch (error) {
console.error(`Action ${action.key} failed:`, error);
notifications.show({
title: 'Action Failed',
message: `Failed to ${action.label.toLowerCase()}`,
color: 'red',
});
}
};
const renderTrigger = () => {
if (customTrigger) {
return customTrigger;
}
if (trigger === 'button') {
return (
<Button
variant="light"
size="xs"
leftSection={<TriggerIcon size={16} />}
{...triggerProps}
>
{triggerLabel}
</Button>
);
}
// Default dots trigger
return (
<ActionIcon
variant="subtle"
color="gray"
size="sm"
aria-label={ariaLabel || `${triggerLabel} menu`}
{...triggerProps}
>
<TriggerIcon size={16} />
</ActionIcon>
);
};
const renderMenuItem = (action: ActionMenuItem) => {
const IconComponent = action.icon;
return (
<Menu.Item
key={action.key}
leftSection={IconComponent && <IconComponent size={14} />}
color={action.color}
disabled={action.disabled}
onClick={() => handleActionClick(action)}
>
{action.label}
</Menu.Item>
);
};
return (
<Menu
position={position}
withArrow={withArrow}
withinPortal={withinPortal}
>
<Menu.Target>
{renderTrigger()}
</Menu.Target>
<Menu.Dropdown>
{groupedActions.map((group, groupIndex) => (
<React.Fragment key={groupIndex}>
{group.map(renderMenuItem)}
{groupIndex < groupedActions.length - 1 && <Menu.Divider />}
</React.Fragment>
))}
</Menu.Dropdown>
</Menu>
);
};
// Helper function to group actions by type for better UX
const groupActionsByType = (actions: ActionMenuItem[]): ActionMenuItem[][] => {
const primaryActions: ActionMenuItem[] = [];
const secondaryActions: ActionMenuItem[] = [];
const destructiveActions: ActionMenuItem[] = [];
actions.forEach(action => {
if (action.color === 'red' || action.key.includes('delete') || action.key.includes('remove')) {
destructiveActions.push(action);
} else if (action.key.includes('edit') || action.key.includes('view') || action.key.includes('copy')) {
primaryActions.push(action);
} else {
secondaryActions.push(action);
}
});
const groups: ActionMenuItem[][] = [];
if (primaryActions.length > 0) groups.push(primaryActions);
if (secondaryActions.length > 0) groups.push(secondaryActions);
if (destructiveActions.length > 0) groups.push(destructiveActions);
return groups;
};
export default ActionMenu;
// Pre-built action creators for common operations
export const createViewAction = (onView: (item: any) => void): ActionMenuItem => ({
key: 'view',
label: 'View Details',
icon: IconEye,
onClick: onView,
});
export const createEditAction = (onEdit: (item: any) => void): ActionMenuItem => ({
key: 'edit',
label: 'Edit',
icon: IconEdit,
color: 'blue',
onClick: onEdit,
});
export const createCopyAction = (onCopy: (item: any) => void): ActionMenuItem => ({
key: 'copy',
label: 'Duplicate',
icon: IconCopy,
onClick: onCopy,
});
export const createDeleteAction = (
onDelete: (item: any) => void | Promise<void>,
itemName = 'item'
): ActionMenuItem => ({
key: 'delete',
label: 'Delete',
icon: IconTrash,
color: 'red',
onClick: onDelete,
confirm: {
title: 'Confirm Delete',
message: `Are you sure you want to delete this ${itemName}? This action cannot be undone.`,
confirmLabel: 'Delete',
cancelLabel: 'Cancel',
},
});
export const createArchiveAction = (onArchive: (item: any) => void): ActionMenuItem => ({
key: 'archive',
label: 'Archive',
icon: IconArchive,
color: 'orange',
onClick: onArchive,
confirm: {
title: 'Archive Item',
message: 'Are you sure you want to archive this item?',
},
});
export const createRestoreAction = (onRestore: (item: any) => void): ActionMenuItem => ({
key: 'restore',
label: 'Restore',
icon: IconRestore,
color: 'green',
onClick: onRestore,
});
// Context-specific action sets
export const getUserActions = (
onEdit: (item: any) => void,
onDelete: (item: any) => void,
onViewDetails?: (item: any) => void
): ActionMenuItem[] => [
...(onViewDetails ? [createViewAction(onViewDetails)] : []),
createEditAction(onEdit),
createDeleteAction(onDelete, 'user'),
];
export const getApplicationActions = (
onEdit: (item: any) => void,
onDelete: (item: any) => void,
onConfigure?: (item: any) => void
): ActionMenuItem[] => [
createEditAction(onEdit),
...(onConfigure ? [{
key: 'configure',
label: 'Configure',
icon: IconSettings,
onClick: onConfigure,
}] : []),
createDeleteAction(onDelete, 'application'),
];
export const getFunctionActions = (
onEdit: (item: any) => void,
onDelete: (item: any) => void,
onExecute?: (item: any) => void,
onViewLogs?: (item: any) => void
): ActionMenuItem[] => [
...(onExecute ? [{
key: 'execute',
label: 'Execute',
icon: IconPlayerPlay,
color: 'green',
onClick: onExecute,
}] : []),
...(onViewLogs ? [{
key: 'logs',
label: 'View Logs',
icon: IconEye,
onClick: onViewLogs,
}] : []),
createEditAction(onEdit),
createDeleteAction(onDelete, 'function'),
];
export const getTokenActions = (
onRevoke: (item: any) => void,
onCopy?: (item: any) => void,
onRefresh?: (item: any) => void
): ActionMenuItem[] => [
...(onCopy ? [createCopyAction(onCopy)] : []),
...(onRefresh ? [{
key: 'refresh',
label: 'Refresh',
icon: IconRefresh,
onClick: onRefresh,
}] : []),
{
key: 'revoke',
label: 'Revoke',
icon: IconPlayerStop,
color: 'red',
onClick: onRevoke,
confirm: {
title: 'Revoke Token',
message: 'Are you sure you want to revoke this token? This action cannot be undone and will immediately disable the token.',
confirmLabel: 'Revoke',
},
},
];

View File

@ -0,0 +1,346 @@
import React from 'react';
import { Stack, Text, Button, Center, Box } from '@mantine/core';
import {
IconDatabase,
IconUsers,
IconApps,
IconFunction,
IconKey,
IconSearch,
IconFilter,
IconPlus,
IconRefresh,
IconAlertCircle,
TablerIconsProps,
} from '@tabler/icons-react';
export type EmptyStateVariant =
| 'no-data'
| 'no-results'
| 'error'
| 'loading-failed'
| 'access-denied'
| 'coming-soon';
export type EmptyStateContext =
| 'users'
| 'applications'
| 'functions'
| 'tokens'
| 'executions'
| 'permissions'
| 'audit'
| 'generic';
export interface EmptyStateAction {
label: string;
onClick: () => void;
variant?: 'filled' | 'light' | 'outline';
color?: string;
leftSection?: React.ReactNode;
}
export interface EmptyStateProps {
variant?: EmptyStateVariant;
context?: EmptyStateContext;
title?: string;
message?: string;
icon?: React.ComponentType<TablerIconsProps>;
iconSize?: number;
iconColor?: string;
actions?: EmptyStateAction[];
height?: number | string;
}
// Default icons for each context
const CONTEXT_ICONS: Record<EmptyStateContext, React.ComponentType<TablerIconsProps>> = {
users: IconUsers,
applications: IconApps,
functions: IconFunction,
tokens: IconKey,
executions: IconFunction,
permissions: IconKey,
audit: IconDatabase,
generic: IconDatabase,
};
// Default messages based on variant and context
const getDefaultContent = (variant: EmptyStateVariant, context: EmptyStateContext) => {
const contextNames: Record<EmptyStateContext, string> = {
users: 'users',
applications: 'applications',
functions: 'functions',
tokens: 'tokens',
executions: 'executions',
permissions: 'permissions',
audit: 'audit events',
generic: 'items',
};
const contextName = contextNames[context];
const capitalizedContext = contextName.charAt(0).toUpperCase() + contextName.slice(1);
switch (variant) {
case 'no-data':
return {
title: `No ${contextName} found`,
message: `You haven't created any ${contextName} yet. Get started by adding your first ${contextName.slice(0, -1)}.`,
};
case 'no-results':
return {
title: 'No matching results',
message: `No ${contextName} match your current filters or search criteria. Try adjusting your search terms or clearing filters.`,
};
case 'error':
return {
title: 'Something went wrong',
message: `We couldn't load your ${contextName}. Please try again or contact support if the problem persists.`,
};
case 'loading-failed':
return {
title: 'Failed to load data',
message: `There was a problem loading ${contextName}. Check your connection and try again.`,
};
case 'access-denied':
return {
title: 'Access denied',
message: `You don't have permission to view ${contextName}. Contact your administrator if you need access.`,
};
case 'coming-soon':
return {
title: 'Coming soon',
message: `${capitalizedContext} functionality is being developed. Check back soon for updates.`,
};
default:
return {
title: `No ${contextName}`,
message: `There are no ${contextName} to display.`,
};
}
};
// Default actions based on variant and context
const getDefaultActions = (
variant: EmptyStateVariant,
context: EmptyStateContext,
onAdd?: () => void,
onRefresh?: () => void,
onClearFilters?: () => void
): EmptyStateAction[] => {
const actions: EmptyStateAction[] = [];
switch (variant) {
case 'no-data':
if (onAdd) {
const contextActions: Record<EmptyStateContext, EmptyStateAction> = {
users: {
label: 'Add User',
onClick: onAdd,
variant: 'filled',
leftSection: <IconPlus size={16} />,
},
applications: {
label: 'Create Application',
onClick: onAdd,
variant: 'filled',
leftSection: <IconPlus size={16} />,
},
functions: {
label: 'Create Function',
onClick: onAdd,
variant: 'filled',
leftSection: <IconPlus size={16} />,
},
tokens: {
label: 'Generate Token',
onClick: onAdd,
variant: 'filled',
leftSection: <IconPlus size={16} />,
},
executions: {
label: 'Run Function',
onClick: onAdd,
variant: 'filled',
leftSection: <IconFunction size={16} />,
},
permissions: {
label: 'Add Permission',
onClick: onAdd,
variant: 'filled',
leftSection: <IconPlus size={16} />,
},
audit: {
label: 'Refresh',
onClick: onAdd,
variant: 'light',
leftSection: <IconRefresh size={16} />,
},
generic: {
label: 'Add New',
onClick: onAdd,
variant: 'filled',
leftSection: <IconPlus size={16} />,
},
};
actions.push(contextActions[context]);
}
break;
case 'no-results':
if (onClearFilters) {
actions.push({
label: 'Clear Filters',
onClick: onClearFilters,
variant: 'light',
leftSection: <IconFilter size={16} />,
});
}
if (onRefresh) {
actions.push({
label: 'Refresh',
onClick: onRefresh,
variant: 'outline',
leftSection: <IconRefresh size={16} />,
});
}
break;
case 'error':
case 'loading-failed':
if (onRefresh) {
actions.push({
label: 'Try Again',
onClick: onRefresh,
variant: 'filled',
leftSection: <IconRefresh size={16} />,
});
}
break;
}
return actions;
};
// Default icon based on variant
const getVariantIcon = (variant: EmptyStateVariant): React.ComponentType<TablerIconsProps> => {
switch (variant) {
case 'no-results':
return IconSearch;
case 'error':
case 'loading-failed':
case 'access-denied':
return IconAlertCircle;
default:
return IconDatabase;
}
};
const EmptyState: React.FC<EmptyStateProps & {
onAdd?: () => void;
onRefresh?: () => void;
onClearFilters?: () => void;
}> = ({
variant = 'no-data',
context = 'generic',
title,
message,
icon,
iconSize = 48,
iconColor = 'dimmed',
actions,
height = 400,
onAdd,
onRefresh,
onClearFilters,
}) => {
const defaultContent = getDefaultContent(variant, context);
const finalTitle = title || defaultContent.title;
const finalMessage = message || defaultContent.message;
const IconComponent = icon || CONTEXT_ICONS[context] || getVariantIcon(variant);
const finalActions = actions || getDefaultActions(
variant,
context,
onAdd,
onRefresh,
onClearFilters
);
return (
<Center h={height}>
<Stack align="center" gap="lg" maw={400} ta="center">
<Box c={iconColor}>
<IconComponent size={iconSize} stroke={1.5} />
</Box>
<Stack align="center" gap="xs">
<Text size="lg" fw={600} c="dimmed">
{finalTitle}
</Text>
<Text size="sm" c="dimmed" lh={1.5}>
{finalMessage}
</Text>
</Stack>
{finalActions.length > 0 && (
<Stack align="center" gap="sm" w="100%">
{finalActions.map((action, index) => (
<Button
key={index}
onClick={action.onClick}
variant={action.variant || 'filled'}
color={action.color}
leftSection={action.leftSection}
size="sm"
>
{action.label}
</Button>
))}
</Stack>
)}
</Stack>
</Center>
);
};
export default EmptyState;
// Convenience components for common scenarios
export const NoUsersState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & { onAddUser?: () => void }> =
({ onAddUser, ...props }) => (
<EmptyState {...props} variant="no-data" context="users" onAdd={onAddUser} />
);
export const NoApplicationsState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & { onCreateApp?: () => void }> =
({ onCreateApp, ...props }) => (
<EmptyState {...props} variant="no-data" context="applications" onAdd={onCreateApp} />
);
export const NoFunctionsState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & { onCreateFunction?: () => void }> =
({ onCreateFunction, ...props }) => (
<EmptyState {...props} variant="no-data" context="functions" onAdd={onCreateFunction} />
);
export const NoTokensState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & { onGenerateToken?: () => void }> =
({ onGenerateToken, ...props }) => (
<EmptyState {...props} variant="no-data" context="tokens" onAdd={onGenerateToken} />
);
export const NoSearchResults: React.FC<Omit<EmptyStateProps, 'variant'> & {
onClearFilters?: () => void;
onRefresh?: () => void;
}> = ({ onClearFilters, onRefresh, ...props }) => (
<EmptyState {...props} variant="no-results" onClearFilters={onClearFilters} onRefresh={onRefresh} />
);
export const ErrorState: React.FC<Omit<EmptyStateProps, 'variant'> & { onRetry?: () => void }> =
({ onRetry, ...props }) => (
<EmptyState {...props} variant="error" onRefresh={onRetry} />
);

View File

@ -0,0 +1,378 @@
import React from 'react';
import {
Stack,
Text,
Loader,
Center,
Progress,
Box,
Group,
Skeleton,
Card,
SimpleGrid,
} from '@mantine/core';
export type LoadingVariant =
| 'spinner' // Simple spinner with text
| 'progress' // Progress bar with percentage
| 'skeleton-table' // Table skeleton
| 'skeleton-cards' // Card grid skeleton
| 'skeleton-form' // Form skeleton
| 'skeleton-text' // Text content skeleton
| 'dots' // Animated dots
| 'overlay'; // Full overlay spinner
export type LoadingSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export interface LoadingStateProps {
variant?: LoadingVariant;
size?: LoadingSize;
height?: number | string;
// Text content
message?: string;
submessage?: string;
// Progress specific
progress?: number; // 0-100
progressLabel?: string;
// Skeleton specific
rows?: number;
columns?: number;
// Styling
color?: string;
withContainer?: boolean;
// Animation
animate?: boolean;
}
const LoadingState: React.FC<LoadingStateProps> = ({
variant = 'spinner',
size = 'md',
height = 200,
message,
submessage,
progress,
progressLabel,
rows = 5,
columns = 3,
color = 'blue',
withContainer = true,
animate = true,
}) => {
const getLoaderSize = (): number => {
const sizeMap: Record<LoadingSize, number> = {
xs: 16,
sm: 24,
md: 32,
lg: 48,
xl: 64,
};
return sizeMap[size];
};
const getTextSize = (): string => {
const sizeMap: Record<LoadingSize, string> = {
xs: 'xs',
sm: 'sm',
md: 'md',
lg: 'lg',
xl: 'xl',
};
return sizeMap[size];
};
const renderSpinner = () => (
<Stack align="center" gap="md">
<Loader size={getLoaderSize()} color={color} />
{message && (
<Text size={getTextSize()} c="dimmed" ta="center">
{message}
</Text>
)}
{submessage && (
<Text size="xs" c="dimmed" ta="center">
{submessage}
</Text>
)}
</Stack>
);
const renderProgress = () => (
<Stack gap="md">
{(message || progressLabel) && (
<Group justify="space-between">
<Text size={getTextSize()}>
{message || 'Loading...'}
</Text>
{progressLabel && (
<Text size="sm" c="dimmed">
{progressLabel}
</Text>
)}
</Group>
)}
<Progress
value={progress || 0}
color={color}
size={size}
animated={animate}
/>
{submessage && (
<Text size="xs" c="dimmed" ta="center">
{submessage}
</Text>
)}
</Stack>
);
const renderSkeletonTable = () => (
<Stack gap="xs">
{/* Table header */}
<Group gap="md">
{Array.from({ length: columns }).map((_, i) => (
<Skeleton key={`header-${i}`} height={20} width={`${100 / columns}%`} />
))}
</Group>
{/* Table rows */}
{Array.from({ length: rows }).map((_, rowIndex) => (
<Group key={`row-${rowIndex}`} gap="md">
{Array.from({ length: columns }).map((_, colIndex) => (
<Skeleton
key={`cell-${rowIndex}-${colIndex}`}
height={16}
width={`${100 / columns}%`}
/>
))}
</Group>
))}
</Stack>
);
const renderSkeletonCards = () => (
<SimpleGrid cols={{ base: 1, sm: 2, lg: columns }}>
{Array.from({ length: rows * columns }).map((_, i) => (
<Card key={`card-${i}`} padding="md" withBorder>
<Stack gap="xs">
<Skeleton height={20} width="70%" />
<Skeleton height={14} />
<Skeleton height={14} width="90%" />
<Group justify="apart" mt="md">
<Skeleton height={12} width="40%" />
<Skeleton height={12} width="30%" />
</Group>
</Stack>
</Card>
))}
</SimpleGrid>
);
const renderSkeletonForm = () => (
<Stack gap="md">
{Array.from({ length: rows }).map((_, i) => (
<Box key={`form-field-${i}`}>
<Skeleton height={12} width="30%" mb="xs" />
<Skeleton height={36} />
</Box>
))}
<Group justify="flex-end" mt="xl">
<Skeleton height={36} width={80} />
<Skeleton height={36} width={100} />
</Group>
</Stack>
);
const renderSkeletonText = () => (
<Stack gap="xs">
{Array.from({ length: rows }).map((_, i) => {
// Vary line widths for more realistic skeleton
const widths = ['100%', '95%', '85%', '90%', '75%'];
return (
<Skeleton
key={`text-${i}`}
height={16}
width={widths[i % widths.length]}
/>
);
})}
</Stack>
);
const renderDots = () => {
const dots = Array.from({ length: 3 }).map((_, i) => (
<Box
key={i}
w={8}
h={8}
bg={color}
style={{
borderRadius: '50%',
animation: animate ? `loading-dots 1.4s infinite ease-in-out ${i * 0.16}s` : undefined,
}}
/>
));
return (
<Stack align="center" gap="md">
<Group gap="xs">
{dots}
</Group>
{message && (
<Text size={getTextSize()} c="dimmed" ta="center">
{message}
</Text>
)}
</Stack>
);
};
const renderOverlay = () => (
<Box
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundColor: 'rgba(255, 255, 255, 0.8)',
backdropFilter: 'blur(2px)',
zIndex: 1000,
}}
>
<Center h="100%">
{renderSpinner()}
</Center>
</Box>
);
const getContent = () => {
switch (variant) {
case 'progress':
return renderProgress();
case 'skeleton-table':
return renderSkeletonTable();
case 'skeleton-cards':
return renderSkeletonCards();
case 'skeleton-form':
return renderSkeletonForm();
case 'skeleton-text':
return renderSkeletonText();
case 'dots':
return renderDots();
case 'overlay':
return renderOverlay();
default:
return renderSpinner();
}
};
// For overlay variant, don't wrap in container
if (variant === 'overlay') {
return (
<>
{getContent()}
<style>{loadingDotsKeyframes}</style>
</>
);
}
const content = (
<>
{getContent()}
{animate && <style>{loadingDotsKeyframes}</style>}
</>
);
if (!withContainer) {
return content;
}
return (
<Center h={height}>
{content}
</Center>
);
};
// CSS keyframes for dot animation
const loadingDotsKeyframes = `
@keyframes loading-dots {
0%, 80%, 100% {
transform: scale(0);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}
`;
export default LoadingState;
// Convenience components for common loading scenarios
export const TableLoadingState: React.FC<{ rows?: number; columns?: number }> =
({ rows = 5, columns = 4 }) => (
<LoadingState variant="skeleton-table" rows={rows} columns={columns} withContainer={false} />
);
export const CardsLoadingState: React.FC<{ count?: number; columns?: number }> =
({ count = 6, columns = 3 }) => (
<LoadingState
variant="skeleton-cards"
rows={Math.ceil(count / columns)}
columns={columns}
withContainer={false}
/>
);
export const FormLoadingState: React.FC<{ fields?: number }> =
({ fields = 4 }) => (
<LoadingState variant="skeleton-form" rows={fields} withContainer={false} />
);
export const PageLoadingState: React.FC<{ message?: string }> =
({ message = 'Loading page...' }) => (
<LoadingState variant="spinner" message={message} height="60vh" size="lg" />
);
export const InlineLoadingState: React.FC<{ message?: string; size?: LoadingSize }> =
({ message = 'Loading...', size = 'sm' }) => (
<Group gap="xs">
<Loader size={size === 'xs' ? 12 : size === 'sm' ? 16 : 20} />
<Text size={size} c="dimmed">{message}</Text>
</Group>
);
// Hook for loading state management
export const useLoadingState = (initialLoading = false) => {
const [loading, setLoading] = React.useState(initialLoading);
const [progress, setProgress] = React.useState(0);
const startLoading = React.useCallback(() => setLoading(true), []);
const stopLoading = React.useCallback(() => {
setLoading(false);
setProgress(0);
}, []);
const updateProgress = React.useCallback((value: number) => {
setProgress(Math.max(0, Math.min(100, value)));
}, []);
return {
loading,
progress,
startLoading,
stopLoading,
updateProgress,
setLoading,
setProgress,
};
};

View File

@ -0,0 +1,266 @@
import React from 'react';
import {
Paper,
Group,
Title,
ActionIcon,
ScrollArea,
Box,
Divider,
} from '@mantine/core';
import { IconX } from '@tabler/icons-react';
export interface SidebarProps {
opened: boolean;
onClose: () => void;
title: string;
width?: number;
position?: 'left' | 'right';
headerActions?: React.ReactNode;
footer?: React.ReactNode;
children: React.ReactNode;
// Styling customization
zIndex?: number;
offsetTop?: number;
backgroundColor?: string;
borderColor?: string;
// Animation
animationDuration?: string;
// Accessibility
'aria-label'?: string;
}
const Sidebar: React.FC<SidebarProps> = ({
opened,
onClose,
title,
width = 450,
position = 'right',
headerActions,
footer,
children,
zIndex = 1000,
offsetTop = 60,
backgroundColor = 'var(--mantine-color-body)',
borderColor = 'var(--mantine-color-gray-3)',
animationDuration = '0.3s',
'aria-label': ariaLabel,
}) => {
// Calculate position styles based on position prop
const getPositionStyles = () => {
const baseStyles = {
position: 'fixed' as const,
top: offsetTop,
bottom: 0,
width: `${width}px`,
zIndex,
borderRadius: 0,
display: 'flex',
flexDirection: 'column' as const,
backgroundColor,
transition: `${position} ${animationDuration} ease`,
};
if (position === 'right') {
return {
...baseStyles,
right: opened ? 0 : `-${width}px`,
borderLeft: `1px solid ${borderColor}`,
};
} else {
return {
...baseStyles,
left: opened ? 0 : `-${width}px`,
borderRight: `1px solid ${borderColor}`,
};
}
};
return (
<Paper
style={getPositionStyles()}
role="dialog"
aria-modal="true"
aria-label={ariaLabel || title}
aria-hidden={!opened}
>
{/* Header */}
<Group
justify="space-between"
p="md"
style={{ borderBottom: `1px solid ${borderColor}` }}
>
<Title order={4} style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</Title>
{/* Header actions (optional) */}
{headerActions && (
<Group gap="xs">
{headerActions}
</Group>
)}
{/* Close button */}
<ActionIcon
variant="subtle"
color="gray"
onClick={onClose}
aria-label="Close sidebar"
ml="xs"
>
<IconX size={18} />
</ActionIcon>
</Group>
{/* Content */}
<ScrollArea style={{ flex: 1 }} scrollbarSize={6}>
<Box p="md">
{children}
</Box>
</ScrollArea>
{/* Footer (optional) */}
{footer && (
<>
<Divider />
<Box p="md" style={{ borderTop: `1px solid ${borderColor}` }}>
{footer}
</Box>
</>
)}
</Paper>
);
};
export default Sidebar;
// Higher-level convenience component that combines Sidebar with common form patterns
export interface FormSidebarWrapperProps extends Omit<SidebarProps, 'children'> {
children: React.ReactNode;
cancelLabel?: string;
submitLabel?: string;
onCancel?: () => void;
onSubmit?: () => void;
submitDisabled?: boolean;
showFooterActions?: boolean;
}
export const FormSidebarWrapper: React.FC<FormSidebarWrapperProps> = ({
children,
cancelLabel = 'Cancel',
submitLabel = 'Save',
onCancel,
onSubmit,
submitDisabled = false,
showFooterActions = true,
onClose,
...sidebarProps
}) => {
const handleCancel = () => {
onCancel?.();
onClose();
};
const footer = showFooterActions ? (
<Group justify="flex-end" gap="sm">
<ActionIcon
variant="subtle"
onClick={handleCancel}
size="sm"
>
{cancelLabel}
</ActionIcon>
<ActionIcon
onClick={onSubmit}
disabled={submitDisabled}
size="sm"
>
{submitLabel}
</ActionIcon>
</Group>
) : undefined;
return (
<Sidebar
{...sidebarProps}
onClose={onClose}
footer={footer}
>
{children}
</Sidebar>
);
};
// Specialized sidebar variants for common use cases
export interface DetailsSidebarProps extends Omit<SidebarProps, 'title'> {
itemName: string;
itemType?: string;
editButton?: React.ReactNode;
deleteButton?: React.ReactNode;
status?: React.ReactNode;
}
export const DetailsSidebar: React.FC<DetailsSidebarProps> = ({
itemName,
itemType = 'Item',
editButton,
deleteButton,
status,
children,
...sidebarProps
}) => {
const headerActions = (
<Group gap="xs">
{status}
{editButton}
{deleteButton}
</Group>
);
return (
<Sidebar
{...sidebarProps}
title={`${itemType}: ${itemName}`}
headerActions={headerActions}
>
{children}
</Sidebar>
);
};
// Quick sidebar for simple content display
export interface QuickSidebarProps extends Omit<SidebarProps, 'children'> {
content: React.ReactNode;
actions?: React.ReactNode;
}
export const QuickSidebar: React.FC<QuickSidebarProps> = ({
content,
actions,
...sidebarProps
}) => (
<Sidebar {...sidebarProps} footer={actions}>
{content}
</Sidebar>
);
// Hooks for sidebar state management
export const useSidebar = (initialOpened = false) => {
const [opened, setOpened] = React.useState(initialOpened);
const open = React.useCallback(() => setOpened(true), []);
const close = React.useCallback(() => setOpened(false), []);
const toggle = React.useCallback(() => setOpened(prev => !prev), []);
return {
opened,
open,
close,
toggle,
setOpened,
};
};

View File

@ -0,0 +1,186 @@
import React from 'react';
import { Badge, BadgeProps } from '@mantine/core';
export type StatusVariant = 'status' | 'role' | 'runtime' | 'type' | 'severity' | 'execution';
export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'children'> {
value: string;
variant?: StatusVariant;
customColorMap?: Record<string, string>;
}
// Standardized color mappings across all Skybridge apps
const COLOR_MAPS: Record<StatusVariant, Record<string, string>> = {
// Common status colors (used in User, KMS, etc.)
status: {
active: 'green',
inactive: 'gray',
pending: 'yellow',
suspended: 'red',
enabled: 'green',
disabled: 'gray',
online: 'green',
offline: 'gray',
running: 'green',
stopped: 'gray',
paused: 'yellow',
failed: 'red',
success: 'green',
completed: 'green',
error: 'red',
warning: 'yellow',
info: 'blue',
},
// User roles (User Management)
role: {
admin: 'red',
moderator: 'orange',
user: 'blue',
viewer: 'gray',
owner: 'purple',
editor: 'cyan',
contributor: 'teal',
guest: 'gray',
},
// Application types (KMS)
type: {
static: 'blue',
user: 'cyan',
service: 'green',
application: 'purple',
api: 'orange',
web: 'teal',
mobile: 'pink',
desktop: 'indigo',
},
// Runtime environments (FaaS)
runtime: {
'nodejs18': 'green',
'nodejs20': 'lime',
'python3.9': 'blue',
'python3.11': 'indigo',
'go1.20': 'cyan',
'go1.21': 'teal',
'java11': 'orange',
'java17': 'red',
'dotnet6': 'purple',
'dotnet7': 'violet',
'rust': 'dark',
'php8': 'grape',
},
// Audit severity levels (KMS Audit)
severity: {
critical: 'red',
high: 'orange',
medium: 'yellow',
low: 'blue',
info: 'gray',
debug: 'dark',
},
// Function execution states (FaaS)
execution: {
queued: 'gray',
running: 'blue',
succeeded: 'green',
failed: 'red',
timeout: 'orange',
cancelled: 'yellow',
retrying: 'cyan',
},
};
// Default fallback colors for unknown values
const DEFAULT_COLORS: Record<StatusVariant, string> = {
status: 'gray',
role: 'blue',
runtime: 'blue',
type: 'blue',
severity: 'gray',
execution: 'gray',
};
const StatusBadge: React.FC<StatusBadgeProps> = ({
value,
variant = 'status',
customColorMap,
size = 'sm',
...badgeProps
}) => {
if (!value) {
return <Badge color="gray" size={size} {...badgeProps}>-</Badge>;
}
// Determine color from appropriate mapping
const colorMap = customColorMap || COLOR_MAPS[variant] || COLOR_MAPS.status;
const color = colorMap[value.toLowerCase()] || DEFAULT_COLORS[variant];
// Format display value (capitalize first letter, handle special cases)
const displayValue = formatDisplayValue(value, variant);
return (
<Badge
color={color}
size={size}
variant="filled"
{...badgeProps}
>
{displayValue}
</Badge>
);
};
// Helper function to format display values consistently
const formatDisplayValue = (value: string, variant: StatusVariant): string => {
// Special formatting for specific variants
switch (variant) {
case 'runtime':
// Format runtime names nicely (e.g., "nodejs18" -> "Node.js 18")
if (value.startsWith('nodejs')) return `Node.js ${value.replace('nodejs', '')}`;
if (value.startsWith('python')) return `Python ${value.replace('python', '')}`;
if (value.startsWith('go')) return `Go ${value.replace('go', '')}`;
if (value.startsWith('java')) return `Java ${value.replace('java', '')}`;
if (value.startsWith('dotnet')) return `.NET ${value.replace('dotnet', '')}`;
break;
case 'type':
// Capitalize application types
if (value === 'api') return 'API';
if (value === 'web') return 'Web App';
if (value === 'mobile') return 'Mobile App';
break;
}
// Default: capitalize first letter
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
};
export default StatusBadge;
// Export additional utilities for advanced use cases
export { COLOR_MAPS, DEFAULT_COLORS };
// Convenience components for common use cases
export const UserRoleBadge: React.FC<Omit<StatusBadgeProps, 'variant'>> = (props) => (
<StatusBadge {...props} variant="role" />
);
export const ApplicationTypeBadge: React.FC<Omit<StatusBadgeProps, 'variant'>> = (props) => (
<StatusBadge {...props} variant="type" />
);
export const RuntimeBadge: React.FC<Omit<StatusBadgeProps, 'variant'>> = (props) => (
<StatusBadge {...props} variant="runtime" />
);
export const ExecutionStatusBadge: React.FC<Omit<StatusBadgeProps, 'variant'>> = (props) => (
<StatusBadge {...props} variant="execution" />
);
export const SeverityBadge: React.FC<Omit<StatusBadgeProps, 'variant'>> = (props) => (
<StatusBadge {...props} variant="severity" />
);

View File

@ -1,6 +1,11 @@
// Components
export { default as FormSidebar } from './components/FormSidebar/FormSidebar';
export { default as DataTable } from './components/DataTable/DataTable';
export { default as StatusBadge, UserRoleBadge, ApplicationTypeBadge, RuntimeBadge, ExecutionStatusBadge, SeverityBadge } from './components/StatusBadge/StatusBadge';
export { default as EmptyState, NoUsersState, NoApplicationsState, NoFunctionsState, NoTokensState, NoSearchResults, ErrorState } from './components/EmptyState/EmptyState';
export { default as Sidebar, FormSidebarWrapper, DetailsSidebar, QuickSidebar, useSidebar } from './components/Sidebar/Sidebar';
export { default as ActionMenu, createViewAction, createEditAction, createCopyAction, createDeleteAction, createArchiveAction, createRestoreAction, getUserActions, getApplicationActions, getFunctionActions, getTokenActions } from './components/ActionMenu/ActionMenu';
export { default as LoadingState, TableLoadingState, CardsLoadingState, FormLoadingState, PageLoadingState, InlineLoadingState, useLoadingState } from './components/LoadingState/LoadingState';
// Types
export * from './types';

View File

@ -84,4 +84,11 @@ export interface FormField {
options?: Array<{ value: string; label: string }>;
validation?: ValidationRule;
defaultValue?: any;
}
}
// Component-specific types
export type { StatusVariant, StatusBadgeProps } from '../components/StatusBadge/StatusBadge';
export type { EmptyStateVariant, EmptyStateContext, EmptyStateProps, EmptyStateAction } from '../components/EmptyState/EmptyState';
export type { SidebarProps, FormSidebarWrapperProps, DetailsSidebarProps, QuickSidebarProps } from '../components/Sidebar/Sidebar';
export type { ActionMenuItem, ActionMenuProps } from '../components/ActionMenu/ActionMenu';
export type { LoadingVariant, LoadingSize, LoadingStateProps } from '../components/LoadingState/LoadingState';