Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5cb51e65be | ||
|
|
4dcd4aaa27 | ||
|
|
58320b9762 | ||
|
|
c48dffaef2 | ||
|
|
3ef4c5686e | ||
|
|
78e28789a5 | ||
|
|
03ec34bb5c |
@@ -246,7 +246,7 @@ import {
|
|||||||
| Checkbox | primitive | Boolean input with label |
|
| Checkbox | primitive | Boolean input with label |
|
||||||
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
| CodeBlock | primitive | Syntax-highlighted code/JSON display |
|
||||||
| Collapsible | primitive | Single expand/collapse section |
|
| Collapsible | primitive | Single expand/collapse section |
|
||||||
| CommandPalette | composite | Full-screen search and command interface |
|
| CommandPalette | composite | Full-screen search and command interface. `SearchCategory` is an open `string` type — known categories (application, exchange, attribute, route, agent) have built-in labels; custom categories render with title-cased labels and appear as dynamic tabs. |
|
||||||
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
| ConfirmDialog | composite | Type-to-confirm destructive action dialog built on Modal. Props: open, onClose, onConfirm, title, message, confirmText, confirmLabel, cancelLabel, variant, loading, className |
|
||||||
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
| DataTable | composite | Sortable, paginated data table with row actions. Use `flush` prop when embedded inside a container that provides its own border/radius |
|
||||||
| DateRangePicker | primitive | Date range selection with presets |
|
| DateRangePicker | primitive | Date range selection with presets |
|
||||||
@@ -306,7 +306,7 @@ import {
|
|||||||
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
|
| Sidebar | Composable compound sidebar shell with icon-rail collapse mode. Sub-components: `Sidebar.Header`, `Sidebar.Section`, `Sidebar.Footer`, `Sidebar.FooterLink`. The app controls all content via children — the DS provides the frame. |
|
||||||
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
| SidebarTree | Data-driven tree for sidebar sections. Accepts `nodes: SidebarTreeNode[]` with expand/collapse, starring, keyboard nav, search filter, and path-based selection highlighting. |
|
||||||
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
| useStarred | Hook for localStorage-backed starred item IDs. Returns `{ starredIds, isStarred, toggleStar }`. |
|
||||||
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment badge, user avatar |
|
| TopBar | Header bar with breadcrumb, search trigger, ButtonGroup status filters, time range selector, theme toggle, environment slot (`ReactNode` — pass a string for a static label or a custom dropdown for interactive selection), user avatar |
|
||||||
|
|
||||||
## Import Paths
|
## Import Paths
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ interface CommandPaletteProps {
|
|||||||
onSubmit?: (query: string) => void
|
onSubmit?: (query: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
const KNOWN_CATEGORY_LABELS: Record<string, string> = {
|
||||||
all: 'All',
|
|
||||||
application: 'Applications',
|
application: 'Applications',
|
||||||
exchange: 'Exchanges',
|
exchange: 'Exchanges',
|
||||||
attribute: 'Attributes',
|
attribute: 'Attributes',
|
||||||
@@ -28,8 +27,8 @@ const CATEGORY_LABELS: Record<SearchCategory | 'all', string> = {
|
|||||||
agent: 'Agents',
|
agent: 'Agents',
|
||||||
}
|
}
|
||||||
|
|
||||||
const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
/** Preferred display order for known categories */
|
||||||
'all',
|
const KNOWN_CATEGORY_ORDER: string[] = [
|
||||||
'application',
|
'application',
|
||||||
'exchange',
|
'exchange',
|
||||||
'attribute',
|
'attribute',
|
||||||
@@ -37,6 +36,13 @@ const ALL_CATEGORIES: Array<SearchCategory | 'all'> = [
|
|||||||
'agent',
|
'agent',
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function categoryLabel(cat: string): string {
|
||||||
|
if (cat === 'all') return 'All'
|
||||||
|
if (KNOWN_CATEGORY_LABELS[cat]) return KNOWN_CATEGORY_LABELS[cat]
|
||||||
|
// Title-case unknown categories: "my-thing" → "My Thing", "foo_bar" → "Foo Bar"
|
||||||
|
return cat.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
function highlightText(text: string, query: string, matchRanges?: [number, number][]): ReactNode {
|
||||||
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
if (!query && (!matchRanges || matchRanges.length === 0)) return text
|
||||||
|
|
||||||
@@ -69,7 +75,7 @@ function highlightText(text: string, query: string, matchRanges?: [number, numbe
|
|||||||
|
|
||||||
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryChange, onSubmit }: CommandPaletteProps) {
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [activeCategory, setActiveCategory] = useState<SearchCategory | 'all'>('all')
|
const [activeCategory, setActiveCategory] = useState<string>('all')
|
||||||
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
const [scopeFilters, setScopeFilters] = useState<ScopeFilter[]>([])
|
||||||
const [focusedIdx, setFocusedIdx] = useState(0)
|
const [focusedIdx, setFocusedIdx] = useState(0)
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||||
@@ -128,7 +134,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
|
|
||||||
// Group results by category
|
// Group results by category
|
||||||
const grouped = useMemo(() => {
|
const grouped = useMemo(() => {
|
||||||
const map = new Map<SearchCategory, SearchResult[]>()
|
const map = new Map<string, SearchResult[]>()
|
||||||
for (const r of filtered) {
|
for (const r of filtered) {
|
||||||
if (!map.has(r.category)) map.set(r.category, [])
|
if (!map.has(r.category)) map.set(r.category, [])
|
||||||
map.get(r.category)!.push(r)
|
map.get(r.category)!.push(r)
|
||||||
@@ -148,6 +154,19 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
return counts
|
return counts
|
||||||
}, [queryFiltered])
|
}, [queryFiltered])
|
||||||
|
|
||||||
|
// Build tab list dynamically: 'all' + known categories (in order) + any unknown categories found in data
|
||||||
|
const visibleCategories = useMemo(() => {
|
||||||
|
const dataCategories = new Set(data.map((r) => r.category))
|
||||||
|
const tabs: string[] = ['all']
|
||||||
|
for (const cat of KNOWN_CATEGORY_ORDER) {
|
||||||
|
if (dataCategories.has(cat)) tabs.push(cat)
|
||||||
|
}
|
||||||
|
for (const cat of dataCategories) {
|
||||||
|
if (!tabs.includes(cat)) tabs.push(cat)
|
||||||
|
}
|
||||||
|
return tabs
|
||||||
|
}, [data])
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleKeyDown(e: React.KeyboardEvent) {
|
||||||
switch (e.key) {
|
switch (e.key) {
|
||||||
case 'Escape':
|
case 'Escape':
|
||||||
@@ -246,7 +265,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
|
|
||||||
{/* Category tabs */}
|
{/* Category tabs */}
|
||||||
<div className={styles.tabs} role="tablist">
|
<div className={styles.tabs} role="tablist">
|
||||||
{ALL_CATEGORIES.map((cat) => (
|
{visibleCategories.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
role="tab"
|
role="tab"
|
||||||
@@ -262,7 +281,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
setFocusedIdx(0)
|
setFocusedIdx(0)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{CATEGORY_LABELS[cat]}
|
{categoryLabel(cat)}
|
||||||
{categoryCounts[cat] != null && (
|
{categoryCounts[cat] != null && (
|
||||||
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
<span className={styles.tabCount}>{categoryCounts[cat]}</span>
|
||||||
)}
|
)}
|
||||||
@@ -283,7 +302,7 @@ export function CommandPalette({ open, onClose, onSelect, data, onOpen, onQueryC
|
|||||||
Array.from(grouped.entries()).map(([category, items]) => (
|
Array.from(grouped.entries()).map(([category, items]) => (
|
||||||
<div key={category} className={styles.group}>
|
<div key={category} className={styles.group}>
|
||||||
<div className={styles.groupHeader}>
|
<div className={styles.groupHeader}>
|
||||||
<SectionHeader>{CATEGORY_LABELS[category]}</SectionHeader>
|
<SectionHeader>{categoryLabel(category)}</SectionHeader>
|
||||||
</div>
|
</div>
|
||||||
{items.map((result) => {
|
{items.map((result) => {
|
||||||
const flatIdx = flatResults.indexOf(result)
|
const flatIdx = flatResults.indexOf(result)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
export type SearchCategory = 'application' | 'exchange' | 'attribute' | 'route' | 'agent'
|
/** Known categories: 'application' | 'exchange' | 'attribute' | 'route' | 'agent'. Custom categories are rendered with title-cased labels and a default icon. */
|
||||||
|
export type SearchCategory = string
|
||||||
|
|
||||||
export interface SearchResult {
|
export interface SearchResult {
|
||||||
id: string
|
id: string
|
||||||
|
|||||||
@@ -153,14 +153,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.env {
|
.env {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 30px;
|
||||||
|
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-family: var(--font-mono);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 3px 10px;
|
|
||||||
border-radius: 10px;
|
|
||||||
background: var(--success-bg);
|
|
||||||
color: var(--success);
|
|
||||||
border: 1px solid var(--success-border);
|
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
import { Search, Moon, Sun, Power } from 'lucide-react'
|
import { Search, Moon, Sun, Power } from 'lucide-react'
|
||||||
import styles from './TopBar.module.css'
|
import styles from './TopBar.module.css'
|
||||||
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
import { Breadcrumb } from '../../composites/Breadcrumb/Breadcrumb'
|
||||||
@@ -14,7 +15,7 @@ import type { BreadcrumbItem } from '../../providers/BreadcrumbProvider'
|
|||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
breadcrumb: BreadcrumbItem[]
|
breadcrumb: BreadcrumbItem[]
|
||||||
environment?: string
|
environment?: ReactNode
|
||||||
user?: { name: string }
|
user?: { name: string }
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
className?: string
|
className?: string
|
||||||
@@ -90,7 +91,7 @@ export function TopBar({
|
|||||||
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
title={globalFilters.autoRefresh ? 'Auto-refresh is on — click to pause' : 'Auto-refresh is paused — click to resume'}
|
||||||
>
|
>
|
||||||
<span className={styles.liveDot} />
|
<span className={styles.liveDot} />
|
||||||
{globalFilters.autoRefresh ? 'LIVE' : 'PAUSED'}
|
{globalFilters.autoRefresh ? 'AUTO' : 'MANUAL'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={styles.themeToggle}
|
className={styles.themeToggle}
|
||||||
@@ -102,7 +103,7 @@ export function TopBar({
|
|||||||
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
{theme === 'light' ? <Moon size={16} /> : <Sun size={16} />}
|
||||||
</button>
|
</button>
|
||||||
{environment && (
|
{environment && (
|
||||||
<span className={styles.env}>{environment}</span>
|
<div className={styles.env}>{environment}</div>
|
||||||
)}
|
)}
|
||||||
{user && (
|
{user && (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type ExchangeStatus = 'completed' | 'failed' | 'running' | 'warning'
|
|||||||
interface GlobalFilterContextValue {
|
interface GlobalFilterContextValue {
|
||||||
timeRange: TimeRange
|
timeRange: TimeRange
|
||||||
setTimeRange: (range: TimeRange) => void
|
setTimeRange: (range: TimeRange) => void
|
||||||
|
refreshTimeRange: () => void
|
||||||
statusFilters: Set<ExchangeStatus>
|
statusFilters: Set<ExchangeStatus>
|
||||||
toggleStatus: (status: ExchangeStatus) => void
|
toggleStatus: (status: ExchangeStatus) => void
|
||||||
clearStatusFilters: () => void
|
clearStatusFilters: () => void
|
||||||
@@ -76,6 +77,14 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
return () => clearInterval(id)
|
return () => clearInterval(id)
|
||||||
}, [autoRefresh, timeRange.preset])
|
}, [autoRefresh, timeRange.preset])
|
||||||
|
|
||||||
|
// Recompute time range from preset on demand (for manual refresh in PAUSED mode)
|
||||||
|
const refreshTimeRange = useCallback(() => {
|
||||||
|
if (timeRange.preset) {
|
||||||
|
const { start, end } = computePresetRange(timeRange.preset)
|
||||||
|
setTimeRangeState({ start, end, preset: timeRange.preset })
|
||||||
|
}
|
||||||
|
}, [timeRange.preset])
|
||||||
|
|
||||||
const isInTimeRange = useCallback(
|
const isInTimeRange = useCallback(
|
||||||
(timestamp: Date) => {
|
(timestamp: Date) => {
|
||||||
if (timeRange.preset) {
|
if (timeRange.preset) {
|
||||||
@@ -90,7 +99,7 @@ export function GlobalFilterProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<GlobalFilterContext.Provider
|
<GlobalFilterContext.Provider
|
||||||
value={{ timeRange, setTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
value={{ timeRange, setTimeRange, refreshTimeRange, statusFilters, toggleStatus, clearStatusFilters, isInTimeRange, autoRefresh, setAutoRefresh }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</GlobalFilterContext.Provider>
|
</GlobalFilterContext.Provider>
|
||||||
|
|||||||
Reference in New Issue
Block a user