EventCatalog
surface이벤트 카탈로그 Surface. DataTable + EventIcon + EventSparkline + EventMetaEditor 조합.
컴포넌트 의존 관계
깊이
100%
기본 사용
Event Catalog
Data Table
| Event | Key | Volume | Trend | Last Seen | |
|---|---|---|---|---|---|
| Page View | page_view | 125,000 | 2 min ago | ||
| Button Click | button_click | 45,000 | 5 min ago | ||
| Sign Up | signup | 3,200 | 15 min ago | ||
| Purchase | purchase | 890 | 1 hour ago |
필터 + 정렬 + 페이지네이션 + 벌크 액션
Data Table
| Event | Key | Volume | Trend | Last Seen | ||
|---|---|---|---|---|---|---|
| Page View | page_view | 125,000 | 2 min ago | |||
| Button Click | button_click | 45,000 | 5 min ago | |||
| Sign Up | signup | 3,200 | 15 min ago | |||
| Purchase | purchase | 890 | 1 hour ago |
테스트 커버리지
2026년 2월 4일생성된 테스트 결과를 찾지 못했습니다.
EventCatalog 항목이 문서 메타에 연결되어 있지만 현재 생성 파일에는 없습니다.
테스트를 추가한 뒤 `bun run generate:test-results`를 실행하거나 `testDescribe` 매핑을 다시 확인하세요.
EventCatalog Props
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
events* | EventCatalogItem[] | — | 이벤트 배열 |
onEventUpdate | (id: string, meta: EventMeta) => void | — | 이벤트 수정 핸들러 |
Surface 설치
CLI가 공식 배포 채널입니다. 필요한 Surface를 프로젝트로 복사한 뒤 직접 수정할 수 있습니다.
bash
npx @reopt-ai/opt-cli surface add event-catalogConsumer target
복사된 파일은 components/surfaces 아래에 저장됩니다.
tsx
import { EventCatalog } from "@/components/surfaces/event-catalog";Registry metadata
- 설명
- 이벤트 카탈로그 Surface. DataTable + EventIcon + EventSparkline + EventMetaEditor 조합.
- 파일 수
- 1개
- Registry dependencies
- 없음
- Package dependencies
- 없음
- 태그
- tablefilter
- Install notes
- 없음
포함 파일
event-catalog.tsxevent-catalog.tsx
Surface 소스 보기
event-catalog.tsx
"use client";
import * as React from "react";
import {
Input,
Badge,
DataTable,
FilterBar,
FloatingActionBar,
EventIcon,
EventMetaEditor,
SurfaceLayout,
SummaryRow,
TabsRoot,
TabList,
Tab,
cn,
TimeRangeSelector,
type ColumnDef,
type DateRange,
type EventMeta,
type FilterGroupDef,
type FloatingAction,
type StatCardType,
} from "@reopt-ai/opt-ui";
import { EventSparkline } from "@reopt-ai/opt-charts";
/** Item shape for `EventCatalog`. */
export interface EventCatalogItem {
id: string;
name: string;
displayName: string;
icon: string;
color: string;
tags: string[];
description: string;
volume: number;
sparkline: number[];
lastSeen: string;
}
/** Option shape for `EventCatalogStatus`. */
export interface EventCatalogStatusOption {
id: string;
label: string;
count?: number;
}
/** Labels for `EventCatalog`. */
export interface EventCatalogLabels {
catalog?: string;
searchPlaceholder?: string;
editEvent?: string;
tagsLabel?: string;
filtersLabel?: string;
selectAllLabel?: string;
selectedCountLabel?: string;
bulkActionsLabel?: string;
statusTabsLabel?: string;
emptyTitle?: string;
emptyDescription?: string;
}
const defaultLabels: Required<EventCatalogLabels> = {
catalog: "Event Catalog",
searchPlaceholder: "Search events...",
editEvent: "Edit Event",
tagsLabel: "Tags",
filtersLabel: "Filters",
selectAllLabel: "Select all",
selectedCountLabel: "{count} selected",
bulkActionsLabel: "Bulk actions",
statusTabsLabel: "Status",
emptyTitle: "No events found",
emptyDescription: "There are no events in the catalog yet.",
};
/** Props for `EventCatalog`. */
export interface EventCatalogProps {
events: EventCatalogItem[];
onEventUpdate?: (id: string, meta: EventMeta) => void;
/** FilterBar filter definitions */
filters?: FilterGroupDef[];
onFilterChange?: (
filterId: string,
value: string | string[] | boolean,
) => void;
/** Enable row selection for bulk actions */
selectable?: boolean;
selectedKeys?: string[];
onSelectionChange?: (keys: string[]) => void;
/** Bulk action definitions shown via FloatingActionBar */
bulkActions?: FloatingAction[];
onBulkAction?: (actionId: string) => void;
/** Enable column sorting */
sortable?: boolean;
/** Enable expandable table rows (e.g. for PropertyExplorer) */
expandableRows?: boolean;
/** Render function for expanded row content */
renderExpandedRow?: (event: EventCatalogItem) => React.ReactNode;
/** Active tag filters shown as pill toggles */
activeTags?: string[];
/** Called when a tag pill is toggled */
onTagToggle?: (tag: string) => void;
/** Status filter tabs (e.g. active/inactive/archived) */
statusFilter?: string;
/** Status tab definitions */
statusOptions?: EventCatalogStatusOption[];
/** Called when a status tab is selected */
onStatusFilterChange?: (status: string) => void;
/** Stats cards displayed above the table */
stats?: StatCardType[];
/** Enable pagination with given page size */
pageSize?: number;
/** Import action slot (rendered in header actions area) */
importAction?: React.ReactNode;
/** Export action slot (rendered in header actions area) */
exportAction?: React.ReactNode;
loading?: boolean;
timeRange?: DateRange;
onTimeRangeChange?: (range: DateRange) => void;
header?: React.ReactNode;
actions?: React.ReactNode;
labels?: EventCatalogLabels;
className?: string;
}
/** Renders the `EventCatalog` component. */
export function EventCatalog({
events,
onEventUpdate,
filters,
onFilterChange,
selectable = false,
selectedKeys,
onSelectionChange,
bulkActions,
onBulkAction,
sortable = false,
expandableRows = false,
renderExpandedRow,
activeTags,
onTagToggle,
statusFilter,
statusOptions,
onStatusFilterChange,
stats,
pageSize,
importAction,
exportAction,
loading = false,
timeRange,
onTimeRangeChange,
header,
actions,
labels: customLabels,
className,
}: EventCatalogProps) {
const labels = { ...defaultLabels, ...customLabels };
const sectionId = React.useId();
const [search, setSearch] = React.useState("");
const [selected, setSelected] = React.useState<string | null>(null);
const filtered = React.useMemo(() => {
if (!search) return events;
const q = search.toLowerCase();
return events.filter(
(e) =>
e.name.toLowerCase().includes(q) ||
e.displayName.toLowerCase().includes(q) ||
e.tags.some((t) => t.toLowerCase().includes(q)),
);
}, [events, search]);
const selectedEvent = events.find((e) => e.id === selected);
const columns: ColumnDef<EventCatalogItem>[] = [
{
id: "icon",
header: "",
accessor: (row) => (
<EventIcon icon={row.icon} color={row.color} size="sm" />
),
sortable: false,
},
{ id: "displayName", header: "Event", accessor: "displayName" },
{
id: "name",
header: "Key",
accessor: (row) => <code className="text-xs">{row.name}</code>,
},
{
id: "volume",
header: "Volume",
accessor: (row) => row.volume.toLocaleString(),
},
{
id: "sparkline",
header: "Trend",
accessor: (row) => (
<EventSparkline data={row.sparkline} color={row.color} />
),
sortable: false,
},
{ id: "lastSeen", header: "Last Seen", accessor: "lastSeen" },
];
// Collect all unique tags for pill filter
const allTags = React.useMemo(() => {
const tagSet = new Set<string>();
for (const e of events) {
for (const t of e.tags) tagSet.add(t);
}
return [...tagSet].sort();
}, [events]);
const hasTagFilter = activeTags && onTagToggle && allTags.length > 0;
const isEmpty = events.length === 0;
const hasFilters = filters && filters.length > 0;
const showBulkBar =
bulkActions &&
bulkActions.length > 0 &&
onBulkAction &&
selectedKeys &&
selectedKeys.length > 0;
return (
<SurfaceLayout loading={loading} className={className}>
{header || actions ? (
<div className="flex items-center justify-between">
{header && <div>{header}</div>}
<div className="gap-element flex items-center">
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
{!isEmpty && (
<div className="w-64">
<Input
placeholder={labels.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label={labels.searchPlaceholder}
/>
</div>
)}
{importAction}
{exportAction}
{actions}
</div>
</div>
) : (
<div className="flex items-center justify-between">
<h2 className="text-text-primary text-lg font-semibold">
{labels.catalog}
</h2>
<div className="gap-element flex items-center">
<TimeRangeSelector value={timeRange} onChange={onTimeRangeChange} />
{!isEmpty && (
<div className="w-64">
<Input
placeholder={labels.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
aria-label={labels.searchPlaceholder}
/>
</div>
)}
{importAction}
{exportAction}
</div>
</div>
)}
{isEmpty ? (
<div
role="status"
className="flex flex-col items-center justify-center py-16 text-center"
>
<h3 className="text-text-primary text-lg font-medium">
{labels.emptyTitle}
</h3>
<p className="text-text-tertiary mt-1 text-sm">
{labels.emptyDescription}
</p>
</div>
) : (
<>
{stats && stats.length > 0 && <SummaryRow stats={stats} />}
{statusOptions &&
statusOptions.length > 0 &&
onStatusFilterChange && (
<TabsRoot
selectedId={statusFilter ?? statusOptions[0]?.id}
setSelectedId={(id) => {
if (id) onStatusFilterChange(id);
}}
>
<TabList aria-label={labels.statusTabsLabel}>
{statusOptions.map((opt) => (
<Tab key={opt.id} id={opt.id}>
<span className="flex items-center gap-1.5">
{opt.label}
{opt.count != null && (
<Badge variant="default" size="sm">
{opt.count}
</Badge>
)}
</span>
</Tab>
))}
</TabList>
</TabsRoot>
)}
{hasFilters && (
<section aria-labelledby={`${sectionId}-filters`}>
<span id={`${sectionId}-filters`} className="sr-only">
{labels.filtersLabel}
</span>
<FilterBar filters={filters} onFilterChange={onFilterChange} />
</section>
)}
{hasTagFilter && (
<div
role="group"
aria-label={labels.tagsLabel}
className="flex flex-wrap gap-1.5"
>
{allTags.map((tag) => {
const isActive = activeTags.includes(tag);
return (
<button
key={tag}
type="button"
onClick={() => onTagToggle(tag)}
aria-pressed={isActive}
className={cn(
"rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors",
isActive
? "bg-accent-subtle text-text-primary"
: "bg-bg-subtle text-text-tertiary hover:text-text-secondary",
)}
>
{tag}
</button>
);
})}
</div>
)}
<div className="gap-group flex">
<section aria-labelledby={`${sectionId}-table`} className="flex-1">
<span id={`${sectionId}-table`} className="sr-only">
{labels.catalog}
</span>
<DataTable
columns={columns}
data={filtered}
keyExtractor={(row) => row.id}
onRowClick={(row) => setSelected(row.id)}
sortable={sortable}
selectable={selectable}
selectedKeys={selectedKeys}
onSelectionChange={onSelectionChange}
pageSize={pageSize}
expandable={expandableRows}
renderExpanded={renderExpandedRow}
/>
</section>
{selectedEvent && onEventUpdate && (
<section
aria-labelledby={`${sectionId}-editor`}
className="border-border w-80 shrink-0 border-l pl-4"
>
<h3
id={`${sectionId}-editor`}
className="text-text-primary mb-3 text-sm font-medium"
>
{labels.editEvent}
</h3>
<EventMetaEditor
value={{
name: selectedEvent.name,
displayName: selectedEvent.displayName,
icon: selectedEvent.icon,
color: selectedEvent.color,
tags: selectedEvent.tags,
description: selectedEvent.description,
}}
onChange={(meta) => onEventUpdate(selectedEvent.id, meta)}
/>
</section>
)}
</div>
{showBulkBar && (
<FloatingActionBar
actions={bulkActions}
selectedCount={selectedKeys.length}
onAction={onBulkAction}
onDismiss={() => onSelectionChange?.([])}
/>
)}
</>
)}
</SurfaceLayout>
);
}
EventCatalog.displayName = "EventCatalog";