refactor: Update subprojects to use shared @metabuilder/components

**workflowui**:
- Add NotificationAdapter that bridges useUINotifications() to shared NotificationContainer
- Update RootLayoutClient to use NotificationAdapter
- Mark local NotificationContainer as deprecated

**pastebin**:
- Update BackendIndicator to use shared BackendStatus component
- Keep as thin wrapper that maps getStorageConfig() to status prop
- Update tests for new implementation

**codegen**:
- Update KeyboardShortcutsDialog to use shared KeyboardShortcutsContent
- Move hardcoded shortcuts to data array using ShortcutCategory type
- Use getPlatformModifier() for cross-platform modifier keys
- Keep local Dialog wrapper, use shared content component

**components**:
- Add tsup.config.ts for building the package
- Add package-lock.json

All subprojects now depend on @metabuilder/components (file:../components)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-02 09:49:02 +00:00
parent 3ad0ca1bc7
commit 6cbd19e7e6
12 changed files with 1792 additions and 207 deletions

View File

@@ -20,6 +20,7 @@
},
"dependencies": {
"@metabuilder/api-clients": "workspace:*",
"@metabuilder/components": "workspace:*",
"@metabuilder/core-hooks": "workspace:*",
"@metabuilder/fakemui": "workspace:*",
"@metabuilder/hooks-auth": "workspace:*",

View File

@@ -5,17 +5,63 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Separator } from '@/components/ui/separator'
import { Keyboard } from '@phosphor-icons/react'
import {
KeyboardShortcutsContent,
getPlatformModifier,
type ShortcutCategory,
} from '@metabuilder/components'
interface KeyboardShortcutsDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
}
/**
* Get codegen-specific keyboard shortcuts
* Shortcuts are defined as data for easy maintenance and localization
*/
function getCodegenShortcuts(): ShortcutCategory[] {
const ctrlKey = getPlatformModifier()
return [
{
category: 'Navigation',
items: [
{ keys: [ctrlKey, '1'], description: 'Go to Dashboard' },
{ keys: [ctrlKey, '2'], description: 'Go to Code Editor' },
{ keys: [ctrlKey, '3'], description: 'Go to Models' },
{ keys: [ctrlKey, '4'], description: 'Go to Components' },
{ keys: [ctrlKey, '5'], description: 'Go to Component Trees' },
{ keys: [ctrlKey, '6'], description: 'Go to Workflows' },
{ keys: [ctrlKey, '7'], description: 'Go to Lambdas' },
{ keys: [ctrlKey, '8'], description: 'Go to Styling' },
{ keys: [ctrlKey, '9'], description: 'Go to Favicon Designer' },
],
},
{
category: 'Actions',
items: [
{ keys: [ctrlKey, 'K'], description: 'Search Everything' },
{ keys: [ctrlKey, 'E'], description: 'Export Project' },
{ keys: [ctrlKey, 'Shift', 'G'], description: 'AI Generate' },
{ keys: [ctrlKey, '/'], description: 'Show Keyboard Shortcuts' },
],
},
{
category: 'Code Editor',
items: [
{ keys: [ctrlKey, 'F'], description: 'Find in file' },
{ keys: [ctrlKey, 'H'], description: 'Find and replace' },
{ keys: [ctrlKey, 'Z'], description: 'Undo' },
{ keys: [ctrlKey, 'Shift', 'Z'], description: 'Redo' },
],
},
]
}
export function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcutsDialogProps) {
const isMac = navigator.platform.includes('Mac')
const ctrlKey = isMac ? '⌘' : 'Ctrl'
const shortcuts = getCodegenShortcuts()
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -30,116 +76,12 @@ export function KeyboardShortcutsDialog({ open, onOpenChange }: KeyboardShortcut
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-3">Navigation</h3>
<div className="space-y-2">
<ShortcutRow
keys={[ctrlKey, '1']}
description="Go to Dashboard"
/>
<ShortcutRow
keys={[ctrlKey, '2']}
description="Go to Code Editor"
/>
<ShortcutRow
keys={[ctrlKey, '3']}
description="Go to Models"
/>
<ShortcutRow
keys={[ctrlKey, '4']}
description="Go to Components"
/>
<ShortcutRow
keys={[ctrlKey, '5']}
description="Go to Component Trees"
/>
<ShortcutRow
keys={[ctrlKey, '6']}
description="Go to Workflows"
/>
<ShortcutRow
keys={[ctrlKey, '7']}
description="Go to Lambdas"
/>
<ShortcutRow
keys={[ctrlKey, '8']}
description="Go to Styling"
/>
<ShortcutRow
keys={[ctrlKey, '9']}
description="Go to Favicon Designer"
/>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold mb-3">Actions</h3>
<div className="space-y-2">
<ShortcutRow
keys={[ctrlKey, 'K']}
description="Search Everything"
/>
<ShortcutRow
keys={[ctrlKey, 'E']}
description="Export Project"
/>
<ShortcutRow
keys={[ctrlKey, 'Shift', 'G']}
description="AI Generate"
/>
<ShortcutRow
keys={[ctrlKey, '/']}
description="Show Keyboard Shortcuts"
/>
</div>
</div>
<Separator />
<div>
<h3 className="font-semibold mb-3">Code Editor</h3>
<div className="space-y-2">
<ShortcutRow
keys={[ctrlKey, 'F']}
description="Find in file"
/>
<ShortcutRow
keys={[ctrlKey, 'H']}
description="Find and replace"
/>
<ShortcutRow
keys={[ctrlKey, 'Z']}
description="Undo"
/>
<ShortcutRow
keys={[ctrlKey, 'Shift', 'Z']}
description="Redo"
/>
</div>
</div>
</div>
<KeyboardShortcutsContent
shortcuts={shortcuts}
title=""
description=""
/>
</DialogContent>
</Dialog>
)
}
function ShortcutRow({ keys, description }: { keys: string[]; description: string }) {
return (
<div className="flex items-center justify-between py-2">
<span className="text-sm text-muted-foreground">{description}</span>
<div className="flex gap-1">
{keys.map((key, index) => (
<kbd
key={index}
className="px-2 py-1 text-xs font-semibold bg-muted border border-border rounded"
>
{key}
</kbd>
))}
</div>
</div>
)
}

