@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.
- package/README.md +5 -0
- package/dist/components/ActivityFeed.d.ts +7 -0
- package/dist/components/ActivityTimeline.d.ts +6 -0
- package/dist/components/AlertBanner.d.ts +10 -0
- package/dist/components/ChangelogPanel.d.ts +6 -0
- package/dist/components/IncidentPanel.d.ts +6 -0
- package/dist/components/MentionList.d.ts +7 -0
- package/dist/components/NotificationCenter.d.ts +10 -0
- package/dist/components/NotificationItem.d.ts +8 -0
- package/dist/components/SubscriptionPreferences.d.ts +7 -0
- package/dist/components/SystemStatusBanner.d.ts +9 -0
- package/dist/components/WatcherList.d.ts +8 -0
- package/dist/components/helpers.d.ts +4 -0
- package/dist/components/types.d.ts +65 -0
- package/dist/index.cjs +944 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +711 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +904 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
- package/src/components/ActivityFeed.tsx +83 -0
- package/src/components/ActivityTimeline.tsx +178 -0
- package/src/components/AlertBanner.tsx +69 -0
- package/src/components/ChangelogPanel.tsx +100 -0
- package/src/components/IncidentPanel.tsx +82 -0
- package/src/components/MentionList.tsx +85 -0
- package/src/components/NotificationCenter.tsx +117 -0
- package/src/components/NotificationItem.tsx +99 -0
- package/src/components/SubscriptionPreferences.test.tsx +64 -0
- package/src/components/SubscriptionPreferences.tsx +140 -0
- package/src/components/SystemStatusBanner.tsx +46 -0
- package/src/components/WatcherList.test.tsx +50 -0
- package/src/components/WatcherList.tsx +122 -0
- package/src/components/helpers.ts +15 -0
- package/src/components/types.ts +71 -0
- package/src/index.tsx +31 -0
- 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
|
+
});
|