@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 +1 -1
- package/src/components/attachment-list.stories.tsx +127 -96
- package/src/components/brief-sections.tsx +4 -2
- package/src/components/compose-action-bar.stories.tsx +38 -4
- package/src/components/compose-body.stories.tsx +20 -3
- package/src/components/compose-mode-toggle.stories.tsx +10 -11
- package/src/components/compose-smtp-missing-banner.stories.tsx +1 -1
- package/src/components/mobile-search-view.stories.tsx +1 -1
- package/src/components/reading-pane.stories.tsx +45 -21
- package/src/components/rich-text-editor.stories.tsx +27 -12
- package/src/components/search-conversion.render.test.ts +24 -0
- package/src/components/search-conversion.ts +18 -0
- package/src/components/search-results.stories.tsx +2 -1
- package/src/index.ts +20 -0
- package/src/lib/brief-filter-query.test.ts +200 -0
- package/src/lib/brief-filter-query.ts +173 -0
- package/src/lib/search-query-words.ts +93 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
{
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
{
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
102
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
{
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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={
|
|
67
|
-
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: ()
|
|
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="
|
|
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 {
|
|
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
|
-
<
|
|
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 {
|
|
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
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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: "
|
|
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)}`;
|