View File

@@ -2,4 +2,9 @@
* KeyboardShortcutsDialogProps - JSON definition interface
* Dialog for displaying keyboard shortcuts
*/
export interface KeyboardShortcutsDialogProps {}
export interface KeyboardShortcutsDialogProps {
/** Whether the dialog is open */
open: boolean
/** Callback when open state changes */
onOpenChange: (open: boolean) => void
}

1544
components/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

22
components/tsup.config.ts Normal file
View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
index: 'index.tsx',
'vanilla/index': 'vanilla/index.ts',
'vanilla/loading/index': 'vanilla/loading/index.tsx',
'vanilla/error/index': 'vanilla/error/index.tsx',
'vanilla/empty-state/index': 'vanilla/empty-state/index.tsx',
'vanilla/skeleton/index': 'vanilla/skeleton/index.tsx',
'vanilla/access-denied/index': 'vanilla/access-denied/index.tsx',
'vanilla/notifications/index': 'vanilla/notifications/index.tsx',
'vanilla/status-indicators/index': 'vanilla/status-indicators/index.tsx',
'radix/dialogs/KeyboardShortcutsDialog': 'radix/dialogs/KeyboardShortcutsDialog.tsx',
},
format: ['cjs', 'esm'],
dts: true,
splitting: false,
sourcemap: true,
clean: true,
external: ['react', 'react-dom'],
})

View File

