@remit/ui 0.0.124 → 0.0.126
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/event-editor.render.test.ts +51 -0
- package/src/components/event-editor.stories.tsx +32 -2
- package/src/components/event-editor.tsx +78 -40
- package/src/components/selection-wizard.render.test.ts +22 -0
- package/src/components/selection-wizard.tsx +8 -6
- package/src/index.ts +6 -0
- package/src/lib/wizard-steps.test.ts +50 -0
- package/src/lib/wizard-steps.ts +68 -0
package/package.json
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { JSDOM } from "jsdom";
|
|
3
4
|
import { createElement } from "react";
|
|
4
5
|
import { renderToString } from "react-dom/server";
|
|
5
6
|
import type { CalendarDescriptor, EventDraft } from "./calendar-types.js";
|
|
@@ -49,6 +50,21 @@ const render = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
|
|
|
49
50
|
}),
|
|
50
51
|
);
|
|
51
52
|
|
|
53
|
+
/** The same render read as a document, so no assertion depends on attribute order. */
|
|
54
|
+
const parse = (props: Partial<Parameters<typeof EventEditor>[0]>) =>
|
|
55
|
+
JSDOM.fragment(render(props));
|
|
56
|
+
|
|
57
|
+
const field = (dom: DocumentFragment, label: string) =>
|
|
58
|
+
dom.querySelector<HTMLInputElement>(`input[aria-label="${label}"]`);
|
|
59
|
+
|
|
60
|
+
const saveControl = (dom: DocumentFragment, label = "Add") =>
|
|
61
|
+
[...dom.querySelectorAll("button")].find(
|
|
62
|
+
(button) => button.textContent === label,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const alertText = (dom: DocumentFragment) =>
|
|
66
|
+
dom.querySelector('[role="alert"]')?.textContent ?? "";
|
|
67
|
+
|
|
52
68
|
describe("EventEditor", () => {
|
|
53
69
|
it("folds everything but title, when and calendar", () => {
|
|
54
70
|
const html = render({});
|
|
@@ -80,4 +96,39 @@ describe("EventEditor", () => {
|
|
|
80
96
|
it("names the save action the caller asked for", () => {
|
|
81
97
|
assert.match(render({ saveLabel: "Save changes" }), /Save changes/);
|
|
82
98
|
});
|
|
99
|
+
|
|
100
|
+
it("says so and holds the save when the end is before the start", () => {
|
|
101
|
+
const dom = parse({
|
|
102
|
+
draft: { ...draft, startTime: "23:00", endTime: "01:00" },
|
|
103
|
+
});
|
|
104
|
+
assert.match(alertText(dom), /Ends before it starts/);
|
|
105
|
+
assert.equal(saveControl(dom)?.disabled, true);
|
|
106
|
+
assert.equal(
|
|
107
|
+
field(dom, "Start time")?.getAttribute("aria-invalid"),
|
|
108
|
+
"true",
|
|
109
|
+
);
|
|
110
|
+
assert.equal(field(dom, "End time")?.getAttribute("aria-invalid"), "true");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("keeps what was typed rather than swapping the two fields", () => {
|
|
114
|
+
const dom = parse({
|
|
115
|
+
draft: { ...draft, startTime: "23:00", endTime: "01:00" },
|
|
116
|
+
});
|
|
117
|
+
assert.equal(field(dom, "Start time")?.value, "23:00");
|
|
118
|
+
assert.equal(field(dom, "End time")?.value, "01:00");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("lets a normal range save", () => {
|
|
122
|
+
const dom = parse({});
|
|
123
|
+
assert.equal(alertText(dom), "");
|
|
124
|
+
assert.equal(saveControl(dom)?.disabled, false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("has no range to reject once the entry is all day", () => {
|
|
128
|
+
const dom = parse({
|
|
129
|
+
draft: { ...draft, startTime: "23:00", endTime: "01:00", allDay: true },
|
|
130
|
+
});
|
|
131
|
+
assert.equal(alertText(dom), "");
|
|
132
|
+
assert.equal(saveControl(dom)?.disabled, false);
|
|
133
|
+
});
|
|
83
134
|
});
|
|
@@ -53,8 +53,22 @@ const seed: EventDraft = {
|
|
|
53
53
|
repeat: "",
|
|
54
54
|
};
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
/** 23:00 to 01:00, which one date cannot hold. */
|
|
57
|
+
const backwards: EventDraft = {
|
|
58
|
+
...seed,
|
|
59
|
+
title: "Release window",
|
|
60
|
+
startTime: "23:00",
|
|
61
|
+
endTime: "01:00",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
function Live({
|
|
65
|
+
startExpanded,
|
|
66
|
+
seed: initial = seed,
|
|
67
|
+
}: {
|
|
68
|
+
startExpanded: boolean;
|
|
69
|
+
seed?: EventDraft;
|
|
70
|
+
}) {
|
|
71
|
+
const [draft, setDraft] = useState(initial);
|
|
58
72
|
const [expanded, setExpanded] = useState(startExpanded);
|
|
59
73
|
return (
|
|
60
74
|
<div className="max-w-sm rounded-lg border border-line bg-surface-raised p-4">
|
|
@@ -76,6 +90,22 @@ export const Folded: Story = { render: () => <Live startExpanded={false} /> };
|
|
|
76
90
|
/** Everything the folded form was hiding. */
|
|
77
91
|
export const Unfolded: Story = { render: () => <Live startExpanded /> };
|
|
78
92
|
|
|
93
|
+
/**
|
|
94
|
+
* An end before the start is one date read backwards, not a night that runs
|
|
95
|
+
* over. The form keeps what was typed, names the problem under the fields and
|
|
96
|
+
* holds the save until it is fixed.
|
|
97
|
+
*/
|
|
98
|
+
export const EndBeforeStart: Story = {
|
|
99
|
+
render: () => <Live startExpanded={false} seed={backwards} />,
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** All day takes the clock fields away, so there is no range left to reject. */
|
|
103
|
+
export const AllDay: Story = {
|
|
104
|
+
render: () => (
|
|
105
|
+
<Live startExpanded={false} seed={{ ...backwards, allDay: true }} />
|
|
106
|
+
),
|
|
107
|
+
};
|
|
108
|
+
|
|
79
109
|
/** The same form sized for a bottom sheet: every control a thumb target. */
|
|
80
110
|
export const Touch: Story = {
|
|
81
111
|
render: () => {
|
|
@@ -71,53 +71,90 @@ export function EventTitleField({
|
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A draft carries one date, so an end before the start is not a night that runs
|
|
76
|
+
* over — it is a span the calendar reads backwards. The form says so and refuses
|
|
77
|
+
* to save it rather than swapping the two fields behind whoever typed them.
|
|
78
|
+
*/
|
|
79
|
+
export function endsBeforeStart(draft: EventDraft): boolean {
|
|
80
|
+
if (draft.allDay) return false;
|
|
81
|
+
if (draft.startTime === "" || draft.endTime === "") return false;
|
|
82
|
+
return draft.endTime < draft.startTime;
|
|
83
|
+
}
|
|
84
|
+
|
|
74
85
|
export function EventWhenField({ draft, onChange, touch }: EventFieldProps) {
|
|
75
86
|
const fieldHeight = touch ? "min-h-11" : "";
|
|
87
|
+
const backwards = endsBeforeStart(draft);
|
|
88
|
+
const messageId = useId();
|
|
76
89
|
return (
|
|
77
|
-
<div className="flex flex-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
<
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
90
|
+
<div className="flex flex-col gap-1.5">
|
|
91
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
92
|
+
<Input
|
|
93
|
+
type="date"
|
|
94
|
+
value={draft.date}
|
|
95
|
+
aria-label="Date"
|
|
96
|
+
onChange={(e) =>
|
|
97
|
+
setField({ draft, onChange }, "date", e.target.value)
|
|
98
|
+
}
|
|
99
|
+
className={cn("w-40", fieldHeight)}
|
|
100
|
+
/>
|
|
101
|
+
{draft.allDay ? (
|
|
102
|
+
<span className="text-sm text-fg-muted">All day</span>
|
|
103
|
+
) : (
|
|
104
|
+
<>
|
|
105
|
+
<Input
|
|
106
|
+
type="time"
|
|
107
|
+
value={draft.startTime}
|
|
108
|
+
aria-label="Start time"
|
|
109
|
+
max={draft.endTime === "" ? undefined : draft.endTime}
|
|
110
|
+
aria-invalid={backwards}
|
|
111
|
+
aria-describedby={backwards ? messageId : undefined}
|
|
112
|
+
onChange={(e) =>
|
|
113
|
+
setField({ draft, onChange }, "startTime", e.target.value)
|
|
114
|
+
}
|
|
115
|
+
className={cn(
|
|
116
|
+
"w-28",
|
|
117
|
+
fieldHeight,
|
|
118
|
+
backwards && "border-danger/60",
|
|
119
|
+
)}
|
|
120
|
+
/>
|
|
121
|
+
<span className="text-sm text-fg-subtle">to</span>
|
|
122
|
+
<Input
|
|
123
|
+
type="time"
|
|
124
|
+
value={draft.endTime}
|
|
125
|
+
aria-label="End time"
|
|
126
|
+
min={draft.startTime === "" ? undefined : draft.startTime}
|
|
127
|
+
aria-invalid={backwards}
|
|
128
|
+
aria-describedby={backwards ? messageId : undefined}
|
|
129
|
+
onChange={(e) =>
|
|
130
|
+
setField({ draft, onChange }, "endTime", e.target.value)
|
|
131
|
+
}
|
|
132
|
+
className={cn(
|
|
133
|
+
"w-28",
|
|
134
|
+
fieldHeight,
|
|
135
|
+
backwards && "border-danger/60",
|
|
136
|
+
)}
|
|
137
|
+
/>
|
|
138
|
+
</>
|
|
139
|
+
)}
|
|
140
|
+
<label className="flex items-center gap-2 text-sm text-fg-muted">
|
|
141
|
+
<input
|
|
142
|
+
type="checkbox"
|
|
143
|
+
checked={draft.allDay}
|
|
103
144
|
onChange={(e) =>
|
|
104
|
-
setField({ draft, onChange }, "
|
|
145
|
+
setField({ draft, onChange }, "allDay", e.target.checked)
|
|
105
146
|
}
|
|
106
|
-
className=
|
|
147
|
+
className="size-4 accent-current"
|
|
107
148
|
/>
|
|
108
|
-
|
|
149
|
+
All day
|
|
150
|
+
</label>
|
|
151
|
+
</div>
|
|
152
|
+
{backwards && (
|
|
153
|
+
<p id={messageId} className="text-xs text-danger" role="alert">
|
|
154
|
+
Ends before it starts. Move the end past {draft.startTime}, or start
|
|
155
|
+
earlier.
|
|
156
|
+
</p>
|
|
109
157
|
)}
|
|
110
|
-
<label className="flex items-center gap-2 text-sm text-fg-muted">
|
|
111
|
-
<input
|
|
112
|
-
type="checkbox"
|
|
113
|
-
checked={draft.allDay}
|
|
114
|
-
onChange={(e) =>
|
|
115
|
-
setField({ draft, onChange }, "allDay", e.target.checked)
|
|
116
|
-
}
|
|
117
|
-
className="size-4 accent-current"
|
|
118
|
-
/>
|
|
119
|
-
All day
|
|
120
|
-
</label>
|
|
121
158
|
</div>
|
|
122
159
|
);
|
|
123
160
|
}
|
|
@@ -294,6 +331,7 @@ export function EventEditor({
|
|
|
294
331
|
variant="primary"
|
|
295
332
|
size={touch || roomy ? "md" : "sm"}
|
|
296
333
|
onClick={onSave}
|
|
334
|
+
disabled={endsBeforeStart(draft)}
|
|
297
335
|
className={touch ? "min-h-11 flex-1" : ""}
|
|
298
336
|
>
|
|
299
337
|
{saveLabel}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
matchDoorsFor,
|
|
8
8
|
stepBlockedReason,
|
|
9
9
|
stepsFor,
|
|
10
|
+
wizardScopeFor,
|
|
10
11
|
} from "../lib/wizard-steps.js";
|
|
11
12
|
import {
|
|
12
13
|
type RuleClause,
|
|
@@ -413,6 +414,27 @@ describe("FolderStepBody", () => {
|
|
|
413
414
|
);
|
|
414
415
|
assert.match(text(html), /Tap a folder to open it/);
|
|
415
416
|
});
|
|
417
|
+
|
|
418
|
+
it("states the restriction the selection actually carries (#525)", () => {
|
|
419
|
+
// Told to pick a single account, a selection already inside one account has
|
|
420
|
+
// nothing to act on and the flow dead-ends here.
|
|
421
|
+
const spansFolders = renderToString(
|
|
422
|
+
createElement(FolderStepBody, {
|
|
423
|
+
...folderProps,
|
|
424
|
+
restriction: wizardScopeFor("acc-personal", "spansFolders").destination,
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
assert.match(text(spansFolders), /within one folder/);
|
|
428
|
+
assert.doesNotMatch(text(spansFolders), /single account/);
|
|
429
|
+
|
|
430
|
+
const spansAccounts = renderToString(
|
|
431
|
+
createElement(FolderStepBody, {
|
|
432
|
+
...folderProps,
|
|
433
|
+
restriction: wizardScopeFor(undefined, "spansAccounts").destination,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
assert.match(text(spansAccounts), /single account/);
|
|
437
|
+
});
|
|
416
438
|
});
|
|
417
439
|
|
|
418
440
|
describe("RuleStepBody", () => {
|
|
@@ -636,9 +636,10 @@ export interface FolderStepProps {
|
|
|
636
636
|
/** The account's hierarchy separator, which the tree nests on. */
|
|
637
637
|
delimiter?: string;
|
|
638
638
|
/**
|
|
639
|
-
* Why
|
|
640
|
-
*
|
|
641
|
-
*
|
|
639
|
+
* Why no destination can be chosen — the selection spans more accounts, or
|
|
640
|
+
* more folders, than a move can take (#477 5.5, #525). In the words of the
|
|
641
|
+
* scope it actually spans, so the instruction is one the user can follow. Said
|
|
642
|
+
* here rather than left as an empty picker.
|
|
642
643
|
*/
|
|
643
644
|
restriction?: string;
|
|
644
645
|
}
|
|
@@ -692,9 +693,10 @@ export interface RuleStepProps {
|
|
|
692
693
|
onScopeChange: (scope: RuleScope) => void;
|
|
693
694
|
onUntilChange: (until: string) => void;
|
|
694
695
|
/**
|
|
695
|
-
* Why the two persisting scopes cannot be reached from here —
|
|
696
|
-
*
|
|
697
|
-
*
|
|
696
|
+
* Why the two persisting scopes cannot be reached from here — the selection
|
|
697
|
+
* spans more accounts, or more folders, than a rule can take (#477 5.5, #525),
|
|
698
|
+
* in the words of the scope it actually spans. They stay pressable and say
|
|
699
|
+
* this; the one-off scope is unaffected.
|
|
698
700
|
*/
|
|
699
701
|
restriction?: string;
|
|
700
702
|
}
|
package/src/index.ts
CHANGED
|
@@ -240,6 +240,7 @@ export {
|
|
|
240
240
|
EventRepeatField,
|
|
241
241
|
EventTitleField,
|
|
242
242
|
EventWhenField,
|
|
243
|
+
endsBeforeStart,
|
|
243
244
|
} from "./components/event-editor.js";
|
|
244
245
|
export {
|
|
245
246
|
EventEditorPane,
|
|
@@ -989,6 +990,8 @@ export {
|
|
|
989
990
|
clauseWords,
|
|
990
991
|
crossAccountDestinationReason,
|
|
991
992
|
crossAccountRuleReason,
|
|
993
|
+
crossFolderDestinationReason,
|
|
994
|
+
crossFolderRuleReason,
|
|
992
995
|
ESCALATED_MATCH_HINT,
|
|
993
996
|
ESCALATED_REVIEW_WARNING,
|
|
994
997
|
ESCALATED_SCOPE_FALLBACK,
|
|
@@ -1006,6 +1009,7 @@ export {
|
|
|
1006
1009
|
type RunState,
|
|
1007
1010
|
runCopy,
|
|
1008
1011
|
type SampleEmptyReason,
|
|
1012
|
+
type SelectionRestriction,
|
|
1009
1013
|
type StepId,
|
|
1010
1014
|
sampleEmptyCopy,
|
|
1011
1015
|
stepBlockedReason,
|
|
@@ -1018,4 +1022,6 @@ export {
|
|
|
1018
1022
|
verbCopy,
|
|
1019
1023
|
type WizardAnswers,
|
|
1020
1024
|
type WizardDraft,
|
|
1025
|
+
type WizardScope,
|
|
1026
|
+
wizardScopeFor,
|
|
1021
1027
|
} from "./lib/wizard-steps.js";
|
|
@@ -10,6 +10,8 @@ import {
|
|
|
10
10
|
clauseSentence,
|
|
11
11
|
clauseWords,
|
|
12
12
|
crossAccountMatchReason,
|
|
13
|
+
crossFolderDestinationReason,
|
|
14
|
+
crossFolderRuleReason,
|
|
13
15
|
ESCALATED_MATCH_HINT,
|
|
14
16
|
ESCALATED_REVIEW_WARNING,
|
|
15
17
|
escalatedMatchLabel,
|
|
@@ -32,6 +34,7 @@ import {
|
|
|
32
34
|
type Verb,
|
|
33
35
|
verbCopy,
|
|
34
36
|
type WizardDraft,
|
|
37
|
+
wizardScopeFor,
|
|
35
38
|
} from "./wizard-steps.js";
|
|
36
39
|
|
|
37
40
|
const VERBS: Verb[] = ["delete", "move", "junk", "markRead", "organize"];
|
|
@@ -858,3 +861,50 @@ describe("the match as words", () => {
|
|
|
858
861
|
);
|
|
859
862
|
});
|
|
860
863
|
});
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* A selection spanning folders of one account was told to pick a single account
|
|
867
|
+
* (#525) — an instruction a user holding one account's mail cannot follow, so
|
|
868
|
+
* the flow dead-ended on the folder step with nowhere to go.
|
|
869
|
+
*/
|
|
870
|
+
describe("the restriction a selection walks the wizard with", () => {
|
|
871
|
+
it("tells a selection spanning folders about folders, not accounts", () => {
|
|
872
|
+
const scope = wizardScopeFor("acc-personal", "spansFolders");
|
|
873
|
+
assert.equal(scope.destination, crossFolderDestinationReason);
|
|
874
|
+
assert.equal(scope.rule, crossFolderRuleReason);
|
|
875
|
+
assert.match(scope.destination ?? "", /within one folder/);
|
|
876
|
+
assert.doesNotMatch(scope.destination ?? "", /account/);
|
|
877
|
+
assert.doesNotMatch(scope.rule ?? "", /account/);
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
it("keeps the account a folder-spanning selection has", () => {
|
|
881
|
+
// The widened doors are counted through a preview that account answers, so
|
|
882
|
+
// there is nothing to withhold them for.
|
|
883
|
+
const scope = wizardScopeFor("acc-personal", "spansFolders");
|
|
884
|
+
assert.equal(scope.accountId, "acc-personal");
|
|
885
|
+
assert.deepEqual(
|
|
886
|
+
[...matchDoorsFor(scope.accountId)],
|
|
887
|
+
["selected", "similar", "properties"],
|
|
888
|
+
);
|
|
889
|
+
});
|
|
890
|
+
|
|
891
|
+
it("tells a selection spanning accounts about accounts", () => {
|
|
892
|
+
const scope = wizardScopeFor(undefined, "spansAccounts");
|
|
893
|
+
assert.equal(scope.accountId, undefined);
|
|
894
|
+
assert.match(scope.destination ?? "", /within one account/);
|
|
895
|
+
assert.match(scope.rule ?? "", /within one account/);
|
|
896
|
+
assert.deepEqual([...matchDoorsFor(scope.accountId)], ["selected"]);
|
|
897
|
+
});
|
|
898
|
+
|
|
899
|
+
it("restricts a selection with no account whatever it was handed", () => {
|
|
900
|
+
const scope = wizardScopeFor(undefined, undefined);
|
|
901
|
+
assert.match(scope.destination ?? "", /within one account/);
|
|
902
|
+
assert.match(scope.rule ?? "", /within one account/);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it("restricts nothing when one account and one folder answer for it", () => {
|
|
906
|
+
assert.deepEqual(wizardScopeFor("acc-personal", undefined), {
|
|
907
|
+
accountId: "acc-personal",
|
|
908
|
+
});
|
|
909
|
+
});
|
|
910
|
+
});
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -232,6 +232,74 @@ export const crossAccountDestinationReason =
|
|
|
232
232
|
export const crossAccountMatchReason =
|
|
233
233
|
"Matching beyond the messages you picked only works within one account — clear the selection, or pick messages from a single account.";
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Why a selection spanning folders of one account cannot become a rule (#525).
|
|
237
|
+
* The account is settled and the source folder is not, so the restriction the
|
|
238
|
+
* wizard states is the folder one — telling a single-account selection to pick
|
|
239
|
+
* one account is an instruction it cannot follow.
|
|
240
|
+
*/
|
|
241
|
+
export const crossFolderRuleReason =
|
|
242
|
+
"A rule only works within one folder — clear the selection, or pick messages from a single folder.";
|
|
243
|
+
|
|
244
|
+
/** The same restriction on the step that asks for a folder. */
|
|
245
|
+
export const crossFolderDestinationReason =
|
|
246
|
+
"A destination only works within one folder — clear the selection, or pick messages from a single folder.";
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Which scope a selection spans more of than the wizard's folder-scoped steps
|
|
250
|
+
* can take. The surface that resolved the selection knows which one it is, so it
|
|
251
|
+
* hands the case over rather than a flag both cases collapse into.
|
|
252
|
+
*/
|
|
253
|
+
export type SelectionRestriction = "spansAccounts" | "spansFolders";
|
|
254
|
+
|
|
255
|
+
const RESTRICTION_COPY: Record<
|
|
256
|
+
SelectionRestriction,
|
|
257
|
+
{ rule: string; destination: string }
|
|
258
|
+
> = {
|
|
259
|
+
spansAccounts: {
|
|
260
|
+
rule: crossAccountRuleReason,
|
|
261
|
+
destination: crossAccountDestinationReason,
|
|
262
|
+
},
|
|
263
|
+
spansFolders: {
|
|
264
|
+
rule: crossFolderRuleReason,
|
|
265
|
+
destination: crossFolderDestinationReason,
|
|
266
|
+
},
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
/** What a restricted selection leaves the wizard's account-scoped steps to work with. */
|
|
270
|
+
export interface WizardScope {
|
|
271
|
+
/**
|
|
272
|
+
* The account a filter, a folder create and a widened door's preview all
|
|
273
|
+
* belong to. Absent only when the selection has no single account — a
|
|
274
|
+
* selection spanning folders of one account keeps it, so its widened doors
|
|
275
|
+
* have something to count against.
|
|
276
|
+
*/
|
|
277
|
+
accountId?: string;
|
|
278
|
+
/** What the rule step states, when the selection cannot reach a saved scope. */
|
|
279
|
+
rule?: string;
|
|
280
|
+
/** What the folder step states, when the selection cannot reach a destination. */
|
|
281
|
+
destination?: string;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* The scope a selection walks the wizard with. A selection with no single
|
|
286
|
+
* account is account-restricted whatever it was handed, since the account is
|
|
287
|
+
* what the filter, the folder create and the preview all hang off.
|
|
288
|
+
*/
|
|
289
|
+
export const wizardScopeFor = (
|
|
290
|
+
accountId: string | undefined,
|
|
291
|
+
restriction: SelectionRestriction | undefined,
|
|
292
|
+
): WizardScope => {
|
|
293
|
+
const applied = accountId === undefined ? "spansAccounts" : restriction;
|
|
294
|
+
if (!applied) return { accountId };
|
|
295
|
+
const copy = RESTRICTION_COPY[applied];
|
|
296
|
+
return {
|
|
297
|
+
accountId: applied === "spansAccounts" ? undefined : accountId,
|
|
298
|
+
rule: copy.rule,
|
|
299
|
+
destination: copy.destination,
|
|
300
|
+
};
|
|
301
|
+
};
|
|
302
|
+
|
|
235
303
|
/**
|
|
236
304
|
* The doors the match step offers. Withholding the two widened ones is what
|
|
237
305
|
* keeps a selection spanning accounts off a review that waits on a count nobody
|