-
This commit is contained in:
@ -11,7 +11,8 @@
|
|||||||
"@mantine/form": "^7.0.0",
|
"@mantine/form": "^7.0.0",
|
||||||
"@mantine/modals": "^7.0.0",
|
"@mantine/modals": "^7.0.0",
|
||||||
"@tabler/icons-react": "^2.40.0",
|
"@tabler/icons-react": "^2.40.0",
|
||||||
"axios": "^1.6.0"
|
"axios": "^1.6.0",
|
||||||
|
"@skybridge/web-components": "workspace:*"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.22.0",
|
"@babel/core": "^7.22.0",
|
||||||
|
|||||||
@ -12,6 +12,132 @@ npm install @skybridge/web-components
|
|||||||
|
|
||||||
## Components
|
## Components
|
||||||
|
|
||||||
|
### StatusBadge
|
||||||
|
|
||||||
|
A standardized badge component with consistent color schemes across all microfrontends.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { StatusBadge, UserRoleBadge, RuntimeBadge } from '@skybridge/web-components';
|
||||||
|
|
||||||
|
// Generic usage
|
||||||
|
<StatusBadge value="active" variant="status" />
|
||||||
|
|
||||||
|
// Context-specific usage
|
||||||
|
<UserRoleBadge value="admin" />
|
||||||
|
<RuntimeBadge value="nodejs18" />
|
||||||
|
|
||||||
|
// Variants: 'status', 'role', 'runtime', 'type', 'severity', 'execution'
|
||||||
|
```
|
||||||
|
|
||||||
|
### EmptyState
|
||||||
|
|
||||||
|
A comprehensive empty state component for consistent empty data handling.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { EmptyState, NoUsersState, NoSearchResults } from '@skybridge/web-components';
|
||||||
|
|
||||||
|
// Generic empty state
|
||||||
|
<EmptyState
|
||||||
|
variant="no-data"
|
||||||
|
context="users"
|
||||||
|
onAdd={() => handleAddUser()}
|
||||||
|
onRefresh={() => handleRefresh()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Convenience components
|
||||||
|
<NoUsersState onAddUser={handleAddUser} />
|
||||||
|
<NoSearchResults onClearFilters={handleClear} onRefresh={handleRefresh} />
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sidebar
|
||||||
|
|
||||||
|
A flexible base sidebar component for consistent slide-out panels.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Sidebar, DetailsSidebar, useSidebar } from '@skybridge/web-components';
|
||||||
|
|
||||||
|
const MySidebar = () => {
|
||||||
|
const { opened, open, close } = useSidebar();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Sidebar
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
title="My Panel"
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<p>Content goes here</p>
|
||||||
|
</Sidebar>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// For item details
|
||||||
|
<DetailsSidebar
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
itemName="John Doe"
|
||||||
|
itemType="User"
|
||||||
|
status={<StatusBadge value="active" />}
|
||||||
|
editButton={<Button onClick={handleEdit}>Edit</Button>}
|
||||||
|
>
|
||||||
|
<UserDetails user={selectedUser} />
|
||||||
|
</DetailsSidebar>
|
||||||
|
```
|
||||||
|
|
||||||
|
### ActionMenu
|
||||||
|
|
||||||
|
A standardized action menu component for table rows and items.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { ActionMenu, getUserActions, createEditAction } from '@skybridge/web-components';
|
||||||
|
|
||||||
|
// Using pre-built action sets
|
||||||
|
<ActionMenu
|
||||||
|
item={user}
|
||||||
|
actions={getUserActions(handleEdit, handleDelete, handleViewDetails)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
// Custom actions
|
||||||
|
<ActionMenu
|
||||||
|
item={item}
|
||||||
|
actions={[
|
||||||
|
createEditAction(handleEdit),
|
||||||
|
createDeleteAction(handleDelete, 'user'),
|
||||||
|
{
|
||||||
|
key: 'custom',
|
||||||
|
label: 'Custom Action',
|
||||||
|
icon: IconSettings,
|
||||||
|
onClick: handleCustomAction,
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### LoadingState
|
||||||
|
|
||||||
|
Comprehensive loading states for different scenarios.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
LoadingState,
|
||||||
|
TableLoadingState,
|
||||||
|
PageLoadingState,
|
||||||
|
useLoadingState
|
||||||
|
} from '@skybridge/web-components';
|
||||||
|
|
||||||
|
// Different loading variants
|
||||||
|
<LoadingState variant="spinner" message="Loading data..." />
|
||||||
|
<LoadingState variant="progress" progress={75} progressLabel="3/4 complete" />
|
||||||
|
<LoadingState variant="skeleton-table" rows={5} columns={3} />
|
||||||
|
|
||||||
|
// Convenience components
|
||||||
|
<TableLoadingState rows={10} />
|
||||||
|
<PageLoadingState message="Loading application..." />
|
||||||
|
|
||||||
|
// Hook for loading state management
|
||||||
|
const { loading, startLoading, stopLoading, updateProgress } = useLoadingState();
|
||||||
|
```
|
||||||
|
|
||||||
### FormSidebar
|
### FormSidebar
|
||||||
|
|
||||||
A reusable sidebar form component that handles create/edit operations with validation.
|
A reusable sidebar form component that handles create/edit operations with validation.
|
||||||
@ -288,14 +414,18 @@ The component library is designed to:
|
|||||||
|
|
||||||
## Common Patterns Extracted
|
## Common Patterns Extracted
|
||||||
|
|
||||||
Based on analysis of the existing microfrontends, this library extracts these common patterns:
|
Based on deep analysis of the existing microfrontends, this library extracts these common patterns:
|
||||||
|
|
||||||
1. **Sidebar Forms**: All microfrontends use similar slide-out forms
|
1. **Status Display**: Standardized color schemes and formatting for status badges across all apps
|
||||||
2. **Data Tables**: Consistent table layouts with actions and filtering
|
2. **Empty States**: Consistent empty data handling with contextual actions and messaging
|
||||||
3. **API Integration**: Standard CRUD operations with error handling
|
3. **Sidebar Components**: All microfrontends use similar slide-out forms and detail panels
|
||||||
4. **Validation**: Common validation rules and patterns
|
4. **Action Menus**: Standardized table actions with confirmation dialogs and consistent UX
|
||||||
5. **Notifications**: Standardized success/error messaging
|
5. **Loading States**: Multiple loading variants (spinners, progress, skeletons) for different scenarios
|
||||||
6. **Filtering**: Client-side search and filter functionality
|
6. **Form Handling**: Reusable form sidebars with validation and error handling
|
||||||
|
7. **Data Tables**: Consistent table layouts with actions, filtering, and pagination
|
||||||
|
8. **API Integration**: Standard CRUD operations with error handling and state management
|
||||||
|
9. **Validation**: Common validation rules and patterns with consistent error messages
|
||||||
|
10. **Notifications**: Standardized success/error messaging and CRUD operation feedback
|
||||||
|
|
||||||
## Compatibility
|
## Compatibility
|
||||||
|
|
||||||
|
|||||||
43
web-components/dist/components/ActionMenu/ActionMenu.d.ts
vendored
Normal file
43
web-components/dist/components/ActionMenu/ActionMenu.d.ts
vendored
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { TablerIconsProps } from '@tabler/icons-react';
|
||||||
|
export interface ActionMenuItem {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: React.ComponentType<TablerIconsProps>;
|
||||||
|
color?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
hidden?: boolean;
|
||||||
|
onClick: (item?: any) => void | Promise<void>;
|
||||||
|
confirm?: {
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
confirmLabel?: string;
|
||||||
|
cancelLabel?: string;
|
||||||
|
};
|
||||||
|
show?: (item: any) => boolean;
|
||||||
|
}
|
||||||
|
export interface ActionMenuProps {
|
||||||
|
item?: any;
|
||||||
|
actions: ActionMenuItem[];
|
||||||
|
trigger?: 'dots' | 'button' | 'custom';
|
||||||
|
triggerLabel?: string;
|
||||||
|
triggerIcon?: React.ComponentType<TablerIconsProps>;
|
||||||
|
triggerProps?: any;
|
||||||
|
customTrigger?: React.ReactNode;
|
||||||
|
position?: 'bottom-end' | 'bottom-start' | 'top-end' | 'top-start';
|
||||||
|
withArrow?: boolean;
|
||||||
|
withinPortal?: boolean;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
declare const ActionMenu: React.FC<ActionMenuProps>;
|
||||||
|
export default ActionMenu;
|
||||||
|
export declare const createViewAction: (onView: (item: any) => void) => ActionMenuItem;
|
||||||
|
export declare const createEditAction: (onEdit: (item: any) => void) => ActionMenuItem;
|
||||||
|
export declare const createCopyAction: (onCopy: (item: any) => void) => ActionMenuItem;
|
||||||
|
export declare const createDeleteAction: (onDelete: (item: any) => void | Promise<void>, itemName?: string) => ActionMenuItem;
|
||||||
|
export declare const createArchiveAction: (onArchive: (item: any) => void) => ActionMenuItem;
|
||||||
|
export declare const createRestoreAction: (onRestore: (item: any) => void) => ActionMenuItem;
|
||||||
|
export declare const getUserActions: (onEdit: (item: any) => void, onDelete: (item: any) => void, onViewDetails?: (item: any) => void) => ActionMenuItem[];
|
||||||
|
export declare const getApplicationActions: (onEdit: (item: any) => void, onDelete: (item: any) => void, onConfigure?: (item: any) => void) => ActionMenuItem[];
|
||||||
|
export declare const getFunctionActions: (onEdit: (item: any) => void, onDelete: (item: any) => void, onExecute?: (item: any) => void, onViewLogs?: (item: any) => void) => ActionMenuItem[];
|
||||||
|
export declare const getTokenActions: (onRevoke: (item: any) => void, onCopy?: (item: any) => void, onRefresh?: (item: any) => void) => ActionMenuItem[];
|
||||||
47
web-components/dist/components/EmptyState/EmptyState.d.ts
vendored
Normal file
47
web-components/dist/components/EmptyState/EmptyState.d.ts
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { 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;
|
||||||
|
}
|
||||||
|
declare const EmptyState: React.FC<EmptyStateProps & {
|
||||||
|
onAdd?: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
}>;
|
||||||
|
export default EmptyState;
|
||||||
|
export declare const NoUsersState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & {
|
||||||
|
onAddUser?: () => void;
|
||||||
|
}>;
|
||||||
|
export declare const NoApplicationsState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & {
|
||||||
|
onCreateApp?: () => void;
|
||||||
|
}>;
|
||||||
|
export declare const NoFunctionsState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & {
|
||||||
|
onCreateFunction?: () => void;
|
||||||
|
}>;
|
||||||
|
export declare const NoTokensState: React.FC<Omit<EmptyStateProps, 'context' | 'variant'> & {
|
||||||
|
onGenerateToken?: () => void;
|
||||||
|
}>;
|
||||||
|
export declare const NoSearchResults: React.FC<Omit<EmptyStateProps, 'variant'> & {
|
||||||
|
onClearFilters?: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}>;
|
||||||
|
export declare const ErrorState: React.FC<Omit<EmptyStateProps, 'variant'> & {
|
||||||
|
onRetry?: () => void;
|
||||||
|
}>;
|
||||||
46
web-components/dist/components/LoadingState/LoadingState.d.ts
vendored
Normal file
46
web-components/dist/components/LoadingState/LoadingState.d.ts
vendored
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export type LoadingVariant = 'spinner' | 'progress' | 'skeleton-table' | 'skeleton-cards' | 'skeleton-form' | 'skeleton-text' | 'dots' | 'overlay';
|
||||||
|
export type LoadingSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
|
||||||
|
export interface LoadingStateProps {
|
||||||
|
variant?: LoadingVariant;
|
||||||
|
size?: LoadingSize;
|
||||||
|
height?: number | string;
|
||||||
|
message?: string;
|
||||||
|
submessage?: string;
|
||||||
|
progress?: number;
|
||||||
|
progressLabel?: string;
|
||||||
|
rows?: number;
|
||||||
|
columns?: number;
|
||||||
|
color?: string;
|
||||||
|
withContainer?: boolean;
|
||||||
|
animate?: boolean;
|
||||||
|
}
|
||||||
|
declare const LoadingState: React.FC<LoadingStateProps>;
|
||||||
|
export default LoadingState;
|
||||||
|
export declare const TableLoadingState: React.FC<{
|
||||||
|
rows?: number;
|
||||||
|
columns?: number;
|
||||||
|
}>;
|
||||||
|
export declare const CardsLoadingState: React.FC<{
|
||||||
|
count?: number;
|
||||||
|
columns?: number;
|
||||||
|
}>;
|
||||||
|
export declare const FormLoadingState: React.FC<{
|
||||||
|
fields?: number;
|
||||||
|
}>;
|
||||||
|
export declare const PageLoadingState: React.FC<{
|
||||||
|
message?: string;
|
||||||
|
}>;
|
||||||
|
export declare const InlineLoadingState: React.FC<{
|
||||||
|
message?: string;
|
||||||
|
size?: LoadingSize;
|
||||||
|
}>;
|
||||||
|
export declare const useLoadingState: (initialLoading?: boolean) => {
|
||||||
|
loading: boolean;
|
||||||
|
progress: number;
|
||||||
|
startLoading: () => void;
|
||||||
|
stopLoading: () => void;
|
||||||
|
updateProgress: (value: number) => void;
|
||||||
|
setLoading: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
setProgress: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
};
|
||||||
49
web-components/dist/components/Sidebar/Sidebar.d.ts
vendored
Normal file
49
web-components/dist/components/Sidebar/Sidebar.d.ts
vendored
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import React from 'react';
|
||||||
|
export interface SidebarProps {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
width?: number;
|
||||||
|
position?: 'left' | 'right';
|
||||||
|
headerActions?: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
zIndex?: number;
|
||||||
|
offsetTop?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
borderColor?: string;
|
||||||
|
animationDuration?: string;
|
||||||
|
'aria-label'?: string;
|
||||||
|
}
|
||||||
|
declare const Sidebar: React.FC<SidebarProps>;
|
||||||
|
export default Sidebar;
|
||||||
|
export interface FormSidebarWrapperProps extends Omit<SidebarProps, 'children'> {
|
||||||
|
children: React.ReactNode;
|
||||||
|
cancelLabel?: string;
|
||||||
|
submitLabel?: string;
|
||||||
|
onCancel?: () => void;
|
||||||
|
onSubmit?: () => void;
|
||||||
|
submitDisabled?: boolean;
|
||||||
|
showFooterActions?: boolean;
|
||||||
|
}
|
||||||
|
export declare const FormSidebarWrapper: React.FC<FormSidebarWrapperProps>;
|
||||||
|
export interface DetailsSidebarProps extends Omit<SidebarProps, 'title'> {
|
||||||
|
itemName: string;
|
||||||
|
itemType?: string;
|
||||||
|
editButton?: React.ReactNode;
|
||||||
|
deleteButton?: React.ReactNode;
|
||||||
|
status?: React.ReactNode;
|
||||||
|
}
|
||||||
|
export declare const DetailsSidebar: React.FC<DetailsSidebarProps>;
|
||||||
|
export interface QuickSidebarProps extends Omit<SidebarProps, 'children'> {
|
||||||
|
content: React.ReactNode;
|
||||||
|
actions?: React.ReactNode;
|
||||||
|
}
|
||||||
|
export declare const QuickSidebar: React.FC<QuickSidebarProps>;
|
||||||
|
export declare const useSidebar: (initialOpened?: boolean) => {
|
||||||
|
opened: boolean;
|
||||||
|
open: () => void;
|
||||||
|
close: () => void;
|
||||||
|
toggle: () => void;
|
||||||
|
setOpened: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
};
|
||||||
18
web-components/dist/components/StatusBadge/StatusBadge.d.ts
vendored
Normal file
18
web-components/dist/components/StatusBadge/StatusBadge.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { 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>;
|
||||||
|
}
|
||||||
|
declare const COLOR_MAPS: Record<StatusVariant, Record<string, string>>;
|
||||||
|
declare const DEFAULT_COLORS: Record<StatusVariant, string>;
|
||||||
|
declare const StatusBadge: React.FC<StatusBadgeProps>;
|
||||||
|
export default StatusBadge;
|
||||||
|
export { COLOR_MAPS, DEFAULT_COLORS };
|
||||||
|
export declare const UserRoleBadge: React.FC<Omit<StatusBadgeProps, 'variant'>>;
|
||||||
|
export declare const ApplicationTypeBadge: React.FC<Omit<StatusBadgeProps, 'variant'>>;
|
||||||
|
export declare const RuntimeBadge: React.FC<Omit<StatusBadgeProps, 'variant'>>;
|
||||||
|
export declare const ExecutionStatusBadge: React.FC<Omit<StatusBadgeProps, 'variant'>>;
|
||||||
|
export declare const SeverityBadge: React.FC<Omit<StatusBadgeProps, 'variant'>>;
|
||||||
5
web-components/dist/index.d.ts
vendored
5
web-components/dist/index.d.ts
vendored
@ -1,5 +1,10 @@
|
|||||||
export { default as FormSidebar } from './components/FormSidebar/FormSidebar';
|
export { default as FormSidebar } from './components/FormSidebar/FormSidebar';
|
||||||
export { default as DataTable } from './components/DataTable/DataTable';
|
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';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export { useApiService } from './hooks/useApiService';
|
export { useApiService } from './hooks/useApiService';
|
||||||
export { useDataFilter } from './hooks/useDataFilter';
|
export { useDataFilter } from './hooks/useDataFilter';
|
||||||
|
|||||||
2
web-components/dist/index.esm.js
vendored
2
web-components/dist/index.esm.js
vendored
File diff suppressed because one or more lines are too long
2
web-components/dist/index.esm.js.map
vendored
2
web-components/dist/index.esm.js.map
vendored
File diff suppressed because one or more lines are too long
2
web-components/dist/index.js
vendored
2
web-components/dist/index.js
vendored
File diff suppressed because one or more lines are too long
2
web-components/dist/index.js.map
vendored
2
web-components/dist/index.js.map
vendored
File diff suppressed because one or more lines are too long
5
web-components/dist/types/index.d.ts
vendored
5
web-components/dist/types/index.d.ts
vendored
@ -77,3 +77,8 @@ export interface FormField {
|
|||||||
validation?: ValidationRule;
|
validation?: ValidationRule;
|
||||||
defaultValue?: any;
|
defaultValue?: any;
|
||||||
}
|
}
|
||||||
|
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';
|
||||||
|
|||||||
374
web-components/src/components/ActionMenu/ActionMenu.tsx
Normal file
374
web-components/src/components/ActionMenu/ActionMenu.tsx
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
346
web-components/src/components/EmptyState/EmptyState.tsx
Normal file
346
web-components/src/components/EmptyState/EmptyState.tsx
Normal 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} />
|
||||||
|
);
|
||||||
378
web-components/src/components/LoadingState/LoadingState.tsx
Normal file
378
web-components/src/components/LoadingState/LoadingState.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
266
web-components/src/components/Sidebar/Sidebar.tsx
Normal file
266
web-components/src/components/Sidebar/Sidebar.tsx
Normal 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,
|
||||||
|
};
|
||||||
|
};
|
||||||
186
web-components/src/components/StatusBadge/StatusBadge.tsx
Normal file
186
web-components/src/components/StatusBadge/StatusBadge.tsx
Normal 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" />
|
||||||
|
);
|
||||||
@ -1,6 +1,11 @@
|
|||||||
// Components
|
// Components
|
||||||
export { default as FormSidebar } from './components/FormSidebar/FormSidebar';
|
export { default as FormSidebar } from './components/FormSidebar/FormSidebar';
|
||||||
export { default as DataTable } from './components/DataTable/DataTable';
|
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
|
// Types
|
||||||
export * from './types';
|
export * from './types';
|
||||||
|
|||||||
@ -84,4 +84,11 @@ export interface FormField {
|
|||||||
options?: Array<{ value: string; label: string }>;
|
options?: Array<{ value: string; label: string }>;
|
||||||
validation?: ValidationRule;
|
validation?: ValidationRule;
|
||||||
defaultValue?: any;
|
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';
|
||||||
Reference in New Issue
Block a user