@remit/ui 0.0.104 → 0.0.106

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.104",
3
+ "version": "0.0.106",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -1,4 +1,5 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
2
3
  import { type AttachmentItem, AttachmentList } from "./attachment-list.js";
3
4
 
4
5
  /**
@@ -25,57 +26,95 @@ const report: AttachmentItem = {
25
26
  download: { status: "idle" },
26
27
  };
27
28
 
29
+ const sitePlan: AttachmentItem = {
30
+ attachmentId: "part-3",
31
+ filename: "site-plan.png",
32
+ typeLabel: "PNG",
33
+ sizeOctets: 486_120,
34
+ download: { status: "idle" },
35
+ };
36
+
37
+ /** How long the app's own fetch takes on a small attachment, near enough. */
38
+ const DOWNLOAD_MS = 900;
39
+
40
+ /**
41
+ * The app owns the fetch and hands per-row state back. The harness plays that
42
+ * part: a press puts the row on the spinner, and the row returns to rest when
43
+ * the file has been handed to the browser. A row is only pressable once at a
44
+ * time, which is the state the component reads to disable it.
45
+ */
46
+ const Harness = ({
47
+ attachments,
48
+ hasUnlistedAttachment,
49
+ }: {
50
+ attachments: readonly AttachmentItem[];
51
+ hasUnlistedAttachment?: boolean;
52
+ }) => {
53
+ const [rows, setRows] = useState<readonly AttachmentItem[]>(attachments);
54
+
55
+ const setDownload = (attachmentId: string, item: AttachmentItem) =>
56
+ setRows((current) =>
57
+ current.map((row) => (row.attachmentId === attachmentId ? item : row)),
58
+ );
59
+
60
+ const download = (attachmentId: string) => {
61
+ const row = rows.find((item) => item.attachmentId === attachmentId);
62
+ if (!row || row.download.status === "downloading") return;
63
+ setDownload(attachmentId, { ...row, download: { status: "downloading" } });
64
+ setTimeout(
65
+ () => setDownload(attachmentId, { ...row, download: { status: "idle" } }),
66
+ DOWNLOAD_MS,
67
+ );
68
+ };
69
+
70
+ return (
71
+ <AttachmentList
72
+ attachments={rows}
73
+ onDownload={download}
74
+ hasUnlistedAttachment={hasUnlistedAttachment}
75
+ />
76
+ );
77
+ };
78
+
28
79
  export const OneAttachment: Story = {
29
- args: {
30
- attachments: [report],
31
- onDownload: (id) => alert(`Download ${id}`),
32
- },
80
+ render: () => <Harness attachments={[report]} />,
33
81
  };
34
82
 
35
83
  export const SeveralAttachments: Story = {
36
- args: {
37
- attachments: [
38
- report,
39
- {
40
- attachmentId: "part-3",
41
- filename: "site-plan.png",
42
- typeLabel: "PNG",
43
- sizeOctets: 486_120,
44
- download: { status: "idle" },
45
- },
46
- {
47
- attachmentId: "part-4",
48
- filename: "notes.txt",
49
- typeLabel: "PLAIN",
50
- sizeOctets: 812,
51
- download: { status: "idle" },
52
- },
53
- {
54
- attachmentId: "part-5",
55
- filename: "archive",
56
- typeLabel: "FILE",
57
- sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
58
- download: { status: "idle" },
59
- },
60
- ],
61
- onDownload: (id) => alert(`Download ${id}`),
62
- },
84
+ render: () => (
85
+ <Harness
86
+ attachments={[
87
+ report,
88
+ sitePlan,
89
+ {
90
+ attachmentId: "part-4",
91
+ filename: "notes.txt",
92
+ typeLabel: "PLAIN",
93
+ sizeOctets: 812,
94
+ download: { status: "idle" },
95
+ },
96
+ {
97
+ attachmentId: "part-5",
98
+ filename: "archive",
99
+ typeLabel: "FILE",
100
+ sizeOctets: 1024 ** 3 + 1024 ** 2 * 200,
101
+ download: { status: "idle" },
102
+ },
103
+ ]}
104
+ />
105
+ ),
63
106
  };
64
107
 
108
+ /** One row mid-fetch. The rows beside it are still pressable. */
65
109
  export const Downloading: Story = {
66
- args: {
67
- attachments: [
68
- { ...report, download: { status: "downloading" } },
69
- {
70
- attachmentId: "part-3",
71
- filename: "site-plan.png",
72
- typeLabel: "PNG",
73
- sizeOctets: 486_120,
74
- download: { status: "idle" },
75
- },
76
- ],
77
- onDownload: (id) => alert(`Download ${id}`),
78
- },
110
+ render: () => (
111
+ <Harness
112
+ attachments={[
113
+ { ...report, download: { status: "downloading" } },
114
+ sitePlan,
115
+ ]}
116
+ />
117
+ ),
79
118
  };
80
119
 
81
120
  /**
@@ -85,28 +124,23 @@ export const Downloading: Story = {
85
124
  * outcome this list exists to make impossible.
86
125
  */
