@remit/web-client 0.0.321 → 0.0.323
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 +4 -4
- package/src/auth/SignInAgain.tsx +18 -0
- package/src/auth/amplify-config.test.ts +1 -107
- package/src/auth/auth-interceptor.test.ts +0 -6
- package/src/auth/cognito/cognito-token.test.ts +2 -4
- package/src/components/calendar/AgendaStrip.tsx +1 -1
- package/src/components/calendar/CalendarWorkspace.tsx +1 -1
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
- package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
- package/src/components/compose/ComposeForm.tsx +11 -6
- package/src/components/compose/FullCompose.tsx +1 -1
- package/src/components/compose/MobileComposeSheet.tsx +1 -2
- package/src/components/compose/compose.stories.tsx +18 -0
- package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
- package/src/components/config-import/config-import.fixtures.ts +8 -9
- package/src/components/config-import/config-import.stories.tsx +2 -2
- package/src/components/layout/MailTopBar.tsx +3 -0
- package/src/components/mail/BriefPane.tsx +0 -3
- package/src/components/mail/ConversationView.tsx +1 -3
- package/src/components/mail/DailyBrief.tsx +5 -28
- package/src/components/mail/FlaggedList.tsx +3 -4
- package/src/components/mail/FlaggedPane.tsx +0 -3
- package/src/components/mail/IntelligenceDrawer.tsx +0 -3
- package/src/components/mail/IntelligencePane.render.test.ts +0 -1
- package/src/components/mail/IntelligencePane.test.ts +1 -95
- package/src/components/mail/IntelligencePane.tsx +3 -93
- package/src/components/mail/MailListHeader.tsx +12 -43
- package/src/components/mail/MailNav.tsx +6 -2
- package/src/components/mail/MailViewChrome.tsx +1 -11
- package/src/components/mail/MailboxPane.tsx +4 -34
- package/src/components/mail/MessageActionMenu.tsx +6 -6
- package/src/components/mail/MessageAttachments.tsx +35 -0
- package/src/components/mail/MessageBody.tsx +1 -0
- package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
- package/src/components/mail/MessageCard.tsx +16 -4
- package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
- package/src/components/mail/MessageList.tsx +23 -11
- package/src/components/mail/MessageRow.render.test.ts +0 -7
- package/src/components/mail/MoveToTrigger.tsx +1 -1
- package/src/components/mail/RawMessageView.tsx +1 -1
- package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
- package/src/components/mail/SelectionWizardHost.tsx +16 -102
- package/src/components/mail/authenticity-warning.stories.tsx +0 -21
- package/src/components/mail/daily-brief.stories.tsx +31 -17
- package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
- package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
- package/src/components/mail/message-gone.stories.tsx +44 -0
- package/src/components/mail/offer-times-reply.render.test.ts +25 -1
- package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
- package/src/components/overlay-escape.render.test.ts +7 -3
- package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
- package/src/components/settings/AccountFormPanel.test.ts +1 -36
- package/src/components/settings/AccountFormPanel.tsx +5 -25
- package/src/components/settings/AccountServices.tsx +1 -1
- package/src/components/settings/AdvancedNavIcon.tsx +3 -3
- package/src/components/settings/CalendarFeedCard.tsx +2 -1
- package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
- package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
- package/src/components/settings/FilterActionError.tsx +1 -1
- package/src/components/settings/FilterEditor.render.test.ts +3 -27
- package/src/components/settings/FilterEditor.tsx +4 -24
- package/src/components/settings/FilterEditorSurface.tsx +0 -3
- package/src/components/settings/FiltersList.render.test.ts +5 -24
- package/src/components/settings/FiltersList.stories.tsx +0 -1
- package/src/components/settings/FiltersList.tsx +10 -35
- package/src/components/settings/WarningsNavIcon.tsx +12 -0
- package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
- package/src/components/settings/WarningsPanel.render.test.ts +56 -0
- package/src/components/settings/WarningsPanel.tsx +244 -0
- package/src/components/settings/accounts.stories.tsx +18 -0
- package/src/components/settings/advanced.stories.tsx +3 -125
- package/src/components/settings/settings-filter.stories.tsx +2 -42
- package/src/components/settings/warnings.stories.tsx +290 -0
- package/src/components/ui/ErrorBannerProvider.tsx +5 -3
- package/src/components/ui/error-banners.test.ts +1 -22
- package/src/components/ui/error-banners.ts +9 -20
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
- package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
- package/src/hooks/calendar/useCalendarWrites.ts +2 -1
- package/src/hooks/intelligence-calendar.render.test.ts +0 -1
- package/src/hooks/useDeleteMessages.test.ts +1 -42
- package/src/hooks/useFilters.test.ts +0 -8
- package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
- package/src/hooks/useIntelligenceData.test.ts +9 -47
- package/src/hooks/useIntelligenceData.ts +7 -100
- package/src/hooks/useLayoutTier.test.ts +0 -48
- package/src/hooks/useReportSpam.ts +1 -1
- package/src/hooks/useRescueCandidates.test.ts +0 -23
- package/src/hooks/useRulePreview.ts +8 -7
- package/src/hooks/useSearchTokenContext.ts +2 -3
- package/src/hooks/useStaleAccountSync.test.ts +0 -17
- package/src/hooks/useStarredThreads.test.ts +0 -10
- package/src/hooks/useTriggerSync.test.ts +0 -6
- package/src/hooks/useVisualViewport.render.test.ts +63 -0
- package/src/hooks/useWarningsAttention.ts +37 -0
- package/src/lib/account-order.test.ts +0 -10
- package/src/lib/adjacent-message.test.ts +0 -9
- package/src/lib/app-info.ts +0 -3
- package/src/lib/autodiscovery.test.ts +0 -47
- package/src/lib/bug-report.test.ts +38 -0
- package/src/lib/bug-report.ts +10 -7
- package/src/lib/bulk-action-copy.ts +1 -1
- package/src/lib/calendar-route.test.ts +0 -5
- package/src/lib/config-import.test.ts +3 -39
- package/src/lib/dedupe-thread-messages.test.ts +0 -4
- package/src/lib/desktop-breakpoint.test.ts +0 -8
- package/src/lib/drafts.test.ts +0 -23
- package/src/lib/fatal-error.test.ts +0 -19
- package/src/lib/folder-roles.test.ts +0 -4
- package/src/lib/hot-sync-window.test.ts +0 -5
- package/src/lib/list-header-chrome.ts +1 -1
- package/src/lib/mail-context.ts +1 -1
- package/src/lib/message-attachments.test.ts +77 -0
- package/src/lib/message-attachments.ts +31 -15
- package/src/lib/message-body-source.test.ts +21 -4
- package/src/lib/message-body-source.ts +5 -2
- package/src/lib/onboarding-completion.test.ts +0 -16
- package/src/lib/organize/filter-edit-model.test.ts +2 -32
- package/src/lib/organize/filter-edit-model.ts +6 -16
- package/src/lib/organize/organize-model.test.ts +7 -40
- package/src/lib/organize/organize-model.ts +16 -18
- package/src/lib/organize/rule-model.test.ts +24 -202
- package/src/lib/organize/rule-model.ts +19 -166
- package/src/lib/organize/search-to-rule.test.ts +2 -36
- package/src/lib/organize/search-to-rule.ts +1 -16
- package/src/lib/provider-presets.test.ts +1 -77
- package/src/lib/query-error-handler.test.ts +0 -61
- package/src/lib/query-escalation.integration.test.ts +4 -4
- package/src/lib/result-count.ts +1 -1
- package/src/lib/result-folder.ts +1 -1
- package/src/lib/rum-adapter.test.ts +0 -104
- package/src/lib/search-query.test.ts +0 -11
- package/src/lib/search-result.test.ts +2 -142
- package/src/lib/search-result.ts +3 -73
- package/src/lib/search-scope.test.ts +1 -92
- package/src/lib/search-scope.ts +0 -29
- package/src/lib/search-surface.ts +2 -2
- package/src/lib/search-token-index.test.ts +0 -8
- package/src/lib/search-tokens.ts +15 -16
- package/src/lib/starred-rows.test.ts +0 -4
- package/src/lib/sync-warning-entries.ts +33 -0
- package/src/lib/wizard-selection.ts +1 -1
- package/src/mocks/story-frame/mail-handlers.ts +12 -8
- package/src/mocks/story-frame/mail-world.ts +5 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/calendar/$view.$date/index.tsx +1 -1
- package/src/routes/calendar.tsx +2 -1
- package/src/routes/mail/brief.tsx +1 -1
- package/src/routes/mail/flagged.tsx +1 -1
- package/src/routes/mail.tsx +5 -3
- package/src/routes/settings/-accounts.test.ts +0 -5
- package/src/routes/settings/-filters.test.ts +0 -1
- package/src/routes/settings/-shared/help-copy.tsx +15 -0
- package/src/routes/settings/accounts.tsx +1 -1
- package/src/routes/settings/advanced.tsx +1 -4
- package/src/routes/settings/filters.tsx +1 -2
- package/src/routes/settings/folders.tsx +1 -1
- package/src/routes/settings/labels.tsx +1 -1
- package/src/routes/settings/senders.tsx +1 -1
- package/src/routes/settings/warnings.tsx +36 -0
- package/src/routes/settings.tsx +7 -0
- package/src/routing/wizard-history.test.ts +1 -45
- package/src/runtime-config.ts +5 -0
- package/src/test-support/fixtures.ts +1 -1
- package/src/components/compose/draft-discard-banner.test.ts +0 -76
- package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
- package/src/components/mail/SpamRescue.render.test.ts +0 -35
- package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
- package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
- package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
- package/src/components/settings/QuarantinePanel.tsx +0 -178
- package/src/components/ui/DropdownMenu.tsx +0 -99
- package/src/components/ui/EmptyState.tsx +0 -11
- package/src/components/ui/ErrorBanner.render.test.ts +0 -104
- package/src/components/ui/ErrorBanner.stories.tsx +0 -132
- package/src/components/ui/ErrorBanner.tsx +0 -106
- package/src/components/ui/ErrorBannerStack.tsx +0 -35
- package/src/components/ui/ErrorState.render.test.ts +0 -82
- package/src/components/ui/ErrorState.tsx +0 -80
- package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
- package/src/hooks/useMoveMessages.test.ts +0 -63
- package/src/hooks/useOrganizeWiden.test.ts +0 -217
- package/src/hooks/useOrganizeWiden.ts +0 -112
- package/src/hooks/useSemanticSearch.ts +0 -132
- package/src/hooks/useSemanticSearchEnabled.ts +0 -23
- package/src/hooks/useVisualViewport.test.ts +0 -126
- package/src/lib/organize/sender-fallback.test.ts +0 -52
- package/src/lib/organize/sender-fallback.ts +0 -38
- package/src/lib/rescue-telemetry.test.ts +0 -75
- package/src/router.test.ts +0 -81
|
@@ -9,7 +9,6 @@ import { describe, it } from "node:test";
|
|
|
9
9
|
import type { FilterRule } from "@remit/ui";
|
|
10
10
|
import { buildOrganizeInput } from "./organize-model";
|
|
11
11
|
import {
|
|
12
|
-
buildInitialRule,
|
|
13
12
|
derivePreview,
|
|
14
13
|
isEvaluablePredicate,
|
|
15
14
|
normalizeClauseValue,
|
|
@@ -17,21 +16,8 @@ import {
|
|
|
17
16
|
predicateSignature,
|
|
18
17
|
rulePredicate,
|
|
19
18
|
ruleToDraft,
|
|
20
|
-
SUPPORTED_CLAUSE_FIELDS,
|
|
21
19
|
} from "./rule-model";
|
|
22
20
|
|
|
23
|
-
describe("SUPPORTED_CLAUSE_FIELDS", () => {
|
|
24
|
-
it("offers every field the backend matcher now evaluates, ListId and FromDomain included (#262)", () => {
|
|
25
|
-
assert.deepEqual(SUPPORTED_CLAUSE_FIELDS, [
|
|
26
|
-
"From",
|
|
27
|
-
"Subject",
|
|
28
|
-
"HasWords",
|
|
29
|
-
"ListId",
|
|
30
|
-
"FromDomain",
|
|
31
|
-
]);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
21
|
describe("normalizeListId", () => {
|
|
36
22
|
it("strips the bracketed identifier, trims, and case-folds — matching the backend", () => {
|
|
37
23
|
assert.equal(
|
|
@@ -55,121 +41,24 @@ describe("normalizeListId", () => {
|
|
|
55
41
|
});
|
|
56
42
|
});
|
|
57
43
|
|
|
58
|
-
|
|
59
|
-
it("opens a semantic-capable rule on the widen anchor with no literal clauses", () => {
|
|
60
|
-
const rule = buildInitialRule({
|
|
61
|
-
anchorMessageId: "msg-1",
|
|
62
|
-
semanticUnavailable: false,
|
|
63
|
-
senders: [],
|
|
64
|
-
selectionCount: 3,
|
|
65
|
-
});
|
|
66
|
-
assert.equal(rule.clauses.length, 0);
|
|
67
|
-
assert.deepEqual(rule.widen, { anchorCount: 3 });
|
|
68
|
-
assert.equal(rule.matchOperator, "all");
|
|
69
|
-
assert.equal(rule.scope, "once");
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
it("opens the sender fallback on visible, editable derived From chips (#251)", () => {
|
|
73
|
-
const rule = buildInitialRule({
|
|
74
|
-
anchorMessageId: "msg-1",
|
|
75
|
-
semanticUnavailable: true,
|
|
76
|
-
senders: ["a@x.com", "b@y.com"],
|
|
77
|
-
selectionCount: 2,
|
|
78
|
-
});
|
|
79
|
-
assert.equal(rule.widen, undefined);
|
|
80
|
-
assert.equal(rule.matchOperator, "any");
|
|
81
|
-
assert.deepEqual(
|
|
82
|
-
rule.clauses.map((clause) => ({
|
|
83
|
-
field: clause.field,
|
|
84
|
-
value: clause.value,
|
|
85
|
-
derived: clause.derived,
|
|
86
|
-
})),
|
|
87
|
-
[
|
|
88
|
-
{ field: "From", value: "a@x.com", derived: true },
|
|
89
|
-
{ field: "From", value: "b@y.com", derived: true },
|
|
90
|
-
],
|
|
91
|
-
);
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
it("collapses senders sharing a registrable domain to one derived FromDomain chip (#262)", () => {
|
|
95
|
-
const rule = buildInitialRule({
|
|
96
|
-
semanticUnavailable: true,
|
|
97
|
-
senders: ["npm@github.com", "ci@sub.github.com"],
|
|
98
|
-
selectionCount: 2,
|
|
99
|
-
});
|
|
100
|
-
assert.equal(rule.matchOperator, "any");
|
|
101
|
-
assert.deepEqual(
|
|
102
|
-
rule.clauses.map((clause) => ({
|
|
103
|
-
field: clause.field,
|
|
104
|
-
value: clause.value,
|
|
105
|
-
derived: clause.derived,
|
|
106
|
-
})),
|
|
107
|
-
[{ field: "FromDomain", value: "github.com", derived: true }],
|
|
108
|
-
);
|
|
109
|
-
});
|
|
110
|
-
|
|
111
|
-
it("carries a Something-else seed's folder and scope", () => {
|
|
112
|
-
const rule = buildInitialRule({
|
|
113
|
-
anchorMessageId: "msg-1",
|
|
114
|
-
semanticUnavailable: false,
|
|
115
|
-
senders: [],
|
|
116
|
-
selectionCount: 1,
|
|
117
|
-
seedMailboxId: "mbx-archive",
|
|
118
|
-
seedScope: "standing",
|
|
119
|
-
});
|
|
120
|
-
assert.equal(rule.moveMailboxId, "mbx-archive");
|
|
121
|
-
assert.equal(rule.scope, "standing");
|
|
122
|
-
});
|
|
123
|
-
|
|
124
|
-
it("offers no widen when there is no anchor to ride on", () => {
|
|
125
|
-
const rule = buildInitialRule({
|
|
126
|
-
semanticUnavailable: false,
|
|
127
|
-
senders: [],
|
|
128
|
-
selectionCount: 0,
|
|
129
|
-
});
|
|
130
|
-
assert.equal(rule.widen, undefined);
|
|
131
|
-
});
|
|
132
|
-
});
|
|
133
|
-
|
|
134
|
-
const semanticRule: FilterRule = {
|
|
44
|
+
const rule: FilterRule = {
|
|
135
45
|
clauses: [{ id: "c1", field: "Subject", value: "receipt" }],
|
|
136
46
|
matchOperator: "all",
|
|
137
|
-
widen: { anchorCount: 2 },
|
|
138
47
|
moveMailboxId: "mbx-archive",
|
|
139
48
|
scope: "once",
|
|
140
49
|
};
|
|
141
50
|
|
|
142
51
|
describe("rulePredicate", () => {
|
|
143
|
-
it("
|
|
144
|
-
const predicate = rulePredicate(
|
|
145
|
-
assert.equal(predicate.anchorMessageId, "msg-1");
|
|
52
|
+
it("carries the operator and the literal clauses", () => {
|
|
53
|
+
const predicate = rulePredicate(rule);
|
|
146
54
|
assert.equal(predicate.matchOperator, "And");
|
|
147
55
|
assert.deepEqual(predicate.literalClauses, [
|
|
148
56
|
{ field: "Subject", value: "receipt" },
|
|
149
57
|
]);
|
|
150
58
|
});
|
|
151
59
|
|
|
152
|
-
it("drops the anchor once the widen is removed", () => {
|
|
153
|
-
const predicate = rulePredicate(
|
|
154
|
-
{ ...semanticRule, widen: undefined },
|
|
155
|
-
"msg-1",
|
|
156
|
-
);
|
|
157
|
-
assert.equal(predicate.anchorMessageId, undefined);
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
it("drops the anchor for a widen the deployment cannot evaluate (D4 inactive)", () => {
|
|
161
|
-
const predicate = rulePredicate(
|
|
162
|
-
{ ...semanticRule, widen: { anchorCount: 2, inactive: true } },
|
|
163
|
-
"msg-1",
|
|
164
|
-
);
|
|
165
|
-
assert.equal(predicate.anchorMessageId, undefined);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
60
|
it("maps the any operator to Or", () => {
|
|
169
|
-
const predicate = rulePredicate(
|
|
170
|
-
{ ...semanticRule, matchOperator: "any" },
|
|
171
|
-
"msg-1",
|
|
172
|
-
);
|
|
61
|
+
const predicate = rulePredicate({ ...rule, matchOperator: "any" });
|
|
173
62
|
assert.equal(predicate.matchOperator, "Or");
|
|
174
63
|
});
|
|
175
64
|
});
|
|
@@ -177,28 +66,19 @@ describe("rulePredicate", () => {
|
|
|
177
66
|
describe("predicateSignature", () => {
|
|
178
67
|
it("is stable across identical predicates", () => {
|
|
179
68
|
assert.equal(
|
|
180
|
-
predicateSignature(rulePredicate(
|
|
181
|
-
predicateSignature(rulePredicate(
|
|
69
|
+
predicateSignature(rulePredicate(rule)),
|
|
70
|
+
predicateSignature(rulePredicate(rule)),
|
|
182
71
|
);
|
|
183
72
|
});
|
|
184
73
|
|
|
185
74
|
it("changes when a clause value changes", () => {
|
|
186
75
|
const changed: FilterRule = {
|
|
187
|
-
...
|
|
76
|
+
...rule,
|
|
188
77
|
clauses: [{ id: "c1", field: "Subject", value: "invoice" }],
|
|
189
78
|
};
|
|
190
79
|
assert.notEqual(
|
|
191
|
-
predicateSignature(rulePredicate(
|
|
192
|
-
predicateSignature(rulePredicate(changed
|
|
193
|
-
);
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
it("changes when the widen is removed", () => {
|
|
197
|
-
assert.notEqual(
|
|
198
|
-
predicateSignature(rulePredicate(semanticRule, "msg-1")),
|
|
199
|
-
predicateSignature(
|
|
200
|
-
rulePredicate({ ...semanticRule, widen: undefined }, "msg-1"),
|
|
201
|
-
),
|
|
80
|
+
predicateSignature(rulePredicate(rule)),
|
|
81
|
+
predicateSignature(rulePredicate(changed)),
|
|
202
82
|
);
|
|
203
83
|
});
|
|
204
84
|
});
|
|
@@ -206,52 +86,35 @@ describe("predicateSignature", () => {
|
|
|
206
86
|
describe("the previewed set equals the applied set", () => {
|
|
207
87
|
it("carries exactly the previewed predicate into the commit draft", () => {
|
|
208
88
|
const preview = buildOrganizeInput({
|
|
209
|
-
...rulePredicate(
|
|
210
|
-
matchOperator: rulePredicate(
|
|
211
|
-
literalClauses: rulePredicate(
|
|
89
|
+
...rulePredicate(rule),
|
|
90
|
+
matchOperator: rulePredicate(rule).matchOperator,
|
|
91
|
+
literalClauses: rulePredicate(rule).literalClauses,
|
|
212
92
|
});
|
|
213
|
-
const apply = buildOrganizeInput(ruleToDraft(
|
|
214
|
-
assert.equal(apply.anchorMessageId, preview.anchorMessageId);
|
|
93
|
+
const apply = buildOrganizeInput(ruleToDraft(rule));
|
|
215
94
|
assert.equal(apply.matchOperator, preview.matchOperator);
|
|
216
95
|
assert.deepEqual(apply.literalClauses, preview.literalClauses);
|
|
217
96
|
});
|
|
218
97
|
|
|
219
|
-
it("keeps them equal for the sender fallback (no anchor, Or)", () => {
|
|
220
|
-
const rule = buildInitialRule({
|
|
221
|
-
semanticUnavailable: true,
|
|
222
|
-
senders: ["a@x.com", "b@y.com"],
|
|
223
|
-
selectionCount: 2,
|
|
224
|
-
});
|
|
225
|
-
const predicate = rulePredicate(rule);
|
|
226
|
-
const apply = buildOrganizeInput(ruleToDraft(rule));
|
|
227
|
-
assert.equal(apply.anchorMessageId, undefined);
|
|
228
|
-
assert.equal(apply.matchOperator, "Or");
|
|
229
|
-
assert.deepEqual(apply.literalClauses, predicate.literalClauses);
|
|
230
|
-
});
|
|
231
|
-
|
|
232
98
|
it("adds the expiry only for the until scope, never touching the match set", () => {
|
|
233
|
-
const once = ruleToDraft(
|
|
99
|
+
const once = ruleToDraft(rule);
|
|
234
100
|
assert.equal(once.expiresAt, undefined);
|
|
235
101
|
|
|
236
|
-
const until = ruleToDraft(
|
|
237
|
-
|
|
238
|
-
"
|
|
239
|
-
|
|
102
|
+
const until = ruleToDraft({
|
|
103
|
+
...rule,
|
|
104
|
+
scope: "until",
|
|
105
|
+
until: "2999-01-02",
|
|
106
|
+
});
|
|
240
107
|
assert.ok(until.expiresAt?.startsWith("2999-01-02"));
|
|
241
108
|
assert.deepEqual(until.literalClauses, once.literalClauses);
|
|
242
|
-
assert.equal(until.anchorMessageId, once.anchorMessageId);
|
|
243
109
|
});
|
|
244
110
|
|
|
245
111
|
it("carries the move destination into the draft", () => {
|
|
246
|
-
assert.equal(
|
|
247
|
-
ruleToDraft(semanticRule, "msg-1").moveMailboxId,
|
|
248
|
-
"mbx-archive",
|
|
249
|
-
);
|
|
112
|
+
assert.equal(ruleToDraft(rule).moveMailboxId, "mbx-archive");
|
|
250
113
|
});
|
|
251
114
|
|
|
252
115
|
it("carries the label target into the draft (issue #26)", () => {
|
|
253
|
-
const labeled = { ...
|
|
254
|
-
assert.equal(ruleToDraft(labeled
|
|
116
|
+
const labeled = { ...rule, labelId: "lbl-receipts" };
|
|
117
|
+
assert.equal(ruleToDraft(labeled).labelId, "lbl-receipts");
|
|
255
118
|
});
|
|
256
119
|
});
|
|
257
120
|
|
|
@@ -276,36 +139,6 @@ describe("derivePreview", () => {
|
|
|
276
139
|
);
|
|
277
140
|
});
|
|
278
141
|
|
|
279
|
-
it("carries the empty-index answer through, so a zero can say why (#452)", () => {
|
|
280
|
-
assert.deepEqual(
|
|
281
|
-
derivePreview(
|
|
282
|
-
{ count: 0, indexEmpty: true, previewedSignature: signature },
|
|
283
|
-
signature,
|
|
284
|
-
),
|
|
285
|
-
{ status: "ready", count: 0, indexEmpty: true },
|
|
286
|
-
);
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
it("keeps the empty-index answer on a stale count", () => {
|
|
290
|
-
assert.deepEqual(
|
|
291
|
-
derivePreview(
|
|
292
|
-
{ count: 0, indexEmpty: true, previewedSignature: "sig-old" },
|
|
293
|
-
signature,
|
|
294
|
-
),
|
|
295
|
-
{ status: "ready", count: 0, stale: true, indexEmpty: true },
|
|
296
|
-
);
|
|
297
|
-
});
|
|
298
|
-
|
|
299
|
-
it("leaves the flag off when the index answered", () => {
|
|
300
|
-
assert.deepEqual(
|
|
301
|
-
derivePreview(
|
|
302
|
-
{ count: 0, indexEmpty: false, previewedSignature: signature },
|
|
303
|
-
signature,
|
|
304
|
-
),
|
|
305
|
-
{ status: "ready", count: 0 },
|
|
306
|
-
);
|
|
307
|
-
});
|
|
308
|
-
|
|
309
142
|
it("surfaces an error raised for the current predicate", () => {
|
|
310
143
|
assert.deepEqual(
|
|
311
144
|
derivePreview(
|
|
@@ -333,7 +166,7 @@ describe("derivePreview", () => {
|
|
|
333
166
|
});
|
|
334
167
|
|
|
335
168
|
describe("isEvaluablePredicate", () => {
|
|
336
|
-
it("accepts literal clauses the
|
|
169
|
+
it("accepts literal clauses the matcher can read off the thread rows", () => {
|
|
337
170
|
assert.equal(
|
|
338
171
|
isEvaluablePredicate({
|
|
339
172
|
matchOperator: "And",
|
|
@@ -346,7 +179,7 @@ describe("isEvaluablePredicate", () => {
|
|
|
346
179
|
);
|
|
347
180
|
});
|
|
348
181
|
|
|
349
|
-
it("rejects a body clause
|
|
182
|
+
it("rejects a body clause — the shape that answered 500", () => {
|
|
350
183
|
assert.equal(
|
|
351
184
|
isEvaluablePredicate({
|
|
352
185
|
matchOperator: "And",
|
|
@@ -369,17 +202,6 @@ describe("isEvaluablePredicate", () => {
|
|
|
369
202
|
);
|
|
370
203
|
});
|
|
371
204
|
|
|
372
|
-
it("accepts a body clause once an anchor puts the matcher on the semantic arm", () => {
|
|
373
|
-
assert.equal(
|
|
374
|
-
isEvaluablePredicate({
|
|
375
|
-
anchorMessageId: "msg-1",
|
|
376
|
-
matchOperator: "And",
|
|
377
|
-
literalClauses: [{ field: "HasWords", value: "receipt" }],
|
|
378
|
-
}),
|
|
379
|
-
true,
|
|
380
|
-
);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
205
|
it("accepts an empty predicate", () => {
|
|
384
206
|
assert.equal(
|
|
385
207
|
isEvaluablePredicate({ matchOperator: "And", literalClauses: [] }),
|
|
@@ -1,16 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type PreviewCount,
|
|
7
|
-
type RuleClause,
|
|
8
|
-
type RuleMatchMode,
|
|
9
|
-
type RuleScope,
|
|
1
|
+
import type {
|
|
2
|
+
ClauseField,
|
|
3
|
+
FilterRule,
|
|
4
|
+
MatchOperator,
|
|
5
|
+
PreviewCount,
|
|
10
6
|
} from "@remit/ui";
|
|
11
7
|
import { pickedDateToExpiresAt } from "./filter-status";
|
|
12
|
-
import type { OrganizeDraft } from "./organize-model";
|
|
13
|
-
import type { OrganizeMatchPredicate } from "./sender-fallback";
|
|
8
|
+
import type { OrganizeDraft, OrganizeMatchPredicate } from "./organize-model";
|
|
14
9
|
|
|
15
10
|
/**
|
|
16
11
|
* The clause fields the Organize editor may offer (RFC 038 D2). Gated on what
|
|
@@ -49,163 +44,31 @@ export const normalizeClauseValue = (
|
|
|
49
44
|
const toApiOperator = (operator: MatchOperator): "And" | "Or" =>
|
|
50
45
|
operator === "all" ? "And" : "Or";
|
|
51
46
|
|
|
52
|
-
export interface InitialRuleInput {
|
|
53
|
-
/** The semantic anchor — the first selected message. */
|
|
54
|
-
anchorMessageId?: string;
|
|
55
|
-
/**
|
|
56
|
-
* The deployment ships no vector pipeline, so the widen cannot run. The rule
|
|
57
|
-
* opens on the property-derived literal clauses instead of an anchor.
|
|
58
|
-
*/
|
|
59
|
-
semanticUnavailable: boolean;
|
|
60
|
-
/**
|
|
61
|
-
* How the rule opens. Defaults to `properties` when the deployment cannot
|
|
62
|
-
* serve the widen, `similar` otherwise — semantic stays the default wherever
|
|
63
|
-
* it can run.
|
|
64
|
-
*/
|
|
65
|
-
matchMode?: RuleMatchMode;
|
|
66
|
-
/** Distinct sender addresses of the selection, for the derived clauses. */
|
|
67
|
-
senders: readonly string[];
|
|
68
|
-
/** Subjects of the selected messages, for the shared-subject prefill. */
|
|
69
|
-
subjects?: readonly string[];
|
|
70
|
-
/** How many messages the selection holds — the widen's anchor count. */
|
|
71
|
-
selectionCount: number;
|
|
72
|
-
/** A folder a "Something else" shortcut pre-picked. */
|
|
73
|
-
seedMailboxId?: string;
|
|
74
|
-
/** A scope a "Something else" shortcut pre-picked. */
|
|
75
|
-
seedScope?: RuleScope;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export const defaultMatchMode = (
|
|
79
|
-
semanticUnavailable: boolean,
|
|
80
|
-
): RuleMatchMode => (semanticUnavailable ? "properties" : "similar");
|
|
81
|
-
|
|
82
|
-
/** The property prefill as editor chips, flagged as derived so a mode switch
|
|
83
|
-
* can take back its own guesses without touching a clause the user wrote. */
|
|
84
|
-
const derivedClauses = (
|
|
85
|
-
senders: readonly string[],
|
|
86
|
-
subjects: readonly string[],
|
|
87
|
-
): RuleClause[] =>
|
|
88
|
-
derivePropertyClauses(senders, subjects).map((clause, index) => ({
|
|
89
|
-
id: `derived-${index}`,
|
|
90
|
-
field: clause.field,
|
|
91
|
-
value: clause.value,
|
|
92
|
-
derived: true,
|
|
93
|
-
}));
|
|
94
|
-
|
|
95
|
-
export interface RuleMatchers {
|
|
96
|
-
clauses: RuleClause[];
|
|
97
|
-
matchOperator: MatchOperator;
|
|
98
|
-
widen?: FilterRule["widen"];
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export interface MatchersInput {
|
|
102
|
-
anchorMessageId?: string;
|
|
103
|
-
senders: readonly string[];
|
|
104
|
-
subjects: readonly string[];
|
|
105
|
-
selectionCount: number;
|
|
106
|
-
/** Clauses the user wrote, carried across a mode switch untouched. */
|
|
107
|
-
keepClauses?: RuleClause[];
|
|
108
|
-
/** The operator to keep when the user already has clauses of their own. */
|
|
109
|
-
currentOperator?: MatchOperator;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/**
|
|
113
|
-
* What a rule matches on in a given mode, with the action and scope left alone.
|
|
114
|
-
*
|
|
115
|
-
* - `similar` — the semantic widen chip, the anchor doing the work.
|
|
116
|
-
* - `properties` — no anchor at all: the selection's own sender or shared
|
|
117
|
-
* subject as ordinary literal chips ({@link derivePropertyClauses}).
|
|
118
|
-
*
|
|
119
|
-
* Both keep clauses the user added by hand, so switching mode is never
|
|
120
|
-
* destructive of their own edits.
|
|
121
|
-
*/
|
|
122
|
-
export const matchersForMode = (
|
|
123
|
-
mode: RuleMatchMode,
|
|
124
|
-
input: MatchersInput,
|
|
125
|
-
): RuleMatchers => {
|
|
126
|
-
const kept = input.keepClauses ?? [];
|
|
127
|
-
if (mode === "properties") {
|
|
128
|
-
const prefill = derivedClauses(input.senders, input.subjects).filter(
|
|
129
|
-
(clause) =>
|
|
130
|
-
!kept.some(
|
|
131
|
-
(existing) =>
|
|
132
|
-
existing.field === clause.field && existing.value === clause.value,
|
|
133
|
-
),
|
|
134
|
-
);
|
|
135
|
-
return {
|
|
136
|
-
clauses: [...kept, ...prefill],
|
|
137
|
-
matchOperator: kept.length > 0 ? (input.currentOperator ?? "any") : "any",
|
|
138
|
-
widen: undefined,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
return {
|
|
142
|
-
clauses: kept,
|
|
143
|
-
matchOperator: kept.length > 0 ? (input.currentOperator ?? "all") : "all",
|
|
144
|
-
widen: input.anchorMessageId
|
|
145
|
-
? { anchorCount: Math.max(input.selectionCount, 1) }
|
|
146
|
-
: undefined,
|
|
147
|
-
};
|
|
148
|
-
};
|
|
149
|
-
|
|
150
47
|
/**
|
|
151
|
-
* The
|
|
152
|
-
*
|
|
153
|
-
* without the vector pipeline, or a user who picked properties matching, opens
|
|
154
|
-
* on the derived `From` / `FromDomain` / `Subject` chips (#251), visible and
|
|
155
|
-
* editable, matched with `Or`. Either way the move destination and scope come
|
|
156
|
-
* from any "Something else" seed.
|
|
157
|
-
*/
|
|
158
|
-
export const buildInitialRule = (input: InitialRuleInput): FilterRule => {
|
|
159
|
-
const scope = input.seedScope ?? "once";
|
|
160
|
-
const mode = input.matchMode ?? defaultMatchMode(input.semanticUnavailable);
|
|
161
|
-
return {
|
|
162
|
-
...matchersForMode(mode, {
|
|
163
|
-
anchorMessageId: input.anchorMessageId,
|
|
164
|
-
senders: input.senders,
|
|
165
|
-
subjects: input.subjects ?? [],
|
|
166
|
-
selectionCount: input.selectionCount,
|
|
167
|
-
}),
|
|
168
|
-
moveMailboxId: input.seedMailboxId,
|
|
169
|
-
scope,
|
|
170
|
-
name: "",
|
|
171
|
-
};
|
|
172
|
-
};
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* The match-relevant projection of a rule — the anchor (only while the widen is
|
|
176
|
-
* present and active), the operator, and the literal clauses. Preview and apply
|
|
48
|
+
* The match-relevant projection of a rule — the operator and the literal
|
|
49
|
+
* clauses. Preview and apply
|
|
177
50
|
* both derive from this, so the set the editor counts is the set a commit acts
|
|
178
51
|
* on. Fields that do not change the match set (folder, scope, name, date) are
|
|
179
52
|
* deliberately absent.
|
|
180
53
|
*/
|
|
181
|
-
export const rulePredicate = (
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
): OrganizeMatchPredicate => {
|
|
185
|
-
const widenActive = rule.widen !== undefined && !rule.widen.inactive;
|
|
186
|
-
const literalClauses = rule.clauses.map((clause) => ({
|
|
54
|
+
export const rulePredicate = (rule: FilterRule): OrganizeMatchPredicate => ({
|
|
55
|
+
matchOperator: toApiOperator(rule.matchOperator),
|
|
56
|
+
literalClauses: rule.clauses.map((clause) => ({
|
|
187
57
|
field: clause.field,
|
|
188
58
|
value: clause.value,
|
|
189
|
-
}))
|
|
190
|
-
|
|
191
|
-
...(widenActive && anchorMessageId ? { anchorMessageId } : {}),
|
|
192
|
-
matchOperator: toApiOperator(rule.matchOperator),
|
|
193
|
-
literalClauses,
|
|
194
|
-
};
|
|
195
|
-
};
|
|
59
|
+
})),
|
|
60
|
+
});
|
|
196
61
|
|
|
197
62
|
/**
|
|
198
63
|
* Whether the matcher behind `/organize/preview` can evaluate this predicate at
|
|
199
|
-
* all.
|
|
200
|
-
*
|
|
201
|
-
* silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
|
|
64
|
+
* all. It reads no message body and rejects a `HasWords` clause rather than
|
|
65
|
+
* narrowing the match silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
|
|
202
66
|
* anyway is refused with a 400, on preview and on a back-apply alike, so the
|
|
203
67
|
* caller must not ask.
|
|
204
68
|
*/
|
|
205
69
|
export const isEvaluablePredicate = (
|
|
206
70
|
predicate: OrganizeMatchPredicate,
|
|
207
71
|
): boolean =>
|
|
208
|
-
predicate.anchorMessageId !== undefined ||
|
|
209
72
|
!predicate.literalClauses.some((clause) => clause.field === "HasWords");
|
|
210
73
|
|
|
211
74
|
/**
|
|
@@ -215,7 +78,6 @@ export const isEvaluablePredicate = (
|
|
|
215
78
|
*/
|
|
216
79
|
export const predicateSignature = (predicate: OrganizeMatchPredicate): string =>
|
|
217
80
|
JSON.stringify({
|
|
218
|
-
anchor: predicate.anchorMessageId ?? null,
|
|
219
81
|
operator: predicate.matchOperator,
|
|
220
82
|
clauses: predicate.literalClauses.map(
|
|
221
83
|
(clause) => `${clause.field} ${clause.value}`,
|
|
@@ -228,15 +90,9 @@ export const predicateSignature = (predicate: OrganizeMatchPredicate): string =>
|
|
|
228
90
|
* and, for the `until` scope, the derived expiry added. The expiry never enters
|
|
229
91
|
* the match set, so preview and apply still count and act on the same messages.
|
|
230
92
|
*/
|
|
231
|
-
export const ruleToDraft = (
|
|
232
|
-
rule
|
|
233
|
-
anchorMessageId?: string,
|
|
234
|
-
): OrganizeDraft => {
|
|
235
|
-
const predicate = rulePredicate(rule, anchorMessageId);
|
|
93
|
+
export const ruleToDraft = (rule: FilterRule): OrganizeDraft => {
|
|
94
|
+
const predicate = rulePredicate(rule);
|
|
236
95
|
return {
|
|
237
|
-
...(predicate.anchorMessageId
|
|
238
|
-
? { anchorMessageId: predicate.anchorMessageId }
|
|
239
|
-
: {}),
|
|
240
96
|
matchOperator: predicate.matchOperator,
|
|
241
97
|
literalClauses: predicate.literalClauses,
|
|
242
98
|
moveMailboxId: rule.moveMailboxId,
|
|
@@ -253,8 +109,6 @@ export interface PreviewState {
|
|
|
253
109
|
count?: number;
|
|
254
110
|
/** The ids that count was counted over, as the server bounded them. */
|
|
255
111
|
matchedIds?: readonly string[];
|
|
256
|
-
/** The server said the semantic index had nothing to answer with (#452). */
|
|
257
|
-
indexEmpty?: boolean;
|
|
258
112
|
/** The signature the {@link count} was counted for. */
|
|
259
113
|
previewedSignature?: string;
|
|
260
114
|
/** The signature the last error was raised for. */
|
|
@@ -276,11 +130,10 @@ export const derivePreview = (
|
|
|
276
130
|
return { status: "error", reason: state.error };
|
|
277
131
|
}
|
|
278
132
|
if (state.count === undefined) return { status: "loading" };
|
|
279
|
-
const indexEmpty = state.indexEmpty === true ? { indexEmpty: true } : {};
|
|
280
133
|
if (state.previewedSignature === currentSignature) {
|
|
281
|
-
return { status: "ready", count: state.count
|
|
134
|
+
return { status: "ready", count: state.count };
|
|
282
135
|
}
|
|
283
|
-
return { status: "ready", count: state.count, stale: true
|
|
136
|
+
return { status: "ready", count: state.count, stale: true };
|
|
284
137
|
};
|
|
285
138
|
|
|
286
139
|
/** How long a rule change settles before the next preview fires. */
|
|
@@ -9,13 +9,8 @@ const CONTEXT: SearchTokenContext = {
|
|
|
9
9
|
accountsByName: new Map([["work", "acc-work"]]),
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
-
const convert = (
|
|
13
|
-
query
|
|
14
|
-
options: { searchHadSemanticReach?: boolean } = {},
|
|
15
|
-
) =>
|
|
16
|
-
convertSearchToRule(parseSearchTokens(query, CONTEXT), {
|
|
17
|
-
searchHadSemanticReach: options.searchHadSemanticReach ?? false,
|
|
18
|
-
});
|
|
12
|
+
const convert = (query: string) =>
|
|
13
|
+
convertSearchToRule(parseSearchTokens(query, CONTEXT));
|
|
19
14
|
|
|
20
15
|
describe("convertSearchToRule — facet → clause mapping", () => {
|
|
21
16
|
it("maps literal terms to a HasWords clause", () => {
|
|
@@ -82,35 +77,6 @@ describe("convertSearchToRule — facets with no clause equivalent", () => {
|
|
|
82
77
|
});
|
|
83
78
|
});
|
|
84
79
|
|
|
85
|
-
describe("convertSearchToRule — semantic honesty (RFC 038 D5)", () => {
|
|
86
|
-
it("states the dropped reach when the search surfaced similar mail", () => {
|
|
87
|
-
// The literal filter cannot reproduce the reach the search just showed.
|
|
88
|
-
const conversion = convert("things like this", {
|
|
89
|
-
searchHadSemanticReach: true,
|
|
90
|
-
});
|
|
91
|
-
assert.equal(conversion.droppedSemantic, true);
|
|
92
|
-
// The literal words are still kept.
|
|
93
|
-
assert.deepEqual(conversion.clauses, [
|
|
94
|
-
{ field: "HasWords", value: "things like this" },
|
|
95
|
-
]);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
it("says nothing when the search had no semantic reach to drop", () => {
|
|
99
|
-
const conversion = convert("things like this", {
|
|
100
|
-
searchHadSemanticReach: false,
|
|
101
|
-
});
|
|
102
|
-
assert.equal(conversion.droppedSemantic, false);
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
it("never reports dropped semantics for a facet-only search", () => {
|
|
106
|
-
const conversion = convert("from:a@b.com", {
|
|
107
|
-
searchHadSemanticReach: true,
|
|
108
|
-
});
|
|
109
|
-
assert.equal(conversion.keptTerms, false);
|
|
110
|
-
assert.equal(conversion.droppedSemantic, false);
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
80
|
describe("convertSearchToRule — nothing left to make a filter from", () => {
|
|
115
81
|
it("yields no clause for a query of only dropped facets", () => {
|
|
116
82
|
assert.equal(isConvertible(convert("has:attachment")), false);
|
|
@@ -19,22 +19,9 @@ import {
|
|
|
19
19
|
* equivalent is never silently folded into the rule: a folder scope is reported
|
|
20
20
|
* as dropped-and-kept-out (the filter matches everywhere, not just there), the
|
|
21
21
|
* attribute facets (attachment / read state / starred / category / date) are
|
|
22
|
-
* reported as left out
|
|
23
|
-
* is reported as having lost its semantic "similar mail" reach on a deployment
|
|
24
|
-
* that cannot embed the query (D5). Pure functions only — the capability is
|
|
25
|
-
* injected, not probed here.
|
|
22
|
+
* reported as left out. Pure functions only.
|
|
26
23
|
*/
|
|
27
24
|
|
|
28
|
-
interface ConvertOptions {
|
|
29
|
-
/**
|
|
30
|
-
* Whether the current search surfaced semantically-similar mail (a non-empty
|
|
31
|
-
* "Related" section). This is the reach the literal filter cannot reproduce —
|
|
32
|
-
* the direct existing signal, read from the search's own semantic results, not
|
|
33
|
-
* a probe of deployment capability.
|
|
34
|
-
*/
|
|
35
|
-
searchHadSemanticReach: boolean;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
25
|
type NoClauseFacet = Extract<SearchToken["type"], DroppedFacetType>;
|
|
39
26
|
|
|
40
27
|
const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> =
|
|
@@ -61,7 +48,6 @@ const hasNoClauseEquivalent = (
|
|
|
61
48
|
*/
|
|
62
49
|
export const convertSearchToRule = (
|
|
63
50
|
parsed: ParsedSearchQuery,
|
|
64
|
-
{ searchHadSemanticReach }: ConvertOptions,
|
|
65
51
|
): SearchConversion => {
|
|
66
52
|
const clauses: SearchConversion["clauses"] = [];
|
|
67
53
|
const droppedFacets: DroppedFacet[] = [];
|
|
@@ -101,7 +87,6 @@ export const convertSearchToRule = (
|
|
|
101
87
|
droppedFacets,
|
|
102
88
|
targetAccountId,
|
|
103
89
|
keptTerms,
|
|
104
|
-
droppedSemantic: keptTerms && searchHadSemanticReach,
|
|
105
90
|
};
|
|
106
91
|
};
|
|
107
92
|
|