@remit/web-client 0.0.92 → 0.0.94
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/DailyBrief.selection.test.ts +6 -2
- package/src/components/mail/DailyBrief.tsx +57 -112
- package/src/components/mail/FlaggedList.tsx +42 -0
- package/src/components/mail/MailListHeader.tsx +41 -39
- package/src/components/mail/MessageList.selection.test.ts +10 -5
- package/src/components/mail/MessageList.tsx +78 -82
- package/src/components/mail/SelectionWizardHost.tsx +721 -64
- package/src/components/mail/ThreadListInteraction.tsx +0 -9
- package/src/components/mail/make-filter-entry.test.ts +63 -0
- package/src/components/settings/FilterEditor.tsx +1 -1
- package/src/hooks/useCreateMailbox.ts +16 -4
- package/src/hooks/useFilters.ts +30 -1
- package/src/hooks/useMatchSample.ts +63 -0
- package/src/hooks/useRulePreview.ts +24 -5
- package/src/lib/list-header-chrome.ts +10 -0
- package/src/lib/mail-context.ts +0 -9
- package/src/lib/organize/organize-model.test.ts +110 -0
- package/src/lib/organize/organize-model.ts +69 -0
- package/src/lib/organize/rule-model.ts +2 -4
- package/src/lib/wizard-history.test.ts +31 -0
- package/src/lib/wizard-history.ts +65 -2
- package/src/routes/mail.tsx +3 -8
- package/src/components/mail/organize/MobileOrganizeFlow.render.test.ts +0 -45
- package/src/components/mail/organize/MobileOrganizeFlow.tsx +0 -157
- package/src/components/mail/organize/OrganizeDialog.render.test.ts +0 -37
- package/src/components/mail/organize/OrganizeDialog.tsx +0 -91
- package/src/components/mail/organize/OrganizeRuleEditor.render.test.ts +0 -498
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +0 -285
- package/src/components/mail/organize/SearchFilterDialog.render.test.ts +0 -47
- package/src/components/mail/organize/SearchFilterDialog.tsx +0 -90
- package/src/components/mail/organize/SearchFilterEditor.render.test.ts +0 -260
- package/src/components/mail/organize/SearchFilterEditor.tsx +0 -136
- package/src/components/mail/organize/SomethingElsePanel.render.test.ts +0 -54
- package/src/components/mail/organize/SomethingElsePanel.tsx +0 -159
- package/src/components/mail/organize/rule-editor-states.stories.tsx +0 -147
- package/src/components/mail/organize/rule-editor-states.tsx +0 -139
- package/src/components/mail/organize/smart-organize.stories.tsx +0 -342
- package/src/hooks/useRuleEditorState.ts +0 -182
- package/src/hooks/useSearchFilterSeed.render.test.ts +0 -118
- package/src/hooks/useSearchFilterSeed.ts +0 -79
- package/src/lib/organize/mobile-organize-flow.test.ts +0 -96
- package/src/lib/organize/mobile-organize-flow.ts +0 -73
|
@@ -1,136 +0,0 @@
|
|
|
1
|
-
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
3
|
-
buildSearchRule,
|
|
4
|
-
type FilterRule,
|
|
5
|
-
FilterRuleEditor,
|
|
6
|
-
type FolderOption,
|
|
7
|
-
type SearchConversion,
|
|
8
|
-
SearchConversionNoticeView,
|
|
9
|
-
} from "@remit/ui";
|
|
10
|
-
import { useQuery } from "@tanstack/react-query";
|
|
11
|
-
import { useMemo, useState } from "react";
|
|
12
|
-
import { useCreateFilter } from "@/hooks/useFilters";
|
|
13
|
-
import { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
14
|
-
import { useRuleEditorState } from "@/hooks/useRuleEditorState";
|
|
15
|
-
import { useRulePreview } from "@/hooks/useRulePreview";
|
|
16
|
-
import { getMailboxDisplayName } from "@/lib/folder-roles";
|
|
17
|
-
import { buildMoveTargets } from "@/lib/move-targets";
|
|
18
|
-
import {
|
|
19
|
-
rulePredicate,
|
|
20
|
-
ruleToDraft,
|
|
21
|
-
SUPPORTED_CLAUSE_FIELDS,
|
|
22
|
-
} from "@/lib/organize/rule-model";
|
|
23
|
-
import {
|
|
24
|
-
CommitError,
|
|
25
|
-
FilterSaved,
|
|
26
|
-
JobProgress,
|
|
27
|
-
SavingState,
|
|
28
|
-
} from "./rule-editor-states";
|
|
29
|
-
|
|
30
|
-
interface SearchFilterEditorProps {
|
|
31
|
-
/** The account the filter is created for (an `account:` facet, else the active account). */
|
|
32
|
-
accountId: string;
|
|
33
|
-
/** The converted search — its clauses seed the rule, its drops seed the notice. */
|
|
34
|
-
conversion: SearchConversion;
|
|
35
|
-
/**
|
|
36
|
-
* The converted literal predicate's live count, seeding the editor without a
|
|
37
|
-
* re-fetch. Absent when the predicate is one the vector-free matcher refuses —
|
|
38
|
-
* a `HasWords` clause from the search's free text — in which case the count
|
|
39
|
-
* region says so and the one-time apply is held; saving it as a standing rule
|
|
40
|
-
* still works, since the index-time matcher does read message bodies.
|
|
41
|
-
*/
|
|
42
|
-
seedCount?: number;
|
|
43
|
-
onClose: () => void;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* The filter-from-search surface as the chip editor (RFC 038 D5). The current
|
|
48
|
-
* search's literal terms and facets arrive already converted to clauses; this
|
|
49
|
-
* opens the shared rule editor pre-filled on them, over the same preview/apply
|
|
50
|
-
* endpoints as Organize. The conversion notice states what the search carried
|
|
51
|
-
* that the filter cannot — a folder scope, non-clause facets, semantic reach.
|
|
52
|
-
*
|
|
53
|
-
* A search-derived rule has no message anchor, so the semantic widen chip is not
|
|
54
|
-
* offered here (its loss, where the deployment could have served it, is the
|
|
55
|
-
* notice's job). The commit gate is the same: the count on screen is the literal
|
|
56
|
-
* set a commit acts on.
|
|
57
|
-
*/
|
|
58
|
-
export function SearchFilterEditor({
|
|
59
|
-
accountId,
|
|
60
|
-
conversion,
|
|
61
|
-
seedCount,
|
|
62
|
-
onClose,
|
|
63
|
-
}: SearchFilterEditorProps) {
|
|
64
|
-
const [initialRule] = useState<FilterRule>(() => buildSearchRule(conversion));
|
|
65
|
-
const { rule, handlers } = useRuleEditorState({ initialRule });
|
|
66
|
-
|
|
67
|
-
const { data: mailboxesData } = useQuery({
|
|
68
|
-
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
69
|
-
staleTime: Number.POSITIVE_INFINITY,
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
const folders: FolderOption[] = 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 preview = useRulePreview(accountId, rulePredicate(rule), seedCount);
|
|
82
|
-
|
|
83
|
-
const organizeJob = useOrganizeJob(accountId);
|
|
84
|
-
const createFilter = useCreateFilter(accountId);
|
|
85
|
-
|
|
86
|
-
const commit = () => {
|
|
87
|
-
const draft = ruleToDraft(rule);
|
|
88
|
-
if (rule.scope === "once") {
|
|
89
|
-
organizeJob.start(draft);
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
createFilter.createFilter(
|
|
93
|
-
draft,
|
|
94
|
-
rule.scope === "standing" ? "standing" : "temporary",
|
|
95
|
-
(rule.name ?? "").trim(),
|
|
96
|
-
);
|
|
97
|
-
};
|
|
98
|
-
|
|
99
|
-
if (organizeJob.isStarting || organizeJob.isRunning || organizeJob.isDone) {
|
|
100
|
-
return (
|
|
101
|
-
<JobProgress
|
|
102
|
-
progress={organizeJob.progress}
|
|
103
|
-
isDone={organizeJob.isDone}
|
|
104
|
-
runningLabel="Organizing your mail…"
|
|
105
|
-
onClose={onClose}
|
|
106
|
-
/>
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
if (createFilter.isPending) return <SavingState />;
|
|
111
|
-
if (createFilter.isSuccess) return <FilterSaved onClose={onClose} />;
|
|
112
|
-
if (createFilter.isError)
|
|
113
|
-
return <CommitError onRetry={createFilter.reset} onClose={onClose} />;
|
|
114
|
-
|
|
115
|
-
return (
|
|
116
|
-
<FilterRuleEditor
|
|
117
|
-
rule={rule}
|
|
118
|
-
folders={folders}
|
|
119
|
-
preview={preview}
|
|
120
|
-
notice={
|
|
121
|
-
<SearchConversionNoticeView
|
|
122
|
-
notice={{
|
|
123
|
-
scopedOutFolder: conversion.scopedOut?.label,
|
|
124
|
-
droppedFacets: conversion.droppedFacets.map((facet) => facet.label),
|
|
125
|
-
droppedSemantic: conversion.droppedSemantic,
|
|
126
|
-
}}
|
|
127
|
-
/>
|
|
128
|
-
}
|
|
129
|
-
semanticAvailable={false}
|
|
130
|
-
clauseFields={SUPPORTED_CLAUSE_FIELDS}
|
|
131
|
-
{...handlers}
|
|
132
|
-
onCommit={commit}
|
|
133
|
-
onCancel={onClose}
|
|
134
|
-
/>
|
|
135
|
-
);
|
|
136
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert/strict";
|
|
2
|
-
import { describe, it } from "node:test";
|
|
3
|
-
import React, { createElement } from "react";
|
|
4
|
-
import { renderToString } from "react-dom/server";
|
|
5
|
-
import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
|
|
6
|
-
import { type FolderOption, SomethingElsePanel } from "./SomethingElsePanel";
|
|
7
|
-
|
|
8
|
-
// The node test loader transpiles remit-ui's `.tsx` with the classic JSX
|
|
9
|
-
// runtime, which references a global `React`. Vite uses the automatic runtime,
|
|
10
|
-
// so this shim only exists for the SSR test harness.
|
|
11
|
-
(globalThis as { React?: typeof React }).React = React;
|
|
12
|
-
|
|
13
|
-
const FOLDERS: FolderOption[] = [
|
|
14
|
-
{ id: "mbx-inbox", label: "Inbox" },
|
|
15
|
-
{ id: "mbx-archive", label: "Archive" },
|
|
16
|
-
{ id: "mbx-junk", label: "Spam" },
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
const render = (
|
|
20
|
-
folderOptions: FolderOption[] = FOLDERS,
|
|
21
|
-
junkMailboxId?: string,
|
|
22
|
-
) =>
|
|
23
|
-
renderToString(
|
|
24
|
-
createElement(SomethingElsePanel, {
|
|
25
|
-
folderOptions,
|
|
26
|
-
junkMailboxId,
|
|
27
|
-
onSeed: (_seed: OrganizeSeed) => undefined,
|
|
28
|
-
}) as never,
|
|
29
|
-
);
|
|
30
|
-
|
|
31
|
-
describe("SomethingElsePanel", () => {
|
|
32
|
-
it("shows the plain-language prompt and input", () => {
|
|
33
|
-
const html = render();
|
|
34
|
-
assert.match(html, /What should Remit do\?/);
|
|
35
|
-
assert.match(html, /Tell Remit what to do/);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it("derives shortcuts from the account's real folders", () => {
|
|
39
|
-
const html = render();
|
|
40
|
-
assert.match(html, /Always keep in Inbox/);
|
|
41
|
-
assert.match(html, /File in Archive/);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it("offers the appointed Junk mailbox by its own label", () => {
|
|
45
|
-
const html = render(FOLDERS, "mbx-junk");
|
|
46
|
-
assert.match(html, /Move to Spam/);
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it("offers no folder shortcuts when the account has none", () => {
|
|
50
|
-
const html = render([]);
|
|
51
|
-
assert.doesNotMatch(html, /Always keep in Inbox/);
|
|
52
|
-
assert.doesNotMatch(html, /File in Archive/);
|
|
53
|
-
});
|
|
54
|
-
});
|
|
@@ -1,159 +0,0 @@
|
|
|
1
|
-
import { Button, Input } from "@remit/ui";
|
|
2
|
-
import { ArrowUp } from "lucide-react";
|
|
3
|
-
import { useMemo, useState } from "react";
|
|
4
|
-
import type { OrganizeSeed } from "@/lib/organize/mobile-organize-flow";
|
|
5
|
-
|
|
6
|
-
export interface FolderOption {
|
|
7
|
-
id: string;
|
|
8
|
-
label: string;
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
interface SomethingElsePanelProps {
|
|
12
|
-
/** The account's move destinations, the same set the sentence's folder picker uses. */
|
|
13
|
-
folderOptions: FolderOption[];
|
|
14
|
-
/** The appointed Junk mailbox, offered as a shortcut when present. */
|
|
15
|
-
junkMailboxId?: string;
|
|
16
|
-
/** Chosen shortcut or parsed input — seeds the organize sentence's folder/scope. */
|
|
17
|
-
onSeed: (seed: OrganizeSeed) => void;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
interface Shortcut {
|
|
21
|
-
id: string;
|
|
22
|
-
label: string;
|
|
23
|
-
seed: OrganizeSeed;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
const findFolder = (
|
|
27
|
-
folderOptions: FolderOption[],
|
|
28
|
-
label: string,
|
|
29
|
-
): FolderOption | undefined =>
|
|
30
|
-
folderOptions.find((folder) => folder.label.toLowerCase() === label);
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Shortcuts are derived from the account's real folders — there is no
|
|
34
|
-
* suggestion endpoint, so the panel offers the moves it can actually commit:
|
|
35
|
-
* keep it in the Inbox as a standing rule, file it in Archive, or send it to
|
|
36
|
-
* the appointed Junk mailbox. Each seeds the organize sentence; the user still
|
|
37
|
-
* confirms the scope.
|
|
38
|
-
*/
|
|
39
|
-
const buildShortcuts = (
|
|
40
|
-
folderOptions: FolderOption[],
|
|
41
|
-
junkMailboxId: string | undefined,
|
|
42
|
-
): Shortcut[] => {
|
|
43
|
-
const shortcuts: Shortcut[] = [];
|
|
44
|
-
const inbox = findFolder(folderOptions, "inbox");
|
|
45
|
-
if (inbox) {
|
|
46
|
-
shortcuts.push({
|
|
47
|
-
id: "keep-inbox",
|
|
48
|
-
label: "Always keep in Inbox",
|
|
49
|
-
seed: { moveMailboxId: inbox.id, scope: "standing" },
|
|
50
|
-
});
|
|
51
|
-
}
|
|
52
|
-
const archive = findFolder(folderOptions, "archive");
|
|
53
|
-
if (archive) {
|
|
54
|
-
shortcuts.push({
|
|
55
|
-
id: "file-archive",
|
|
56
|
-
label: "File in Archive",
|
|
57
|
-
seed: { moveMailboxId: archive.id },
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
const junk = junkMailboxId
|
|
61
|
-
? folderOptions.find((folder) => folder.id === junkMailboxId)
|
|
62
|
-
: undefined;
|
|
63
|
-
if (junk) {
|
|
64
|
-
shortcuts.push({
|
|
65
|
-
id: "move-junk",
|
|
66
|
-
label: `Move to ${junk.label}`,
|
|
67
|
-
seed: { moveMailboxId: junk.id },
|
|
68
|
-
});
|
|
69
|
-
}
|
|
70
|
-
return shortcuts;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/**
|
|
74
|
-
* Reads a plain-language instruction into a seed without an NLP endpoint: it
|
|
75
|
-
* matches the typed words against the account's own folder names and reads an
|
|
76
|
-
* "always"/"keep" phrasing as the standing scope. Whatever it can't resolve to
|
|
77
|
-
* a folder still opens the sentence, where the picker is one tap away — never a
|
|
78
|
-
* dead end.
|
|
79
|
-
*/
|
|
80
|
-
const parseInstruction = (
|
|
81
|
-
text: string,
|
|
82
|
-
folderOptions: FolderOption[],
|
|
83
|
-
): OrganizeSeed => {
|
|
84
|
-
const lower = text.toLowerCase();
|
|
85
|
-
const folder = folderOptions.find((option) =>
|
|
86
|
-
lower.includes(option.label.toLowerCase()),
|
|
87
|
-
);
|
|
88
|
-
const standing = /\b(always|keep|future|from now)\b/.test(lower);
|
|
89
|
-
return {
|
|
90
|
-
moveMailboxId: folder?.id,
|
|
91
|
-
scope: standing ? "standing" : undefined,
|
|
92
|
-
};
|
|
93
|
-
};
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* The "Something else" fallback (issue #211): smart shortcuts plus a
|
|
97
|
-
* plain-language input, both of which seed the organize sentence's folder and
|
|
98
|
-
* scope. Presentational — the flow owns the folder data and what a seed does.
|
|
99
|
-
*/
|
|
100
|
-
export function SomethingElsePanel({
|
|
101
|
-
folderOptions,
|
|
102
|
-
junkMailboxId,
|
|
103
|
-
onSeed,
|
|
104
|
-
}: SomethingElsePanelProps) {
|
|
105
|
-
const [text, setText] = useState("");
|
|
106
|
-
const shortcuts = useMemo(
|
|
107
|
-
() => buildShortcuts(folderOptions, junkMailboxId),
|
|
108
|
-
[folderOptions, junkMailboxId],
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
const submit = () => {
|
|
112
|
-
const trimmed = text.trim();
|
|
113
|
-
if (!trimmed) return;
|
|
114
|
-
onSeed(parseInstruction(trimmed, folderOptions));
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
return (
|
|
118
|
-
<div className="flex min-h-0 flex-col">
|
|
119
|
-
<div className="border-b border-line px-5 py-3">
|
|
120
|
-
<h2 className="text-sm font-semibold text-fg">What should Remit do?</h2>
|
|
121
|
-
<p className="mt-0.5 text-xs text-fg-muted">
|
|
122
|
-
Pick a shortcut or say it in your own words.
|
|
123
|
-
</p>
|
|
124
|
-
</div>
|
|
125
|
-
|
|
126
|
-
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto px-5 py-4">
|
|
127
|
-
{shortcuts.map((shortcut) => (
|
|
128
|
-
<Button
|
|
129
|
-
key={shortcut.id}
|
|
130
|
-
variant="secondary"
|
|
131
|
-
onClick={() => onSeed(shortcut.seed)}
|
|
132
|
-
className="h-12 w-full justify-start px-4"
|
|
133
|
-
>
|
|
134
|
-
{shortcut.label}
|
|
135
|
-
</Button>
|
|
136
|
-
))}
|
|
137
|
-
</div>
|
|
138
|
-
|
|
139
|
-
<div className="flex items-center gap-2 border-t border-line px-5 py-3">
|
|
140
|
-
<Input
|
|
141
|
-
value={text}
|
|
142
|
-
onChange={(event) => setText(event.target.value)}
|
|
143
|
-
onKeyDown={(event) => event.key === "Enter" && submit()}
|
|
144
|
-
placeholder="Tell Remit what to do…"
|
|
145
|
-
aria-label="Tell Remit what to do"
|
|
146
|
-
className="flex-1"
|
|
147
|
-
/>
|
|
148
|
-
<Button
|
|
149
|
-
variant="primary"
|
|
150
|
-
aria-label="Send"
|
|
151
|
-
onClick={submit}
|
|
152
|
-
disabled={!text.trim()}
|
|
153
|
-
icon={<ArrowUp className="size-4" />}
|
|
154
|
-
className="size-9 shrink-0 px-0"
|
|
155
|
-
/>
|
|
156
|
-
</div>
|
|
157
|
-
</div>
|
|
158
|
-
);
|
|
159
|
-
}
|
|
@@ -1,147 +0,0 @@
|
|
|
1
|
-
import { BottomSheet } from "@remit/ui";
|
|
2
|
-
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
-
import type { ReactNode } from "react";
|
|
4
|
-
import {
|
|
5
|
-
BackApplyError,
|
|
6
|
-
CommitError,
|
|
7
|
-
FilterSaved,
|
|
8
|
-
JobProgress,
|
|
9
|
-
SavingState,
|
|
10
|
-
} from "./rule-editor-states";
|
|
11
|
-
|
|
12
|
-
/**
|
|
13
|
-
* `Flows/Smart Organize/Rule editor states` — the non-editing states the rule
|
|
14
|
-
* editor lands in after a commit, rendered from the real components the Organize
|
|
15
|
-
* surface mounts. Creating a standing filter now runs the retroactive back-apply,
|
|
16
|
-
* so a standing save flows Saving → back-apply in flight → done, the same job
|
|
17
|
-
* states the one-time apply reaches. Every state here is what ships, so the flow
|
|
18
|
-
* cannot drift from the story.
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
function SheetStage({ children }: { children: ReactNode }) {
|
|
22
|
-
return (
|
|
23
|
-
<div className="relative mx-auto h-dvh w-full shrink-0 overflow-hidden bg-surface sm:my-6 sm:h-[520px] sm:w-[390px] sm:rounded-[2rem] sm:border sm:border-line sm:shadow-sm">
|
|
24
|
-
<BottomSheet open onClose={() => undefined}>
|
|
25
|
-
{children}
|
|
26
|
-
</BottomSheet>
|
|
27
|
-
</div>
|
|
28
|
-
);
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const meta: Meta = {
|
|
32
|
-
title: "Flows/Smart Organize/Rule editor states",
|
|
33
|
-
parameters: { layout: "fullscreen" },
|
|
34
|
-
};
|
|
35
|
-
export default meta;
|
|
36
|
-
|
|
37
|
-
type Story = StoryObj;
|
|
38
|
-
|
|
39
|
-
/** Saving the filter — held while the create request is in flight. */
|
|
40
|
-
export const Saving: Story = {
|
|
41
|
-
render: () => (
|
|
42
|
-
<SheetStage>
|
|
43
|
-
<SavingState />
|
|
44
|
-
</SheetStage>
|
|
45
|
-
),
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* The back-apply running after a standing filter is created — the pass that
|
|
50
|
-
* reaches the mail already in the mailbox, not only the mail that arrives next.
|
|
51
|
-
*/
|
|
52
|
-
export const BackApplyInFlight: Story = {
|
|
53
|
-
name: "Back-apply in flight",
|
|
54
|
-
render: () => (
|
|
55
|
-
<SheetStage>
|
|
56
|
-
<JobProgress
|
|
57
|
-
progress={{
|
|
58
|
-
state: "Running",
|
|
59
|
-
matchedCount: 24,
|
|
60
|
-
appliedCount: 0,
|
|
61
|
-
failedCount: 0,
|
|
62
|
-
errorMessage: "",
|
|
63
|
-
}}
|
|
64
|
-
isDone={false}
|
|
65
|
-
runningLabel="Organizing mail from these senders…"
|
|
66
|
-
onClose={() => undefined}
|
|
67
|
-
/>
|
|
68
|
-
</SheetStage>
|
|
69
|
-
),
|
|
70
|
-
};
|
|
71
|
-
|
|
72
|
-
/** The back-apply finished — the count moved is stated plainly. */
|
|
73
|
-
export const BackApplyDone: Story = {
|
|
74
|
-
name: "Back-apply done",
|
|
75
|
-
render: () => (
|
|
76
|
-
<SheetStage>
|
|
77
|
-
<JobProgress
|
|
78
|
-
progress={{
|
|
79
|
-
state: "Complete",
|
|
80
|
-
matchedCount: 24,
|
|
81
|
-
appliedCount: 24,
|
|
82
|
-
failedCount: 0,
|
|
83
|
-
errorMessage: "",
|
|
84
|
-
}}
|
|
85
|
-
isDone
|
|
86
|
-
runningLabel="Organizing mail from these senders…"
|
|
87
|
-
onClose={() => undefined}
|
|
88
|
-
/>
|
|
89
|
-
</SheetStage>
|
|
90
|
-
),
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/** The back-apply failed — the rule is still saved, the move is what did not run. */
|
|
94
|
-
export const BackApplyFailed: Story = {
|
|
95
|
-
name: "Back-apply failed",
|
|
96
|
-
render: () => (
|
|
97
|
-
<SheetStage>
|
|
98
|
-
<JobProgress
|
|
99
|
-
progress={{
|
|
100
|
-
state: "Failed",
|
|
101
|
-
matchedCount: 24,
|
|
102
|
-
appliedCount: 6,
|
|
103
|
-
failedCount: 18,
|
|
104
|
-
errorMessage: "Could not reach the mail server. Please try again.",
|
|
105
|
-
}}
|
|
106
|
-
isDone
|
|
107
|
-
runningLabel="Organizing mail from these senders…"
|
|
108
|
-
onClose={() => undefined}
|
|
109
|
-
/>
|
|
110
|
-
</SheetStage>
|
|
111
|
-
),
|
|
112
|
-
};
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* The filter saved, but the back-apply's own request never got off the ground
|
|
116
|
-
* (a 5xx or dropped connection on start). The rule is live for new mail; moving
|
|
117
|
-
* the existing mail is what failed, and it is offered again — never swallowed
|
|
118
|
-
* behind a plain "saved".
|
|
119
|
-
*/
|
|
120
|
-
export const BackApplyStartFailed: Story = {
|
|
121
|
-
name: "Back-apply start failed",
|
|
122
|
-
render: () => (
|
|
123
|
-
<SheetStage>
|
|
124
|
-
<BackApplyError onRetry={() => undefined} onClose={() => undefined} />
|
|
125
|
-
</SheetStage>
|
|
126
|
-
),
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
/** The filter saved with nothing to back-apply — the plain confirmation. */
|
|
130
|
-
export const FilterSavedState: Story = {
|
|
131
|
-
name: "Filter saved",
|
|
132
|
-
render: () => (
|
|
133
|
-
<SheetStage>
|
|
134
|
-
<FilterSaved onClose={() => undefined} />
|
|
135
|
-
</SheetStage>
|
|
136
|
-
),
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
/** The create failed — retry or dismiss. */
|
|
140
|
-
export const CommitFailed: Story = {
|
|
141
|
-
name: "Commit failed",
|
|
142
|
-
render: () => (
|
|
143
|
-
<SheetStage>
|
|
144
|
-
<CommitError onRetry={() => undefined} onClose={() => undefined} />
|
|
145
|
-
</SheetStage>
|
|
146
|
-
),
|
|
147
|
-
};
|
|
@@ -1,139 +0,0 @@
|
|
|
1
|
-
import { Button } from "@remit/ui";
|
|
2
|
-
import { CheckCircle2, Loader2 } from "lucide-react";
|
|
3
|
-
import type { useOrganizeJob } from "@/hooks/useOrganizeJob";
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The non-editing states a filter-rule editor lands in after commit, shared by
|
|
7
|
-
* the Organize and filter-from-search surfaces so a back-apply job, a saved
|
|
8
|
-
* standing rule, and a commit failure read identically wherever the rule was
|
|
9
|
-
* authored (RFC 038 D1).
|
|
10
|
-
*/
|
|
11
|
-
|
|
12
|
-
export function JobProgress({
|
|
13
|
-
progress,
|
|
14
|
-
isDone,
|
|
15
|
-
runningLabel,
|
|
16
|
-
onClose,
|
|
17
|
-
}: {
|
|
18
|
-
progress: ReturnType<typeof useOrganizeJob>["progress"];
|
|
19
|
-
isDone: boolean;
|
|
20
|
-
/** In-flight copy — the caller names what it is organizing. */
|
|
21
|
-
runningLabel: string;
|
|
22
|
-
onClose: () => void;
|
|
23
|
-
}) {
|
|
24
|
-
const failed = progress.state === "Failed";
|
|
25
|
-
return (
|
|
26
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
27
|
-
{!isDone ? (
|
|
28
|
-
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
29
|
-
) : failed ? (
|
|
30
|
-
<span className="text-sm font-semibold text-danger">
|
|
31
|
-
Organize failed
|
|
32
|
-
</span>
|
|
33
|
-
) : (
|
|
34
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
35
|
-
)}
|
|
36
|
-
|
|
37
|
-
{!isDone && <p className="text-sm font-medium text-fg">{runningLabel}</p>}
|
|
38
|
-
|
|
39
|
-
{isDone && !failed && (
|
|
40
|
-
<div className="text-sm text-fg">
|
|
41
|
-
<p className="font-medium">Done</p>
|
|
42
|
-
<p className="mt-1 text-xs text-fg-muted">
|
|
43
|
-
{progress.appliedCount} of {progress.matchedCount} moved
|
|
44
|
-
{progress.failedCount > 0
|
|
45
|
-
? ` · ${progress.failedCount} failed`
|
|
46
|
-
: ""}
|
|
47
|
-
.
|
|
48
|
-
</p>
|
|
49
|
-
</div>
|
|
50
|
-
)}
|
|
51
|
-
|
|
52
|
-
{isDone && failed && (
|
|
53
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
54
|
-
{progress.errorMessage || "Something went wrong. Please try again."}
|
|
55
|
-
</p>
|
|
56
|
-
)}
|
|
57
|
-
|
|
58
|
-
{isDone && (
|
|
59
|
-
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
60
|
-
Done
|
|
61
|
-
</Button>
|
|
62
|
-
)}
|
|
63
|
-
</div>
|
|
64
|
-
);
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function SavingState() {
|
|
68
|
-
return (
|
|
69
|
-
<div className="flex flex-col items-center gap-3 px-5 py-10 text-center">
|
|
70
|
-
<Loader2 className="size-8 animate-spin text-accent-2" />
|
|
71
|
-
<p className="text-sm font-medium text-fg">Saving rule…</p>
|
|
72
|
-
</div>
|
|
73
|
-
);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
export function FilterSaved({ onClose }: { onClose: () => void }) {
|
|
77
|
-
return (
|
|
78
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
79
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
80
|
-
<p className="text-sm font-medium text-fg">Filter saved</p>
|
|
81
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
82
|
-
You can see it, and when it expires, under Settings › Filters.
|
|
83
|
-
</p>
|
|
84
|
-
<Button variant="primary" onClick={onClose} className="mt-2">
|
|
85
|
-
Done
|
|
86
|
-
</Button>
|
|
87
|
-
</div>
|
|
88
|
-
);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function BackApplyError({
|
|
92
|
-
onRetry,
|
|
93
|
-
onClose,
|
|
94
|
-
}: {
|
|
95
|
-
onRetry: () => void;
|
|
96
|
-
onClose: () => void;
|
|
97
|
-
}) {
|
|
98
|
-
return (
|
|
99
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
100
|
-
<CheckCircle2 className="size-8 text-positive" />
|
|
101
|
-
<p className="text-sm font-medium text-fg">Rule saved</p>
|
|
102
|
-
<p className="max-w-xs text-xs text-fg-muted">
|
|
103
|
-
New mail follows it automatically. Moving the mail already in your
|
|
104
|
-
mailbox didn't run — try again?
|
|
105
|
-
</p>
|
|
106
|
-
<div className="mt-2 flex gap-2">
|
|
107
|
-
<Button variant="primary" onClick={onRetry}>
|
|
108
|
-
Move existing mail
|
|
109
|
-
</Button>
|
|
110
|
-
<Button variant="ghost" onClick={onClose}>
|
|
111
|
-
Not now
|
|
112
|
-
</Button>
|
|
113
|
-
</div>
|
|
114
|
-
</div>
|
|
115
|
-
);
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export function CommitError({
|
|
119
|
-
onRetry,
|
|
120
|
-
onClose,
|
|
121
|
-
}: {
|
|
122
|
-
onRetry: () => void;
|
|
123
|
-
onClose: () => void;
|
|
124
|
-
}) {
|
|
125
|
-
return (
|
|
126
|
-
<div className="flex flex-col items-center gap-3 px-5 py-8 text-center">
|
|
127
|
-
<p className="text-sm font-medium text-danger">Couldn't save the rule</p>
|
|
128
|
-
<p className="max-w-xs text-xs text-fg-muted">Please try again.</p>
|
|
129
|
-
<div className="mt-2 flex gap-2">
|
|
130
|
-
<Button variant="primary" onClick={onRetry}>
|
|
131
|
-
Try again
|
|
132
|
-
</Button>
|
|
133
|
-
<Button variant="ghost" onClick={onClose}>
|
|
134
|
-
Not now
|
|
135
|
-
</Button>
|
|
136
|
-
</div>
|
|
137
|
-
</div>
|
|
138
|
-
);
|
|
139
|
-
}
|