Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
499c86b680 | ||
|
|
63e16d2685 | ||
|
|
19dccb8685 | ||
|
|
4b873194c9 | ||
|
|
5f1b039056 | ||
|
|
095abe1751 | ||
|
|
e8859e53ce | ||
|
|
021f6c7811 | ||
|
|
c18ba7d085 | ||
|
|
795ffef9dc | ||
|
|
039f2fa5fe |
@@ -17,7 +17,7 @@ jobs:
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npx vitest run
|
||||
run: npx vitest run --exclude 'e2e/**'
|
||||
|
||||
- name: Build library
|
||||
run: npm run build:lib
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
case "$GITHUB_REF" in
|
||||
refs/tags/v*)
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
npm version "$VERSION" --no-git-tag-version
|
||||
npm version "$VERSION" --no-git-tag-version --allow-same-version
|
||||
TAG="latest"
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -207,8 +207,8 @@ URL-driven progressive filtering: /agents → /agents/:appId → /agents/:appId/
|
||||
| MonoText | primitive | Inline monospace text (xs, sm, md) |
|
||||
| Pagination | primitive | Page navigation controls |
|
||||
| Popover | composite | Click-triggered floating panel with arrow |
|
||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows. Props: processors, totalMs, onProcessorClick?, selectedIndex? |
|
||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, and click support. Props: nodes, onNodeClick?, selectedIndex? |
|
||||
| ProcessorTimeline | composite | Gantt-style pipeline visualization with selectable rows and optional action menus. Props: processors, totalMs, onProcessorClick?, selectedIndex?, actions?, getActions?. Use `actions` for static menus or `getActions` for per-processor dynamic actions. |
|
||||
| RouteFlow | composite | Vertical processor node flow diagram with status coloring, connectors, click support, and optional action menus. Props: nodes, onNodeClick?, selectedIndex?, actions?, getActions?. Same action pattern as ProcessorTimeline. |
|
||||
| ProgressBar | primitive | Determinate/indeterminate progress indicator |
|
||||
| RadioGroup | primitive | Single-select option group (use with RadioItem) |
|
||||
| RadioItem | primitive | Individual radio option within RadioGroup |
|
||||
|
||||
77
package-lock.json
generated
77
package-lock.json
generated
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"name": "cameleer3",
|
||||
"version": "0.0.0",
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "cameleer3",
|
||||
"version": "0.0.0",
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.6",
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
@@ -24,6 +25,11 @@
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-dts": "^4.5.4",
|
||||
"vitest": "^3.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-router-dom": "^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
@@ -925,6 +931,22 @@
|
||||
"resolve": "~1.22.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-beta.27",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
|
||||
@@ -2874,6 +2896,53 @@
|
||||
"pathe": "^2.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.8",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
|
||||
|
||||
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@cameleer/design-system",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.6",
|
||||
"type": "module",
|
||||
"main": "./dist/index.es.js",
|
||||
"module": "./dist/index.es.js",
|
||||
@@ -12,8 +12,12 @@
|
||||
},
|
||||
"./style.css": "./dist/style.css"
|
||||
},
|
||||
"files": ["dist"],
|
||||
"sideEffects": ["*.css"],
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"sideEffects": [
|
||||
"*.css"
|
||||
],
|
||||
"publishConfig": {
|
||||
"registry": "https://gitea.siegeln.net/api/packages/cameleer/npm/"
|
||||
},
|
||||
@@ -27,7 +31,8 @@
|
||||
"build:lib": "vite build --config vite.lib.config.ts",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest"
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
@@ -40,6 +45,7 @@
|
||||
"react-router-dom": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
|
||||
@@ -12,6 +12,7 @@ interface CommandPaletteProps {
|
||||
onSelect: (result: SearchResult) => void
|
||||
data: SearchResult[]
|
||||
onOpen?: () => void
|
||||
onQueryChange?: (query: string) => void
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
||||
@@ -60,7 +61,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
||||
return <>{parts}</>
|
||||
}
|
||||
|
||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen }: CommandPaletteProps) {
|
||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange }: CommandPaletteProps) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||
@@ -102,7 +103,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
if (query.trim()) {
|
||||
const q = query.toLowerCase()
|
||||
results = results.filter(
|
||||
(r) => r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||
(r) => r.serverFiltered || r.title.toLowerCase().includes(q) || r.meta.toLowerCase().includes(q),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -208,6 +209,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen }: Comman
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value)
|
||||
setFocusedIdx(0)
|
||||
onQueryChange?.(e.target.value)
|
||||
}}
|
||||
aria-label="Search"
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface SearchResult {
|
||||
path?: string
|
||||
expandedContent?: string
|
||||
matchRanges?: [number, number][]
|
||||
/** Skip client-side query filtering (result already matched server-side) */
|
||||
serverFiltered?: boolean
|
||||
}
|
||||
|
||||
export interface ScopeFilter {
|
||||
|
||||
@@ -24,6 +24,7 @@ export function DataTable<T extends { id: string }>({
|
||||
rowAccent,
|
||||
expandedContent,
|
||||
flush = false,
|
||||
onSortChange,
|
||||
}: DataTableProps<T>) {
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc')
|
||||
@@ -31,14 +32,16 @@ export function DataTable<T extends { id: string }>({
|
||||
const [pageSize, setPageSize] = useState(initialPageSize)
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
// When onSortChange is provided (controlled mode), skip client-side sorting
|
||||
const sorted = useMemo(() => {
|
||||
if (onSortChange) return data
|
||||
if (!sortKey) return data
|
||||
return [...data].sort((a, b) => {
|
||||
const av = (a as Record<string, unknown>)[sortKey]
|
||||
const bv = (b as Record<string, unknown>)[sortKey]
|
||||
return compareValues(av, bv, sortDir)
|
||||
})
|
||||
}, [data, sortKey, sortDir])
|
||||
}, [data, sortKey, sortDir, onSortChange])
|
||||
|
||||
const totalRows = sorted.length
|
||||
const totalPages = Math.max(1, Math.ceil(totalRows / pageSize))
|
||||
@@ -52,13 +55,17 @@ export function DataTable<T extends { id: string }>({
|
||||
|
||||
function handleHeaderClick(col: Column<T>) {
|
||||
if (!sortable && !col.sortable) return
|
||||
let newDir: SortDir
|
||||
if (sortKey === col.key) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
newDir = sortDir === 'asc' ? 'desc' : 'asc'
|
||||
setSortDir(newDir)
|
||||
} else {
|
||||
newDir = 'asc'
|
||||
setSortKey(col.key)
|
||||
setSortDir('asc')
|
||||
setSortDir(newDir)
|
||||
}
|
||||
setPage(1)
|
||||
onSortChange?.(col.key, newDir)
|
||||
}
|
||||
|
||||
function handleRowClick(row: T) {
|
||||
|
||||
@@ -20,4 +20,8 @@ export interface DataTableProps<T extends { id: string }> {
|
||||
expandedContent?: (row: T) => ReactNode | null
|
||||
/** Strip border, radius, and shadow so the table sits flush inside a parent container. */
|
||||
flush?: boolean
|
||||
/** Controlled sort: called when the user clicks a sortable column header.
|
||||
* When provided, the component skips client-side sorting — the caller is
|
||||
* responsible for providing `data` in the desired order. */
|
||||
onSortChange?: (key: string, dir: 'asc' | 'desc') => void
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import styles from './DetailPanel.module.css'
|
||||
|
||||
interface Tab {
|
||||
@@ -22,7 +23,7 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
||||
|
||||
const activeContent = tabs?.find((t) => t.value === activeTab)?.content
|
||||
|
||||
return (
|
||||
const panel = (
|
||||
<aside
|
||||
className={`${styles.panel} ${open ? styles.open : ''} ${className ?? ''}`}
|
||||
aria-hidden={!open}
|
||||
@@ -65,4 +66,8 @@ export function DetailPanel({ open, onClose, title, tabs, children, actions, cla
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
|
||||
// Portal to AppShell level if target exists, otherwise render in place
|
||||
const portalTarget = document.getElementById('cameleer-detail-panel-root')
|
||||
return portalTarget ? createPortal(panel, portalTarget) : panel
|
||||
}
|
||||
|
||||
@@ -96,6 +96,58 @@
|
||||
padding: 2px 0 2px 4px;
|
||||
}
|
||||
|
||||
/* Action trigger — hidden by default, shown on hover/selected */
|
||||
.actionsTrigger {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.row:hover .actionsTrigger,
|
||||
.actionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actionsBtn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.actionsBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 7px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
border-radius: 6px;
|
||||
padding: 0 4px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badgeInfo { background: var(--running); }
|
||||
.badgeSuccess { background: var(--success); }
|
||||
.badgeWarning { background: var(--amber); }
|
||||
.badgeError { background: var(--error); }
|
||||
|
||||
.empty {
|
||||
color: var(--text-muted);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { ProcessorTimeline } from './ProcessorTimeline'
|
||||
|
||||
const processors = [
|
||||
{ name: 'Validate', type: 'validator', durationMs: 12, status: 'ok' as const, startMs: 0 },
|
||||
{ name: 'Enrich', type: 'enricher', durationMs: 35, status: 'slow' as const, startMs: 12 },
|
||||
{ name: 'Route', type: 'router', durationMs: 8, status: 'fail' as const, startMs: 47 },
|
||||
]
|
||||
|
||||
describe('ProcessorTimeline', () => {
|
||||
it('renders processor names', () => {
|
||||
render(<ProcessorTimeline processors={processors} totalMs={55} />)
|
||||
expect(screen.getByText('Validate')).toBeInTheDocument()
|
||||
expect(screen.getByText('Enrich')).toBeInTheDocument()
|
||||
expect(screen.getByText('Route')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render action trigger when no actions provided', () => {
|
||||
const { container } = render(
|
||||
<ProcessorTimeline processors={processors} totalMs={55} />,
|
||||
)
|
||||
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders action trigger when actions provided', () => {
|
||||
const { container } = render(
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={55}
|
||||
actions={[{ label: 'Change Log Level', onClick: () => {} }]}
|
||||
/>,
|
||||
)
|
||||
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||
expect(triggers.length).toBe(3)
|
||||
})
|
||||
|
||||
it('clicking action trigger does not fire onProcessorClick', async () => {
|
||||
const onProcessorClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={55}
|
||||
onProcessorClick={onProcessorClick}
|
||||
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||
/>,
|
||||
)
|
||||
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||
await user.click(trigger)
|
||||
expect(onProcessorClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls action onClick when menu item clicked', async () => {
|
||||
const actionClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={55}
|
||||
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||
/>,
|
||||
)
|
||||
const trigger = container.querySelector('[aria-label="Actions for Validate"]')!
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByText('Change Log Level'))
|
||||
expect(actionClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('supports dynamic getActions per processor', () => {
|
||||
const { container } = render(
|
||||
<ProcessorTimeline
|
||||
processors={processors}
|
||||
totalMs={55}
|
||||
getActions={(proc) =>
|
||||
proc.status === 'fail'
|
||||
? [{ label: 'View Error', onClick: () => {} }]
|
||||
: []
|
||||
}
|
||||
/>,
|
||||
)
|
||||
// Only the failing processor should have an action trigger
|
||||
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||
expect(triggers.length).toBe(1)
|
||||
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for Route')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './ProcessorTimeline.module.css'
|
||||
import { Dropdown } from '../Dropdown/Dropdown'
|
||||
import type { NodeBadge } from '../RouteFlow/RouteFlow'
|
||||
|
||||
export interface ProcessorStep {
|
||||
name: string
|
||||
@@ -6,6 +9,15 @@ export interface ProcessorStep {
|
||||
durationMs: number
|
||||
status: 'ok' | 'slow' | 'fail'
|
||||
startMs: number
|
||||
badges?: NodeBadge[]
|
||||
}
|
||||
|
||||
export interface ProcessorAction {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface ProcessorTimelineProps {
|
||||
@@ -13,6 +25,8 @@ interface ProcessorTimelineProps {
|
||||
totalMs: number
|
||||
onProcessorClick?: (processor: ProcessorStep, index: number) => void
|
||||
selectedIndex?: number
|
||||
actions?: ProcessorAction[]
|
||||
getActions?: (processor: ProcessorStep, index: number) => ProcessorAction[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -26,6 +40,8 @@ export function ProcessorTimeline({
|
||||
totalMs,
|
||||
onProcessorClick,
|
||||
selectedIndex,
|
||||
actions,
|
||||
getActions,
|
||||
className,
|
||||
}: ProcessorTimelineProps) {
|
||||
const safeTotal = totalMs || 1
|
||||
@@ -70,6 +86,16 @@ export function ProcessorTimeline({
|
||||
>
|
||||
<div className={styles.name} title={proc.name}>
|
||||
{proc.name}
|
||||
{proc.badges?.map((badge, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.barBg}>
|
||||
<div
|
||||
@@ -82,6 +108,30 @@ export function ProcessorTimeline({
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.dur}>{formatDuration(proc.durationMs)}</div>
|
||||
{(() => {
|
||||
const resolvedActions = getActions ? getActions(proc, i) : (actions ?? [])
|
||||
if (resolvedActions.length === 0) return null
|
||||
return (
|
||||
<div
|
||||
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button
|
||||
className={styles.actionsBtn}
|
||||
aria-label={`Actions for ${proc.name}`}
|
||||
type="button"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
}
|
||||
items={resolvedActions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -188,17 +188,63 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Bottleneck badge */
|
||||
.bottleneckBadge {
|
||||
/* Action trigger — hidden by default, shown on hover/selected */
|
||||
.actionsTrigger {
|
||||
opacity: 0;
|
||||
transition: opacity 0.1s;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.node:hover .actionsTrigger,
|
||||
.actionsVisible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.actionsBtn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
padding: 0 4px;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
color: var(--text-muted);
|
||||
transition: all 0.1s;
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
.actionsBtn:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-subtle);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badgeRow {
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
gap: 3px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 8px;
|
||||
font-weight: 600;
|
||||
padding: 1px 6px;
|
||||
border-radius: 8px;
|
||||
background: var(--error);
|
||||
color: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1px 6px;
|
||||
text-transform: uppercase;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.badgeInfo { background: var(--running); }
|
||||
.badgeSuccess { background: var(--success); }
|
||||
.badgeWarning { background: var(--amber); }
|
||||
.badgeError { background: var(--error); }
|
||||
|
||||
83
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
83
src/design-system/composites/RouteFlow/RouteFlow.test.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { RouteFlow } from './RouteFlow'
|
||||
|
||||
const nodes = [
|
||||
{ name: 'jms:orders', type: 'from' as const, durationMs: 4, status: 'ok' as const },
|
||||
{ name: 'OrderValidator', type: 'process' as const, durationMs: 8, status: 'ok' as const },
|
||||
{ name: 'http:payment-api', type: 'to' as const, durationMs: 187, status: 'slow' as const },
|
||||
{ name: 'dead-letter:failed', type: 'error-handler' as const, durationMs: 14, status: 'fail' as const },
|
||||
]
|
||||
|
||||
describe('RouteFlow', () => {
|
||||
it('renders node names', () => {
|
||||
render(<RouteFlow nodes={nodes} />)
|
||||
expect(screen.getByText('jms:orders')).toBeInTheDocument()
|
||||
expect(screen.getByText('OrderValidator')).toBeInTheDocument()
|
||||
expect(screen.getByText('http:payment-api')).toBeInTheDocument()
|
||||
expect(screen.getByText('dead-letter:failed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not render action trigger when no actions provided', () => {
|
||||
const { container } = render(<RouteFlow nodes={nodes} />)
|
||||
expect(container.querySelector('[aria-label*="Actions for"]')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders action trigger on all nodes including error handlers when actions provided', () => {
|
||||
const { container } = render(
|
||||
<RouteFlow
|
||||
nodes={nodes}
|
||||
actions={[{ label: 'View Config', onClick: () => {} }]}
|
||||
/>,
|
||||
)
|
||||
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||
expect(triggers.length).toBe(4) // 3 main + 1 error handler
|
||||
})
|
||||
|
||||
it('clicking action trigger does not fire onNodeClick', async () => {
|
||||
const onNodeClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<RouteFlow
|
||||
nodes={nodes}
|
||||
onNodeClick={onNodeClick}
|
||||
actions={[{ label: 'Test Action', onClick: () => {} }]}
|
||||
/>,
|
||||
)
|
||||
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||
await user.click(trigger)
|
||||
expect(onNodeClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls action onClick when menu item clicked', async () => {
|
||||
const actionClick = vi.fn()
|
||||
const user = userEvent.setup()
|
||||
const { container } = render(
|
||||
<RouteFlow
|
||||
nodes={nodes}
|
||||
actions={[{ label: 'Change Log Level', onClick: actionClick }]}
|
||||
/>,
|
||||
)
|
||||
const trigger = container.querySelector('[aria-label="Actions for jms:orders"]')!
|
||||
await user.click(trigger)
|
||||
await user.click(screen.getByText('Change Log Level'))
|
||||
expect(actionClick).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('supports dynamic getActions per node', () => {
|
||||
const { container } = render(
|
||||
<RouteFlow
|
||||
nodes={nodes}
|
||||
getActions={(node) =>
|
||||
node.type === 'process'
|
||||
? [{ label: 'Edit Processor', onClick: () => {} }]
|
||||
: []
|
||||
}
|
||||
/>,
|
||||
)
|
||||
const triggers = container.querySelectorAll('[aria-label*="Actions for"]')
|
||||
expect(triggers.length).toBe(1)
|
||||
expect(triggers[0]).toHaveAttribute('aria-label', 'Actions for OrderValidator')
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import styles from './RouteFlow.module.css'
|
||||
import { Dropdown } from '../Dropdown/Dropdown'
|
||||
|
||||
export interface NodeBadge {
|
||||
label: string
|
||||
variant?: 'info' | 'success' | 'warning' | 'error'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
export interface RouteNode {
|
||||
name: string
|
||||
@@ -6,12 +14,23 @@ export interface RouteNode {
|
||||
durationMs: number
|
||||
status: 'ok' | 'slow' | 'fail'
|
||||
isBottleneck?: boolean
|
||||
badges?: NodeBadge[]
|
||||
}
|
||||
|
||||
export interface NodeAction {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick: () => void
|
||||
disabled?: boolean
|
||||
divider?: boolean
|
||||
}
|
||||
|
||||
interface RouteFlowProps {
|
||||
nodes: RouteNode[]
|
||||
onNodeClick?: (node: RouteNode, index: number) => void
|
||||
selectedIndex?: number
|
||||
actions?: NodeAction[]
|
||||
getActions?: (node: RouteNode, index: number) => NodeAction[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
@@ -52,7 +71,38 @@ function nodeStatusClass(node: RouteNode): string {
|
||||
return styles.nodeHealthy
|
||||
}
|
||||
|
||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: RouteFlowProps) {
|
||||
function renderActionTrigger(
|
||||
node: RouteNode,
|
||||
index: number,
|
||||
isSelected: boolean,
|
||||
actions?: NodeAction[],
|
||||
getActions?: (node: RouteNode, index: number) => NodeAction[],
|
||||
) {
|
||||
const resolvedActions = getActions ? getActions(node, index) : (actions ?? [])
|
||||
if (resolvedActions.length === 0) return null
|
||||
return (
|
||||
<div
|
||||
className={`${styles.actionsTrigger} ${isSelected ? styles.actionsVisible : ''}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button
|
||||
className={styles.actionsBtn}
|
||||
aria-label={`Actions for ${node.name}`}
|
||||
type="button"
|
||||
>
|
||||
⋮
|
||||
</button>
|
||||
}
|
||||
items={resolvedActions}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function RouteFlow({ nodes, onNodeClick, selectedIndex, actions, getActions, className }: RouteFlowProps) {
|
||||
const mainNodes = nodes.filter((n) => n.type !== 'error-handler')
|
||||
const errorHandlers = nodes.filter((n) => n.type === 'error-handler')
|
||||
|
||||
@@ -89,7 +139,21 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
}
|
||||
}}
|
||||
>
|
||||
{node.isBottleneck && <span className={styles.bottleneckBadge}>BOTTLENECK</span>}
|
||||
{(node.isBottleneck || node.badges?.length) ? (
|
||||
<span className={styles.badgeRow}>
|
||||
{node.isBottleneck && <span className={`${styles.badge} ${styles.badgeError}`}>BOTTLENECK</span>}
|
||||
{node.badges?.map((badge, bi) => (
|
||||
<span
|
||||
key={bi}
|
||||
className={`${styles.badge} ${styles[`badge${(badge.variant ?? 'info').charAt(0).toUpperCase()}${(badge.variant ?? 'info').slice(1)}`] ?? styles.badgeInfo}`}
|
||||
onClick={badge.onClick ? (e) => { e.stopPropagation(); badge.onClick!() } : undefined}
|
||||
style={badge.onClick ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{badge.label}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
) : null}
|
||||
<div className={`${styles.icon} ${ICON_CLASSES[node.type] ?? styles.iconTo}`}>
|
||||
{TYPE_ICONS[node.type] ?? '\u25A2'}
|
||||
</div>
|
||||
@@ -102,6 +166,7 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
{renderActionTrigger(node, originalIndex, isSelected, actions, getActions)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -110,22 +175,26 @@ export function RouteFlow({ nodes, onNodeClick, selectedIndex, className }: Rout
|
||||
{errorHandlers.length > 0 && (
|
||||
<div className={styles.errorSection}>
|
||||
<div className={styles.errorLabel}>Error Handler</div>
|
||||
{errorHandlers.map((node, i) => (
|
||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||
{TYPE_ICONS['error-handler']}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
{errorHandlers.map((node, i) => {
|
||||
const errOriginalIndex = nodes.indexOf(node)
|
||||
return (
|
||||
<div key={i} className={`${styles.node} ${styles.nodeError}`}>
|
||||
<div className={`${styles.icon} ${styles.iconErrorHandler}`}>
|
||||
{TYPE_ICONS['error-handler']}
|
||||
</div>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.type}>{node.type}</div>
|
||||
<div className={styles.label} title={node.name}>{node.name}</div>
|
||||
</div>
|
||||
<div className={styles.stats}>
|
||||
<div className={`${styles.duration} ${durationClass(node.durationMs, node.status)}`}>
|
||||
{formatDuration(node.durationMs)}
|
||||
</div>
|
||||
</div>
|
||||
{renderActionTrigger(node, errOriginalIndex, false, actions, getActions)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,9 +32,9 @@ export { MultiSelect } from './MultiSelect/MultiSelect'
|
||||
export type { MultiSelectOption } from './MultiSelect/MultiSelect'
|
||||
export { Popover } from './Popover/Popover'
|
||||
export { ProcessorTimeline } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export type { ProcessorStep, ProcessorAction } from './ProcessorTimeline/ProcessorTimeline'
|
||||
export { RouteFlow } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode } from './RouteFlow/RouteFlow'
|
||||
export type { RouteNode, NodeAction, NodeBadge } from './RouteFlow/RouteFlow'
|
||||
export { ShortcutsBar } from './ShortcutsBar/ShortcutsBar'
|
||||
export { SegmentedTabs } from './SegmentedTabs/SegmentedTabs'
|
||||
export { SplitPane } from './SplitPane/SplitPane'
|
||||
|
||||
@@ -4,17 +4,18 @@ import type { ReactNode } from 'react'
|
||||
interface AppShellProps {
|
||||
sidebar: ReactNode
|
||||
children: ReactNode
|
||||
/** @deprecated DetailPanel now portals itself automatically. This prop is ignored. */
|
||||
detail?: ReactNode
|
||||
}
|
||||
|
||||
export function AppShell({ sidebar, children, detail }: AppShellProps) {
|
||||
export function AppShell({ sidebar, children }: AppShellProps) {
|
||||
return (
|
||||
<div className={styles.app}>
|
||||
{sidebar}
|
||||
<div className={styles.main}>
|
||||
{children}
|
||||
</div>
|
||||
{detail}
|
||||
<div id="cameleer-detail-panel-root" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -81,6 +81,56 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveToggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s, background 0.15s;
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.liveToggle:hover {
|
||||
border-color: var(--text-faint);
|
||||
}
|
||||
|
||||
.liveToggleActive {
|
||||
color: var(--success);
|
||||
border-color: var(--success-border);
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.liveToggleActive:hover {
|
||||
border-color: var(--success);
|
||||
}
|
||||
|
||||
.liveDot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.liveToggleActive .liveDot {
|
||||
background: var(--success);
|
||||
animation: livePulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes livePulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
.themeToggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -84,8 +84,18 @@ export function TopBar({
|
||||
onChange={globalFilters.setTimeRange}
|
||||
/>
|
||||
|
||||
{/* Right: theme toggle, env badge, user */}
|
||||
{/* Right: auto-refresh toggle, theme toggle, env badge, user */}
|
||||
<div className={styles.right}>
|
||||
<button
|
||||
className={`${styles.liveToggle} ${globalFilters.autoRefresh ? styles.liveToggleActive : ''}`}
|
||||
onClick={() => globalFilters.setAutoRefresh(!globalFilters.autoRefresh)}
|
||||
type="button"
|
||||
aria-label={globalFilters.autoRefresh ? 'Disable auto-refresh' : 'Enable auto-refresh'}
|
||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||
>
|
||||
<span className={styles.liveDot} />
|
||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
||||
</button>
|
||||
<button
|
||||
className={styles.themeToggle}
|
||||
onClick={toggleTheme}
|
||||
|
||||
@@ -16,6 +16,8 @@ interface GlobalFilterContextValue {
|
||||
toggleStatus: (status: ExchangeStatus) => void
|
||||
clearStatusFilters: () => void
|
||||
isInTimeRange: (timestamp: Date) => boolean
|
||||
autoRefresh: boolean
|
||||
setAutoRefresh: (enabled: boolean) => void
|
||||
}
|
||||
|
||||
const GlobalFilterContext = createContext<GlobalFilterContextValue | null>(null)
|
||||
@@ -27,9 +29,17 @@ function getDefaultTimeRange(): TimeRange {
|
||||
return { start, end, preset: DEFAULT_PRESET }
|
||||
}
|
||||
|
||||
function getInitialAutoRefresh(): boolean {
|
||||
try {
|
||||
const stored = localStorage.getItem('cameleer:auto-refresh')
|
||||
return stored === null ? true : stored === 'true'
|
||||
} catch { return true }
|
||||
}
|
||||
|
||||
export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
const [timeRange, setTimeRangeState] = useState<TimeRange>(getDefaultTimeRange)
|
||||
const [statusFilters, setStatusFilters] = useState<Set<ExchangeStatus>>(new Set())
|
||||
const [autoRefresh, setAutoRefreshState] = useState<boolean>(getInitialAutoRefresh)
|
||||
|
||||
const setTimeRange = useCallback((range: TimeRange) => {
|
||||
setTimeRangeState(range)
|
||||
@@ -51,6 +61,11 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
setStatusFilters(new Set())
|
||||
}, [])
|
||||
|
||||
const setAutoRefresh = useCallback((enabled: boolean) => {
|
||||
setAutoRefreshState(enabled)
|
||||
try { localStorage.setItem('cameleer:auto-refresh', String(enabled)) } catch {}
|
||||
}, [])
|
||||
|
||||
const isInTimeRange = useCallback(
|
||||
(timestamp: Date) => {
|
||||
if (timeRange.preset) {
|
||||
@@ -65,7 +80,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
return (
|
||||
<GlobalFilterContext.Provider
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange }}
|
||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||
>
|
||||
{children}
|
||||
</GlobalFilterContext.Provider>
|
||||
|
||||
@@ -769,7 +769,7 @@ export function CompositesSection() {
|
||||
<DemoCard
|
||||
id="processortimeline"
|
||||
title="ProcessorTimeline"
|
||||
description="Horizontal Gantt-style timeline showing processor execution order, duration, and status."
|
||||
description="Horizontal Gantt-style timeline with selectable rows and optional action menus via actions or getActions prop."
|
||||
>
|
||||
<div style={{ width: '100%' }}>
|
||||
<ProcessorTimeline
|
||||
@@ -780,6 +780,11 @@ export function CompositesSection() {
|
||||
{ name: 'RouteToQueue', type: 'router', durationMs: 8, status: 'ok', startMs: 47 },
|
||||
{ name: 'AuditLog', type: 'logger', durationMs: 65, status: 'fail', startMs: 55 },
|
||||
]}
|
||||
getActions={(proc) => [
|
||||
{ label: 'Change Log Level', onClick: () => {} },
|
||||
{ label: 'View Configuration', onClick: () => {} },
|
||||
...(proc.status === 'fail' ? [{ label: 'View Stack Trace', onClick: () => {} }] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
@@ -788,7 +793,7 @@ export function CompositesSection() {
|
||||
<DemoCard
|
||||
id="routeflow"
|
||||
title="RouteFlow"
|
||||
description="Vertical processor node diagram showing route execution flow with status coloring and connectors."
|
||||
description="Vertical processor node diagram with status coloring, connectors, and optional action menus."
|
||||
>
|
||||
<div style={{ width: '100%', maxWidth: 360 }}>
|
||||
<RouteFlow
|
||||
@@ -802,6 +807,10 @@ export function CompositesSection() {
|
||||
{ name: 'kafka:order-completed', type: 'to', durationMs: 11, status: 'ok' },
|
||||
{ name: 'dead-letter:failed-orders', type: 'error-handler', durationMs: 14, status: 'fail' },
|
||||
]}
|
||||
actions={[
|
||||
{ label: 'Change Log Level', onClick: () => {} },
|
||||
{ label: 'Enable Tracing', onClick: () => {} },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</DemoCard>
|
||||
|
||||
Reference in New Issue
Block a user