@remit/ui 0.0.58 → 0.0.60
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/filter-rule.ts +43 -20
- package/src/components/filter-sheet.stories.tsx +14 -0
- package/src/components/filter-sheet.tsx +46 -35
- package/src/components/message-list-pane.stories.tsx +21 -10
- package/src/components/message-list-pane.tsx +7 -7
- package/src/components/popover-menu.render.test.ts +34 -0
- package/src/components/popover-menu.stories.tsx +59 -0
- package/src/components/popover-menu.tsx +38 -7
- package/src/components/selection-top-bar.render.test.ts +208 -73
- package/src/components/selection-top-bar.stories.tsx +156 -42
- package/src/components/selection-top-bar.tsx +237 -72
- package/src/components/selection-wizard.render.test.ts +725 -0
- package/src/components/selection-wizard.tsx +1078 -0
- package/src/index.ts +57 -0
- package/src/lib/rule-name.test.ts +28 -0
- package/src/lib/rule-name.ts +27 -0
- package/src/lib/sender-derivation.test.ts +53 -0
- package/src/lib/sender-derivation.ts +35 -0
- package/src/lib/wizard-steps.test.ts +673 -0
- package/src/lib/wizard-steps.ts +478 -0
package/src/index.ts
CHANGED
|
@@ -167,10 +167,12 @@ export {
|
|
|
167
167
|
matchOperatorLabel,
|
|
168
168
|
type PreviewCount,
|
|
169
169
|
previewCountSummary,
|
|
170
|
+
previewSettledReason,
|
|
170
171
|
type RuleClause,
|
|
171
172
|
type RuleMatchMode,
|
|
172
173
|
type RuleScope,
|
|
173
174
|
type RuleWiden,
|
|
175
|
+
ruleBlockedCopy,
|
|
174
176
|
scopeLabel,
|
|
175
177
|
unreadableBodyClauses,
|
|
176
178
|
widenChipLabel,
|
|
@@ -482,7 +484,30 @@ export {
|
|
|
482
484
|
type SelectionTopBarNotice,
|
|
483
485
|
type SelectionTopBarNoticeAction,
|
|
484
486
|
type SelectionTopBarProps,
|
|
487
|
+
type SelectionTopBarSelectAll,
|
|
485
488
|
} from "./components/selection-top-bar.js";
|
|
489
|
+
export {
|
|
490
|
+
FolderStepBody,
|
|
491
|
+
type FolderStepProps,
|
|
492
|
+
MatchStepBody,
|
|
493
|
+
type MatchStepProps,
|
|
494
|
+
NameStepBody,
|
|
495
|
+
type NameStepProps,
|
|
496
|
+
PropertiesStepBody,
|
|
497
|
+
type PropertiesStepProps,
|
|
498
|
+
ReviewStepBody,
|
|
499
|
+
type ReviewStepProps,
|
|
500
|
+
RuleStepBody,
|
|
501
|
+
type RuleStepProps,
|
|
502
|
+
RunFooter,
|
|
503
|
+
RunStepBody,
|
|
504
|
+
type RunStepProps,
|
|
505
|
+
SelectionSample,
|
|
506
|
+
type SelectionSampleProps,
|
|
507
|
+
SelectionWizard,
|
|
508
|
+
type SelectionWizardProps,
|
|
509
|
+
type WizardMessage,
|
|
510
|
+
} from "./components/selection-wizard.js";
|
|
486
511
|
export {
|
|
487
512
|
demoLogsCommand,
|
|
488
513
|
demoRelease,
|
|
@@ -611,6 +636,7 @@ export {
|
|
|
611
636
|
type UseRovingFocusOptions,
|
|
612
637
|
useRovingFocus,
|
|
613
638
|
} from "./lib/roving-focus.js";
|
|
639
|
+
export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
|
|
614
640
|
export {
|
|
615
641
|
buildSearchRule,
|
|
616
642
|
type DroppedFacet,
|
|
@@ -622,7 +648,9 @@ export {
|
|
|
622
648
|
collapsibleDomain,
|
|
623
649
|
deriveSenderClauses,
|
|
624
650
|
distinctSenders,
|
|
651
|
+
dominantSender,
|
|
625
652
|
senderDomain,
|
|
653
|
+
senderLabel,
|
|
626
654
|
} from "./lib/sender-derivation.js";
|
|
627
655
|
export {
|
|
628
656
|
type SuggestAction,
|
|
@@ -640,3 +668,32 @@ export {
|
|
|
640
668
|
type UseSuggestListInput,
|
|
641
669
|
useSuggestList,
|
|
642
670
|
} from "./lib/use-suggest-list.js";
|
|
671
|
+
export {
|
|
672
|
+
backExits,
|
|
673
|
+
clauseSentence,
|
|
674
|
+
clauseWords,
|
|
675
|
+
type MatchCount,
|
|
676
|
+
type MatchDescription,
|
|
677
|
+
type MatchMode,
|
|
678
|
+
matchDoorHint,
|
|
679
|
+
matchDoorLabel,
|
|
680
|
+
matchPhrase,
|
|
681
|
+
matchSummary,
|
|
682
|
+
type RunCopy,
|
|
683
|
+
type RunOutcome,
|
|
684
|
+
type RunState,
|
|
685
|
+
runCopy,
|
|
686
|
+
type SampleEmptyReason,
|
|
687
|
+
type StepId,
|
|
688
|
+
sampleEmptyCopy,
|
|
689
|
+
stepBlockedReason,
|
|
690
|
+
stepIndex,
|
|
691
|
+
stepLabel,
|
|
692
|
+
stepsFor,
|
|
693
|
+
unreadableDraftClauses,
|
|
694
|
+
type Verb,
|
|
695
|
+
type VerbCopy,
|
|
696
|
+
verbCopy,
|
|
697
|
+
type WizardAnswers,
|
|
698
|
+
type WizardDraft,
|
|
699
|
+
} from "./lib/wizard-steps.js";
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { suggestRuleName } from "./rule-name.js";
|
|
4
|
+
|
|
5
|
+
describe("suggestRuleName", () => {
|
|
6
|
+
it("names the match and its destination when it knows both", () => {
|
|
7
|
+
assert.equal(
|
|
8
|
+
suggestRuleName({ match: "Your receipt", folder: "Receipts" }),
|
|
9
|
+
"Your receipt → Receipts",
|
|
10
|
+
);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("falls back through match, then sender, then destination", () => {
|
|
14
|
+
assert.equal(
|
|
15
|
+
suggestRuleName({ match: "Your receipt" }),
|
|
16
|
+
"Mail matching Your receipt",
|
|
17
|
+
);
|
|
18
|
+
assert.equal(
|
|
19
|
+
suggestRuleName({ sender: "Booking.com" }),
|
|
20
|
+
"Mail from Booking.com",
|
|
21
|
+
);
|
|
22
|
+
assert.equal(suggestRuleName({ folder: "Travel" }), "Mail to Travel");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("suggests nothing when it knows nothing", () => {
|
|
26
|
+
assert.equal(suggestRuleName({}), "");
|
|
27
|
+
});
|
|
28
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The name a rule is offered under before the user writes their own (#477 4.10).
|
|
3
|
+
* A suggestion and never a decision: the field it fills is editable and
|
|
4
|
+
* clearable, and a rule with no name is held by `ruleBlockedCopy.unnamed`, not
|
|
5
|
+
* by a guess.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export interface RuleNameParts {
|
|
9
|
+
/** What a property match is anchored on — the leading clause value. */
|
|
10
|
+
match?: string;
|
|
11
|
+
/** The sender carrying most of the selection, as it reads on screen. */
|
|
12
|
+
sender?: string;
|
|
13
|
+
/** The destination, once one has been chosen. */
|
|
14
|
+
folder?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const suggestRuleName = ({
|
|
18
|
+
match,
|
|
19
|
+
sender,
|
|
20
|
+
folder,
|
|
21
|
+
}: RuleNameParts): string => {
|
|
22
|
+
const subject = match ?? sender;
|
|
23
|
+
if (!subject) return folder ? `Mail to ${folder}` : "";
|
|
24
|
+
if (folder) return `${subject} → ${folder}`;
|
|
25
|
+
if (match) return `Mail matching ${match}`;
|
|
26
|
+
return `Mail from ${subject}`;
|
|
27
|
+
};
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
collapsibleDomain,
|
|
5
5
|
deriveSenderClauses,
|
|
6
6
|
distinctSenders,
|
|
7
|
+
dominantSender,
|
|
8
|
+
senderLabel,
|
|
7
9
|
} from "./sender-derivation.js";
|
|
8
10
|
|
|
9
11
|
describe("distinctSenders", () => {
|
|
@@ -104,3 +106,54 @@ describe("deriveSenderClauses", () => {
|
|
|
104
106
|
]);
|
|
105
107
|
});
|
|
106
108
|
});
|
|
109
|
+
|
|
110
|
+
describe("dominantSender", () => {
|
|
111
|
+
const envelope = (normalizedEmail: string, displayName?: string) => ({
|
|
112
|
+
normalizedEmail,
|
|
113
|
+
displayName,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("takes the sender carrying most of the selection", () => {
|
|
117
|
+
assert.deepEqual(
|
|
118
|
+
dominantSender([
|
|
119
|
+
envelope("noreply@booking.com", "Booking.com"),
|
|
120
|
+
envelope("automated@airbnb.com", "Airbnb"),
|
|
121
|
+
envelope("noreply@booking.com", "Booking.com"),
|
|
122
|
+
]),
|
|
123
|
+
envelope("noreply@booking.com", "Booking.com"),
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("counts one address writing under two names as one sender", () => {
|
|
128
|
+
assert.deepEqual(
|
|
129
|
+
dominantSender([
|
|
130
|
+
envelope("info@klm.com", "KLM"),
|
|
131
|
+
envelope("INFO@klm.com", "KLM Royal Dutch Airlines"),
|
|
132
|
+
envelope("noreply@sixt.com", "Sixt"),
|
|
133
|
+
]),
|
|
134
|
+
envelope("info@klm.com", "KLM"),
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("has no answer for an empty selection", () => {
|
|
139
|
+
assert.equal(dominantSender([]), undefined);
|
|
140
|
+
assert.equal(dominantSender([envelope(" ")]), undefined);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("senderLabel", () => {
|
|
145
|
+
it("reads the display name, and the address when there is none", () => {
|
|
146
|
+
assert.equal(
|
|
147
|
+
senderLabel({ normalizedEmail: "a@b.example", displayName: "Ada" }),
|
|
148
|
+
"Ada",
|
|
149
|
+
);
|
|
150
|
+
assert.equal(
|
|
151
|
+
senderLabel({ normalizedEmail: "a@b.example", displayName: " " }),
|
|
152
|
+
"a@b.example",
|
|
153
|
+
);
|
|
154
|
+
assert.equal(
|
|
155
|
+
senderLabel({ normalizedEmail: "a@b.example" }),
|
|
156
|
+
"a@b.example",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import { getDomain } from "tldts";
|
|
13
|
+
import type { EnvelopeAddress } from "../components/address-display.js";
|
|
13
14
|
import type { RuleClause } from "../components/filter-rule.js";
|
|
14
15
|
|
|
15
16
|
/**
|
|
@@ -83,3 +84,37 @@ export const deriveSenderClauses = (
|
|
|
83
84
|
(value): Omit<RuleClause, "id"> => ({ field: "From", value }),
|
|
84
85
|
);
|
|
85
86
|
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The sender carrying most of a selection, or `undefined` when it carries none.
|
|
90
|
+
* Whole envelope addresses rather than bare strings: this reads the display name
|
|
91
|
+
* a person recognises, where the clause derivations above read the address a
|
|
92
|
+
* matcher compares, and the two are not interchangeable.
|
|
93
|
+
*
|
|
94
|
+
* Counted on the normalized address, so one sender writing under two display
|
|
95
|
+
* names is one sender; the answer is the first envelope seen for the winner.
|
|
96
|
+
*/
|
|
97
|
+
export const dominantSender = (
|
|
98
|
+
senders: readonly EnvelopeAddress[],
|
|
99
|
+
): EnvelopeAddress | undefined => {
|
|
100
|
+
const counts = new Map<string, { sender: EnvelopeAddress; count: number }>();
|
|
101
|
+
for (const sender of senders) {
|
|
102
|
+
const key = sender.normalizedEmail.trim().toLowerCase();
|
|
103
|
+
if (key === "") continue;
|
|
104
|
+
const seen = counts.get(key);
|
|
105
|
+
if (seen) seen.count += 1;
|
|
106
|
+
else counts.set(key, { sender, count: 1 });
|
|
107
|
+
}
|
|
108
|
+
let best: EnvelopeAddress | undefined;
|
|
109
|
+
let bestCount = 0;
|
|
110
|
+
for (const { sender, count } of counts.values()) {
|
|
111
|
+
if (count <= bestCount) continue;
|
|
112
|
+
best = sender;
|
|
113
|
+
bestCount = count;
|
|
114
|
+
}
|
|
115
|
+
return best;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** What a sender is called on screen — the display name, or the address when it has none. */
|
|
119
|
+
export const senderLabel = (sender: EnvelopeAddress): string =>
|
|
120
|
+
sender.displayName?.trim() || sender.normalizedEmail;
|