@remit/web-client 0.0.56 → 0.0.58
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/mail/MailListHeader.tsx +26 -4
- package/src/components/mail/MailViewChrome.tsx +8 -0
- package/src/components/mail/MailboxPane.tsx +5 -0
- package/src/components/mail/MessageList.selection.test.ts +54 -0
- package/src/components/mail/MessageList.tsx +136 -66
- package/src/components/mail/SelectionToolbar.render.test.ts +129 -0
- package/src/components/mail/SelectionToolbar.stories.tsx +194 -0
- package/src/components/mail/SelectionToolbar.tsx +187 -73
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +45 -0
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +138 -0
- package/src/components/mail/organize/OrganizePanel.render.test.ts +14 -1
- package/src/components/mail/organize/OrganizePanel.tsx +30 -4
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +54 -0
- package/src/components/mail/organize/SomethingElsePanel.tsx +159 -0
- package/src/components/mail/organize/smart-organize.stories.tsx +197 -0
- package/src/hooks/useEscalatedActions.ts +2 -2
- package/src/lib/organize/mobile-organize-flow.test.ts +96 -0
- package/src/lib/organize/mobile-organize-flow.ts +73 -0
- package/src/lib/search-surface.test.ts +74 -0
- package/src/lib/search-surface.ts +34 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { SelectionToolbar } from "./SelectionToolbar";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The desktop bulk-action bar. It keeps its bounded verbs (mark-read, move,
|
|
6
|
+
* delete, organize) and, from issue #212, gains the search-escalation states
|
|
7
|
+
* the mobile `SelectionSheet` already carries: the "Select all N matching…"
|
|
8
|
+
* offer, a running count, the escalated predicate, a chunked run's progress,
|
|
9
|
+
* and a partial-failure Retry. Both surfaces read the same derivations in
|
|
10
|
+
* `MessageList`, so these stories and the kit `SelectionTopBar` stories track
|
|
11
|
+
* the one state matrix.
|
|
12
|
+
*
|
|
13
|
+
* The Move verb needs the account-scoped `MoveToTrigger` (its own query and
|
|
14
|
+
* folder data), so it is left out here to keep these stories provider-free —
|
|
15
|
+
* its visual is covered by the kit `SelectionTopBar` stories' move slot. The
|
|
16
|
+
* escalated stories below still assert verb parity through the mark-read and
|
|
17
|
+
* delete verbs remaining available over the predicate.
|
|
18
|
+
*/
|
|
19
|
+
const meta: Meta<typeof SelectionToolbar> = {
|
|
20
|
+
title: "Screens/WebClient/SelectionToolbar",
|
|
21
|
+
component: SelectionToolbar,
|
|
22
|
+
parameters: { layout: "padded" },
|
|
23
|
+
args: {
|
|
24
|
+
selectedCount: 3,
|
|
25
|
+
onDelete: () => undefined,
|
|
26
|
+
onClearSelection: () => undefined,
|
|
27
|
+
onMarkAsRead: () => undefined,
|
|
28
|
+
},
|
|
29
|
+
render: (args) => (
|
|
30
|
+
<div className="w-full rounded-md border border-line">
|
|
31
|
+
<SelectionToolbar {...args} />
|
|
32
|
+
</div>
|
|
33
|
+
),
|
|
34
|
+
};
|
|
35
|
+
export default meta;
|
|
36
|
+
|
|
37
|
+
type Story = StoryObj<typeof SelectionToolbar>;
|
|
38
|
+
|
|
39
|
+
/** A plain bounded selection: the count in words, mark-read and delete. */
|
|
40
|
+
export const Bounded: Story = {};
|
|
41
|
+
|
|
42
|
+
export const OneSelected: Story = { args: { selectedCount: 1 } };
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Some but not all loaded rows checked while searching: the select-all control
|
|
46
|
+
* renders the tri-state dash.
|
|
47
|
+
*/
|
|
48
|
+
export const SelectAllIndeterminate: Story = {
|
|
49
|
+
args: {
|
|
50
|
+
selectAll: {
|
|
51
|
+
checked: false,
|
|
52
|
+
indeterminate: true,
|
|
53
|
+
onChange: () => undefined,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every loaded row checked. The label names the loaded scope — "All 47 loaded
|
|
60
|
+
* selected" — rather than a bare count next to a fully ticked box.
|
|
61
|
+
*/
|
|
62
|
+
export const AllLoadedSelected: Story = {
|
|
63
|
+
args: {
|
|
64
|
+
selectedCount: 47,
|
|
65
|
+
selectAll: { checked: true, onChange: () => undefined },
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Search has more matches than are loaded: an escalation offer naming the
|
|
71
|
+
* scope (a real button, not prose). Tapping it pays for the real count.
|
|
72
|
+
*/
|
|
73
|
+
export const EscalationAvailable: Story = {
|
|
74
|
+
args: {
|
|
75
|
+
selectedCount: 47,
|
|
76
|
+
selectAll: { checked: true, onChange: () => undefined },
|
|
77
|
+
notice: {
|
|
78
|
+
tone: "info",
|
|
79
|
+
text: "",
|
|
80
|
+
action: {
|
|
81
|
+
label: 'Select all matching "npm"',
|
|
82
|
+
onClick: () => undefined,
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The result set is still paging to its total: a running count, Delete hidden
|
|
90
|
+
* (the count it would act on isn't known yet), and an explicit Stop.
|
|
91
|
+
*/
|
|
92
|
+
export const Counting: Story = {
|
|
93
|
+
args: {
|
|
94
|
+
selectedCount: 47,
|
|
95
|
+
isCounting: true,
|
|
96
|
+
statusLabel: "Counting… 1,900 so far",
|
|
97
|
+
selectAll: { checked: true, onChange: () => undefined },
|
|
98
|
+
notice: {
|
|
99
|
+
tone: "info",
|
|
100
|
+
text: "",
|
|
101
|
+
action: { label: "Stop", onClick: () => undefined },
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/** Past ~10s the counting state says so rather than looking stuck. */
|
|
107
|
+
export const CountingLargeResultSet: Story = {
|
|
108
|
+
args: {
|
|
109
|
+
selectedCount: 0,
|
|
110
|
+
isCounting: true,
|
|
111
|
+
statusLabel: "Counting… 12,400 so far. This is a big result set.",
|
|
112
|
+
notice: {
|
|
113
|
+
tone: "info",
|
|
114
|
+
text: "",
|
|
115
|
+
action: { label: "Stop", onClick: () => undefined },
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The selection is now the search predicate: the count names the query's total,
|
|
122
|
+
* every verb stays available over it (#114 — not delete-only), and the notice
|
|
123
|
+
* offers a way back to the bounded selection.
|
|
124
|
+
*/
|
|
125
|
+
export const Escalated: Story = {
|
|
126
|
+
args: {
|
|
127
|
+
selectedCount: 3412,
|
|
128
|
+
statusLabel: 'All 3,412 matching "npm" selected',
|
|
129
|
+
notice: {
|
|
130
|
+
tone: "info",
|
|
131
|
+
text: "",
|
|
132
|
+
action: { label: "Clear selection", onClick: () => undefined },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* A chunked delete over the predicate: a running total and a determinate
|
|
139
|
+
* progress bar, the verbs off screen (the bar is the only thing that can act
|
|
140
|
+
* mid-run), toned as destructive.
|
|
141
|
+
*/
|
|
142
|
+
export const DeletingWithProgress: Story = {
|
|
143
|
+
args: {
|
|
144
|
+
selectedCount: 3412,
|
|
145
|
+
statusLabel: "Deleting 1,200 of 3,412…",
|
|
146
|
+
progress: { value: 1200, max: 3412, tone: "danger" },
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** A move over the escalated selection: the same run, toned as ordinary
|
|
151
|
+
* progress and worded for the action that is running. */
|
|
152
|
+
export const MovingWithProgress: Story = {
|
|
153
|
+
args: {
|
|
154
|
+
selectedCount: 3412,
|
|
155
|
+
statusLabel: "Moving 1,200 of 3,412…",
|
|
156
|
+
progress: { value: 1200, max: 3412, tone: "info" },
|
|
157
|
+
},
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
/** Mark-read over the escalated selection. */
|
|
161
|
+
export const MarkingReadWithProgress: Story = {
|
|
162
|
+
args: {
|
|
163
|
+
selectedCount: 3412,
|
|
164
|
+
statusLabel: "Marking 1,200 of 3,412 as read…",
|
|
165
|
+
progress: { value: 1200, max: 3412, tone: "info" },
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* A run left some messages unreached: the notice names how many landed, the
|
|
171
|
+
* count reflects only what is still selected (the failures), and Retry targets
|
|
172
|
+
* exactly that.
|
|
173
|
+
*/
|
|
174
|
+
export const PartialFailure: Story = {
|
|
175
|
+
args: {
|
|
176
|
+
selectedCount: 340,
|
|
177
|
+
notice: {
|
|
178
|
+
tone: "danger",
|
|
179
|
+
text: "3,072 moved to Trash. 340 couldn't be deleted.",
|
|
180
|
+
action: { label: "Retry 340", onClick: () => undefined },
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A selection spanning accounts: Move is withdrawn (it only works within one
|
|
187
|
+
* account) and the reason is stated inline.
|
|
188
|
+
*/
|
|
189
|
+
export const CrossAccountHint: Story = {
|
|
190
|
+
args: {
|
|
191
|
+
moveDisabledHint:
|
|
192
|
+
"Move only works within one account — clear selection or pick messages from a single account",
|
|
193
|
+
},
|
|
194
|
+
};
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
+
import { Banner, type BannerTone, Checkbox, ProgressBar } from "@remit/ui";
|
|
1
2
|
import { Loader2, MailOpen, Sparkles, Trash2, X } from "lucide-react";
|
|
2
3
|
import { cn } from "@/lib/utils";
|
|
3
4
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
4
5
|
|
|
6
|
+
export interface SelectionToolbarNotice {
|
|
7
|
+
tone: BannerTone;
|
|
8
|
+
text: string;
|
|
9
|
+
action?: { label: string; onClick: () => void };
|
|
10
|
+
}
|
|
11
|
+
|
|
5
12
|
interface SelectionToolbarProps {
|
|
6
13
|
selectedCount: number;
|
|
7
14
|
onDelete: () => void;
|
|
@@ -33,6 +40,42 @@ interface SelectionToolbarProps {
|
|
|
33
40
|
* Move only works within one account.
|
|
34
41
|
*/
|
|
35
42
|
moveDisabledHint?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Select-all-loaded control, rendered between the clear button and the
|
|
45
|
+
* count. Presence renders the checkbox; `indeterminate` is the some-selected
|
|
46
|
+
* tri-state. Wired only while searching, where escalating past the loaded
|
|
47
|
+
* page is possible (issue #212).
|
|
48
|
+
*/
|
|
49
|
+
selectAll?: {
|
|
50
|
+
checked: boolean;
|
|
51
|
+
indeterminate?: boolean;
|
|
52
|
+
onChange: () => void;
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Overrides the "{count} messages selected" text. Names the scope once it is
|
|
56
|
+
* anything other than a materialized selection — an escalated predicate
|
|
57
|
+
* ("All 3,412 matching \"npm\" selected"), a running count ("Counting… 1,900
|
|
58
|
+
* so far"), or bulk-run progress ("Deleting 1,200 of 3,412…").
|
|
59
|
+
*/
|
|
60
|
+
statusLabel?: string;
|
|
61
|
+
/**
|
|
62
|
+
* True while a search result set is still paging to its total. Hides Delete
|
|
63
|
+
* (and Move) — the count they would act on isn't known yet.
|
|
64
|
+
*/
|
|
65
|
+
isCounting?: boolean;
|
|
66
|
+
/**
|
|
67
|
+
* Determinate progress for a chunked/escalated run in flight. Renders a
|
|
68
|
+
* `ProgressBar` below the action row and takes the verbs off screen — the
|
|
69
|
+
* bar is the only thing that can act mid-run.
|
|
70
|
+
*/
|
|
71
|
+
progress?: { value: number; max: number; tone?: BannerTone };
|
|
72
|
+
/**
|
|
73
|
+
* At-most-one toned status line below the action row, optionally carrying an
|
|
74
|
+
* action button — the "Select all N matching…" escalation offer, a "Stop"
|
|
75
|
+
* during counting, a "Clear selection" once escalated, or a partial-failure
|
|
76
|
+
* "Retry N" (issue #212).
|
|
77
|
+
*/
|
|
78
|
+
notice?: SelectionToolbarNotice;
|
|
36
79
|
}
|
|
37
80
|
|
|
38
81
|
export const SelectionToolbar = ({
|
|
@@ -47,93 +90,164 @@ export const SelectionToolbar = ({
|
|
|
47
90
|
accountId,
|
|
48
91
|
currentMailboxId,
|
|
49
92
|
moveDisabledHint,
|
|
93
|
+
selectAll,
|
|
94
|
+
statusLabel,
|
|
95
|
+
isCounting = false,
|
|
96
|
+
progress,
|
|
97
|
+
notice,
|
|
50
98
|
}: SelectionToolbarProps) => {
|
|
51
|
-
const isBusy = isDeleting || isMoving;
|
|
52
99
|
if (selectedCount === 0) return null;
|
|
53
100
|
|
|
101
|
+
// A chunked/escalated run owns the bar: the progress bar is the only signal
|
|
102
|
+
// that can act, so the verbs come off screen until it ends (issue #212).
|
|
103
|
+
const isRunning = progress !== undefined;
|
|
104
|
+
const isBusy = isDeleting || isMoving || isRunning;
|
|
105
|
+
// The verbs are suppressed while a run is in flight or a count is still
|
|
106
|
+
// paging; an escalated-but-idle selection keeps every verb (verb parity).
|
|
107
|
+
const showVerbs = !isRunning && !isCounting;
|
|
108
|
+
|
|
54
109
|
const canShowMove = !!onMove && !!accountId && !!currentMailboxId;
|
|
110
|
+
// Organize has no escalated-predicate path — it acts on the materialized
|
|
111
|
+
// selection — so it's withdrawn the moment the selection escalates or a run
|
|
112
|
+
// takes over (any state that names itself through `statusLabel`).
|
|
55
113
|
const canShowOrganize =
|
|
56
|
-
!!onOrganize &&
|
|
114
|
+
!!onOrganize &&
|
|
115
|
+
!!accountId &&
|
|
116
|
+
!!currentMailboxId &&
|
|
117
|
+
!moveDisabledHint &&
|
|
118
|
+
!statusLabel;
|
|
119
|
+
|
|
120
|
+
const defaultLabel = selectAll?.checked
|
|
121
|
+
? `All ${selectedCount} loaded selected`
|
|
122
|
+
: `${selectedCount} ${selectedCount === 1 ? "message" : "messages"} selected`;
|
|
57
123
|
|
|
58
124
|
return (
|
|
59
|
-
<div className="sticky top-0 z-10 flex
|
|
60
|
-
<div className="flex items-center
|
|
61
|
-
<
|
|
62
|
-
type="button"
|
|
63
|
-
onClick={onClearSelection}
|
|
64
|
-
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
|
|
65
|
-
aria-label="Clear selection"
|
|
66
|
-
>
|
|
67
|
-
<X className="size-4 text-fg-muted" />
|
|
68
|
-
</button>
|
|
69
|
-
<span className="text-sm font-medium">
|
|
70
|
-
{selectedCount} {selectedCount === 1 ? "message" : "messages"}{" "}
|
|
71
|
-
selected
|
|
72
|
-
</span>
|
|
73
|
-
{moveDisabledHint && (
|
|
74
|
-
// biome-ignore lint/a11y/useSemanticElements: <span> with role="status" keeps inline layout; <output> would shift flow
|
|
75
|
-
<span
|
|
76
|
-
className="text-xs text-fg-muted"
|
|
77
|
-
role="status"
|
|
78
|
-
aria-live="polite"
|
|
79
|
-
>
|
|
80
|
-
{moveDisabledHint}
|
|
81
|
-
</span>
|
|
82
|
-
)}
|
|
83
|
-
</div>
|
|
84
|
-
<div className="flex items-center gap-1">
|
|
85
|
-
{canShowOrganize && onOrganize && (
|
|
86
|
-
<button
|
|
87
|
-
type="button"
|
|
88
|
-
onClick={isBusy ? undefined : onOrganize}
|
|
89
|
-
className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
|
|
90
|
-
aria-label="Organize similar messages"
|
|
91
|
-
>
|
|
92
|
-
<Sparkles className="size-4" />
|
|
93
|
-
<span className="hidden sm:inline">Organize</span>
|
|
94
|
-
</button>
|
|
95
|
-
)}
|
|
96
|
-
{onMarkAsRead && (
|
|
125
|
+
<div className="sticky top-0 z-10 flex flex-col bg-surface-sunken border-b border-line">
|
|
126
|
+
<div className="flex items-center justify-between px-3 py-2">
|
|
127
|
+
<div className="flex items-center gap-3">
|
|
97
128
|
<button
|
|
98
129
|
type="button"
|
|
99
|
-
onClick={
|
|
100
|
-
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded
|
|
101
|
-
aria-label="
|
|
102
|
-
aria-busy={isBusy}
|
|
130
|
+
onClick={onClearSelection}
|
|
131
|
+
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded hover:bg-surface-raised transition-colors"
|
|
132
|
+
aria-label="Clear selection"
|
|
103
133
|
>
|
|
104
|
-
<
|
|
134
|
+
<X className="size-4 text-fg-muted" />
|
|
105
135
|
</button>
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
)}
|
|
117
|
-
<button
|
|
118
|
-
type="button"
|
|
119
|
-
onClick={isBusy ? undefined : onDelete}
|
|
120
|
-
className={cn(
|
|
121
|
-
"min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
|
|
122
|
-
"bg-danger text-canvas hover:bg-danger/90",
|
|
136
|
+
{selectAll && (
|
|
137
|
+
// biome-ignore lint/a11y/noLabelWithoutControl: the label wraps Checkbox's own input, giving the 20px control a 44px hit area
|
|
138
|
+
<label className="flex size-11 shrink-0 cursor-pointer items-center justify-center">
|
|
139
|
+
<Checkbox
|
|
140
|
+
aria-label="Select all"
|
|
141
|
+
checked={selectAll.checked}
|
|
142
|
+
indeterminate={selectAll.indeterminate}
|
|
143
|
+
onChange={selectAll.onChange}
|
|
144
|
+
/>
|
|
145
|
+
</label>
|
|
123
146
|
)}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
>
|
|
127
|
-
{isDeleting ? (
|
|
128
|
-
<Loader2 className="size-4 animate-spin" />
|
|
129
|
-
) : (
|
|
130
|
-
<Trash2 className="size-4" />
|
|
131
|
-
)}
|
|
132
|
-
<span className="hidden sm:inline">
|
|
133
|
-
{isDeleting ? "Deleting..." : "Delete"}
|
|
147
|
+
<span className="text-sm font-medium">
|
|
148
|
+
{statusLabel ?? defaultLabel}
|
|
134
149
|
</span>
|
|
135
|
-
|
|
150
|
+
{moveDisabledHint && !notice && (
|
|
151
|
+
<span
|
|
152
|
+
className="text-xs text-fg-muted"
|
|
153
|
+
role="status"
|
|
154
|
+
aria-live="polite"
|
|
155
|
+
>
|
|
156
|
+
{moveDisabledHint}
|
|
157
|
+
</span>
|
|
158
|
+
)}
|
|
159
|
+
</div>
|
|
160
|
+
<div className="flex items-center gap-1">
|
|
161
|
+
{canShowOrganize && onOrganize && (
|
|
162
|
+
<button
|
|
163
|
+
type="button"
|
|
164
|
+
onClick={isBusy ? undefined : onOrganize}
|
|
165
|
+
className="min-h-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
|
|
166
|
+
aria-label="Organize similar messages"
|
|
167
|
+
>
|
|
168
|
+
<Sparkles className="size-4" />
|
|
169
|
+
<span className="hidden sm:inline">Organize</span>
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
{showVerbs && onMarkAsRead && (
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
onClick={isBusy ? undefined : onMarkAsRead}
|
|
176
|
+
className="min-h-11 min-w-11 inline-flex items-center justify-center rounded text-sm font-medium transition-colors hover:bg-surface-raised"
|
|
177
|
+
aria-label="Mark as read"
|
|
178
|
+
aria-busy={isBusy}
|
|
179
|
+
>
|
|
180
|
+
<MailOpen className="size-4" />
|
|
181
|
+
</button>
|
|
182
|
+
)}
|
|
183
|
+
{showVerbs &&
|
|
184
|
+
canShowMove &&
|
|
185
|
+
onMove &&
|
|
186
|
+
accountId &&
|
|
187
|
+
currentMailboxId && (
|
|
188
|
+
<MoveToTrigger
|
|
189
|
+
accountId={accountId}
|
|
190
|
+
currentMailboxId={currentMailboxId}
|
|
191
|
+
onMove={isBusy ? () => {} : onMove}
|
|
192
|
+
disabledHint={moveDisabledHint}
|
|
193
|
+
variant="icon-only"
|
|
194
|
+
label="Move selected messages"
|
|
195
|
+
/>
|
|
196
|
+
)}
|
|
197
|
+
{showVerbs && (
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
onClick={isBusy ? undefined : onDelete}
|
|
201
|
+
className={cn(
|
|
202
|
+
"min-h-11 min-w-11 inline-flex items-center justify-center gap-1.5 px-3 rounded text-sm font-medium transition-colors",
|
|
203
|
+
"bg-danger text-canvas hover:bg-danger/90",
|
|
204
|
+
)}
|
|
205
|
+
aria-label="Delete selected messages"
|
|
206
|
+
aria-busy={isDeleting}
|
|
207
|
+
>
|
|
208
|
+
{isDeleting ? (
|
|
209
|
+
<Loader2 className="size-4 animate-spin" />
|
|
210
|
+
) : (
|
|
211
|
+
<Trash2 className="size-4" />
|
|
212
|
+
)}
|
|
213
|
+
<span className="hidden sm:inline">
|
|
214
|
+
{isDeleting ? "Deleting..." : "Delete"}
|
|
215
|
+
</span>
|
|
216
|
+
</button>
|
|
217
|
+
)}
|
|
218
|
+
</div>
|
|
136
219
|
</div>
|
|
220
|
+
{progress && (
|
|
221
|
+
<div className="px-3 pb-2">
|
|
222
|
+
<ProgressBar
|
|
223
|
+
value={progress.value}
|
|
224
|
+
max={progress.max}
|
|
225
|
+
tone={progress.tone}
|
|
226
|
+
/>
|
|
227
|
+
</div>
|
|
228
|
+
)}
|
|
229
|
+
{notice && (
|
|
230
|
+
<Banner
|
|
231
|
+
tone={notice.tone}
|
|
232
|
+
variant="soft"
|
|
233
|
+
role="status"
|
|
234
|
+
aria-live="polite"
|
|
235
|
+
className="mx-3 mb-2"
|
|
236
|
+
>
|
|
237
|
+
<div className="flex items-center justify-between gap-2">
|
|
238
|
+
{notice.text && <span>{notice.text}</span>}
|
|
239
|
+
{notice.action && (
|
|
240
|
+
<button
|
|
241
|
+
type="button"
|
|
242
|
+
onClick={notice.action.onClick}
|
|
243
|
+
className="-my-1 min-h-11 shrink-0 inline-flex items-center px-3 rounded text-sm font-medium transition-colors hover:bg-surface-raised"
|
|
244
|
+
>
|
|
245
|
+
{notice.action.label}
|
|
246
|
+
</button>
|
|
247
|
+
)}
|
|
248
|
+
</div>
|
|
249
|
+
</Banner>
|
|
250
|
+
)}
|
|
137
251
|
</div>
|
|
138
252
|
);
|
|
139
253
|
};
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import React, { createElement } from "react";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
7
|
+
import type { OrganizeEntry } from "@/lib/organize/mobile-organize-flow";
|
|
8
|
+
import { MobileOrganizeFlow } from "./MobileOrganizeFlow";
|
|
9
|
+
|
|
10
|
+
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
11
|
+
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
12
|
+
// so this shim only exists for the SSR test harness.
|
|
13
|
+
(globalThis as { React?: typeof React }).React = React;
|
|
14
|
+
|
|
15
|
+
const render = (entry: OrganizeEntry) =>
|
|
16
|
+
renderToString(
|
|
17
|
+
createElement(
|
|
18
|
+
QueryClientProvider,
|
|
19
|
+
{ client: new QueryClient() },
|
|
20
|
+
createElement(
|
|
21
|
+
ErrorBannerProvider,
|
|
22
|
+
null,
|
|
23
|
+
createElement(MobileOrganizeFlow, {
|
|
24
|
+
entry,
|
|
25
|
+
accountId: "acc-1",
|
|
26
|
+
mailboxId: "mbx-inbox",
|
|
27
|
+
selectedMessageIds: ["msg-1", "msg-2"],
|
|
28
|
+
onClose: () => undefined,
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
) as never,
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
describe("MobileOrganizeFlow", () => {
|
|
35
|
+
it("select-similar opens on the widening state before the preview resolves", () => {
|
|
36
|
+
const html = render("select-similar");
|
|
37
|
+
assert.match(html, /Finding similar messages/);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("something-else opens on the shortcuts + plain-language input", () => {
|
|
41
|
+
const html = render("something-else");
|
|
42
|
+
assert.match(html, /What should Remit do\?/);
|
|
43
|
+
assert.match(html, /Tell Remit what to do/);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import { BottomSheet, Button } from "@remit/ui";
|
|
3
|
+
import { useQuery } from "@tanstack/react-query";
|
|
4
|
+
import { Loader2 } from "lucide-react";
|
|
5
|
+
import { useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { useOrganizePreview } from "@/hooks/useOrganizePreview";
|
|
7
|
+
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
8
|
+
import { buildMoveTargets } from "@/lib/move-targets";
|
|
9
|
+
import {
|
|
10
|
+
type OrganizeEntry,
|
|
11
|
+
type OrganizeSeed,
|
|
12
|
+
type PreviewStatus,
|
|
13
|
+
resolveOrganizeStage,
|
|
14
|
+
} from "@/lib/organize/mobile-organize-flow";
|
|
15
|
+
import { OrganizePanel } from "./OrganizePanel";
|
|
16
|
+
import { SomethingElsePanel } from "./SomethingElsePanel";
|
|
17
|
+
|
|
18
|
+
interface MobileOrganizeFlowProps {
|
|
19
|
+
entry: OrganizeEntry;
|
|
20
|
+
accountId: string;
|
|
21
|
+
mailboxId: string;
|
|
22
|
+
selectedMessageIds: string[];
|
|
23
|
+
junkMailboxId?: string;
|
|
24
|
+
/** Close the flow and return to the list — dismiss, "Not now", and Done all use it. */
|
|
25
|
+
onClose: () => void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const previewStatusOf = (
|
|
29
|
+
isError: boolean,
|
|
30
|
+
isPending: boolean,
|
|
31
|
+
matchedCount: number | undefined,
|
|
32
|
+
): PreviewStatus => {
|
|
33
|
+
if (isError) return "error";
|
|
34
|
+
if (isPending) return "pending";
|
|
35
|
+
return matchedCount !== undefined ? "success" : "idle";
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The guided select-similar → organize flow, the mobile home for organizing
|
|
40
|
+
* (issue #211). Entered from the selection sheet, it widens the selection once
|
|
41
|
+
* with the read-only matcher (POST /organize/preview), shows a brief widening
|
|
42
|
+
* state, and renders the organize sentence inside a bottom sheet on that
|
|
43
|
+
* widened set — the same {@link OrganizePanel} the desktop dialog uses, so the
|
|
44
|
+
* two never drift. "Something else" collects a folder/scope seed first; a widen
|
|
45
|
+
* that matches nothing falls back to organizing the selection. Desktop keeps
|
|
46
|
+
* its `OrganizeDialog` — this is the touch surface only.
|
|
47
|
+
*/
|
|
48
|
+
export function MobileOrganizeFlow({
|
|
49
|
+
entry,
|
|
50
|
+
accountId,
|
|
51
|
+
mailboxId,
|
|
52
|
+
selectedMessageIds,
|
|
53
|
+
junkMailboxId,
|
|
54
|
+
onClose,
|
|
55
|
+
}: MobileOrganizeFlowProps) {
|
|
56
|
+
const anchorMessageId = selectedMessageIds[0];
|
|
57
|
+
const [seed, setSeed] = useState<OrganizeSeed | undefined>();
|
|
58
|
+
|
|
59
|
+
const { preview, matchedCount, isPending, isError, error } =
|
|
60
|
+
useOrganizePreview(accountId);
|
|
61
|
+
|
|
62
|
+
useEffect(() => {
|
|
63
|
+
if (!anchorMessageId) return;
|
|
64
|
+
preview({ anchorMessageId, matchOperator: "And", literalClauses: [] });
|
|
65
|
+
}, [anchorMessageId, preview]);
|
|
66
|
+
|
|
67
|
+
const { data: mailboxesData } = useQuery({
|
|
68
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
69
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const folderOptions = useMemo(
|
|
73
|
+
() =>
|
|
74
|
+
buildMoveTargets(mailboxesData?.items ?? []).map((mailbox) => ({
|
|
75
|
+
id: mailbox.mailboxId,
|
|
76
|
+
label: getMailboxDisplayName(mailbox.fullPath),
|
|
77
|
+
})),
|
|
78
|
+
[mailboxesData?.items],
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const stage = resolveOrganizeStage({
|
|
82
|
+
entry,
|
|
83
|
+
hasSeed: seed !== undefined,
|
|
84
|
+
previewStatus: previewStatusOf(isError, isPending, matchedCount),
|
|
85
|
+
matchedCount,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return (
|
|
89
|
+
<BottomSheet open onClose={onClose} dismissLabel="Dismiss organize">
|
|
90
|
+
{stage.kind === "something-else" && (
|
|
91
|
+
<SomethingElsePanel
|
|
92
|
+
folderOptions={folderOptions}
|
|
93
|
+
junkMailboxId={junkMailboxId}
|
|
94
|
+
onSeed={setSeed}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
|
|
98
|
+
{stage.kind === "widening" && <WideningState />}
|
|
99
|
+
|
|
100
|
+
{stage.kind === "error" && (
|
|
101
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
102
|
+
<p className="text-sm font-medium text-danger">
|
|
103
|
+
Couldn't find similar messages
|
|
104
|
+
</p>
|
|
105
|
+
<p className="max-w-xs text-xs text-fg-muted">
|
|
106
|
+
{error instanceof Error ? error.message : "Please try again."}
|
|
107
|
+
</p>
|
|
108
|
+
<Button variant="ghost" onClick={onClose} className="mt-2">
|
|
109
|
+
Close
|
|
110
|
+
</Button>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
113
|
+
|
|
114
|
+
{stage.kind === "organize" && (
|
|
115
|
+
<OrganizePanel
|
|
116
|
+
accountId={accountId}
|
|
117
|
+
mailboxId={mailboxId}
|
|
118
|
+
selectedMessageIds={selectedMessageIds}
|
|
119
|
+
anchorMessageId={anchorMessageId}
|
|
120
|
+
matchedCount={stage.matchedCount}
|
|
121
|
+
initialScope={seed?.scope}
|
|
122
|
+
seedMailboxId={seed?.moveMailboxId}
|
|
123
|
+
fallback={stage.fallback}
|
|
124
|
+
onClose={onClose}
|
|
125
|
+
/>
|
|
126
|
+
)}
|
|
127
|
+
</BottomSheet>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function WideningState() {
|
|
132
|
+
return (
|
|
133
|
+
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
134
|
+
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
135
|
+
<p className="text-sm font-medium text-fg">Finding similar messages…</p>
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|