@echothink-ui/inbox 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/AgentDraftReviewPanel.d.ts +18 -0
- package/dist/components/ApprovalToSendPanel.d.ts +11 -0
- package/dist/components/AttachmentList.d.ts +8 -0
- package/dist/components/AttachmentPreview.d.ts +7 -0
- package/dist/components/EmailAutomationRulePanel.d.ts +8 -0
- package/dist/components/InboxShell.d.ts +10 -0
- package/dist/components/LabelManager.d.ts +9 -0
- package/dist/components/MailboxFolderList.d.ts +8 -0
- package/dist/components/MessageComposer.d.ts +13 -0
- package/dist/components/MessageFilterBar.d.ts +13 -0
- package/dist/components/MessageList.d.ts +8 -0
- package/dist/components/MessagePreview.d.ts +9 -0
- package/dist/components/MessageSearch.d.ts +11 -0
- package/dist/components/MessageThread.d.ts +22 -0
- package/dist/components/PriorityInboxView.d.ts +14 -0
- package/dist/components/RecipientPicker.d.ts +10 -0
- package/dist/components/ThreadSummaryPanel.d.ts +9 -0
- package/dist/index.cjs +1699 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +2155 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.js +1645 -0
- package/dist/index.js.map +1 -0
- package/dist/types.d.ts +75 -0
- package/dist/utils.d.ts +5 -0
- package/package.json +44 -0
- package/src/components/AgentDraftReviewPanel.tsx +128 -0
- package/src/components/ApprovalToSendPanel.tsx +118 -0
- package/src/components/AttachmentList.tsx +85 -0
- package/src/components/AttachmentPreview.tsx +207 -0
- package/src/components/EmailAutomationRulePanel.tsx +100 -0
- package/src/components/InboxShell.tsx +62 -0
- package/src/components/LabelManager.tsx +147 -0
- package/src/components/MailboxFolderList.tsx +66 -0
- package/src/components/MessageComposer.tsx +160 -0
- package/src/components/MessageFilterBar.test.tsx +34 -0
- package/src/components/MessageFilterBar.tsx +69 -0
- package/src/components/MessageList.tsx +101 -0
- package/src/components/MessagePreview.test.tsx +48 -0
- package/src/components/MessagePreview.tsx +84 -0
- package/src/components/MessageSearch.tsx +96 -0
- package/src/components/MessageThread.tsx +173 -0
- package/src/components/PriorityInboxView.tsx +74 -0
- package/src/components/RecipientPicker.tsx +181 -0
- package/src/components/ThreadSummaryPanel.tsx +107 -0
- package/src/index.test.tsx +276 -0
- package/src/index.tsx +60 -0
- package/src/styles.css +2523 -0
- package/src/types.ts +85 -0
- package/src/utils.ts +33 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button, FormField, Panel, TextInput } from "@echothink-ui/core";
|
|
3
|
+
import { EditIcon, PlusIcon } from "@echothink-ui/icons";
|
|
4
|
+
import type { MailLabel } from "../types";
|
|
5
|
+
|
|
6
|
+
export interface LabelManagerProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
7
|
+
labels?: MailLabel[];
|
|
8
|
+
onCreate?: (label: string) => void;
|
|
9
|
+
onRename?: (id: string, label: string) => void;
|
|
10
|
+
onDelete?: (id: string) => void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function LabelManager({
|
|
14
|
+
labels = [],
|
|
15
|
+
onCreate,
|
|
16
|
+
onRename,
|
|
17
|
+
onDelete,
|
|
18
|
+
className,
|
|
19
|
+
...props
|
|
20
|
+
}: LabelManagerProps) {
|
|
21
|
+
const [newLabel, setNewLabel] = React.useState("");
|
|
22
|
+
const [edits, setEdits] = React.useState<Record<string, string>>({});
|
|
23
|
+
const trackedLabels = labels.filter((label) => typeof label.count === "number");
|
|
24
|
+
const trackedMessageCount = trackedLabels.reduce((total, label) => total + (label.count ?? 0), 0);
|
|
25
|
+
const labelSummary = `${labels.length.toLocaleString()} ${labels.length === 1 ? "label" : "labels"}`;
|
|
26
|
+
const subtitle = labels.length
|
|
27
|
+
? trackedLabels.length
|
|
28
|
+
? `${labelSummary} · ${trackedMessageCount.toLocaleString()} tracked messages`
|
|
29
|
+
: `${labelSummary} configured for mailbox categories and automation tags.`
|
|
30
|
+
: "Create categories and automation tags for mailbox workflows.";
|
|
31
|
+
|
|
32
|
+
const submitNewLabel = (event: React.FormEvent<HTMLFormElement>) => {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
|
|
35
|
+
const nextLabel = newLabel.trim();
|
|
36
|
+
if (!nextLabel || !onCreate) return;
|
|
37
|
+
|
|
38
|
+
onCreate(nextLabel);
|
|
39
|
+
setNewLabel("");
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Panel
|
|
44
|
+
{...props}
|
|
45
|
+
className={`eth-inbox-label-manager ${className ?? ""}`}
|
|
46
|
+
title="Labels"
|
|
47
|
+
subtitle={subtitle}
|
|
48
|
+
data-eth-component="LabelManager"
|
|
49
|
+
>
|
|
50
|
+
{labels.length ? (
|
|
51
|
+
<div className="eth-inbox-label-manager__list" role="table" aria-label="Managed labels">
|
|
52
|
+
<div className="eth-inbox-label-manager__table-head" role="row">
|
|
53
|
+
<span role="columnheader">Label</span>
|
|
54
|
+
<span role="columnheader">Messages</span>
|
|
55
|
+
<span role="columnheader">Actions</span>
|
|
56
|
+
</div>
|
|
57
|
+
{labels.map((label) => {
|
|
58
|
+
const draft = edits[label.id] ?? label.label;
|
|
59
|
+
const trimmedDraft = draft.trim();
|
|
60
|
+
const countLabel =
|
|
61
|
+
typeof label.count === "number"
|
|
62
|
+
? `${label.count.toLocaleString()} ${label.count === 1 ? "message" : "messages"}`
|
|
63
|
+
: "Message count not tracked";
|
|
64
|
+
|
|
65
|
+
return (
|
|
66
|
+
<div key={label.id} className="eth-inbox-label-manager__row" role="row">
|
|
67
|
+
<div className="eth-inbox-label-manager__name-cell" role="cell">
|
|
68
|
+
<TextInput
|
|
69
|
+
aria-label={`Label name for ${label.label}`}
|
|
70
|
+
density="compact"
|
|
71
|
+
placeholder="Label name"
|
|
72
|
+
value={draft}
|
|
73
|
+
onChange={(event) =>
|
|
74
|
+
setEdits((current) => ({
|
|
75
|
+
...current,
|
|
76
|
+
[label.id]: event.currentTarget.value
|
|
77
|
+
}))
|
|
78
|
+
}
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
<div
|
|
82
|
+
className="eth-inbox-label-manager__count-cell"
|
|
83
|
+
role="cell"
|
|
84
|
+
aria-label={countLabel}
|
|
85
|
+
>
|
|
86
|
+
{typeof label.count === "number" ? (
|
|
87
|
+
<>
|
|
88
|
+
<strong>{label.count.toLocaleString()}</strong>
|
|
89
|
+
<span>{label.count === 1 ? "message" : "messages"}</span>
|
|
90
|
+
</>
|
|
91
|
+
) : (
|
|
92
|
+
<span className="eth-inbox-label-manager__count-muted">Not tracked</span>
|
|
93
|
+
)}
|
|
94
|
+
</div>
|
|
95
|
+
<div className="eth-inbox-label-manager__actions" role="cell">
|
|
96
|
+
<Button
|
|
97
|
+
density="compact"
|
|
98
|
+
disabled={!onRename || !trimmedDraft}
|
|
99
|
+
icon={<EditIcon size={16} />}
|
|
100
|
+
intent="secondary"
|
|
101
|
+
onClick={() => {
|
|
102
|
+
if (trimmedDraft) onRename?.(label.id, trimmedDraft);
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
Rename
|
|
106
|
+
</Button>
|
|
107
|
+
<Button
|
|
108
|
+
density="compact"
|
|
109
|
+
disabled={!onDelete}
|
|
110
|
+
intent="danger"
|
|
111
|
+
onClick={() => onDelete?.(label.id)}
|
|
112
|
+
>
|
|
113
|
+
Delete
|
|
114
|
+
</Button>
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
</div>
|
|
120
|
+
) : (
|
|
121
|
+
<p className="eth-inbox-label-manager__empty">
|
|
122
|
+
No labels configured. Create a label to route mailbox views and automation rules.
|
|
123
|
+
</p>
|
|
124
|
+
)}
|
|
125
|
+
<form className="eth-inbox-label-manager__create" onSubmit={submitNewLabel}>
|
|
126
|
+
<FormField
|
|
127
|
+
label="New label"
|
|
128
|
+
helperText="Use concise names for categories, filters, and automation rules."
|
|
129
|
+
>
|
|
130
|
+
<TextInput
|
|
131
|
+
density="compact"
|
|
132
|
+
placeholder="e.g. renewal-risk"
|
|
133
|
+
value={newLabel}
|
|
134
|
+
onChange={(event) => setNewLabel(event.currentTarget.value)}
|
|
135
|
+
/>
|
|
136
|
+
</FormField>
|
|
137
|
+
<Button
|
|
138
|
+
disabled={!onCreate || !newLabel.trim()}
|
|
139
|
+
icon={<PlusIcon size={16} />}
|
|
140
|
+
type="submit"
|
|
141
|
+
>
|
|
142
|
+
Create
|
|
143
|
+
</Button>
|
|
144
|
+
</form>
|
|
145
|
+
</Panel>
|
|
146
|
+
);
|
|
147
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge } from "@echothink-ui/core";
|
|
3
|
+
import type { MailboxFolder } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface MailboxFolderListProps extends Omit<
|
|
6
|
+
React.HTMLAttributes<HTMLElement>,
|
|
7
|
+
"onSelect"
|
|
8
|
+
> {
|
|
9
|
+
folders?: MailboxFolder[];
|
|
10
|
+
activeFolderId?: string;
|
|
11
|
+
onSelect?: (id: string) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function MailboxFolderList({
|
|
15
|
+
folders = [],
|
|
16
|
+
activeFolderId,
|
|
17
|
+
onSelect,
|
|
18
|
+
className,
|
|
19
|
+
"aria-label": ariaLabel = "Mailbox folders",
|
|
20
|
+
...props
|
|
21
|
+
}: MailboxFolderListProps) {
|
|
22
|
+
return (
|
|
23
|
+
<nav
|
|
24
|
+
{...props}
|
|
25
|
+
className={["eth-inbox-folders", className].filter(Boolean).join(" ")}
|
|
26
|
+
data-eth-component="MailboxFolderList"
|
|
27
|
+
aria-label={ariaLabel}
|
|
28
|
+
>
|
|
29
|
+
<ul className="eth-inbox-folders__list">
|
|
30
|
+
{folders.map((folder) => {
|
|
31
|
+
const isActive = activeFolderId === folder.id;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<li key={folder.id}>
|
|
35
|
+
<button
|
|
36
|
+
type="button"
|
|
37
|
+
className={[
|
|
38
|
+
"eth-inbox-folders__item",
|
|
39
|
+
isActive ? "eth-inbox-folders__item--active" : "",
|
|
40
|
+
folder.smart ? "eth-inbox-folders__item--smart" : ""
|
|
41
|
+
]
|
|
42
|
+
.filter(Boolean)
|
|
43
|
+
.join(" ")}
|
|
44
|
+
aria-current={isActive ? "page" : undefined}
|
|
45
|
+
onClick={() => onSelect?.(folder.id)}
|
|
46
|
+
title={folder.label}
|
|
47
|
+
>
|
|
48
|
+
{folder.icon ? (
|
|
49
|
+
<span className="eth-inbox-folders__icon" aria-hidden="true">
|
|
50
|
+
{folder.icon}
|
|
51
|
+
</span>
|
|
52
|
+
) : null}
|
|
53
|
+
<span className="eth-inbox-folders__label">{folder.label}</span>
|
|
54
|
+
{typeof folder.count === "number" ? (
|
|
55
|
+
<Badge className="eth-inbox-folders__count" aria-label={`${folder.count} items`}>
|
|
56
|
+
{folder.count}
|
|
57
|
+
</Badge>
|
|
58
|
+
) : null}
|
|
59
|
+
</button>
|
|
60
|
+
</li>
|
|
61
|
+
);
|
|
62
|
+
})}
|
|
63
|
+
</ul>
|
|
64
|
+
</nav>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Button, FormField, Textarea, TextInput } from "@echothink-ui/core";
|
|
3
|
+
import { DocumentIcon, MessageIcon } from "@echothink-ui/icons";
|
|
4
|
+
import type { MessageDraft } from "../types";
|
|
5
|
+
import { inputToList, listToInput } from "../utils";
|
|
6
|
+
import { AttachmentList } from "./AttachmentList";
|
|
7
|
+
|
|
8
|
+
export interface MessageComposerProps extends Omit<
|
|
9
|
+
React.FormHTMLAttributes<HTMLFormElement>,
|
|
10
|
+
"onChange" | "onSubmit"
|
|
11
|
+
> {
|
|
12
|
+
draft?: MessageDraft;
|
|
13
|
+
onChange?: (draft: MessageDraft) => void;
|
|
14
|
+
onSend?: (draft: MessageDraft) => void;
|
|
15
|
+
onDiscard?: () => void;
|
|
16
|
+
onAttach?: () => void;
|
|
17
|
+
recipients?: React.ReactNode;
|
|
18
|
+
attachments?: React.ReactNode;
|
|
19
|
+
statusText?: React.ReactNode;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function MessageComposer({
|
|
23
|
+
draft = {},
|
|
24
|
+
onChange,
|
|
25
|
+
onSend,
|
|
26
|
+
onDiscard,
|
|
27
|
+
onAttach,
|
|
28
|
+
recipients,
|
|
29
|
+
attachments,
|
|
30
|
+
statusText,
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: MessageComposerProps) {
|
|
34
|
+
const [localDraft, setLocalDraft] = React.useState<MessageDraft>(draft);
|
|
35
|
+
|
|
36
|
+
React.useEffect(() => setLocalDraft(draft), [draft]);
|
|
37
|
+
|
|
38
|
+
const update = (next: MessageDraft) => {
|
|
39
|
+
setLocalDraft(next);
|
|
40
|
+
onChange?.(next);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const draftAttachments = localDraft.attachments ?? [];
|
|
44
|
+
const hasAttachmentsRegion = Boolean(attachments || draftAttachments.length || onAttach);
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<form
|
|
48
|
+
{...props}
|
|
49
|
+
className={["eth-inbox-composer", className].filter(Boolean).join(" ")}
|
|
50
|
+
data-eth-component="MessageComposer"
|
|
51
|
+
onSubmit={(event) => {
|
|
52
|
+
event.preventDefault();
|
|
53
|
+
onSend?.(localDraft);
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
<div className="eth-inbox-composer__fields">
|
|
57
|
+
{recipients ?? (
|
|
58
|
+
<div className="eth-inbox-composer__address-fields" role="group" aria-label="Recipients">
|
|
59
|
+
<FormField
|
|
60
|
+
className="eth-inbox-composer__field eth-inbox-composer__field--address"
|
|
61
|
+
label="To"
|
|
62
|
+
>
|
|
63
|
+
<TextInput
|
|
64
|
+
density="compact"
|
|
65
|
+
placeholder="name@company.com"
|
|
66
|
+
value={listToInput(localDraft.to)}
|
|
67
|
+
onChange={(event) =>
|
|
68
|
+
update({ ...localDraft, to: inputToList(event.currentTarget.value) })
|
|
69
|
+
}
|
|
70
|
+
/>
|
|
71
|
+
</FormField>
|
|
72
|
+
<FormField
|
|
73
|
+
className="eth-inbox-composer__field eth-inbox-composer__field--address"
|
|
74
|
+
label="CC"
|
|
75
|
+
>
|
|
76
|
+
<TextInput
|
|
77
|
+
density="compact"
|
|
78
|
+
placeholder="Optional"
|
|
79
|
+
value={listToInput(localDraft.cc)}
|
|
80
|
+
onChange={(event) =>
|
|
81
|
+
update({ ...localDraft, cc: inputToList(event.currentTarget.value) })
|
|
82
|
+
}
|
|
83
|
+
/>
|
|
84
|
+
</FormField>
|
|
85
|
+
<FormField
|
|
86
|
+
className="eth-inbox-composer__field eth-inbox-composer__field--address"
|
|
87
|
+
label="BCC"
|
|
88
|
+
>
|
|
89
|
+
<TextInput
|
|
90
|
+
density="compact"
|
|
91
|
+
placeholder="Optional"
|
|
92
|
+
value={listToInput(localDraft.bcc)}
|
|
93
|
+
onChange={(event) =>
|
|
94
|
+
update({ ...localDraft, bcc: inputToList(event.currentTarget.value) })
|
|
95
|
+
}
|
|
96
|
+
/>
|
|
97
|
+
</FormField>
|
|
98
|
+
</div>
|
|
99
|
+
)}
|
|
100
|
+
<FormField
|
|
101
|
+
className="eth-inbox-composer__field eth-inbox-composer__field--subject"
|
|
102
|
+
label="Subject"
|
|
103
|
+
>
|
|
104
|
+
<TextInput
|
|
105
|
+
density="compact"
|
|
106
|
+
value={localDraft.subject ?? ""}
|
|
107
|
+
onChange={(event) => update({ ...localDraft, subject: event.currentTarget.value })}
|
|
108
|
+
/>
|
|
109
|
+
</FormField>
|
|
110
|
+
<FormField
|
|
111
|
+
className="eth-inbox-composer__field eth-inbox-composer__field--body"
|
|
112
|
+
label="Body"
|
|
113
|
+
>
|
|
114
|
+
<Textarea
|
|
115
|
+
value={localDraft.body ?? ""}
|
|
116
|
+
rows={10}
|
|
117
|
+
onChange={(event) => update({ ...localDraft, body: event.currentTarget.value })}
|
|
118
|
+
/>
|
|
119
|
+
</FormField>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{hasAttachmentsRegion ? (
|
|
123
|
+
<section className="eth-inbox-composer__attachments" aria-label="Draft attachments">
|
|
124
|
+
<div className="eth-inbox-composer__attachments-header">
|
|
125
|
+
<span>Attachments</span>
|
|
126
|
+
{onAttach ? (
|
|
127
|
+
<Button
|
|
128
|
+
type="button"
|
|
129
|
+
intent="tertiary"
|
|
130
|
+
density="compact"
|
|
131
|
+
icon={<DocumentIcon size={16} />}
|
|
132
|
+
onClick={onAttach}
|
|
133
|
+
>
|
|
134
|
+
Attach file
|
|
135
|
+
</Button>
|
|
136
|
+
) : null}
|
|
137
|
+
</div>
|
|
138
|
+
{attachments ??
|
|
139
|
+
(draftAttachments.length ? (
|
|
140
|
+
<AttachmentList attachments={draftAttachments} />
|
|
141
|
+
) : (
|
|
142
|
+
<p className="eth-inbox-composer__empty-attachments">No attachments added</p>
|
|
143
|
+
))}
|
|
144
|
+
</section>
|
|
145
|
+
) : null}
|
|
146
|
+
|
|
147
|
+
<div className="eth-inbox-composer__footer">
|
|
148
|
+
{statusText ? <p className="eth-inbox-composer__status">{statusText}</p> : null}
|
|
149
|
+
<div className="eth-inbox-composer__actions">
|
|
150
|
+
<Button type="submit" icon={<MessageIcon size={16} />}>
|
|
151
|
+
Send
|
|
152
|
+
</Button>
|
|
153
|
+
<Button type="button" intent="tertiary" onClick={onDiscard}>
|
|
154
|
+
Discard
|
|
155
|
+
</Button>
|
|
156
|
+
</div>
|
|
157
|
+
</div>
|
|
158
|
+
</form>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { MessageFilterBar, type MessageFilter } from "./MessageFilterBar";
|
|
4
|
+
|
|
5
|
+
const filters = [
|
|
6
|
+
{ id: "priority", label: "High priority", kind: "priority", active: true },
|
|
7
|
+
{ id: "sender", label: "Jane Doe", kind: "sender" },
|
|
8
|
+
{ id: "archived", label: "Archived", kind: "status", disabled: true }
|
|
9
|
+
] satisfies MessageFilter[];
|
|
10
|
+
|
|
11
|
+
describe("@echothink-ui/inbox MessageFilterBar", () => {
|
|
12
|
+
it("renders typed filter buttons with pressed state semantics", () => {
|
|
13
|
+
render(<MessageFilterBar filters={filters} />);
|
|
14
|
+
|
|
15
|
+
const priority = screen.getByRole("button", { name: "Priority: High priority" });
|
|
16
|
+
expect(priority.getAttribute("aria-pressed")).toBe("true");
|
|
17
|
+
expect(screen.getByText("Priority")).toBeTruthy();
|
|
18
|
+
expect(screen.getByText("High priority")).toBeTruthy();
|
|
19
|
+
|
|
20
|
+
const sender = screen.getByRole("button", { name: "Sender: Jane Doe" });
|
|
21
|
+
expect(sender.getAttribute("aria-pressed")).toBe("false");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("toggles enabled filters and leaves disabled filters inert", () => {
|
|
25
|
+
const onToggle = vi.fn();
|
|
26
|
+
render(<MessageFilterBar filters={filters} onToggle={onToggle} />);
|
|
27
|
+
|
|
28
|
+
fireEvent.click(screen.getByRole("button", { name: "Sender: Jane Doe" }));
|
|
29
|
+
expect(onToggle).toHaveBeenCalledWith("sender", true);
|
|
30
|
+
|
|
31
|
+
fireEvent.click(screen.getByRole("button", { name: "Status: Archived" }));
|
|
32
|
+
expect(onToggle).toHaveBeenCalledTimes(1);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
export interface MessageFilter {
|
|
4
|
+
id: string;
|
|
5
|
+
label: string;
|
|
6
|
+
active?: boolean;
|
|
7
|
+
disabled?: boolean;
|
|
8
|
+
kind?: "priority" | "sender" | "date" | "label" | "unread" | "status";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface MessageFilterBarProps
|
|
12
|
+
extends Omit<React.HTMLAttributes<HTMLDivElement>, "onToggle"> {
|
|
13
|
+
filters?: MessageFilter[];
|
|
14
|
+
onToggle?: (id: string, active: boolean) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const filterKindLabels: Record<NonNullable<MessageFilter["kind"]>, string> = {
|
|
18
|
+
priority: "Priority",
|
|
19
|
+
sender: "Sender",
|
|
20
|
+
date: "Date",
|
|
21
|
+
label: "Label",
|
|
22
|
+
unread: "Read",
|
|
23
|
+
status: "Status"
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function MessageFilterBar({
|
|
27
|
+
filters = [],
|
|
28
|
+
onToggle,
|
|
29
|
+
className,
|
|
30
|
+
"aria-label": ariaLabel = "Message filters",
|
|
31
|
+
...props
|
|
32
|
+
}: MessageFilterBarProps) {
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
{...props}
|
|
36
|
+
className={`eth-inbox-filter-bar ${className ?? ""}`}
|
|
37
|
+
data-eth-component="MessageFilterBar"
|
|
38
|
+
role="toolbar"
|
|
39
|
+
aria-label={ariaLabel}
|
|
40
|
+
>
|
|
41
|
+
{filters.map((filter) => {
|
|
42
|
+
const kindLabel = filter.kind ? filterKindLabels[filter.kind] : undefined;
|
|
43
|
+
const accessibleLabel = kindLabel ? `${kindLabel}: ${filter.label}` : filter.label;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<button
|
|
47
|
+
key={filter.id}
|
|
48
|
+
type="button"
|
|
49
|
+
className={`eth-inbox-filter-bar__chip ${
|
|
50
|
+
filter.active ? "eth-inbox-filter-bar__chip--active" : ""
|
|
51
|
+
} ${filter.disabled ? "eth-inbox-filter-bar__chip--disabled" : ""}`}
|
|
52
|
+
aria-label={accessibleLabel}
|
|
53
|
+
aria-pressed={Boolean(filter.active)}
|
|
54
|
+
data-filter-kind={filter.kind}
|
|
55
|
+
disabled={filter.disabled}
|
|
56
|
+
onClick={() => onToggle?.(filter.id, !Boolean(filter.active))}
|
|
57
|
+
>
|
|
58
|
+
{kindLabel ? (
|
|
59
|
+
<span className="eth-inbox-filter-bar__kind" aria-hidden="true">
|
|
60
|
+
{kindLabel}
|
|
61
|
+
</span>
|
|
62
|
+
) : null}
|
|
63
|
+
<span className="eth-inbox-filter-bar__label">{filter.label}</span>
|
|
64
|
+
</button>
|
|
65
|
+
);
|
|
66
|
+
})}
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { Badge, StatusDot, Tag, statusLabel } from "@echothink-ui/core";
|
|
3
|
+
import type { InboxThread } from "../types";
|
|
4
|
+
import { formatDateTime, prioritySeverity } from "../utils";
|
|
5
|
+
|
|
6
|
+
export interface MessageListProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "onSelect"> {
|
|
7
|
+
threads?: InboxThread[];
|
|
8
|
+
selectedThreadId?: string;
|
|
9
|
+
onSelect?: (threadId: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function MessageList({
|
|
13
|
+
threads = [],
|
|
14
|
+
selectedThreadId,
|
|
15
|
+
onSelect,
|
|
16
|
+
className,
|
|
17
|
+
...props
|
|
18
|
+
}: MessageListProps) {
|
|
19
|
+
const hasThreads = threads.length > 0;
|
|
20
|
+
|
|
21
|
+
return (
|
|
22
|
+
<div
|
|
23
|
+
{...props}
|
|
24
|
+
className={`eth-inbox-message-list ${className ?? ""}`}
|
|
25
|
+
role="listbox"
|
|
26
|
+
aria-label="Message threads"
|
|
27
|
+
data-eth-component="MessageList"
|
|
28
|
+
>
|
|
29
|
+
{hasThreads ? (
|
|
30
|
+
threads.map((thread, index) => {
|
|
31
|
+
const receivedAtLabel = formatDateTime(thread.receivedAt);
|
|
32
|
+
const selected = selectedThreadId === thread.id;
|
|
33
|
+
const hasMeta = Boolean(thread.priority || thread.labels?.length || thread.status);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<button
|
|
37
|
+
key={thread.id}
|
|
38
|
+
type="button"
|
|
39
|
+
role="option"
|
|
40
|
+
aria-label={[
|
|
41
|
+
thread.unread ? "Unread" : "Read",
|
|
42
|
+
`From ${thread.from}`,
|
|
43
|
+
thread.subject,
|
|
44
|
+
`Received ${receivedAtLabel}`,
|
|
45
|
+
thread.priority ? `${thread.priority} priority` : null,
|
|
46
|
+
thread.status ? statusLabel(thread.status) : null
|
|
47
|
+
]
|
|
48
|
+
.filter(Boolean)
|
|
49
|
+
.join(", ")}
|
|
50
|
+
aria-posinset={index + 1}
|
|
51
|
+
aria-selected={selected}
|
|
52
|
+
aria-setsize={threads.length}
|
|
53
|
+
className={`eth-inbox-message-list__row ${
|
|
54
|
+
thread.unread ? "eth-inbox-message-list__row--unread" : ""
|
|
55
|
+
}`}
|
|
56
|
+
onClick={() => onSelect?.(thread.id)}
|
|
57
|
+
>
|
|
58
|
+
<span className="eth-inbox-message-list__selection" aria-hidden="true" />
|
|
59
|
+
<span className="eth-inbox-message-list__content">
|
|
60
|
+
<span className="eth-inbox-message-list__header">
|
|
61
|
+
<span className="eth-inbox-message-list__from-group">
|
|
62
|
+
<span className="eth-inbox-message-list__unread" aria-hidden="true" />
|
|
63
|
+
<span className="eth-inbox-message-list__from">{thread.from}</span>
|
|
64
|
+
</span>
|
|
65
|
+
<time className="eth-inbox-message-list__date" dateTime={thread.receivedAt}>
|
|
66
|
+
{receivedAtLabel}
|
|
67
|
+
</time>
|
|
68
|
+
</span>
|
|
69
|
+
<strong className="eth-inbox-message-list__subject">{thread.subject}</strong>
|
|
70
|
+
<span className="eth-inbox-message-list__preview">{thread.preview}</span>
|
|
71
|
+
{hasMeta ? (
|
|
72
|
+
<span className="eth-inbox-message-list__meta">
|
|
73
|
+
{thread.priority ? (
|
|
74
|
+
<Badge severity={prioritySeverity(thread.priority)}>
|
|
75
|
+
{thread.priority}
|
|
76
|
+
</Badge>
|
|
77
|
+
) : null}
|
|
78
|
+
{thread.labels?.map((label) => (
|
|
79
|
+
<Tag key={label}>{label}</Tag>
|
|
80
|
+
))}
|
|
81
|
+
{thread.status ? (
|
|
82
|
+
<StatusDot status={thread.status} label={statusLabel(thread.status)} />
|
|
83
|
+
) : null}
|
|
84
|
+
</span>
|
|
85
|
+
) : null}
|
|
86
|
+
</span>
|
|
87
|
+
</button>
|
|
88
|
+
);
|
|
89
|
+
})
|
|
90
|
+
) : (
|
|
91
|
+
<div
|
|
92
|
+
className="eth-inbox-message-list__empty"
|
|
93
|
+
role="status"
|
|
94
|
+
aria-label="No message threads"
|
|
95
|
+
>
|
|
96
|
+
No message threads
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
</div>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { MessagePreview } from "./MessagePreview";
|
|
4
|
+
import type { ThreadMessage } from "../types";
|
|
5
|
+
|
|
6
|
+
const message: ThreadMessage = {
|
|
7
|
+
id: "m1",
|
|
8
|
+
from: "Jane Doe",
|
|
9
|
+
to: ["you@echothink.example"],
|
|
10
|
+
cc: ["launch-ops@echothink.example"],
|
|
11
|
+
date: "2026-05-27T10:42:00Z",
|
|
12
|
+
subject: "Q3 launch - final brief",
|
|
13
|
+
body: "Please review the attached campaign brief and approve the messaging direction.",
|
|
14
|
+
attachments: [{ id: "a1", name: "brief.pdf", size: 240_000, mimeType: "application/pdf" }]
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("@echothink-ui/inbox MessagePreview", () => {
|
|
18
|
+
it("renders the selected message as an article with structured routing metadata", () => {
|
|
19
|
+
render(<MessagePreview message={message} />);
|
|
20
|
+
|
|
21
|
+
expect(screen.getByRole("article", { name: "Q3 launch - final brief" })).toBeTruthy();
|
|
22
|
+
expect(screen.getByText("From")).toBeTruthy();
|
|
23
|
+
expect(screen.getByText("Jane Doe")).toBeTruthy();
|
|
24
|
+
expect(screen.getByText("To")).toBeTruthy();
|
|
25
|
+
expect(screen.getByText("you@echothink.example")).toBeTruthy();
|
|
26
|
+
expect(screen.getByText("Cc")).toBeTruthy();
|
|
27
|
+
expect(screen.getByText("launch-ops@echothink.example")).toBeTruthy();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("wires attachment preview and download actions from the message pane", () => {
|
|
31
|
+
const onPreviewAttachment = vi.fn();
|
|
32
|
+
const onDownloadAttachment = vi.fn();
|
|
33
|
+
|
|
34
|
+
render(
|
|
35
|
+
<MessagePreview
|
|
36
|
+
message={message}
|
|
37
|
+
onPreviewAttachment={onPreviewAttachment}
|
|
38
|
+
onDownloadAttachment={onDownloadAttachment}
|
|
39
|
+
/>
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
fireEvent.click(screen.getByRole("button", { name: "Preview brief.pdf" }));
|
|
43
|
+
fireEvent.click(screen.getByRole("button", { name: "Download brief.pdf" }));
|
|
44
|
+
|
|
45
|
+
expect(onPreviewAttachment).toHaveBeenCalledWith(message.attachments![0]);
|
|
46
|
+
expect(onDownloadAttachment).toHaveBeenCalledWith(message.attachments![0]);
|
|
47
|
+
});
|
|
48
|
+
});
|