@remit/ui 0.0.58 → 0.0.59
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/selection-wizard.render.test.ts +725 -0
- package/src/components/selection-wizard.tsx +1078 -0
- package/src/index.ts +56 -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,
|
|
@@ -483,6 +485,28 @@ export {
|
|
|
483
485
|
type SelectionTopBarNoticeAction,
|
|
484
486
|
type SelectionTopBarProps,
|
|
485
487
|
} from "./components/selection-top-bar.js";
|
|
488
|
+
export {
|
|
489
|
+
FolderStepBody,
|
|
490
|
+
type FolderStepProps,
|
|
491
|
+
MatchStepBody,
|
|
492
|
+
type MatchStepProps,
|
|
493
|
+
NameStepBody,
|
|
494
|
+
type NameStepProps,
|
|
495
|
+
PropertiesStepBody,
|
|
496
|
+
type PropertiesStepProps,
|
|
497
|
+
ReviewStepBody,
|
|
498
|
+
type ReviewStepProps,
|
|
499
|
+
RuleStepBody,
|
|
500
|
+
type RuleStepProps,
|
|
501
|
+
RunFooter,
|
|
502
|
+
RunStepBody,
|
|
503
|
+
type RunStepProps,
|
|
504
|
+
SelectionSample,
|
|
505
|
+
type SelectionSampleProps,
|
|
506
|
+
SelectionWizard,
|
|
507
|
+
type SelectionWizardProps,
|
|
508
|
+
type WizardMessage,
|
|
509
|
+
} from "./components/selection-wizard.js";
|
|
486
510
|
export {
|
|
487
511
|
demoLogsCommand,
|
|
488
512
|
demoRelease,
|
|
@@ -611,6 +635,7 @@ export {
|
|
|
611
635
|
type UseRovingFocusOptions,
|
|
612
636
|
useRovingFocus,
|
|
613
637
|
} from "./lib/roving-focus.js";
|
|
638
|
+
export { type RuleNameParts, suggestRuleName } from "./lib/rule-name.js";
|
|
614
639
|
export {
|
|
615
640
|
buildSearchRule,
|
|
616
641
|
type DroppedFacet,
|
|
@@ -622,7 +647,9 @@ export {
|
|
|
622
647
|
collapsibleDomain,
|
|
623
648
|
deriveSenderClauses,
|
|
624
649
|
distinctSenders,
|
|
650
|
+
dominantSender,
|
|
625
651
|
senderDomain,
|
|
652
|
+
senderLabel,
|
|
626
653
|
} from "./lib/sender-derivation.js";
|
|
627
654
|
export {
|
|
628
655
|
type SuggestAction,
|
|
@@ -640,3 +667,32 @@ export {
|
|
|
640
667
|
type UseSuggestListInput,
|
|
641
668
|
useSuggestList,
|
|
642
669
|
} from "./lib/use-suggest-list.js";
|
|
670
|
+
export {
|
|
671
|
+
backExits,
|
|
672
|
+
clauseSentence,
|
|
673
|
+
clauseWords,
|
|
674
|
+
type MatchCount,
|
|
675
|
+
type MatchDescription,
|
|
676
|
+
type MatchMode,
|
|
677
|
+
matchDoorHint,
|
|
678
|
+
matchDoorLabel,
|
|
679
|
+
matchPhrase,
|
|
680
|
+
matchSummary,
|
|
681
|
+
type RunCopy,
|
|
682
|
+
type RunOutcome,
|
|
683
|
+
type RunState,
|
|
684
|
+
runCopy,
|
|
685
|
+
type SampleEmptyReason,
|
|
686
|
+
type StepId,
|
|
687
|
+
sampleEmptyCopy,
|
|
688
|
+
stepBlockedReason,
|
|
689
|
+
stepIndex,
|
|
690
|
+
stepLabel,
|
|
691
|
+
stepsFor,
|
|
692
|
+
unreadableDraftClauses,
|
|
693
|
+
type Verb,
|
|
694
|
+
type VerbCopy,
|
|
695
|
+
verbCopy,
|
|
696
|
+
type WizardAnswers,
|
|
697
|
+
type WizardDraft,
|
|
698
|
+
} 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;
|