@@ -21,6 +21,7 @@
},
"dependencies": {
"@babel/standalone": "^7.24.0",
"@metabuilder/components": "file:../components",
"@monaco-editor/react": "^4.6.0",
"@phosphor-icons/react": "^2.1.7",
"@reduxjs/toolkit": "^2.5.0",

View File

@@ -4,6 +4,17 @@ import * as storageModule from '@/lib/storage';
jest.mock('@/lib/storage');
// Mock the styles injection to avoid DOM manipulation in tests
jest.mock('@metabuilder/components', () => ({
BackendStatus: ({ status, showDot }: { status: string; showDot?: boolean }) => (
<div data-testid="status-badge" data-status={status} role="status">
<span>{status === 'connected' ? 'Connected' : 'Local'}</span>
{showDot && <span data-testid="activity-dot" />}
</div>
),
statusIndicatorStyles: '',
}));
describe('BackendIndicator', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -22,37 +33,37 @@ describe('BackendIndicator', () => {
expect(screen.getByText('Local')).toBeInTheDocument();
});
test('has disconnected styling', () => {
test('has disconnected status', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('status-badge');
expect(indicator).toHaveAttribute('data-status', 'disconnected');
});
test('renders wrapper with test id', () => {
render(<BackendIndicator />);
const wrapper = screen.getByTestId('backend-indicator');
expect(wrapper).toBeInTheDocument();
});
test('renders status badge', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('status-badge');
expect(indicator).toBeInTheDocument();
});
test('has status role for accessibility', () => {
render(<BackendIndicator />);
const indicator = screen.getByRole('status');
expect(indicator).toBeInTheDocument();
});
test('renders with correct content', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toBeInTheDocument();
});
test('renders database icon', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toBeInTheDocument();
});
test('has styling applied', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toHaveClass('flex');
});
test('renders with background and border', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toBeInTheDocument();
});
test('renders with proper sizing', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toBeInTheDocument();
});
test('has tooltip on hover', () => {
test('has proper structure', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toBeInTheDocument();
@@ -71,27 +82,27 @@ describe('BackendIndicator', () => {
expect(screen.getByText('Connected')).toBeInTheDocument();
});
test('has connected styling', () => {
test('has connected status', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toBeInTheDocument();
const indicator = screen.getByTestId('status-badge');
expect(indicator).toHaveAttribute('data-status', 'connected');
});
test('displays connected state', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
const indicator = screen.getByTestId('status-badge');
expect(indicator).toBeInTheDocument();
});
test('renders with accent styling', () => {
test('renders with connected styling', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
const indicator = screen.getByTestId('status-badge');
expect(indicator).toBeInTheDocument();
});
test('renders cloud check icon', () => {
test('renders status component', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
const indicator = screen.getByTestId('status-badge');
expect(indicator).toBeInTheDocument();
});
});
@@ -105,8 +116,8 @@ describe('BackendIndicator', () => {
test('shows dot indicator when auto-configured', () => {
(process.env as any).NEXT_PUBLIC_FLASK_BACKEND_URL = 'http://localhost:5000';
const { container } = render(<BackendIndicator />);
const dot = container.querySelector('[class*="rounded-full"]');
render(<BackendIndicator />);
const dot = screen.getByTestId('activity-dot');
expect(dot).toBeInTheDocument();
});
@@ -117,20 +128,20 @@ describe('BackendIndicator', () => {
});
render(<BackendIndicator />);
expect(screen.getByText('Connected')).toBeInTheDocument();
expect(screen.queryByTestId('activity-dot')).not.toBeInTheDocument();
});
});
describe('Tooltip functionality', () => {
test('renders tooltip for local storage', () => {
test('renders status for local storage', () => {
(storageModule.getStorageConfig as jest.Mock).mockReturnValue({
backend: 'indexeddb',
});
render(<BackendIndicator />);
// Tooltip content is rendered in TooltipProvider
expect(screen.getByTestId('backend-indicator')).toBeInTheDocument();
});
test('renders tooltip for Flask backend', () => {
test('renders status for Flask backend', () => {
(storageModule.getStorageConfig as jest.Mock).mockReturnValue({
backend: 'flask',
});
@@ -152,16 +163,16 @@ describe('BackendIndicator', () => {
expect(indicator).toHaveTextContent('Local');
});
test('renders with proper font weight', () => {
test('renders status badge component', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
const indicator = screen.getByTestId('status-badge');
expect(indicator).toBeInTheDocument();
});
test('renders flex container for alignment', () => {
test('renders wrapper element', () => {
render(<BackendIndicator />);
const indicator = screen.getByTestId('backend-indicator');
expect(indicator).toHaveClass('flex');
expect(indicator).toBeInTheDocument();
});
});

View File

@@ -1,56 +1,44 @@
import { Database, CloudCheck } from '@phosphor-icons/react'
import { getStorageConfig } from '@/lib/storage'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
'use client'
import { BackendStatus, statusIndicatorStyles } from '@metabuilder/components'
import { getStorageConfig } from '@/lib/storage'
import { useEffect } from 'react'
// Inject status indicator styles (only once)
if (typeof document !== 'undefined') {
const styleId = 'backend-status-styles'
if (!document.getElementById(styleId)) {
const styleTag = document.createElement('style')
styleTag.id = styleId
styleTag.textContent = statusIndicatorStyles
document.head.appendChild(styleTag)
}
}
/**
* Backend connection indicator component.
* Uses the shared BackendStatus component from @metabuilder/components.
* Reads storage configuration and displays connection status.
*/
export function BackendIndicator() {
const { backend } = getStorageConfig()
const isEnvConfigured = Boolean(process.env.NEXT_PUBLIC_FLASK_BACKEND_URL)
if (backend === 'indexeddb') {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
data-testid="backend-indicator"
className="backend-indicator disconnected flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/50 border border-border"
role="status"
aria-label="Backend connection status: Local storage"
>
<Database size={16} className="text-muted-foreground" aria-hidden="true" />
<span className="text-xs font-medium text-muted-foreground">Local</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>Disconnected from backend - Using local storage</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)
}
// Determine status based on backend type
const status = backend === 'indexeddb' ? 'disconnected' : 'connected'
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div
data-testid="backend-indicator"
className="backend-indicator connected flex items-center gap-2 px-3 py-1.5 rounded-full bg-accent/10 border border-accent/30"
role="status"
aria-label={`Backend connection status: Connected${isEnvConfigured ? ' (Auto-configured)' : ''}`}
>
<CloudCheck size={16} className="text-accent" weight="fill" aria-hidden="true" />
<span className="text-xs font-medium text-accent">Connected</span>
{isEnvConfigured && (
<span className="w-1.5 h-1.5 rounded-full bg-accent" aria-hidden="true" />
)}
</div>
</TooltipTrigger>
<TooltipContent>
<p>Connected to Flask Backend</p>
{isEnvConfigured && <p className="text-xs text-muted-foreground">Auto-configured</p>}
</TooltipContent>
</Tooltip>
</TooltipProvider>
<div data-testid="backend-indicator">
<BackendStatus
status={status}
showDot={status === 'connected' && isEnvConfigured}
disconnectedTooltip="Disconnected from backend - Using local storage"
connectedTooltip={
isEnvConfigured
? 'Connected to Flask Backend (Auto-configured)'
: 'Connected to Flask Backend'
}
/>
</div>
)
}

View File

@@ -21,6 +21,7 @@
"dependencies": {
"@metabuilder/api-clients": "file:../../redux/api-clients",
"@metabuilder/core-hooks": "file:../../redux/core-hooks",
"@metabuilder/components": "file:../components",
"@metabuilder/fakemui": "file:../fakemui",
"@reduxjs/toolkit": "^2.5.2",
"axios": "^1.7.7",

View File

@@ -9,7 +9,7 @@ import React from 'react';
import { Provider } from 'react-redux';
import { store } from '../../store/store';
import MainLayout from './MainLayout';
import { NotificationContainer } from '../UI/NotificationContainer';
import { NotificationAdapter } from '../UI/NotificationAdapter';
import { LoadingOverlay } from '../UI/LoadingOverlay';
import { AuthInitializer } from '../Auth/AuthInitializer';
@@ -24,7 +24,7 @@ export default function RootLayoutClient({ children }: RootLayoutClientProps) {
<MainLayout showSidebar={true}>
{children}
</MainLayout>
<NotificationContainer />
<NotificationAdapter />
<LoadingOverlay />
</Provider>
);

View File

@@ -0,0 +1,62 @@
/**
* Notification Adapter Component
* Bridges the useUI() Redux hook to the shared NotificationContainer component
*
* This adapter connects workflowui's Redux-based notification state
* to the framework-agnostic NotificationContainer from @metabuilder/components
*/
'use client';
import React from 'react';
import {
NotificationContainer,
type NotificationData,
type NotificationPosition,
} from '@metabuilder/components';
import { useUINotifications } from '../../hooks/ui';
export interface NotificationAdapterProps {
/** Position on screen - defaults to top-right */
position?: NotificationPosition;
/** Maximum notifications to show at once */
maxVisible?: number;
/** Custom className for container */
className?: string;
}
/**
* Notification adapter that connects Redux state to the shared component
*
* @example
* // In your layout:
* <NotificationAdapter position="top-right" maxVisible={5} />
*/
export const NotificationAdapter: React.FC<NotificationAdapterProps> = ({
position = 'top-right',
maxVisible = 5,
className,
}) => {
const { notifications, removeNotification } = useUINotifications();
// Map workflowui Notification type to shared NotificationData type
// Both types are compatible - id, type, message, duration
const mappedNotifications: NotificationData[] = notifications.map((n) => ({
id: n.id,
type: n.type,
message: n.message,
duration: n.duration,
}));
return (
<NotificationContainer
notifications={mappedNotifications}
onClose={removeNotification}
position={position}
maxVisible={maxVisible}
className={className}
/>
);
};
export default NotificationAdapter;

View File

@@ -1,6 +1,14 @@
/**
* Notification Container Component
* Displays notifications from Redux state
* @deprecated This component is deprecated in favor of the shared NotificationContainer
* from @metabuilder/components. Use NotificationAdapter instead, which bridges
* the Redux state to the shared component.
*
* Migration guide:
* - Replace imports of NotificationContainer with NotificationAdapter
* - The adapter automatically connects to useUI() hook
*
* @see /src/components/UI/NotificationAdapter.tsx
* @see @metabuilder/components NotificationContainer
*/
import React, { useEffect } from 'react';