@echothink-ui/activity 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +5 -0
  2. package/dist/components/ActivityFeed.d.ts +7 -0
  3. package/dist/components/ActivityTimeline.d.ts +6 -0
  4. package/dist/components/AlertBanner.d.ts +10 -0
  5. package/dist/components/ChangelogPanel.d.ts +6 -0
  6. package/dist/components/IncidentPanel.d.ts +6 -0
  7. package/dist/components/MentionList.d.ts +7 -0
  8. package/dist/components/NotificationCenter.d.ts +10 -0
  9. package/dist/components/NotificationItem.d.ts +8 -0
  10. package/dist/components/SubscriptionPreferences.d.ts +7 -0
  11. package/dist/components/SystemStatusBanner.d.ts +9 -0
  12. package/dist/components/WatcherList.d.ts +8 -0
  13. package/dist/components/helpers.d.ts +4 -0
  14. package/dist/components/types.d.ts +65 -0
  15. package/dist/index.cjs +944 -0
  16. package/dist/index.cjs.map +1 -0
  17. package/dist/index.css +711 -0
  18. package/dist/index.css.map +1 -0
  19. package/dist/index.d.ts +16 -0
  20. package/dist/index.js +904 -0
  21. package/dist/index.js.map +1 -0
  22. package/package.json +43 -0
  23. package/src/components/ActivityFeed.tsx +83 -0
  24. package/src/components/ActivityTimeline.tsx +178 -0
  25. package/src/components/AlertBanner.tsx +69 -0
  26. package/src/components/ChangelogPanel.tsx +100 -0
  27. package/src/components/IncidentPanel.tsx +82 -0
  28. package/src/components/MentionList.tsx +85 -0
  29. package/src/components/NotificationCenter.tsx +117 -0
  30. package/src/components/NotificationItem.tsx +99 -0
  31. package/src/components/SubscriptionPreferences.test.tsx +64 -0
  32. package/src/components/SubscriptionPreferences.tsx +140 -0
  33. package/src/components/SystemStatusBanner.tsx +46 -0
  34. package/src/components/WatcherList.test.tsx +50 -0
  35. package/src/components/WatcherList.tsx +122 -0
  36. package/src/components/helpers.ts +15 -0
  37. package/src/components/types.ts +71 -0
  38. package/src/index.tsx +31 -0
  39. package/src/styles.css +854 -0
