@remit/web-client 0.0.277 → 0.0.278

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.277",
3
+ "version": "0.0.278",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -159,16 +159,25 @@ function IntelligenceSkeleton() {
159
159
  /**
160
160
  * Reclassify picker: a small modal listing the category-override options. On
161
161
  * selection it PATCHes `AddressFlags.category` for the sender.
162
+ *
163
+ * A sender that already carries an override also gets the way back out. The
164
+ * override is an enum with no false-equivalent member, so removal is not a
165
+ * value to send — it is `clearFlags: ["category"]`, the contract's removal
166
+ * form for every flag (#615).
162
167
  */
163
168
  function ReclassifyDialog({
164
169
  isOpen,
165
170
  current,
171
+ hasOverride,
166
172
  onSelect,
173
+ onClear,
167
174
  onCancel,
168
175
  }: {
169
176
  isOpen: boolean;
170
177
  current: string;
178
+ hasOverride: boolean;
171
179
  onSelect: (category: CategoryOverride) => void;
180
+ onClear: () => void;
172
181
  onCancel: () => void;
173
182
  }) {
174
183
  const dialogRef = useRef<HTMLDivElement>(null);
@@ -210,6 +219,15 @@ function ReclassifyDialog({
210
219
  )}
211
220
  </button>
212
221
  ))}
222
+ {hasOverride && (
223
+ <button
224
+ type="button"
225
+ onClick={onClear}
226
+ className="mt-1 flex min-h-11 items-center rounded border-t border-line px-3 text-left text-sm text-fg transition-colors hover:bg-surface-raised"
227
+ >
228
+ Remove override — classify automatically again
229
+ </button>
230
+ )}
213
231
  </div>
214
232
  <div className="mt-6 flex justify-end">
215
233
  <button
