@remit/ui 0.0.103 → 0.0.105
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/brief-sections.tsx +4 -2
- package/src/components/mobile-search-view.stories.tsx +1 -1
- 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/src/lib/use-rendered-row-ids.test.ts +109 -0
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -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
|
};
|
|
@@ -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)}`;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rendered rows are read from the DOM, so a list body that caps, collapses
|
|
3
|
+
* or filters itself below its consumer still reports what it shows.
|
|
4
|
+
*/
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
7
|
+
import type { JSDOM } from "jsdom";
|
|
8
|
+
import { act, createElement, useState } from "react";
|
|
9
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
10
|
+
import { useRenderedRowIds } from "./use-rendered-row-ids.js";
|
|
11
|
+
|
|
12
|
+
let dom: JSDOM;
|
|
13
|
+
let container: HTMLElement;
|
|
14
|
+
let root: Root;
|
|
15
|
+
let rowIds: string[] | undefined;
|
|
16
|
+
let showRows: (ids: string[]) => void;
|
|
17
|
+
|
|
18
|
+
function Harness({ initialIds }: { initialIds: string[] }) {
|
|
19
|
+
const [ids, setIds] = useState(initialIds);
|
|
20
|
+
const [element, setElement] = useState<HTMLElement | null>(null);
|
|
21
|
+
showRows = setIds;
|
|
22
|
+
rowIds = useRenderedRowIds(element);
|
|
23
|
+
return createElement(
|
|
24
|
+
"div",
|
|
25
|
+
{ ref: setElement },
|
|
26
|
+
ids.map((id) =>
|
|
27
|
+
createElement("button", {
|
|
28
|
+
key: id,
|
|
29
|
+
type: "button",
|
|
30
|
+
"data-message-id": id,
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const settle = async () => {
|
|
37
|
+
await act(async () => {
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
});
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
before(async () => {
|
|
43
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
44
|
+
dom = new JSDOMCtor(
|
|
45
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
46
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
47
|
+
);
|
|
48
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
49
|
+
globalThis.document = dom.window.document;
|
|
50
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
51
|
+
globalThis.Element = dom.window.Element;
|
|
52
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
53
|
+
(
|
|
54
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
55
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
after(() => dom.window.close());
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
container = dom.window.document.getElementById(
|
|
62
|
+
"root",
|
|
63
|
+
) as unknown as HTMLElement;
|
|
64
|
+
container.innerHTML = "";
|
|
65
|
+
root = createRoot(container);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(() => {
|
|
69
|
+
act(() => root.unmount());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const mount = (initialIds: string[]) => {
|
|
73
|
+
act(() => {
|
|
74
|
+
root.render(createElement(Harness, { initialIds }));
|
|
75
|
+
});
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
describe("useRenderedRowIds", () => {
|
|
79
|
+
it("reads the rows in document order", () => {
|
|
80
|
+
mount(["m1", "m2", "m3"]);
|
|
81
|
+
assert.deepEqual(rowIds, ["m1", "m2", "m3"]);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("follows rows appearing and disappearing under it", async () => {
|
|
85
|
+
mount(["m1", "m2"]);
|
|
86
|
+
act(() => showRows(["m1", "m2", "m3", "m4"]));
|
|
87
|
+
await settle();
|
|
88
|
+
assert.deepEqual(rowIds, ["m1", "m2", "m3", "m4"]);
|
|
89
|
+
|
|
90
|
+
act(() => showRows(["m3"]));
|
|
91
|
+
await settle();
|
|
92
|
+
assert.deepEqual(rowIds, ["m3"]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("says a mounted list showing nothing shows nothing", async () => {
|
|
96
|
+
mount(["m1"]);
|
|
97
|
+
act(() => showRows([]));
|
|
98
|
+
await settle();
|
|
99
|
+
assert.deepEqual(rowIds, []);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("keeps the same array while the rows are unchanged", async () => {
|
|
103
|
+
mount(["m1", "m2"]);
|
|
104
|
+
const first = rowIds;
|
|
105
|
+
act(() => showRows(["m1", "m2"]));
|
|
106
|
+
await settle();
|
|
107
|
+
assert.equal(rowIds, first);
|
|
108
|
+
});
|
|
109
|
+
});
|