87
126
  export const DownloadFailed: Story = {
88
- args: {
89
- attachments: [
90
- {
91
- ...report,
92
- download: {
93
- status: "failed",
94
- title: "This attachment is missing from storage",
95
- detail:
96
- "Remit has the message but not the file. Re-sync the account from Settings, then try again.",
97
- reportUrl: "https://github.com/remit-mail/reader/issues/new",
127
+ render: () => (
128
+ <Harness
129
+ attachments={[
130
+ {
131
+ ...report,
132
+ download: {
133
+ status: "failed",
134
+ title: "This attachment is missing from storage",
135
+ detail:
136
+ "Remit has the message but not the file. Re-sync the account from Settings, then try again.",
137
+ reportUrl: "https://github.com/remit-mail/reader/issues/new",
138
+ },
98
139
  },
99
- },
100
- {
101
- attachmentId: "part-3",
102
- filename: "site-plan.png",
103
- typeLabel: "PNG",
104
- sizeOctets: 486_120,
105
- download: { status: "idle" },
106
- },
107
- ],
108
- onDownload: (id) => alert(`Download ${id}`),
109
- },
140
+ sitePlan,
141
+ ]}
142
+ />
143
+ ),
110
144
  };
111
145
 
112
146
  /**
@@ -117,32 +151,33 @@ export const DownloadFailed: Story = {
117
151
  * what lands.
118
152
  */
119
153
  export const HostileFilename: Story = {
120
- args: {
121
- attachments: [
122
- {
123
- attachmentId: "part-6",
124
- filename: "passwd",
125
- typeLabel: "FILE",
126
- sizeOctets: 3_120,
127
- download: { status: "idle" },
128
- },
129
- {
130
- attachmentId: "part-7",
131
- filename: "invoicegnp.exe",
132
- typeLabel: "FILE",
133
- sizeOctets: 118_400,
134
- download: { status: "idle" },
135
- },
136
- {
137
- attachmentId: "part-8",
138
- filename: `${"long-name-".repeat(11)}report.pdf`,
139
- typeLabel: "PDF",
140
- sizeOctets: 44_000,
141
- download: { status: "idle" },
142
- },
143
- ],
144
- onDownload: (id) => alert(`Download ${id}`),
145
- },
154
+ render: () => (
155
+ <Harness
156
+ attachments={[
157
+ {
158
+ attachmentId: "part-6",
159
+ filename: "passwd",
160
+ typeLabel: "FILE",
161
+ sizeOctets: 3_120,
162
+ download: { status: "idle" },
163
+ },
164
+ {
165
+ attachmentId: "part-7",
166
+ filename: "invoicegnp.exe",
167
+ typeLabel: "FILE",
168
+ sizeOctets: 118_400,
169
+ download: { status: "idle" },
170
+ },
171
+ {
172
+ attachmentId: "part-8",
173
+ filename: `${"long-name-".repeat(11)}report.pdf`,
174
+ typeLabel: "PDF",
175
+ sizeOctets: 44_000,
176
+ download: { status: "idle" },
177
+ },
178
+ ]}
179
+ />
180
+ ),
146
181
  };
147
182
 
148
183
  /**
@@ -151,9 +186,5 @@ export const HostileFilename: Story = {
151
186
  * read as broken.
152
187
  */
153
188
  export const UnlistedAttachment: Story = {
154
- args: {
155
- attachments: [],
156
- onDownload: () => undefined,
157
- hasUnlistedAttachment: true,
158
- },
189
+ render: () => <Harness attachments={[]} hasUnlistedAttachment />,
159
190
  };
@@ -90,7 +90,9 @@ export function matchesBriefFilters(
90
90
  * The attribute chips are either this component's own or entirely the
91
91
  * consumer's. A consumer narrowing the same rows on a second surface (the phone
92
92
  * search takeover) holds the set so both surfaces answer to one selection, and
93
- * takes every control over it with the set.
93
+ * takes every control over it with the set. `onClearFilters` is then the whole
94
+ * of Clear, category scope included — one handler reading one state, rather
95
+ * than two reading the same one and racing to write it.
94
96
  */
95
97
  export type BriefFilterControl =
96
98
  | {
@@ -236,11 +238,11 @@ export function BriefSections({
236
238
  const sheetFilters = briefFilterChips;
237
239
 
238
240
  const clearFilters = () => {
239
- onSelectBriefCategory?.("all");
240
241
  if (onClearFilters) {
241
242
  onClearFilters();
242
243
  return;
243
244
  }
245
+ onSelectBriefCategory?.("all");
244
246
  setOwnFilters(new Set());
245
247
  };
246
248
 
@@ -1,4 +1,6 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { expect, userEvent, within } from "storybook/test";
2
4
  import { ComposeActionBar } from "./compose-action-bar.js";
3
5
 
4
6
  const meta: Meta<typeof ComposeActionBar> = {
@@ -30,11 +32,43 @@ export const Sending: Story = {
30
32
  args: { sending: true },
31
33
  };
32
34
 
35
+ /**
36
+ * Send is never greyed out. Pressing it with nothing to send on reports the
37
+ * reason where the app would raise its banner — a control that swallowed the
38
+ * press would be the dead button this bar exists to avoid.
39
+ */
33
40
  export const CannotSend: Story = {
34
41
  name: "Cannot send — stays pressable",
35
- args: {
36
- canSend: false,
37
- unavailableReason: "SMTP not configured",
38
- onUnavailable: () => undefined,
42
+ render: () => {
43
+ const [reason, setReason] = useState<string>();
44
+ return (
45
+ <div className="space-y-2">
46
+ {reason && (
47
+ <div
48
+ role="alert"
49
+ data-testid="compose-unavailable"
50
+ className="rounded-md bg-danger-soft px-3 py-2 text-sm text-danger"
51
+ >
52
+ {reason}
53
+ </div>
54
+ )}
55
+ <ComposeActionBar
56
+ onSend={() => undefined}
57
+ onDiscard={() => undefined}
58
+ sending={false}
59
+ canSend={false}
60
+ saveStatus="idle"
61
+ unavailableReason="SMTP not configured"
62
+ onUnavailable={setReason}
63
+ />
64
+ </div>
65
+ );
66
+ },
67
+ play: async ({ canvasElement }) => {
68
+ const canvas = within(canvasElement);
69
+ await userEvent.click(canvas.getByRole("button", { name: "Send" }));
70
+ await expect(canvas.getByTestId("compose-unavailable")).toHaveTextContent(
71
+ "SMTP not configured",
72
+ );
39
73
  },
40
74
  };
@@ -39,7 +39,7 @@ const Harness = ({
39
39
  initialHtml = "",
40
40
  initialText = "",
41
41
  startIn = "rich",
42
- onConversionError = () => undefined,
42
+ onConversionError,
43
43
  conversions,
44
44
  languages = LANGUAGES,
45
45
  quoted,
@@ -56,15 +56,29 @@ const Harness = ({
56
56
  quoted?: string;
57
57
  }) => {
58
58
  const [mode, setMode] = useState<"rich" | "plain">(startIn);
59
+ const [failure, setFailure] = useState<ConversionFailure>();
59
60
  return (
60
61
  <div className="flex h-[460px] w-[680px] flex-col overflow-auto rounded-md border border-line bg-canvas">
62
+ {failure && (
63
+ <div
64
+ role="alert"
65
+ data-testid="compose-conversion-error"
66
+ className="border-b border-danger/30 bg-danger-soft px-3 py-2 text-xs"
67
+ >
68
+ <p className="font-medium text-danger">{failure.title}</p>
69
+ <p className="text-fg-muted">{failure.detail}</p>
70
+ </div>
71
+ )}
61
72
  <ComposeBody
62
73
  mode={mode}
63
74
  onModeChange={setMode}
64
75
  initialHtml={initialHtml}
65
76
  initialText={initialText}
66
- onChange={() => undefined}
67
- onConversionError={onConversionError}
77
+ onChange={noop}
78
+ onConversionError={(reported) => {
79
+ setFailure(reported);
80
+ onConversionError?.(reported);
81
+ }}
68
82
  conversions={conversions}
69
83
  languages={languages}
70
84
  onLanguageChange={noop}
@@ -278,6 +292,9 @@ export const ConversionCameBackEmpty: Story = {
278
292
  title: "Couldn't switch to rich text",
279
293
  detail: "The conversion came back empty, so your message is unchanged.",
280
294
  });
295
+ await expect(
296
+ within(canvasElement).getByTestId("compose-conversion-error"),
297
+ ).toHaveTextContent("Couldn't switch to rich text");
281
298
  const textarea = plainSurface(canvasElement);
282
299
  if (!textarea) throw new Error("the plain surface left");
283
300
  await expect(textarea.value).toBe("Everything I wrote this morning.");
@@ -15,22 +15,11 @@ const meta: Meta<typeof ComposeModeToggle> = {
15
15
  title: "Mail/ComposeModeToggle",
16
16
  component: ComposeModeToggle,
17
17
  parameters: { layout: "centered" },
18
- args: { onToggle: () => undefined },
19
18
  };
20
19
  export default meta;
21
20
 
22
21
  type Story = StoryObj<typeof ComposeModeToggle>;
23
22
 
24
- export const RichText: Story = {
25
- name: "Rich text — plain text on offer",
26
- args: { mode: "rich" },
27
- };
28
-
29
- export const PlainText: Story = {
30
- name: "Plain text — the mode is on",
31
- args: { mode: "plain" },
32
- };
33
-
34
23
  const Harness = ({ start }: { start: ComposeBodyMode }) => {
35
24
  const [mode, setMode] = useState<ComposeBodyMode>(start);
36
25
  return (
@@ -41,6 +30,16 @@ const Harness = ({ start }: { start: ComposeBodyMode }) => {
41
30
  );
42
31
  };
43
32
 
33
+ export const RichText: Story = {
34
+ name: "Rich text — plain text on offer",
35
+ render: () => <Harness start="rich" />,
36
+ };
37
+
38
+ export const PlainText: Story = {
39
+ name: "Plain text — the mode is on",
40
+ render: () => <Harness start="plain" />,
41
+ };
42
+
44
43
  export const PressedStateFollowsTheMode: Story = {
45
44
  render: () => <Harness start="rich" />,
46
45
  play: async ({ canvasElement }) => {
@@ -11,7 +11,7 @@ const meta: Meta<typeof ComposeSmtpMissingBanner> = {
11
11
  title: "Mail/ComposeSmtpMissingBanner",
12
12
  component: ComposeSmtpMissingBanner,
13
13
  parameters: { layout: "padded" },
14
- args: { onConfigure: () => undefined },
14
+ args: { onConfigure: fn().mockName("onConfigure") },
15
15
  };
16
16
  export default meta;
17
17
 
@@ -353,7 +353,7 @@ export const NothingToConvert: Story = {
353
353
  initialValue="has:attachment"
354
354
  sections={resultSections}
355
355
  preset="inbox"
356
- makeFilterBlockedReason="Add a sender or words to filter on"
356
+ makeFilterBlockedReason="Has attachment isn't a filter condition — add a sender or words to filter on"
357
357
  />
358
358
  ),
359
359
  };
@@ -1,7 +1,12 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { Paperclip, Star } from "lucide-react";
3
+ import { useState } from "react";
3
4
  import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
4
- import { AttachmentList } from "./attachment-list.js";
5
+ import {
6
+ type AttachmentDownloadState,
7
+ type AttachmentItem,
8
+ AttachmentList,
9
+ } from "./attachment-list.js";
5
10
  import { MessageBodyView } from "./message-body-view.js";
6
11
  import {
7
12
  CollapsedMessage,
@@ -198,6 +203,44 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
198
203
  ),
199
204
  };
200
205
 
206
+ /** The app owns the fetch and hands the row its state back; so does this. */
207
+ const ThreadAttachments = () => {
208
+ const [rows, setRows] = useState<AttachmentItem[]>([
209
+ {
210
+ attachmentId: "part-2",
211
+ filename: "Q3 board pack.pdf",
212
+ typeLabel: "PDF",
213
+ sizeOctets: 2_411_724,
214
+ download: { status: "idle" },
215
+ },
216
+ {
217
+ attachmentId: "part-3",
218
+ filename: "headcount.csv",
219
+ typeLabel: "CSV",
220
+ sizeOctets: 4_180,
221
+ download: { status: "idle" },
222
+ },
223
+ ]);
224
+
225
+ const setStatus = (attachmentId: string, download: AttachmentDownloadState) =>
226
+ setRows((current) =>
227
+ current.map((row) =>
228
+ row.attachmentId === attachmentId ? { ...row, download } : row,
229
+ ),
230
+ );
231
+
232
+ return (
233
+ <AttachmentList
234
+ className="mt-4 px-2 lg:px-0"
235
+ attachments={rows}
236
+ onDownload={(attachmentId) => {
237
+ setStatus(attachmentId, { status: "downloading" });
238
+ setTimeout(() => setStatus(attachmentId, { status: "idle" }), 900);
239
+ }}
240
+ />
241
+ );
242
+ };
243
+
201
244
  /**
202
245
  * The expanded row as `MessageCard` composes it when the message carries files
203
246
  * (#683): body first, attachment list under it. The indicators row holds no
@@ -222,26 +265,7 @@ export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
222
265
  category="personal"
223
266
  allowImages
224
267
  />
225
- <AttachmentList
226
- className="mt-4 px-2 lg:px-0"
227
- attachments={[
228
- {
229
- attachmentId: "part-2",
230
- filename: "Q3 board pack.pdf",
231
- typeLabel: "PDF",
232
- sizeOctets: 2_411_724,
233
- download: { status: "idle" },
234
- },
235
- {
236
- attachmentId: "part-3",
237
- filename: "headcount.csv",
238
- typeLabel: "CSV",
239
- sizeOctets: 4_180,
240
- download: { status: "idle" },
241
- },
242
- ]}
243
- onDownload={(id) => alert(`Download ${id}`)}
244
- />
268
+ <ThreadAttachments />
245
269
  </div>
246
270
  }
247
271
  />
@@ -1,8 +1,12 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
2
3
  import { expect, userEvent } from "storybook/test";
3
4
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
4
5
  import { ComposeLanguageChip } from "./compose-language-chip.js";
5
- import { ComposeModeToggle } from "./compose-mode-toggle.js";
6
+ import {
7
+ type ComposeBodyMode,
8
+ ComposeModeToggle,
9
+ } from "./compose-mode-toggle.js";
6
10
  import { RichTextEditor } from "./rich-text-editor.js";
7
11
 
8
12
  /**
@@ -108,18 +112,29 @@ export const ClickBelowTheText: Story = {
108
112
  /**
109
113
  * The two pinned controls, in the order compose ships them: the chip first, so
110
114
  * one Shift+Tab out of the body still reaches the mode toggle and two reach the
111
- * chip.
115
+ * chip. Both hold their own state here — the editor knows nothing about either,
116
+ * and a pinned control that did not answer a press would read as a broken
117
+ * toolbar rather than a layout story.
112
118
  */
113
- const pinnedControls = (
114
- <>
115
- <ComposeLanguageChip
116
- language="nl"
117
- languages={["nl", "en", "de"]}
118
- onSelect={() => undefined}
119
- />
120
- <ComposeModeToggle mode="rich" onToggle={() => undefined} />
121
- </>
122
- );
119
+ const PinnedControls = () => {
120
+ const [language, setLanguage] = useState("nl");
121
+ const [mode, setMode] = useState<ComposeBodyMode>("rich");
122
+ return (
123
+ <>
124
+ <ComposeLanguageChip
125
+ language={language}
126
+ languages={["nl", "en", "de"]}
127
+ onSelect={setLanguage}
128
+ />
129
+ <ComposeModeToggle
130
+ mode={mode}
131
+ onToggle={() => setMode(mode === "plain" ? "rich" : "plain")}
132
+ />
133
+ </>
134
+ );
135
+ };
136
+
137
+ const pinnedControls = <PinnedControls />;
123
138
 
124
139
  /** The toolbar as compose ships it: the formatting cluster, then the two pinned controls. */
125
140
  export const ToolbarInRich: Story = {
@@ -6,6 +6,7 @@ import {
6
6
  DROPPED_SEMANTIC_COPY,
7
7
  droppedFacetsCopy,
8
8
  hasConversionNotice,
9
+ makeFilterBlockedCopy,
9
10
  scopedOutCopy,
10
11
  } from "./search-conversion.js";
11
12
  import { SearchConversionNoticeView } from "./search-conversion-notice.js";
@@ -48,6 +49,29 @@ describe("search-conversion copy", () => {
48
49
  assert.equal(hasConversionNotice({ droppedFacets: ["Unread"] }), true);
49
50
  assert.equal(hasConversionNotice({ droppedSemantic: true }), true);
50
51
  });
52
+
53
+ it("names the facets a chip-composed query is made of, not just the gap", () => {
54
+ const copy = makeFilterBlockedCopy(["Unread", "Category: Newsletter"]);
55
+ assert.match(
56
+ copy,
57
+ /Unread and Category: Newsletter aren't filter conditions/,
58
+ );
59
+ assert.match(copy, /add a sender or words to filter on/);
60
+ });
61
+
62
+ it("reads singular for one facet", () => {
63
+ assert.match(
64
+ makeFilterBlockedCopy(["Unread"]),
65
+ /^Unread isn't a filter condition — /,
66
+ );
67
+ });
68
+
69
+ it("asks for what is missing when no facet is what is in the way", () => {
70
+ assert.equal(
71
+ makeFilterBlockedCopy([]),
72
+ "Add a sender or words to filter on",
73
+ );
74
+ });
51
75
  });
52
76
 
53
77
  describe("SearchConversionNoticeView", () => {
@@ -50,6 +50,24 @@ export function droppedFacetsCopy(facets: string[]): string {
50
50
  } left out — the filter still matches everything else you searched for.`;
51
51
  }
52
52
 
53
+ /** What a query needs before there is a rule to open on. */
54
+ const NEEDS_A_CLAUSE = "add a sender or words to filter on";
55
+
56
+ /**
57
+ * Why the current query has no filter in it. A query narrowed only by chips —
58
+ * ticked in the brief's filter panel, which writes them into the query as terms
59
+ * — is all attribute facets and no clause, so the reason names the facets that
60
+ * cannot be one instead of asking for something the user just supplied.
61
+ */
62
+ export function makeFilterBlockedCopy(droppedFacets: string[]): string {
63
+ if (droppedFacets.length === 0)
64
+ return `${NEEDS_A_CLAUSE[0].toUpperCase()}${NEEDS_A_CLAUSE.slice(1)}`;
65
+ const verb = droppedFacets.length === 1 ? "isn't" : "aren't";
66
+ const noun =
67
+ droppedFacets.length === 1 ? "a filter condition" : "filter conditions";
68
+ return `${joinFacets(droppedFacets)} ${verb} ${noun} — ${NEEDS_A_CLAUSE}`;
69
+ }
70
+
53
71
  /**
54
72
  * States the filter is literal-only, so the search's similar-mail reach is not
55
73
  * carried (RFC 038 D5). Shown only where that reach existed — a capable search
@@ -1,5 +1,6 @@
1
1
  import type { Decorator, Meta, StoryObj } from "@storybook/react";
2
2
  import { useState } from "react";
3
+ import { makeFilterBlockedCopy } from "./search-conversion.js";
3
4
  import type { SearchResult } from "./search-result-row.js";
4
5
  import { type SearchResultSection, SearchResults } from "./search-results.js";
5
6
 
@@ -201,7 +202,7 @@ export const MakeFilterBlocked: Story = {
201
202
  sections={resultSections}
202
203
  makeFilter={{
203
204
  onClick: () => {},
204
- blockedReason: "Add a sender or words to filter on",
205
+ blockedReason: makeFilterBlockedCopy(["Has attachment"]),
205
206
  }}
206
207
  />
207
208
  ),
package/src/index.ts CHANGED
@@ -502,6 +502,7 @@ export {
502
502
  DROPPED_SEMANTIC_COPY,
503
503
  droppedFacetsCopy,
504
504
  hasConversionNotice,
505
+ makeFilterBlockedCopy,
505
506
  type SearchConversionNotice,
506
507
  scopedOutCopy,
507
508
  } from "./components/search-conversion.js";
@@ -678,6 +679,17 @@ export {
678
679
  formatByteSize,
679
680
  sanitizeAttachmentFilename,
680
681
  } from "./lib/attachment-file.js";
682
+ export {
683
+ briefChipCategory,
684
+ briefChipFilters,
685
+ briefFilterHasTerm,
686
+ briefQueryCategory,
687
+ briefQueryFilters,
688
+ briefQueryIsActive,
689
+ clearBriefFiltersInQuery,
690
+ setBriefCategoryInQuery,
691
+ toggleBriefFilterInQuery,
692
+ } from "./lib/brief-filter-query.js";
681
693
  export {
682
694
  buildCidResolver,
683
695
  type CidResolvableBodyPart,
@@ -775,6 +787,14 @@ export {
775
787
  useRovingFocus,
776
788
  } from "./lib/roving-focus.js";
777
789
  export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
790
+ export {
791
+ quoteSearchTokenValue,
792
+ type SearchQueryWord,
793
+ type SearchTermParts,
794
+ searchTokenTerm,
795
+ splitSearchTerm,
796
+ splitSearchWords,
797
+ } from "./lib/search-query-words.js";
778
798
  export {
779
799
  type DroppedFacet,
780
800
  type DroppedFacetType,
@@ -0,0 +1,200 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { BriefFilterId } from "../components/brief-sections.js";
4
+ import {
5
+ briefChipCategory,
6
+ briefChipFilters,
7
+ briefFilterHasTerm,
8
+ briefQueryCategory,
9
+ briefQueryFilters,
10
+ clearBriefFiltersInQuery,
11
+ setBriefCategoryInQuery,
12
+ toggleBriefFilterInQuery,
13
+ } from "./brief-filter-query.js";
14
+
15
+ const ids = (filters: ReadonlySet<BriefFilterId>): BriefFilterId[] =>
16
+ [...filters].sort();
17
+
18
+ describe("ticking a chip while a search is on", () => {
19
+ it("writes its term into the query, where it can be read", () => {
20
+ assert.equal(
21
+ toggleBriefFilterInQuery("Odido", "unread"),
22
+ "Odido is:unread",
23
+ );
24
+ });
25
+
26
+ it("writes the attachment term the same way", () => {
27
+ assert.equal(
28
+ toggleBriefFilterInQuery("Odido", "attachment"),
29
+ "Odido has:attachment",
30
+ );
31
+ });
32
+
33
+ it("takes the term back out when the chip is unticked", () => {
34
+ assert.equal(
35
+ toggleBriefFilterInQuery("Odido is:unread", "unread"),
36
+ "Odido",
37
+ );
38
+ });
39
+
40
+ it("leaves a chips-only query behind once the words are deleted", () => {
41
+ assert.equal(toggleBriefFilterInQuery("", "unread"), "is:unread");
42
+ });
43
+
44
+ it("says a chip has no term rather than writing one that means nothing", () => {
45
+ assert.equal(toggleBriefFilterInQuery("Odido", "contacts"), undefined);
46
+ assert.equal(toggleBriefFilterInQuery("Odido", "today"), undefined);
47
+ assert.equal(briefFilterHasTerm("contacts"), false);
48
+ assert.equal(briefFilterHasTerm("today"), false);
49
+ });
50
+ });
51
+
52
+ describe("scoping to a category while a search is on", () => {
53
+ it("writes the category term", () => {
54
+ assert.equal(
55
+ setBriefCategoryInQuery("Odido", "newsletter"),
56
+ "Odido category:newsletter",
57
+ );
58
+ });
59
+
60
+ it("swaps one category for another rather than stacking them", () => {
61
+ assert.equal(
62
+ setBriefCategoryInQuery("Odido category:newsletter", "marketing"),
63
+ "Odido category:marketing",
64
+ );
65
+ });
66
+
67
+ it("takes the term out again when the scope goes back to all", () => {
68
+ assert.equal(
69
+ setBriefCategoryInQuery("Odido category:newsletter", "all"),
70
+ "Odido",
71
+ );
72
+ });
73
+ });
74
+
75
+ describe("a term edited or deleted by hand", () => {
76
+ it("ticks the chip it names", () => {
77
+ assert.deepEqual(ids(briefQueryFilters("Odido is:unread")), ["unread"]);
78
+ });
79
+
80
+ it("reads the same whatever case it is typed in", () => {
81
+ assert.deepEqual(ids(briefQueryFilters("Odido IS:Unread")), ["unread"]);
82
+ });
83
+
84
+ it("unticks the chip once it is deleted", () => {
85
+ assert.deepEqual(ids(briefQueryFilters("Odido")), []);
86
+ });
87
+
88
+ it("scopes the category the term names, alias included", () => {
89
+ assert.equal(briefQueryCategory("Odido category:newsletter"), "newsletter");
90
+ assert.equal(
91
+ briefQueryCategory("Odido category:unclassified"),
92
+ "uncategorized",
93
+ );
94
+ });
95
+
96
+ it("leaves the scope alone for a category nobody has", () => {
97
+ assert.equal(briefQueryCategory("Odido category:nonsense"), "all");
98
+ });
99
+
100
+ it("never reads its own word as a category", () => {
101
+ assert.equal(briefQueryCategory("category:all"), "all");
102
+ });
103
+
104
+ it("keeps the words the query is otherwise made of", () => {
105
+ assert.equal(
106
+ clearBriefFiltersInQuery(
107
+ "Odido is:unread category:newsletter from:a@b.c",
108
+ ),
109
+ "Odido from:a@b.c",
110
+ );
111
+ });
112
+ });
113
+
114
+ /**
115
+ * Terms are cut the way the parser cuts them. A splitter that broke on
116
+ * whitespace alone read a facet inside a quoted value as a facet of its own,
117
+ * and missed a quoted one that the parser applies — so the chip row disagreed
118
+ * with the rows on screen, and editing reached inside what the user typed.
119
+ */
120
+ describe("a quoted value", () => {
121
+ it("keeps a facet spelled inside it out of the chips", () => {
122
+ assert.deepEqual(ids(briefQueryFilters('subject:"a is:unread b"')), []);
123
+ });
124
+
125
+ it("is left whole when a chip is ticked beside it", () => {
126
+ assert.equal(
127
+ toggleBriefFilterInQuery('subject:"a is:unread b"', "unread"),
128
+ 'subject:"a is:unread b" is:unread',
129
+ );
130
+ });
131
+
132
+ it("ticks the chip when it is the facet's own value", () => {
133
+ assert.deepEqual(ids(briefQueryFilters('is:"unread"')), ["unread"]);
134
+ });
135
+
136
+ it("is removed by unticking rather than joined by a second term", () => {
137
+ assert.equal(toggleBriefFilterInQuery('is:"unread"', "unread"), "");
138
+ });
139
+
140
+ it("scopes the category it names", () => {
141
+ assert.equal(briefQueryCategory('category:"Newsletter"'), "newsletter");
142
+ });
143
+
144
+ // Two category terms AND to nothing (`matchesSearchTokens`), so a category
145
+ // pill that left the old one behind emptied the list on a click.
146
+ it("leaves one category term behind when another is picked", () => {
147
+ assert.equal(
148
+ setBriefCategoryInQuery('category:"Newsletter"', "marketing"),
149
+ "category:marketing",
150
+ );
151
+ });
152
+
153
+ it("is cleared with the rest of the chip terms", () => {
154
+ assert.equal(
155
+ clearBriefFiltersInQuery('Odido is:"unread" category:"Newsletter"'),
156
+ "Odido",
157
+ );
158
+ });
159
+ });
160
+
161
+ describe("the chips the panel shows ticked", () => {
162
+ it("are the panel's own while nothing is being searched", () => {
163
+ assert.deepEqual(
164
+ ids(briefChipFilters({ query: "", ownFilters: new Set(["unread"]) })),
165
+ ["unread"],
166
+ );
167
+ assert.equal(
168
+ briefChipCategory({ query: "", ownCategory: "newsletter" }),
169
+ "newsletter",
170
+ );
171
+ });
172
+
173
+ it("are the query's terms while a search is on", () => {
174
+ assert.deepEqual(
175
+ ids(
176
+ briefChipFilters({
177
+ query: "Odido has:attachment",
178
+ ownFilters: new Set(["unread"]),
179
+ }),
180
+ ),
181
+ ["attachment"],
182
+ );
183
+ assert.equal(
184
+ briefChipCategory({ query: "Odido", ownCategory: "newsletter" }),
185
+ "all",
186
+ );
187
+ });
188
+
189
+ it("carry the chips the vocabulary cannot spell across a search", () => {
190
+ assert.deepEqual(
191
+ ids(
192
+ briefChipFilters({
193
+ query: "Odido is:unread",
194
+ ownFilters: new Set(["today", "contacts"]),
195
+ }),
196
+ ),
197
+ ["contacts", "today", "unread"],
198
+ );
199
+ });
200
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * The brief's chips as terms of the search query (#460).
3
+ *
4
+ * While something is being searched, a chip and a typed term are the same
5
+ * thing: ticking "Unread" over `Odido` leaves `Odido is:unread` in the field,
6
+ * which the user can read, edit and delete, and deleting it unticks the chip.
7
+ * The vocabulary is the one the search field already parses — `is:unread`,
8
+ * `has:attachment`, `category:<id>` — so a chip writes a term the engines
9
+ * already honour rather than a second, invisible filter stack over the same
10
+ * rows.
11
+ *
12
+ * Two of the brief's chips have no term in that vocabulary: "From contacts"
13
+ * matches on sender trust, which no facet names, and "Today" is a relative
14
+ * window where the vocabulary carries only absolute `before:`/`after:` dates.
15
+ * Those two keep narrowing from the panel's own state, which is why
16
+ * {@link briefChipFilters} carries them across a search rather than dropping
17
+ * them.
18
+ *
19
+ * Terms are cut and read with `search-query-words.ts`, the splitter the token
20
+ * parser itself uses, so a chip ticks for exactly what the parser applies:
21
+ * `is:"unread"` is the unread facet, and an `is:unread` inside a quoted value
22
+ * belongs to that value.
23
+ */
24
+
25
+ import {
26
+ type BriefCategoryFilter,
27
+ isBriefCategory,
28
+ } from "../components/app-shell-types.js";
29
+ import type { BriefFilterId } from "../components/brief-sections.js";
30
+ import {
31
+ searchTokenTerm,
32
+ splitSearchTerm,
33
+ splitSearchWords,
34
+ } from "./search-query-words.js";
35
+
36
+ interface FilterTerm {
37
+ id: BriefFilterId;
38
+ name: string;
39
+ value: string;
40
+ }
41
+
42
+ const FILTER_TERMS: readonly FilterTerm[] = [
43
+ { id: "unread", name: "is", value: "unread" },
44
+ { id: "attachment", name: "has", value: "attachment" },
45
+ ];
46
+
47
+ const CATEGORY_TERM_NAME = "category";
48
+
49
+ /** Category spellings beyond the ids themselves, as the token parser reads them. */
50
+ const CATEGORY_ALIASES: Record<string, BriefCategoryFilter> = {
51
+ unclassified: "uncategorized",
52
+ };
53
+
54
+ const words = (query: string): string[] =>
55
+ splitSearchWords(query).map((word) => word.raw);
56
+
57
+ const rejoin = (kept: string[]): string => kept.join(" ");
58
+
59
+ /**
60
+ * The chip a term names, read the way the parser reads it: `is:"unread"` is the
61
+ * unread facet, and an `is:unread` sitting inside a quoted value is not a term
62
+ * at all.
63
+ */
64
+ const filterOfWord = (word: string): BriefFilterId | undefined => {
65
+ const parts = splitSearchTerm(word);
66
+ if (!parts) return undefined;
67
+ return FILTER_TERMS.find(
68
+ (term) =>
69
+ term.name === parts.name && term.value === parts.value.toLowerCase(),
70
+ )?.id;
71
+ };
72
+
73
+ const categoryOfWord = (word: string): BriefCategoryFilter | undefined => {
74
+ const parts = splitSearchTerm(word);
75
+ if (!parts || parts.name !== CATEGORY_TERM_NAME) return undefined;
76
+ const value = parts.value.toLowerCase();
77
+ const alias = CATEGORY_ALIASES[value];
78
+ if (alias) return alias;
79
+ if (!isBriefCategory(value) || value === "all") return undefined;
80
+ return value;
81
+ };
82
+
83
+ const termFor = (id: BriefFilterId): FilterTerm | undefined =>
84
+ FILTER_TERMS.find((term) => term.id === id);
85
+
86
+ /** Whether a chip is one the search vocabulary can express. */
87
+ export const briefFilterHasTerm = (id: BriefFilterId): boolean =>
88
+ termFor(id) !== undefined;
89
+
90
+ /** The chips a query's own terms tick. */
91
+ export const briefQueryFilters = (
92
+ query: string,
93
+ ): ReadonlySet<BriefFilterId> => {
94
+ const ticked = new Set<BriefFilterId>();
95
+ for (const word of words(query)) {
96
+ const id = filterOfWord(word);
97
+ if (id) ticked.add(id);
98
+ }
99
+ return ticked;
100
+ };
101
+
102
+ /** The category a query is scoped to by its own terms, `"all"` when none is. */
103
+ export const briefQueryCategory = (query: string): BriefCategoryFilter => {
104
+ let category: BriefCategoryFilter = "all";
105
+ for (const word of words(query)) {
106
+ const named = categoryOfWord(word);
107
+ if (named) category = named;
108
+ }
109
+ return category;
110
+ };
111
+
112
+ /**
113
+ * The query with a chip's term written in or taken out, or `undefined` when the
114
+ * chip has no term — the caller narrows the same rows from the panel's own
115
+ * state instead, rather than pressing a control that writes nothing.
116
+ */
117
+ export const toggleBriefFilterInQuery = (
118
+ query: string,
119
+ id: BriefFilterId,
120
+ ): string | undefined => {
121
+ const term = termFor(id);
122
+ if (!term) return undefined;
123
+ const typed = words(query);
124
+ const kept = typed.filter((word) => filterOfWord(word) !== id);
125
+ if (kept.length < typed.length) return rejoin(kept);
126
+ return rejoin([...typed, searchTokenTerm(term.name, term.value)]);
127
+ };
128
+
129
+ /** The query scoped to one category, or with its category term taken out for `"all"`. */
130
+ export const setBriefCategoryInQuery = (
131
+ query: string,
132
+ category: BriefCategoryFilter,
133
+ ): string => {
134
+ const kept = words(query).filter((word) => !categoryOfWord(word));
135
+ if (category === "all") return rejoin(kept);
136
+ return rejoin([...kept, searchTokenTerm(CATEGORY_TERM_NAME, category)]);
137
+ };
138
+
139
+ /** The query with every chip term taken out, leaving what was typed. */
140
+ export const clearBriefFiltersInQuery = (query: string): string =>
141
+ rejoin(
142
+ words(query).filter((word) => !filterOfWord(word) && !categoryOfWord(word)),
143
+ );
144
+
145
+ /** Whether a query is narrowing the list — the state in which chips are terms. */
146
+ export const briefQueryIsActive = (query: string): boolean =>
147
+ query.trim().length > 0;
148
+
149
+ /**
150
+ * The chips shown ticked. Under a query the terms are the whole answer for
151
+ * every chip that has one; the two that have none keep answering to the panel's
152
+ * own state, so a search never silently stops them narrowing.
153
+ */
154
+ export const briefChipFilters = (input: {
155
+ query: string;
156
+ ownFilters: ReadonlySet<BriefFilterId>;
157
+ }): ReadonlySet<BriefFilterId> => {
158
+ if (!briefQueryIsActive(input.query)) return input.ownFilters;
159
+ const ticked = new Set(briefQueryFilters(input.query));
160
+ for (const id of input.ownFilters) {
161
+ if (!briefFilterHasTerm(id)) ticked.add(id);
162
+ }
163
+ return ticked;
164
+ };
165
+
166
+ /** The category scope shown selected — the query's under a query, else the panel's. */
167
+ export const briefChipCategory = (input: {
168
+ query: string;
169
+ ownCategory: BriefCategoryFilter;
170
+ }): BriefCategoryFilter =>
171
+ briefQueryIsActive(input.query)
172
+ ? briefQueryCategory(input.query)
173
+ : input.ownCategory;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * How a search query is cut into terms.
3
+ *
4
+ * One implementation, because two would disagree: the token parser
5
+ * (`web-client/src/lib/search-tokens.ts`) reads `in:"Sent Items"` as one term
6
+ * and the brief's chips write and remove terms of the same query
7
+ * (`brief-filter-query.ts`). A splitter that broke on whitespace alone would
8
+ * read `is:unread` inside a quoted value as a term of its own, and editing it
9
+ * would reach inside what the user typed between quotes.
10
+ *
11
+ * A value carrying whitespace is written in double quotes; an unterminated
12
+ * quote runs to the end of the input, so a value stays one term while it is
13
+ * still being typed.
14
+ */
15
+
16
+ /** One whitespace-separated term of a query, with where it sits in the input. */
17
+ export interface SearchQueryWord {
18
+ /** The term exactly as typed, quotes included. */
19
+ raw: string;
20
+ /** Index of the term's first character in the query. */
21
+ start: number;
22
+ /** Index just past the term's last character. */
23
+ end: number;
24
+ }
25
+
26
+ /** Split a query into terms on whitespace, except inside double quotes. */
27
+ export function splitSearchWords(query: string): SearchQueryWord[] {
28
+ const words: SearchQueryWord[] = [];
29
+ let start = -1;
30
+ let quoted = false;
31
+ for (let i = 0; i < query.length; i++) {
32
+ const char = query[i] as string;
33
+ if (char === '"') {
34
+ quoted = !quoted;
35
+ if (start < 0) start = i;
36
+ continue;
37
+ }
38
+ if (!quoted && /\s/.test(char)) {
39
+ if (start >= 0) words.push({ raw: query.slice(start, i), start, end: i });
40
+ start = -1;
41
+ continue;
42
+ }
43
+ if (start < 0) start = i;
44
+ }
45
+ if (start >= 0) {
46
+ words.push({ raw: query.slice(start), start, end: query.length });
47
+ }
48
+ return words;
49
+ }
50
+
51
+ /** A term split at its first colon: the token name and the value as typed. */
52
+ export interface SearchTermParts {
53
+ name: string;
54
+ /** The value with its quotes removed. */
55
+ value: string;
56
+ /** The value exactly as typed, quotes included. */
57
+ rawValue: string;
58
+ }
59
+
60
+ const unquote = (value: string): string => {
61
+ if (!value.startsWith('"')) return value;
62
+ const inner = value.slice(1);
63
+ return inner.endsWith('"') ? inner.slice(0, -1) : inner;
64
+ };
65
+
66
+ /**
67
+ * Split `name:value` at the first colon. A term with no colon, or one starting
68
+ * with a colon, is not a token attempt and returns `undefined`.
69
+ */
70
+ export function splitSearchTerm(word: string): SearchTermParts | undefined {
71
+ const colon = word.indexOf(":");
72
+ if (colon <= 0) return undefined;
73
+ const rawValue = word.slice(colon + 1);
74
+ return {
75
+ name: word.slice(0, colon).toLowerCase(),
76
+ value: unquote(rawValue),
77
+ rawValue,
78
+ };
79
+ }
80
+
81
+ const needsQuotes = (value: string): boolean => /[\s"]/.test(value);
82
+
83
+ /**
84
+ * A value as it is written in a query: quoted when it carries whitespace, bare
85
+ * otherwise. The inverse of the unquoting above, so a suggestion the user picks
86
+ * parses back to the value it was built from.
87
+ */
88
+ export const quoteSearchTokenValue = (value: string): string =>
89
+ needsQuotes(value) ? `"${value.replace(/"/g, "")}"` : value;
90
+
91
+ /** `name:value`, quoted as needed — the text a query carries for one token. */
92
+ export const searchTokenTerm = (name: string, value: string): string =>
93
+ `${name}:${quoteSearchTokenValue(value)}`;