@remit/web-client 0.0.76 → 0.0.78
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/LabelApplyTrigger.tsx +61 -0
- package/src/components/mail/MailboxPane.tsx +1 -34
- package/src/components/mail/MessageList.tsx +1 -0
- package/src/components/mail/MessageListItem.test.ts +43 -0
- package/src/components/mail/MessageListItem.tsx +1 -0
- package/src/components/mail/SelectionToolbar.render.test.ts +51 -1
- package/src/components/mail/SelectionToolbar.stories.tsx +5 -0
- package/src/components/mail/SelectionToolbar.tsx +21 -0
- package/src/components/mail/organize/OrganizeRuleEditor.tsx +20 -0
- package/src/components/settings/FilterEditor.render.test.ts +1 -0
- package/src/components/settings/FilterEditor.tsx +18 -0
- package/src/components/settings/FilterEditorSurface.tsx +9 -1
- package/src/components/settings/FiltersList.render.test.ts +16 -0
- package/src/components/settings/FiltersList.tsx +32 -8
- package/src/components/settings/LabelsList.tsx +112 -0
- package/src/components/settings/settings-filter.stories.tsx +10 -1
- package/src/hooks/useApplyLabel.ts +68 -0
- package/src/hooks/useLabels.ts +124 -0
- package/src/hooks/useRuleEditorState.ts +8 -0
- package/src/lib/inbox-filters.test.ts +106 -0
- package/src/lib/inbox-filters.ts +44 -0
- package/src/lib/organize/filter-edit-model.test.ts +38 -0
- package/src/lib/organize/filter-edit-model.ts +15 -10
- package/src/lib/organize/label-delete-copy.test.ts +21 -0
- package/src/lib/organize/label-delete-copy.ts +22 -0
- package/src/lib/organize/organize-model.test.ts +35 -7
- package/src/lib/organize/organize-model.ts +17 -12
- package/src/lib/organize/rule-model.test.ts +5 -0
- package/src/lib/organize/rule-model.ts +1 -0
- package/src/routeTree.gen.ts +21 -0
- package/src/routes/settings/filters.tsx +19 -1
- package/src/routes/settings/labels.tsx +242 -0
- package/src/routes/settings.tsx +7 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { RemitImapLabelResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { Button, Input, LabelChip, labelColorOptions, Select } from "@remit/ui";
|
|
3
|
+
import { Trash2 } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
|
|
6
|
+
interface LabelsListProps {
|
|
7
|
+
labels: RemitImapLabelResponse[];
|
|
8
|
+
onRename: (labelId: string, name: string) => void;
|
|
9
|
+
onRecolor: (labelId: string, color: string) => void;
|
|
10
|
+
onDelete: (label: RemitImapLabelResponse) => void;
|
|
11
|
+
deletingLabelId?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The account's labels, each renamable and recolorable inline and deletable
|
|
16
|
+
* with the cascade-confirming dialog the caller opens (issue #26). Mirrors
|
|
17
|
+
* `FiltersList`'s row shape: a name, a color chip, and a destructive action.
|
|
18
|
+
*/
|
|
19
|
+
export function LabelsList({
|
|
20
|
+
labels,
|
|
21
|
+
onRename,
|
|
22
|
+
onRecolor,
|
|
23
|
+
onDelete,
|
|
24
|
+
deletingLabelId,
|
|
25
|
+
}: LabelsListProps) {
|
|
26
|
+
const [editingId, setEditingId] = useState<string>();
|
|
27
|
+
const [draftName, setDraftName] = useState("");
|
|
28
|
+
|
|
29
|
+
if (labels.length === 0) {
|
|
30
|
+
return (
|
|
31
|
+
<p className="py-6 text-sm text-fg-muted">
|
|
32
|
+
No labels yet. Create one below, then use it in a filter or apply it to
|
|
33
|
+
mail directly.
|
|
34
|
+
</p>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const commitRename = (labelId: string) => {
|
|
39
|
+
const trimmed = draftName.trim();
|
|
40
|
+
if (trimmed !== "") onRename(labelId, trimmed);
|
|
41
|
+
setEditingId(undefined);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<ul className="divide-y divide-line rounded-md border border-line">
|
|
46
|
+
{labels.map((label) => (
|
|
47
|
+
<li key={label.labelId} className="flex items-center gap-3 px-3 py-2.5">
|
|
48
|
+
<div className="min-w-0 flex-1">
|
|
49
|
+
{editingId === label.labelId ? (
|
|
50
|
+
<Input
|
|
51
|
+
autoFocus
|
|
52
|
+
value={draftName}
|
|
53
|
+
onChange={(event) => setDraftName(event.target.value)}
|
|
54
|
+
onBlur={() => commitRename(label.labelId)}
|
|
55
|
+
onKeyDown={(event) => {
|
|
56
|
+
if (event.key === "Enter") {
|
|
57
|
+
event.preventDefault();
|
|
58
|
+
commitRename(label.labelId);
|
|
59
|
+
}
|
|
60
|
+
if (event.key === "Escape") {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
setEditingId(undefined);
|
|
63
|
+
}
|
|
64
|
+
}}
|
|
65
|
+
aria-label={`Rename label ${label.name}`}
|
|
66
|
+
/>
|
|
67
|
+
) : (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
onClick={() => {
|
|
71
|
+
setEditingId(label.labelId);
|
|
72
|
+
setDraftName(label.name);
|
|
73
|
+
}}
|
|
74
|
+
aria-label={`Rename label ${label.name}`}
|
|
75
|
+
className="flex items-center gap-2 rounded-sm text-left hover:opacity-80"
|
|
76
|
+
>
|
|
77
|
+
<LabelChip label={label} />
|
|
78
|
+
</button>
|
|
79
|
+
)}
|
|
80
|
+
<p className="mt-0.5 text-xs text-fg-subtle">
|
|
81
|
+
{label.filterCount === 0
|
|
82
|
+
? "Not used in any filter"
|
|
83
|
+
: `Used in ${label.filterCount} ${
|
|
84
|
+
label.filterCount === 1 ? "filter" : "filters"
|
|
85
|
+
}`}
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
<Select
|
|
89
|
+
aria-label={`Color for ${label.name}`}
|
|
90
|
+
value={label.color}
|
|
91
|
+
onChange={(event) => onRecolor(label.labelId, event.target.value)}
|
|
92
|
+
className="w-28 shrink-0"
|
|
93
|
+
>
|
|
94
|
+
{labelColorOptions.map((color) => (
|
|
95
|
+
<option key={color} value={color}>
|
|
96
|
+
{color}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</Select>
|
|
100
|
+
<Button
|
|
101
|
+
variant="ghost"
|
|
102
|
+
size="sm"
|
|
103
|
+
icon={<Trash2 className="size-4 text-danger" />}
|
|
104
|
+
onClick={() => onDelete(label)}
|
|
105
|
+
disabled={deletingLabelId === label.labelId}
|
|
106
|
+
aria-label={`Delete label ${label.name}`}
|
|
107
|
+
/>
|
|
108
|
+
</li>
|
|
109
|
+
))}
|
|
110
|
+
</ul>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { RemitImapFilterResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import { BottomSheet, type FolderOption } from "@remit/ui";
|
|
2
|
+
import { BottomSheet, type FolderOption, type LabelOption } from "@remit/ui";
|
|
3
3
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
4
4
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
5
|
import type { ReactNode } from "react";
|
|
@@ -24,6 +24,11 @@ const FOLDERS: FolderOption[] = [
|
|
|
24
24
|
{ id: "mbx-travel", label: "Travel" },
|
|
25
25
|
];
|
|
26
26
|
|
|
27
|
+
const LABELS: LabelOption[] = [
|
|
28
|
+
{ id: "lbl-receipts", name: "Receipts", color: "Blue" },
|
|
29
|
+
{ id: "lbl-travel", name: "Travel", color: "Green" },
|
|
30
|
+
];
|
|
31
|
+
|
|
27
32
|
const makeFilter = (
|
|
28
33
|
overrides: Partial<RemitImapFilterResponse> = {},
|
|
29
34
|
): RemitImapFilterResponse => ({
|
|
@@ -104,6 +109,7 @@ export const EditStandingRule: Story = {
|
|
|
104
109
|
accountId={ACCOUNT_ID}
|
|
105
110
|
filter={makeFilter()}
|
|
106
111
|
folders={FOLDERS}
|
|
112
|
+
labels={LABELS}
|
|
107
113
|
onClose={() => undefined}
|
|
108
114
|
/>
|
|
109
115
|
</SheetStage>
|
|
@@ -119,6 +125,7 @@ export const EditAnchoredRule: Story = {
|
|
|
119
125
|
accountId={ACCOUNT_ID}
|
|
120
126
|
filter={makeFilter({ hasAnchor: true, name: "GitHub" })}
|
|
121
127
|
folders={FOLDERS}
|
|
128
|
+
labels={LABELS}
|
|
122
129
|
onClose={() => undefined}
|
|
123
130
|
/>
|
|
124
131
|
</SheetStage>
|
|
@@ -138,6 +145,7 @@ export const EditUntilADate: Story = {
|
|
|
138
145
|
expiresAt: "2027-09-01T23:59:59+00:00",
|
|
139
146
|
})}
|
|
140
147
|
folders={FOLDERS}
|
|
148
|
+
labels={LABELS}
|
|
141
149
|
onClose={() => undefined}
|
|
142
150
|
/>
|
|
143
151
|
</SheetStage>
|
|
@@ -157,6 +165,7 @@ export const DegradedAnchor: Story = {
|
|
|
157
165
|
accountId={ACCOUNT_ID}
|
|
158
166
|
filter={makeFilter({ hasAnchor: true, name: "Newsletters" })}
|
|
159
167
|
folders={FOLDERS}
|
|
168
|
+
labels={LABELS}
|
|
160
169
|
semanticUnavailable
|
|
161
170
|
onClose={() => undefined}
|
|
162
171
|
/>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { messageBulkOperationsUpdateMessageLabelsMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
+
import type {
|
|
3
|
+
RemitImapLabelAction,
|
|
4
|
+
RemitImapThreadMessageResponse,
|
|
5
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
6
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
7
|
+
import { useCallback } from "react";
|
|
8
|
+
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
9
|
+
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
10
|
+
import { resolveMailboxesForMessages } from "@/hooks/useMarkAsRead";
|
|
11
|
+
import {
|
|
12
|
+
invalidateThreadListQueries,
|
|
13
|
+
threadListCacheKeys,
|
|
14
|
+
} from "@/lib/thread-list-cache";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Apply or remove a label on a "just these" selection (issue #26, RFC 034
|
|
18
|
+
* recap). No optimistic patch: a label is metadata on the row, not something
|
|
19
|
+
* that removes it from the current view, so settling on the server response
|
|
20
|
+
* and invalidating is enough — unlike a move or delete, which the row leaves
|
|
21
|
+
* immediately.
|
|
22
|
+
*/
|
|
23
|
+
export const useApplyLabel = (options: {
|
|
24
|
+
mailboxId: string;
|
|
25
|
+
accountId?: string;
|
|
26
|
+
/** The threads the selection may span, for resolving which mailbox lists to invalidate. */
|
|
27
|
+
messages?: RemitImapThreadMessageResponse[];
|
|
28
|
+
}) => {
|
|
29
|
+
const { mailboxId, messages } = options;
|
|
30
|
+
const queryClient = useQueryClient();
|
|
31
|
+
const { pushError } = useErrorBanners();
|
|
32
|
+
|
|
33
|
+
const { mutate, isPending } = useMutation({
|
|
34
|
+
...messageBulkOperationsUpdateMessageLabelsMutation(),
|
|
35
|
+
onError: (error, variables) => {
|
|
36
|
+
pushError({
|
|
37
|
+
title:
|
|
38
|
+
variables.body.action === "Remove"
|
|
39
|
+
? "Couldn't remove label"
|
|
40
|
+
: "Couldn't apply label",
|
|
41
|
+
detail: formatErrorDetail(error),
|
|
42
|
+
error,
|
|
43
|
+
});
|
|
44
|
+
},
|
|
45
|
+
onSettled: (_data, _error, variables) => {
|
|
46
|
+
invalidateThreadListQueries(
|
|
47
|
+
queryClient,
|
|
48
|
+
threadListCacheKeys(
|
|
49
|
+
resolveMailboxesForMessages(
|
|
50
|
+
variables.body.messageIds ?? [],
|
|
51
|
+
messages ?? [],
|
|
52
|
+
mailboxId,
|
|
53
|
+
),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const applyLabel = useCallback(
|
|
60
|
+
(messageIds: string[], labelId: string, action: RemitImapLabelAction) => {
|
|
61
|
+
if (messageIds.length === 0) return;
|
|
62
|
+
mutate({ body: { messageIds, labelId, action } });
|
|
63
|
+
},
|
|
64
|
+
[mutate],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return { applyLabel, isPending };
|
|
68
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
labelDetailOperationsDeleteLabelMutation,
|
|
3
|
+
labelDetailOperationsUpdateLabelMutation,
|
|
4
|
+
labelOperationsCreateLabelMutation,
|
|
5
|
+
labelOperationsListLabelsOptions,
|
|
6
|
+
labelOperationsListLabelsQueryKey,
|
|
7
|
+
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
8
|
+
import type {
|
|
9
|
+
RemitImapLabelColor,
|
|
10
|
+
RemitImapUpdateLabelInput,
|
|
11
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
+
import { useCallback } from "react";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The query key the label list reads and every mutation invalidates on
|
|
17
|
+
* success — the same contract `buildFilterListKey` pins for filters, so the
|
|
18
|
+
* settings list and the rule editor's label picker never go stale after a
|
|
19
|
+
* label is created, renamed, recolored, or deleted (issue #26).
|
|
20
|
+
*/
|
|
21
|
+
export const buildLabelListKey = (accountId: string) =>
|
|
22
|
+
labelOperationsListLabelsQueryKey({ path: { accountId } });
|
|
23
|
+
|
|
24
|
+
/** List the account's labels. */
|
|
25
|
+
export const useLabelList = (accountId: string | undefined) => {
|
|
26
|
+
const query = useQuery({
|
|
27
|
+
...labelOperationsListLabelsOptions({
|
|
28
|
+
path: { accountId: accountId ?? "" },
|
|
29
|
+
}),
|
|
30
|
+
enabled: !!accountId,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
labels: query.data?.items ?? [],
|
|
35
|
+
isPending: query.isPending,
|
|
36
|
+
isError: query.isError,
|
|
37
|
+
error: query.error,
|
|
38
|
+
refetch: query.refetch,
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const useCreateLabel = (accountId: string | undefined) => {
|
|
43
|
+
const queryClient = useQueryClient();
|
|
44
|
+
const mutation = useMutation({
|
|
45
|
+
...labelOperationsCreateLabelMutation(),
|
|
46
|
+
onSuccess: () => {
|
|
47
|
+
if (!accountId) return;
|
|
48
|
+
queryClient.invalidateQueries({ queryKey: buildLabelListKey(accountId) });
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
const { mutateAsync } = mutation;
|
|
52
|
+
|
|
53
|
+
const createLabel = useCallback(
|
|
54
|
+
(name: string, color: RemitImapLabelColor = "Default") => {
|
|
55
|
+
if (!accountId) return Promise.reject(new Error("No account"));
|
|
56
|
+
return mutateAsync({ path: { accountId }, body: { name, color } });
|
|
57
|
+
},
|
|
58
|
+
[accountId, mutateAsync],
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
createLabel,
|
|
63
|
+
isPending: mutation.isPending,
|
|
64
|
+
isError: mutation.isError,
|
|
65
|
+
error: mutation.error,
|
|
66
|
+
reset: mutation.reset,
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const useUpdateLabel = (accountId: string | undefined) => {
|
|
71
|
+
const queryClient = useQueryClient();
|
|
72
|
+
const mutation = useMutation({
|
|
73
|
+
...labelDetailOperationsUpdateLabelMutation(),
|
|
74
|
+
onSuccess: () => {
|
|
75
|
+
if (!accountId) return;
|
|
76
|
+
queryClient.invalidateQueries({ queryKey: buildLabelListKey(accountId) });
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
const { mutate } = mutation;
|
|
80
|
+
|
|
81
|
+
const updateLabel = useCallback(
|
|
82
|
+
(labelId: string, input: RemitImapUpdateLabelInput) => {
|
|
83
|
+
if (!accountId) return;
|
|
84
|
+
mutate({ path: { accountId, labelId }, body: input });
|
|
85
|
+
},
|
|
86
|
+
[accountId, mutate],
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
updateLabel,
|
|
91
|
+
isPending: mutation.isPending,
|
|
92
|
+
updatingLabelId: mutation.isPending
|
|
93
|
+
? mutation.variables?.path.labelId
|
|
94
|
+
: undefined,
|
|
95
|
+
};
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const useDeleteLabel = (accountId: string | undefined) => {
|
|
99
|
+
const queryClient = useQueryClient();
|
|
100
|
+
const mutation = useMutation({
|
|
101
|
+
...labelDetailOperationsDeleteLabelMutation(),
|
|
102
|
+
onSuccess: () => {
|
|
103
|
+
if (!accountId) return;
|
|
104
|
+
queryClient.invalidateQueries({ queryKey: buildLabelListKey(accountId) });
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
const { mutate } = mutation;
|
|
108
|
+
|
|
109
|
+
const deleteLabel = useCallback(
|
|
110
|
+
(labelId: string) => {
|
|
111
|
+
if (!accountId) return;
|
|
112
|
+
mutate({ path: { accountId, labelId } });
|
|
113
|
+
},
|
|
114
|
+
[accountId, mutate],
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
deleteLabel,
|
|
119
|
+
isPending: mutation.isPending,
|
|
120
|
+
deletingLabelId: mutation.isPending
|
|
121
|
+
? mutation.variables?.path.labelId
|
|
122
|
+
: undefined,
|
|
123
|
+
};
|
|
124
|
+
};
|
|
@@ -28,6 +28,7 @@ export interface RuleEditorState {
|
|
|
28
28
|
onRemoveWiden: () => void;
|
|
29
29
|
onChangeMatchOperator: (matchOperator: MatchOperator) => void;
|
|
30
30
|
onChangeMove: (mailboxId: string) => void;
|
|
31
|
+
onChangeLabel: (labelId: string) => void;
|
|
31
32
|
onChangeScope: (scope: RuleScope) => void;
|
|
32
33
|
onChangeName: (name: string) => void;
|
|
33
34
|
onChangeUntil: (until: string) => void;
|
|
@@ -141,6 +142,12 @@ export const useRuleEditorState = ({
|
|
|
141
142
|
moveMailboxId: mailboxId || undefined,
|
|
142
143
|
}));
|
|
143
144
|
|
|
145
|
+
const changeLabel = (labelId: string) =>
|
|
146
|
+
setRuleState((current) => ({
|
|
147
|
+
...current,
|
|
148
|
+
labelId: labelId || undefined,
|
|
149
|
+
}));
|
|
150
|
+
|
|
144
151
|
const changeScope = (scope: RuleScope) =>
|
|
145
152
|
setRuleState((current) => ({ ...current, scope }));
|
|
146
153
|
|
|
@@ -166,6 +173,7 @@ export const useRuleEditorState = ({
|
|
|
166
173
|
onRemoveWiden: removeWiden,
|
|
167
174
|
onChangeMatchOperator: changeMatchOperator,
|
|
168
175
|
onChangeMove: changeMove,
|
|
176
|
+
onChangeLabel: changeLabel,
|
|
169
177
|
onChangeScope: changeScope,
|
|
170
178
|
onChangeName: changeName,
|
|
171
179
|
onChangeUntil: changeUntil,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { applyInboxFilters } from "./inbox-filters.js";
|
|
5
|
+
|
|
6
|
+
// Only the fields the filter reads, following the fixture idiom in
|
|
7
|
+
// starred-rows.test.ts.
|
|
8
|
+
const thread = (
|
|
9
|
+
fields: Partial<RemitImapThreadMessageResponse> & { messageId: string },
|
|
10
|
+
): RemitImapThreadMessageResponse =>
|
|
11
|
+
({ isRead: false, ...fields }) as unknown as RemitImapThreadMessageResponse;
|
|
12
|
+
|
|
13
|
+
const personal = thread({ messageId: "m1", category: "personal" });
|
|
14
|
+
const unclassified = thread({ messageId: "m2", category: "uncategorized" });
|
|
15
|
+
const preClassification = thread({ messageId: "m3" });
|
|
16
|
+
|
|
17
|
+
const ids = (threads: RemitImapThreadMessageResponse[]): string[] =>
|
|
18
|
+
threads.map((t) => t.messageId);
|
|
19
|
+
|
|
20
|
+
describe("applyInboxFilters", () => {
|
|
21
|
+
test("returns the loaded list when nothing narrows it", () => {
|
|
22
|
+
const threads = [personal, unclassified, preClassification];
|
|
23
|
+
assert.deepEqual(applyInboxFilters(threads, "all", new Set()), threads);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("matches a thread whose category is set", () => {
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
ids(applyInboxFilters([personal, unclassified], "personal", new Set())),
|
|
29
|
+
["m1"],
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("counts a thread with no category as unclassified (#45)", () => {
|
|
34
|
+
// A pre-classification thread already renders an `uncategorized` badge,
|
|
35
|
+
// so the Unclassified chip has to find the row the user can see. Reading
|
|
36
|
+
// the response field raw made that row vanish under its own chip.
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
ids(
|
|
39
|
+
applyInboxFilters(
|
|
40
|
+
[personal, unclassified, preClassification],
|
|
41
|
+
"uncategorized",
|
|
42
|
+
new Set(),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
["m2", "m3"],
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("never lets unclassified mail answer to personal (#45)", () => {
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
applyInboxFilters(
|
|
52
|
+
[unclassified, preClassification],
|
|
53
|
+
"personal",
|
|
54
|
+
new Set(),
|
|
55
|
+
),
|
|
56
|
+
[],
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("applies attribute predicates alongside a category", () => {
|
|
61
|
+
const read = thread({
|
|
62
|
+
messageId: "m4",
|
|
63
|
+
category: "personal",
|
|
64
|
+
isRead: true,
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
ids(applyInboxFilters([personal, read], "personal", new Set(["unread"]))),
|
|
68
|
+
["m1"],
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("applies attribute predicates without a category", () => {
|
|
73
|
+
const read = thread({
|
|
74
|
+
messageId: "m4",
|
|
75
|
+
category: "newsletter",
|
|
76
|
+
isRead: true,
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
ids(applyInboxFilters([personal, read], "all", new Set(["unread"]))),
|
|
80
|
+
["m1"],
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("matches starred and attachment threads", () => {
|
|
85
|
+
const starred = thread({ messageId: "m5", hasStars: true });
|
|
86
|
+
const withAttachment = thread({ messageId: "m6", hasAttachment: true });
|
|
87
|
+
const plain = thread({ messageId: "m7" });
|
|
88
|
+
const threads = [starred, withAttachment, plain];
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
ids(applyInboxFilters(threads, "all", new Set(["flagged"]))),
|
|
91
|
+
["m5"],
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
ids(applyInboxFilters(threads, "all", new Set(["attachment"]))),
|
|
95
|
+
["m6"],
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("ignores an attribute id with no predicate behind it", () => {
|
|
100
|
+
const threads = [personal, unclassified];
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
applyInboxFilters(threads, "all", new Set(["nonsense"])),
|
|
103
|
+
threads,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { toDisplayCategory } from "./display-category.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inbox filter predicates — the inbox preset offers Unread / Starred / Has
|
|
6
|
+
* attachment (never accounts; an inbox is one account already). The `flagged`
|
|
7
|
+
* id is the wire name for IMAP \Flagged; the label is "Starred".
|
|
8
|
+
*/
|
|
9
|
+
const INBOX_FILTER_PREDICATES: Record<
|
|
10
|
+
string,
|
|
11
|
+
(t: RemitImapThreadMessageResponse) => boolean
|
|
12
|
+
> = {
|
|
13
|
+
unread: (t) => !t.isRead,
|
|
14
|
+
flagged: (t) => t.hasStars === true,
|
|
15
|
+
attachment: (t) => Boolean(t.hasAttachment),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Narrow the loaded threads to one category and a set of attributes. Applied
|
|
20
|
+
* over the loaded pages until #306 moves the predicate into the query.
|
|
21
|
+
*
|
|
22
|
+
* The category comparison goes through `toDisplayCategory` so the inbox agrees
|
|
23
|
+
* with Starred and the brief, which filter mapped rows. `category` is optional
|
|
24
|
+
* on the response and absent reads as `uncategorized` everywhere else — a
|
|
25
|
+
* pre-classification thread renders an `uncategorized` badge, so a raw
|
|
26
|
+
* comparison made the row you can see vanish under its own chip (#45).
|
|
27
|
+
*/
|
|
28
|
+
export function applyInboxFilters(
|
|
29
|
+
threads: RemitImapThreadMessageResponse[],
|
|
30
|
+
category: string,
|
|
31
|
+
attributes: ReadonlySet<string>,
|
|
32
|
+
): RemitImapThreadMessageResponse[] {
|
|
33
|
+
const predicates = Array.from(attributes)
|
|
34
|
+
.map((id) => INBOX_FILTER_PREDICATES[id])
|
|
35
|
+
.filter(
|
|
36
|
+
(p): p is (t: RemitImapThreadMessageResponse) => boolean => p != null,
|
|
37
|
+
);
|
|
38
|
+
if (category === "all" && predicates.length === 0) return threads;
|
|
39
|
+
return threads.filter(
|
|
40
|
+
(t) =>
|
|
41
|
+
(category === "all" || toDisplayCategory(t.category) === category) &&
|
|
42
|
+
predicates.every((p) => p(t)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -58,6 +58,16 @@ describe("filterToRule", () => {
|
|
|
58
58
|
assert.equal(rule.moveMailboxId, undefined);
|
|
59
59
|
});
|
|
60
60
|
|
|
61
|
+
it("loads a label action (issue #26)", () => {
|
|
62
|
+
const rule = filterToRule(filter({ actionLabelId: "lbl-1" }));
|
|
63
|
+
assert.equal(rule.labelId, "lbl-1");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("maps the None label sentinel to no action", () => {
|
|
67
|
+
const rule = filterToRule(filter({ actionLabelId: "None" }));
|
|
68
|
+
assert.equal(rule.labelId, undefined);
|
|
69
|
+
});
|
|
70
|
+
|
|
61
71
|
it("loads a Temporary filter as an until-a-date rule", () => {
|
|
62
72
|
const rule = filterToRule(
|
|
63
73
|
filter({ scope: "Temporary", expiresAt: "2027-03-04T23:59:59+00:00" }),
|
|
@@ -131,6 +141,13 @@ describe("ruleChangesPredicateOrAction", () => {
|
|
|
131
141
|
true,
|
|
132
142
|
);
|
|
133
143
|
});
|
|
144
|
+
|
|
145
|
+
it("is true when the label target changes (issue #26)", () => {
|
|
146
|
+
assert.equal(
|
|
147
|
+
ruleChangesPredicateOrAction({ ...base, labelId: "lbl-new" }, base),
|
|
148
|
+
true,
|
|
149
|
+
);
|
|
150
|
+
});
|
|
134
151
|
});
|
|
135
152
|
|
|
136
153
|
describe("ruleChangesScopeOrExpiry (reader #266)", () => {
|
|
@@ -212,6 +229,27 @@ describe("buildUpdateFilterInput", () => {
|
|
|
212
229
|
assert.equal(body.actionMailboxId, "None");
|
|
213
230
|
});
|
|
214
231
|
|
|
232
|
+
it("sends a chosen label target (issue #26)", () => {
|
|
233
|
+
const changed: FilterRule = {
|
|
234
|
+
...original,
|
|
235
|
+
labelId: "lbl-receipts",
|
|
236
|
+
matchOperator: "any",
|
|
237
|
+
};
|
|
238
|
+
const body = buildUpdateFilterInput(changed, original);
|
|
239
|
+
assert.equal(body.actionLabelId, "lbl-receipts");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("drops a cleared label target to the None sentinel", () => {
|
|
243
|
+
const withLabel = { ...original, labelId: "lbl-receipts" };
|
|
244
|
+
const changed = {
|
|
245
|
+
...withLabel,
|
|
246
|
+
labelId: undefined,
|
|
247
|
+
matchOperator: "any" as const,
|
|
248
|
+
};
|
|
249
|
+
const body = buildUpdateFilterInput(changed, withLabel);
|
|
250
|
+
assert.equal(body.actionLabelId, "None");
|
|
251
|
+
});
|
|
252
|
+
|
|
215
253
|
it("is empty when nothing changed", () => {
|
|
216
254
|
assert.deepEqual(buildUpdateFilterInput(original, original), {});
|
|
217
255
|
});
|
|
@@ -14,8 +14,9 @@ import { NO_ACTION } from "./organize-model";
|
|
|
14
14
|
* cannot evaluate it (D4) — the rule then matches by its literal clauses only.
|
|
15
15
|
*
|
|
16
16
|
* The scope maps `Standing` → "standing" and `Temporary` → "until"; a persisted
|
|
17
|
-
* filter is never the one-time "once" scope. `moveMailboxId`
|
|
18
|
-
* sentinel to `undefined` so an empty
|
|
17
|
+
* filter is never the one-time "once" scope. `moveMailboxId` and `labelId` both
|
|
18
|
+
* drop the `"None"` sentinel to `undefined` so an empty select reads as "no
|
|
19
|
+
* action" (issue #26).
|
|
19
20
|
*/
|
|
20
21
|
export const filterToRule = (
|
|
21
22
|
filter: RemitImapFilterResponse,
|
|
@@ -34,6 +35,8 @@ export const filterToRule = (
|
|
|
34
35
|
: undefined,
|
|
35
36
|
moveMailboxId:
|
|
36
37
|
filter.actionMailboxId !== NO_ACTION ? filter.actionMailboxId : undefined,
|
|
38
|
+
labelId:
|
|
39
|
+
filter.actionLabelId !== NO_ACTION ? filter.actionLabelId : undefined,
|
|
37
40
|
scope: filter.scope === "Temporary" ? "until" : "standing",
|
|
38
41
|
until:
|
|
39
42
|
filter.scope === "Temporary"
|
|
@@ -65,15 +68,16 @@ const predicateActionKey = (rule: FilterRule): string =>
|
|
|
65
68
|
clauses: rule.clauses.map((clause) => [clause.field, clause.value]),
|
|
66
69
|
operator: rule.matchOperator,
|
|
67
70
|
move: rule.moveMailboxId ?? null,
|
|
71
|
+
label: rule.labelId ?? null,
|
|
68
72
|
widen: rule.widen ? (rule.widen.inactive ? "inactive" : "active") : "none",
|
|
69
73
|
});
|
|
70
74
|
|
|
71
75
|
/**
|
|
72
76
|
* Whether the edited rule changes the predicate or the action versus the one it
|
|
73
|
-
* was loaded from — the clauses, the match operator, the move target,
|
|
74
|
-
* widen's presence. A change here is what bumps
|
|
75
|
-
* re-back-apply (RFC 034 Decision 3.2); the name
|
|
76
|
-
* cosmetic rename is not a rule change.
|
|
77
|
+
* was loaded from — the clauses, the match operator, the move target, the
|
|
78
|
+
* label target, or the widen's presence. A change here is what bumps
|
|
79
|
+
* `ruleChangedAt` and offers the re-back-apply (RFC 034 Decision 3.2); the name
|
|
80
|
+
* is deliberately excluded, so a cosmetic rename is not a rule change.
|
|
77
81
|
*/
|
|
78
82
|
export const ruleChangesPredicateOrAction = (
|
|
79
83
|
rule: FilterRule,
|
|
@@ -104,10 +108,10 @@ export const ruleChangesScopeOrExpiry = (
|
|
|
104
108
|
* The PATCH body for an edited filter. A cosmetic rename sends `{ name }` only,
|
|
105
109
|
* so the server's `changesRuleAssertion` guard leaves `ruleChangedAt`
|
|
106
110
|
* untouched (RFC 034 Decision 3.2). A predicate or action change sends the
|
|
107
|
-
* operator, clauses, and
|
|
108
|
-
* and, for the `until` scope, `expiresAt` (reader #266) —
|
|
109
|
-
* `ruleChangedAt`. The
|
|
110
|
-
*
|
|
111
|
+
* operator, clauses, move target, and label target; a scope or expiry change
|
|
112
|
+
* sends `scope` and, for the `until` scope, `expiresAt` (reader #266) —
|
|
113
|
+
* either bumps `ruleChangedAt`. The anchor is never in the editor's gift, so
|
|
114
|
+
* it never enters the patch; the partial update preserves it.
|
|
111
115
|
*/
|
|
112
116
|
export const buildUpdateFilterInput = (
|
|
113
117
|
rule: FilterRule,
|
|
@@ -123,6 +127,7 @@ export const buildUpdateFilterInput = (
|
|
|
123
127
|
value: clause.value,
|
|
124
128
|
}));
|
|
125
129
|
body.actionMailboxId = rule.moveMailboxId ?? NO_ACTION;
|
|
130
|
+
body.actionLabelId = rule.labelId ?? NO_ACTION;
|
|
126
131
|
}
|
|
127
132
|
if (ruleChangesScopeOrExpiry(rule, original)) {
|
|
128
133
|
body.scope = rule.scope === "until" ? "Temporary" : "Standing";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { deleteLabelConfirmCopy } from "./label-delete-copy";
|
|
4
|
+
|
|
5
|
+
describe("deleteLabelConfirmCopy", () => {
|
|
6
|
+
it("names the label with no blast-radius note when no filter uses it", () => {
|
|
7
|
+
const copy = deleteLabelConfirmCopy("Receipts", 0);
|
|
8
|
+
assert.equal(copy.title, 'Delete the "Receipts" label?');
|
|
9
|
+
assert.equal(copy.description, undefined);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("names exactly one filter in the singular", () => {
|
|
13
|
+
const copy = deleteLabelConfirmCopy("Receipts", 1);
|
|
14
|
+
assert.match(copy.description ?? "", /1 filter that applies it/);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("names several filters in the plural", () => {
|
|
18
|
+
const copy = deleteLabelConfirmCopy("Receipts", 3);
|
|
19
|
+
assert.match(copy.description ?? "", /3 filters that apply it/);
|
|
20
|
+
});
|
|
21
|
+
});
|