@@ -247,6 +265,7 @@ function WiredPanel({
247
265
  }: WiredPanelProps) {
248
266
  const {
249
267
  data,
268
+ address,
250
269
  addressId,
251
270
  isSimilarLoading,
252
271
  similarError,
@@ -256,7 +275,7 @@ function WiredPanel({
256
275
  const [reclassifyOpen, setReclassifyOpen] = useState(false);
257
276
  const senderEmail = thread.fromEmail ?? undefined;
258
277
 
259
- const { updateFlags } = useUpdateAddressFlags({
278
+ const { updateFlags, clearFlags } = useUpdateAddressFlags({
260
279
  addressId,
261
280
  senderEmail,
262
281
  });
@@ -315,6 +334,11 @@ function WiredPanel({
315
334
  [updateFlags],
316
335
  );
317
336
 
337
+ const handleReclassifyClear = useCallback(() => {
338
+ setReclassifyOpen(false);
339
+ clearFlags(["category"]);
340
+ }, [clearFlags]);
341
+
318
342
  // Every per-sender flag toggle PATCHes the sender's address row, so none of
319
343
  // them can be serviced until that row resolves. Leave them unwired until it
320
344
  // does: the panel renders an unwired quick action as disabled, so the flow is
@@ -383,7 +407,9 @@ function WiredPanel({
383
407
  <ReclassifyDialog
384
408
  isOpen={reclassifyOpen}
385
409
  current={data.category.value}
410
+ hasOverride={address?.flags?.category?.value != null}
386
411
  onSelect={handleReclassifySelect}
412
+ onClear={handleReclassifyClear}
387
413
  onCancel={() => setReclassifyOpen(false)}
388
414
  />
389
415
  </>
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Removing a sender's category override (#615).
3
+ *
4
+ * A sender flag is removed by naming its key in `clearFlags`. `category` is the
5
+ * flag that has no other way out: its value is a `MessageCategory` enum with no
6
+ * false-equivalent member, so the `{ value: false }` form the boolean flags use
7
+ * cannot express "no override", and the `null` form the schema documents is
8
+ * rejected by the request validator. Before this the reclassify picker was
9
+ * one-way — a reader could set an override and never take it back.
10
+ *
11
+ * Drives the real chain: the real `IntelligencePane`, the real
12
+ * `useUpdateAddressFlags` mutation, and a fetch mock that answers the way the
13
+ * server does. Both halves are asserted — the body that goes on the wire, and
14
+ * that the override is gone from what the panel renders.
15
+ */
16
+
17
+ import assert from "node:assert/strict";
18
+ import { afterEach, describe, it } from "node:test";
19
+ import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
20
+ import { createElement } from "react";
21
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
22
+ import {
23
+ makeAccount,
24
+ makeConfig,
25
+ makeThreadMessage,
26
+ } from "@/test-support/fixtures";
27
+ import { type HttpMock, mockFetch } from "@/test-support/http";
28
+ import { IntelligencePane } from "./IntelligencePane";
29
+
30
+ const thread = makeThreadMessage({ messageId: "msg-1", category: "personal" });
31
+
32
+ const addressWith = (
33
+ flags: RemitImapAddressResponse["flags"],
34
+ ): RemitImapAddressResponse => ({
35
+ addressId: "addr-1",
36
+ accountConfigId: "cfg-1",
37
+ displayName: "Alice",
38
+ localPart: "alice",
39
+ domain: "example.com",
40
+ normalizedEmail: "alice@example.com",
41
+ flags,
42
+ inboundCount: 6,
43
+ outboundCount: 1,
44
+ replyCount: 2,
45
+ lastInboundAt: 1_000,
46
+ lastReplyAt: 900,
47
+ createdAt: 0,
48
+ updatedAt: 0,
49
+ });
50
+
51
+ let harness: DomHarness | undefined;
52
+ let http: HttpMock;
53
+
54
+ afterEach(() => {
55
+ harness?.close();
56
+ harness = undefined;
57
+ http.restore();
58
+ });
59
+
60
+ describe("the reclassify picker on a sender that already carries an override", () => {
61
+ it("removes the override with clearFlags, and the panel stops claiming one", async () => {
62
+ let stored: RemitImapAddressResponse = addressWith({
63
+ category: { value: "newsletter", setAt: 10 },
64
+ });
65
+
66
+ http = mockFetch((call) => {
67
+ if (call.path.endsWith("/config")) {
68
+ return makeConfig([makeAccount({ accountId: "acc-1" })], {
69
+ semanticSearchEnabled: false,
70
+ });
71
+ }
72
+ if (call.path.endsWith("/addresses/search")) {
73
+ return { items: [stored] };
74
+ }
75
+ if (call.method === "PATCH" && call.path.endsWith("/addresses/addr-1")) {
76
+ stored = addressWith({});
77
+ return stored;
78
+ }
79
+ return { items: [] };
80
+ });
81
+
82
+ harness = createDomHarness();
83
+ harness.renderApp(
84
+ createElement(IntelligencePane, { thread, onClose: () => {} }),
85
+ );
86
+
87
+ await harness.waitFor(
88
+ () => harness?.text().includes("your override") === true,
89
+ "the panel never showed the sender's category override",
90
+ );
91
+
92
+ harness.click(harness.byText("button", "reclassify"));
93
+ harness.click(
94
+ harness.byText(
95
+ "button",
96
+ "Remove override — classify automatically again",
97
+ ),
98
+ );
99
+
100
+ await harness.waitFor(
101
+ () => http.to("/addresses/addr-1").length === 1,
102
+ "the remove control sent no request",
103
+ );
104
+
105
+ assert.deepEqual(http.to("/addresses/addr-1")[0].body, {
106
+ clearFlags: ["category"],
107
+ });
108
+
109
+ await harness.waitFor(
110
+ () => harness?.text().includes("your override") === false,
111
+ "the override survived its own removal",
112
+ );
113
+ });
114
+
115
+ it("offers no removal on a sender that carries no override", async () => {
116
+ http = mockFetch((call) => {
117
+ if (call.path.endsWith("/config")) {
118
+ return makeConfig([makeAccount({ accountId: "acc-1" })], {
119
+ semanticSearchEnabled: false,
120
+ });
121
+ }
122
+ if (call.path.endsWith("/addresses/search")) {
123
+ return { items: [addressWith({})] };
124
+ }
125
+ return { items: [] };
126
+ });
127
+
128
+ harness = createDomHarness();
129
+ harness.renderApp(
130
+ createElement(IntelligencePane, { thread, onClose: () => {} }),
131
+ );
132
+
133
+ await harness.waitFor(
134
+ () => harness?.text().includes("reclassify") === true,
135
+ "the panel never rendered the category section",
136
+ );
137
+
138
+ harness.click(harness.byText("button", "reclassify"));
139
+
140
+ assert.equal(harness.text().includes("Remove override"), false);
141
+ });
142
+ });
@@ -4,6 +4,7 @@ import {
4
4
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
5
  import type {
6
6
  AddressOperationsSearchAddressesResponse,
7
+ RemitImapAddressFlagKey,
7
8
  RemitImapAddressFlags,
8
9
  RemitImapUpdateAddressFlagsInput,
9
10
  } from "@remit/api-http-client/types.gen.ts";
@@ -25,15 +26,17 @@ interface MutationContext {
25
26
  }
26
27
 
27
28
  /**
28
- * Apply a flag-update patch onto an existing AddressFlags object. Each key in
29
- * `patch` is a flag update object (set) or `null` (remove). Mirrors the
30
- * server-side merge semantics so the optimistic cache matches what the API
31
- * returns. Typed loosely (the per-key flag value types differ — boolean for
32
- * most, a category enum for `category`) and re-narrowed at the return.
29
+ * Apply a flag update onto an existing AddressFlags object. Each key in
30
+ * `patch` is a flag update object; each key in `clear` is removed outright,
31
+ * after the patch, matching the server's ordering. Mirrors the server-side
32
+ * merge semantics so the optimistic cache matches what the API returns. Typed
33
+ * loosely (the per-key flag value types differ — boolean for most, a category
34
+ * enum for `category`) and re-narrowed at the return.
33
35
  */
34
36
  function applyFlagPatch(
35
37
  current: RemitImapAddressFlags | undefined,
36
38
  patch: RemitImapUpdateAddressFlagsInput,
39
+ clear: readonly RemitImapAddressFlagKey[] = [],
37
40
  ): RemitImapAddressFlags {
38
41
  const next: Record<string, unknown> = { ...(current ?? {}) };
39
42
  for (const [key, update] of Object.entries(patch)) {
@@ -45,6 +48,9 @@ function applyFlagPatch(
45
48
  next[key] = { ...update, setAt: update.setAt ?? Date.now() };
46
49
  }
47
50
  }
51
+ for (const key of clear) {
52
+ delete next[key];
53
+ }
48
54
  return next as RemitImapAddressFlags;
49
55
  }
50
56
 
@@ -69,6 +75,7 @@ export function useUpdateAddressFlags({
69
75
  ...addressDetailOperationsUpdateAddressMutation(),
70
76
  onMutate: async (vars): Promise<MutationContext> => {
71
77
  const patch = vars.body.flags ?? {};
78
+ const clear = vars.body.clearFlags ?? [];
72
79
  await queryClient.cancelQueries({ queryKey: addressCacheKey });
73
80
 
74
81
  const previous =
@@ -84,7 +91,7 @@ export function useUpdateAddressFlags({
84
91
  ...old,
85
92
  items: old.items.map((addr) =>
86
93
  addr.addressId === vars.path.addressId
87
- ? { ...addr, flags: applyFlagPatch(addr.flags, patch) }
94
+ ? { ...addr, flags: applyFlagPatch(addr.flags, patch, clear) }
88
95
  : addr,
89
96
  ),
90
97
  };
@@ -108,8 +115,11 @@ export function useUpdateAddressFlags({
108
115
  },
109
116
  });
110
117
 
111
- const updateFlags = useCallback(
112
- (flags: RemitImapUpdateAddressFlagsInput) => {
118
+ const submit = useCallback(
119
+ (body: {
120
+ flags?: RemitImapUpdateAddressFlagsInput;
121
+ clearFlags?: RemitImapAddressFlagKey[];
122
+ }) => {
113
123
  if (!addressId) {
114
124
  reportFatalError(
115
125
  new Error(
@@ -118,10 +128,25 @@ export function useUpdateAddressFlags({
118
128
  );
119
129
  return;
120
130
  }
121
- mutate({ path: { addressId }, body: { flags } });
131
+ mutate({ path: { addressId }, body });
122
132
  },
123
133
  [addressId, mutate, senderEmail],
124
134
  );
125
135
 
126
- return { updateFlags, isPending };
136
+ const updateFlags = useCallback(
137
+ (flags: RemitImapUpdateAddressFlagsInput) => submit({ flags }),
138
+ [submit],
139
+ );
140
+
141
+ /**
142
+ * Remove flags outright. This is the removal form for every flag whatever
143
+ * its value type: `category` holds an enum with no false-equivalent member,
144
+ * so `{ value: false }` cannot express "no override" for it.
145
+ */
146
+ const clearFlags = useCallback(
147
+ (keys: RemitImapAddressFlagKey[]) => submit({ clearFlags: keys }),
148
+ [submit],
149
+ );
150
+
151
+ return { updateFlags, clearFlags, isPending };
127
152
  }