@@ -0,0 +1,82 @@
1
+ import { Badge, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
2
+ import { severityToCore } from "./helpers";
3
+ import type { ActivityIncident, ActivitySeverity } from "./types";
4
+
5
+ export interface IncidentPanelProps extends Omit<SurfaceComponentProps, "children"> {
6
+ incidents: ActivityIncident[];
7
+ }
8
+
9
+ export function IncidentPanel({ incidents, title, className, ...props }: IncidentPanelProps) {
10
+ const hasIncidents = incidents.length > 0;
11
+
12
+ return (
13
+ <Surface
14
+ {...props}
15
+ title={title ?? "Incidents"}
16
+ className={["eth-activity-incident-panel", className].filter(Boolean).join(" ")}
17
+ data-eth-component="IncidentPanel"
18
+ >
19
+ {hasIncidents ? (
20
+ <div className="eth-activity-incident-panel__list" role="list">
21
+ {incidents.map((incident) => (
22
+ <article
23
+ key={incident.id}
24
+ className="eth-activity-incident-panel__incident"
25
+ data-severity={incident.severity ? severityToCore(incident.severity) : undefined}
26
+ role="listitem"
27
+ aria-label={incident.title}
28
+ >
29
+ <header className="eth-activity-incident-panel__incident-header">
30
+ <div className="eth-activity-incident-panel__summary">
31
+ <h3>{incident.title}</h3>
32
+ {incident.description ? <p>{incident.description}</p> : null}
33
+ </div>
34
+ {incident.severity ? (
35
+ <Badge
36
+ className="eth-activity-incident-panel__severity"
37
+ severity={severityToCore(incident.severity)}
38
+ >
39
+ {formatIncidentLabel(incident.severity)}
40
+ </Badge>
41
+ ) : null}
42
+ </header>
43
+ <dl className="eth-activity-incident-panel__metadata">
44
+ {incident.status ? (
45
+ <div>
46
+ <dt>Status</dt>
47
+ <dd>
48
+ <span
49
+ className="eth-activity-incident-panel__status"
50
+ data-status={incident.status}
51
+ >
52
+ <span aria-hidden="true" />
53
+ {formatIncidentLabel(incident.status)}
54
+ </span>
55
+ </dd>
56
+ </div>
57
+ ) : null}
58
+ {incident.startedAt ? (
59
+ <div>
60
+ <dt>Started</dt>
61
+ <dd>
62
+ <time>{incident.startedAt}</time>
63
+ </dd>
64
+ </div>
65
+ ) : null}
66
+ </dl>
67
+ </article>
68
+ ))}
69
+ </div>
70
+ ) : (
71
+ <p className="eth-activity-incident-panel__empty">No active incidents.</p>
72
+ )}
73
+ </Surface>
74
+ );
75
+ }
76
+
77
+ function formatIncidentLabel(value: NonNullable<ActivityIncident["status"]> | ActivitySeverity) {
78
+ return value
79
+ .split("-")
80
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
81
+ .join(" ");
82
+ }
@@ -0,0 +1,85 @@
1
+ import { Button, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
2
+ import type { Mention } from "./types";
3
+
4
+ export interface MentionListProps extends Omit<SurfaceComponentProps, "children"> {
5
+ mentions: Mention[];
6
+ onOpen?: (messageRef: string) => void;
7
+ }
8
+
9
+ export function MentionList({
10
+ mentions,
11
+ onOpen,
12
+ title,
13
+ subtitle,
14
+ className,
15
+ ...props
16
+ }: MentionListProps) {
17
+ const hasMentions = mentions.length > 0;
18
+ const countLabel = hasMentions
19
+ ? `${mentions.length} pending ${mentions.length === 1 ? "reference" : "references"}`
20
+ : "No pending references";
21
+
22
+ return (
23
+ <Surface
24
+ {...props}
25
+ title={title ?? "Mentions"}
26
+ subtitle={subtitle ?? countLabel}
27
+ className={["eth-activity-mention-list", className].filter(Boolean).join(" ")}
28
+ data-eth-component="MentionList"
29
+ >
30
+ {hasMentions ? (
31
+ <ul className="eth-activity-mention-list__items" aria-label="Mention assignments">
32
+ {mentions.map((mention) => (
33
+ <li key={mention.id} className="eth-activity-mention-list__item">
34
+ <span className="eth-activity-mention-list__avatar" aria-hidden="true">
35
+ {initialsForName(mention.from)}
36
+ </span>
37
+ <div className="eth-activity-mention-list__content">
38
+ <div className="eth-activity-mention-list__header">
39
+ <strong className="eth-activity-mention-list__sender">{mention.from}</strong>
40
+ <span className="eth-activity-mention-list__reference">{mention.messageRef}</span>
41
+ </div>
42
+ <p className="eth-activity-mention-list__excerpt">{mention.excerpt}</p>
43
+ <time
44
+ className="eth-activity-mention-list__time"
45
+ dateTime={dateTimeValue(mention.createdAt)}
46
+ >
47
+ {mention.createdAt}
48
+ </time>
49
+ </div>
50
+ <Button
51
+ type="button"
52
+ intent="tertiary"
53
+ density="compact"
54
+ disabled={!onOpen}
55
+ aria-label={`Open mention ${mention.messageRef}`}
56
+ onClick={() => onOpen?.(mention.messageRef)}
57
+ >
58
+ Open
59
+ </Button>
60
+ </li>
61
+ ))}
62
+ </ul>
63
+ ) : (
64
+ <p className="eth-activity-mention-list__empty" role="status">
65
+ No mentions assigned to you.
66
+ </p>
67
+ )}
68
+ </Surface>
69
+ );
70
+ }
71
+
72
+ function initialsForName(value: string) {
73
+ const parts = value.trim().split(/\s+/).filter(Boolean);
74
+ if (parts.length === 0) return "?";
75
+ if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase();
76
+ return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase();
77
+ }
78
+
79
+ function dateTimeValue(value: string) {
80
+ const trimmed = value.trim();
81
+ if (/^\d{2}:\d{2}(:\d{2})?$/.test(trimmed)) return trimmed;
82
+
83
+ const date = new Date(trimmed);
84
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : undefined;
85
+ }
@@ -0,0 +1,117 @@
1
+ import { Button, Surface, type SurfaceComponentProps } from "@echothink-ui/core";
2
+ import { NotificationItem as NotificationRow } from "./NotificationItem";
3
+ import type { NotificationItemData } from "./types";
4
+
5
+ export interface NotificationCenterProps extends Omit<SurfaceComponentProps, "children"> {
6
+ notifications: NotificationItemData[];
7
+ unreadCount?: number;
8
+ onMarkRead?: (id: string) => void;
9
+ onMarkAllRead?: () => void;
10
+ onDismiss?: (id: string) => void;
11
+ }
12
+
13
+ export function NotificationCenter({
14
+ notifications,
15
+ unreadCount,
16
+ onMarkRead,
17
+ onMarkAllRead,
18
+ onDismiss,
19
+ title,
20
+ className,
21
+ role,
22
+ "aria-label": ariaLabel,
23
+ ...props
24
+ }: NotificationCenterProps) {
25
+ const groups = groupByDate(notifications);
26
+ const unreadTotal = unreadCount ?? notifications.filter((item) => !item.read).length;
27
+ const displayTitle = title ?? "Notifications";
28
+ const regionLabel =
29
+ ariaLabel ?? (typeof displayTitle === "string" ? displayTitle : "Notification center");
30
+
31
+ return (
32
+ <Surface
33
+ {...props}
34
+ title={displayTitle}
35
+ subtitle={`${unreadTotal} unread`}
36
+ className={["eth-activity-notification-center", className].filter(Boolean).join(" ")}
37
+ data-eth-component="NotificationCenter"
38
+ role={role ?? "region"}
39
+ aria-label={regionLabel}
40
+ >
41
+ {onMarkAllRead ? (
42
+ <div className="eth-activity-notification-center__toolbar">
43
+ <Button
44
+ type="button"
45
+ intent="secondary"
46
+ density="compact"
47
+ disabled={unreadTotal === 0}
48
+ onClick={onMarkAllRead}
49
+ >
50
+ Mark all read
51
+ </Button>
52
+ </div>
53
+ ) : null}
54
+ {notifications.length ? (
55
+ <div className="eth-activity-notification-center__groups">
56
+ {groups.map((group) => (
57
+ <section key={group.date} className="eth-activity-notification-center__group">
58
+ <h3>{formatGroupLabel(group.date)}</h3>
59
+ <ol className="eth-activity-notification-center__items">
60
+ {group.items.map((notification) => (
61
+ <li key={notification.id} className="eth-activity-notification-center__row">
62
+ <NotificationRow
63
+ notification={notification}
64
+ onRead={
65
+ onMarkRead && !notification.read
66
+ ? () => onMarkRead(notification.id)
67
+ : undefined
68
+ }
69
+ />
70
+ {onDismiss ? (
71
+ <Button
72
+ type="button"
73
+ intent="ghost"
74
+ density="compact"
75
+ onClick={() => onDismiss(notification.id)}
76
+ >
77
+ Dismiss
78
+ </Button>
79
+ ) : null}
80
+ </li>
81
+ ))}
82
+ </ol>
83
+ </section>
84
+ ))}
85
+ </div>
86
+ ) : (
87
+ <p className="eth-activity-notification-center__empty">No notifications.</p>
88
+ )}
89
+ </Surface>
90
+ );
91
+ }
92
+
93
+ function groupByDate(notifications: NotificationItemData[]) {
94
+ const groups = new Map<string, NotificationItemData[]>();
95
+ for (const notification of notifications) {
96
+ const key = groupKey(notification.createdAt);
97
+ groups.set(key, [...(groups.get(key) ?? []), notification]);
98
+ }
99
+ return Array.from(groups, ([date, items]) => ({ date, items }));
100
+ }
101
+
102
+ function groupKey(value: string) {
103
+ const date = new Date(value);
104
+ return Number.isFinite(date.valueOf()) ? date.toISOString().slice(0, 10) : "Recent";
105
+ }
106
+
107
+ function formatGroupLabel(value: string) {
108
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return value;
109
+
110
+ return new Intl.DateTimeFormat(undefined, {
111
+ day: "numeric",
112
+ month: "short",
113
+ timeZone: "UTC",
114
+ weekday: "short",
115
+ year: "numeric"
116
+ }).format(new Date(`${value}T00:00:00Z`));
117
+ }
@@ -0,0 +1,99 @@
1
+ import { ActionGroup, Badge, Button, type SurfaceComponentProps } from "@echothink-ui/core";
2
+ import { severityToCore } from "./helpers";
3
+ import type { NotificationItemData } from "./types";
4
+
5
+ export interface NotificationItemProps extends Omit<SurfaceComponentProps, "children"> {
6
+ notification: NotificationItemData;
7
+ onRead?: () => void;
8
+ onClick?: () => void;
9
+ }
10
+
11
+ export function NotificationItem({
12
+ notification,
13
+ onRead,
14
+ onClick,
15
+ className,
16
+ title: _title,
17
+ subtitle: _subtitle,
18
+ description: _description,
19
+ eyebrow: _eyebrow,
20
+ density: _density,
21
+ status: _status,
22
+ severity: _severity,
23
+ loading: _loading,
24
+ empty: _empty,
25
+ error: _error,
26
+ items: _items,
27
+ actions: _actions,
28
+ metadata: _metadata,
29
+ footer: _footer,
30
+ ...props
31
+ }: NotificationItemProps) {
32
+ const severity = severityToCore(notification.severity);
33
+ const mainContent = (
34
+ <>
35
+ <Badge className="eth-activity-notification-item__severity" severity={severity}>
36
+ {notification.severity}
37
+ </Badge>
38
+ <span className="eth-activity-notification-item__content">
39
+ <strong className="eth-activity-notification-item__title">{notification.title}</strong>
40
+ {notification.body ? (
41
+ <span className="eth-activity-notification-item__body">{notification.body}</span>
42
+ ) : null}
43
+ </span>
44
+ <time
45
+ className="eth-activity-notification-item__time"
46
+ dateTime={dateTimeValue(notification.createdAt)}
47
+ >
48
+ {formatTimestamp(notification.createdAt)}
49
+ </time>
50
+ </>
51
+ );
52
+ const hasActions = Boolean((!notification.read && onRead) || notification.actions?.length);
53
+
54
+ return (
55
+ <article
56
+ {...props}
57
+ className={[
58
+ "eth-activity-notification-item",
59
+ notification.read ? "eth-activity-notification-item--read" : undefined,
60
+ className
61
+ ]
62
+ .filter(Boolean)
63
+ .join(" ")}
64
+ data-eth-component="NotificationItem"
65
+ data-severity={severity}
66
+ >
67
+ {onClick ? (
68
+ <button type="button" className="eth-activity-notification-item__main" onClick={onClick}>
69
+ {mainContent}
70
+ </button>
71
+ ) : (
72
+ <div className="eth-activity-notification-item__main">{mainContent}</div>
73
+ )}
74
+ {hasActions ? (
75
+ <div className="eth-activity-notification-item__actions">
76
+ {!notification.read && onRead ? (
77
+ <Button type="button" intent="ghost" density="compact" onClick={onRead}>
78
+ Mark read
79
+ </Button>
80
+ ) : null}
81
+ <ActionGroup actions={notification.actions} />
82
+ </div>
83
+ ) : null}
84
+ </article>
85
+ );
86
+ }
87
+
88
+ function dateTimeValue(value: string) {
89
+ const date = new Date(value);
90
+ return Number.isFinite(date.valueOf()) ? date.toISOString() : undefined;
91
+ }
92
+
93
+ function formatTimestamp(value: string) {
94
+ const date = new Date(value);
95
+ if (!Number.isFinite(date.valueOf())) return value;
96
+
97
+ const iso = date.toISOString();
98
+ return `${iso.slice(11, 16)} UTC`;
99
+ }
@@ -0,0 +1,64 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { SubscriptionPreferences } from "./SubscriptionPreferences";
4
+ import type { SubscriptionCategory } from "./types";
5
+
6
+ const categories = [
7
+ {
8
+ id: "approvals",
9
+ label: "Approvals",
10
+ description: "Reviewer requests and policy exceptions.",
11
+ channels: [
12
+ { id: "in-app", label: "In-app", enabled: true, description: "Activity center" },
13
+ { id: "email", label: "Email", enabled: true, description: "Reviewer digest" }
14
+ ]
15
+ },
16
+ {
17
+ id: "incidents",
18
+ label: "Incidents",
19
+ description: "Service interruptions and escalations.",
20
+ channels: [
21
+ {
22
+ id: "push",
23
+ label: "Push",
24
+ enabled: false,
25
+ description: "Managed by policy",
26
+ disabled: true
27
+ }
28
+ ]
29
+ }
30
+ ] satisfies SubscriptionCategory[];
31
+
32
+ describe("SubscriptionPreferences", () => {
33
+ it("renders notification categories as accessible compact channel groups", () => {
34
+ const { container } = render(
35
+ <SubscriptionPreferences title="Notifications" categories={categories} />
36
+ );
37
+
38
+ expect(screen.getByRole("region", { name: "Notifications" })).toBeTruthy();
39
+ expect(screen.getByRole("group", { name: "Approvals" })).toBeTruthy();
40
+ expect(screen.getByText("Reviewer requests and policy exceptions.")).toBeTruthy();
41
+ expect(screen.getByLabelText("Approvals Email")).toBeTruthy();
42
+ expect(
43
+ container.querySelectorAll(".eth-activity-subscription-preferences__channel")
44
+ ).toHaveLength(3);
45
+ expect(
46
+ container.querySelector(".eth-activity-subscription-preferences__channel--disabled")
47
+ ).toBeTruthy();
48
+ });
49
+
50
+ it("calls onToggle with the category and channel identifiers", () => {
51
+ const onToggle = vi.fn();
52
+ render(<SubscriptionPreferences categories={categories} onToggle={onToggle} />);
53
+
54
+ fireEvent.click(screen.getByLabelText("Approvals Email"));
55
+
56
+ expect(onToggle).toHaveBeenCalledWith("approvals", "email", false);
57
+ });
58
+
59
+ it("shows an empty state when no subscription categories are configured", () => {
60
+ render(<SubscriptionPreferences categories={[]} />);
61
+
62
+ expect(screen.getByRole("status").textContent).toBe("No notification categories configured.");
63
+ });
64
+ });
@@ -0,0 +1,140 @@
1
+ import * as React from "react";
2
+ import { Surface, Toggle, type SurfaceComponentProps } from "@echothink-ui/core";
3
+ import type { SubscriptionCategory, SubscriptionChannel } from "./types";
4
+
5
+ export interface SubscriptionPreferencesProps
6
+ extends Omit<SurfaceComponentProps, "children" | "onToggle"> {
7
+ categories: SubscriptionCategory[];
8
+ onToggle?: (categoryId: string, channelId: string, enabled: boolean) => void;
9
+ }
10
+
11
+ export function SubscriptionPreferences({
12
+ categories,
13
+ onToggle,
14
+ title,
15
+ className,
16
+ role,
17
+ "aria-label": ariaLabel,
18
+ ...props
19
+ }: SubscriptionPreferencesProps) {
20
+ const displayTitle = title ?? "Notification preferences";
21
+ const panelLabel = ariaLabel ?? (typeof displayTitle === "string" ? displayTitle : undefined);
22
+
23
+ return (
24
+ <Surface
25
+ {...props}
26
+ title={displayTitle}
27
+ role={role ?? (panelLabel ? "region" : undefined)}
28
+ aria-label={panelLabel}
29
+ className={["eth-activity-subscription-preferences", className].filter(Boolean).join(" ")}
30
+ data-eth-component="SubscriptionPreferences"
31
+ >
32
+ {categories.length ? (
33
+ <div className="eth-activity-subscription-preferences__categories">
34
+ {categories.map((category) => (
35
+ <SubscriptionCategoryGroup key={category.id} category={category} onToggle={onToggle} />
36
+ ))}
37
+ </div>
38
+ ) : (
39
+ <p className="eth-activity-subscription-preferences__empty" role="status">
40
+ No notification categories configured.
41
+ </p>
42
+ )}
43
+ </Surface>
44
+ );
45
+ }
46
+
47
+ function SubscriptionCategoryGroup({
48
+ category,
49
+ onToggle
50
+ }: {
51
+ category: SubscriptionCategory;
52
+ onToggle?: SubscriptionPreferencesProps["onToggle"];
53
+ }) {
54
+ const headingId = React.useId();
55
+ const descriptionId = category.description ? `${headingId}-description` : undefined;
56
+
57
+ return (
58
+ <section
59
+ className="eth-activity-subscription-preferences__category"
60
+ role="group"
61
+ aria-labelledby={headingId}
62
+ aria-describedby={descriptionId}
63
+ >
64
+ <header className="eth-activity-subscription-preferences__category-header">
65
+ <h3 id={headingId}>{category.label}</h3>
66
+ {category.description ? (
67
+ <p
68
+ id={descriptionId}
69
+ className="eth-activity-subscription-preferences__category-description"
70
+ >
71
+ {category.description}
72
+ </p>
73
+ ) : null}
74
+ </header>
75
+ {category.channels.length ? (
76
+ <div className="eth-activity-subscription-preferences__channels">
77
+ {category.channels.map((channel) => (
78
+ <SubscriptionChannelRow
79
+ key={channel.id}
80
+ category={category}
81
+ channel={channel}
82
+ onToggle={onToggle}
83
+ />
84
+ ))}
85
+ </div>
86
+ ) : (
87
+ <p className="eth-activity-subscription-preferences__channel-empty">
88
+ No channels configured.
89
+ </p>
90
+ )}
91
+ </section>
92
+ );
93
+ }
94
+
95
+ function SubscriptionChannelRow({
96
+ category,
97
+ channel,
98
+ onToggle
99
+ }: {
100
+ category: SubscriptionCategory;
101
+ channel: SubscriptionChannel;
102
+ onToggle?: SubscriptionPreferencesProps["onToggle"];
103
+ }) {
104
+ const accessibleLabel = `${category.label} ${channel.label}`;
105
+
106
+ return (
107
+ <div
108
+ className={[
109
+ "eth-activity-subscription-preferences__channel",
110
+ channel.disabled ? "eth-activity-subscription-preferences__channel--disabled" : undefined
111
+ ]
112
+ .filter(Boolean)
113
+ .join(" ")}
114
+ >
115
+ <div className="eth-activity-subscription-preferences__channel-copy">
116
+ <span className="eth-activity-subscription-preferences__channel-label">
117
+ {channel.label}
118
+ </span>
119
+ {channel.description ? (
120
+ <span className="eth-activity-subscription-preferences__channel-description">
121
+ {channel.description}
122
+ </span>
123
+ ) : null}
124
+ </div>
125
+ <Toggle
126
+ id={`eth-subscription-${safeId(category.id)}-${safeId(channel.id)}`}
127
+ label={accessibleLabel}
128
+ hideLabel
129
+ density="compact"
130
+ checked={channel.enabled}
131
+ disabled={channel.disabled}
132
+ onChange={(event) => onToggle?.(category.id, channel.id, event.currentTarget.checked)}
133
+ />
134
+ </div>
135
+ );
136
+ }
137
+
138
+ function safeId(value: string) {
139
+ return value.replace(/[^a-zA-Z0-9_-]+/g, "-") || "channel";
140
+ }
@@ -0,0 +1,46 @@
1
+ import type { EthAction, SurfaceComponentProps } from "@echothink-ui/core";
2
+ import { AlertBanner } from "./AlertBanner";
3
+ import type { ActivitySeverity } from "./types";
4
+
5
+ export interface SystemStatusBannerProps
6
+ extends Omit<SurfaceComponentProps, "children" | "actions" | "status"> {
7
+ status: "operational" | "degraded" | "down" | "maintenance";
8
+ title?: string;
9
+ description?: string;
10
+ actions?: EthAction[];
11
+ onDismiss?: () => void;
12
+ }
13
+
14
+ export function SystemStatusBanner({
15
+ status,
16
+ title,
17
+ description,
18
+ actions,
19
+ onDismiss,
20
+ className
21
+ }: SystemStatusBannerProps) {
22
+ return (
23
+ <AlertBanner
24
+ severity={severityForStatus(status)}
25
+ title={title ?? defaultTitleForStatus(status)}
26
+ description={description}
27
+ actions={actions}
28
+ onDismiss={onDismiss}
29
+ className={["eth-activity-system-status-banner", className].filter(Boolean).join(" ")}
30
+ data-eth-component="SystemStatusBanner"
31
+ />
32
+ );
33
+ }
34
+
35
+ function defaultTitleForStatus(status: SystemStatusBannerProps["status"]) {
36
+ if (status === "operational") return "System operational";
37
+ if (status === "degraded") return "System degraded";
38
+ if (status === "maintenance") return "Scheduled maintenance";
39
+ return "System unavailable";
40
+ }
41
+
42
+ function severityForStatus(status: SystemStatusBannerProps["status"]): ActivitySeverity {
43
+ if (status === "operational") return "success";
44
+ if (status === "degraded" || status === "maintenance") return "warning";
45
+ return "error";
46
+ }
@@ -0,0 +1,50 @@
1
+ import { fireEvent, render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { WatcherList } from "./WatcherList";
4
+ import type { IdentityRef } from "./types";
5
+
6
+ const watchers = [
7
+ {
8
+ id: "jd",
9
+ label: "Jane Doe",
10
+ email: "jane.doe@example.com",
11
+ role: "Project owner",
12
+ kind: "user"
13
+ },
14
+ { id: "marketing", label: "Marketing", role: "Launch channel", kind: "group" }
15
+ ] satisfies IdentityRef[];
16
+
17
+ describe("WatcherList", () => {
18
+ it("renders watchers as an accessible activity surface with identity metadata", () => {
19
+ render(<WatcherList watchers={watchers} />);
20
+
21
+ expect(screen.getByRole("region", { name: "Watchers" })).toBeTruthy();
22
+ expect(screen.getByText("2 watchers notified on updates")).toBeTruthy();
23
+ expect(screen.getByRole("list", { name: "Watchers" })).toBeTruthy();
24
+ expect(screen.getByText("Jane Doe")).toBeTruthy();
25
+ expect(screen.getByText("Project owner / jane.doe@example.com")).toBeTruthy();
26
+ expect(screen.getByText("Group")).toBeTruthy();
27
+ });
28
+
29
+ it("exposes add and remove actions only when handlers are provided", () => {
30
+ const onAdd = vi.fn();
31
+ const onRemove = vi.fn();
32
+ render(<WatcherList watchers={watchers} onAdd={onAdd} onRemove={onRemove} />);
33
+
34
+ fireEvent.click(screen.getByRole("button", { name: "Add watcher" }));
35
+ fireEvent.click(
36
+ screen.getByRole("button", { name: "Remove Jane Doe from watchers" })
37
+ );
38
+
39
+ expect(onAdd).toHaveBeenCalledTimes(1);
40
+ expect(onRemove).toHaveBeenCalledWith("jd");
41
+ });
42
+
43
+ it("shows a compact empty state when no watchers are configured", () => {
44
+ render(<WatcherList watchers={[]} />);
45
+
46
+ expect(screen.getByText("No watchers configured")).toBeTruthy();
47
+ expect(screen.getByRole("status").textContent).toBe("No watchers have been added.");
48
+ expect(screen.queryByRole("button", { name: "Add watcher" })).toBeNull();
49
+ });
50
+ });