@remit/ui 0.0.45 → 0.0.47

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/ui",
3
- "version": "0.0.45",
3
+ "version": "0.0.47",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -0,0 +1,249 @@
1
+ /**
2
+ * The move-to "New folder…" affordance: the option shows only when
3
+ * `onCreateFolder` is wired; choosing it reveals a name field, and a resolved
4
+ * create picks the new folder as the destination. Mounted against jsdom for the
5
+ * select interaction, the inline field state, and the async resolve. React is
6
+ * imported after the jsdom globals are installed so the controlled value tracker
7
+ * binds to jsdom's prototypes.
8
+ */
9
+ import assert from "node:assert/strict";
10
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
11
+ import type { JSDOM } from "jsdom";
12
+ import type {
13
+ act as reactAct,
14
+ createElement as reactCreateElement,
15
+ } from "react";
16
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
17
+ import type { FilterRule, FolderOption, PreviewCount } from "./filter-rule.js";
18
+ import type { FilterRuleEditor as FilterRuleEditorType } from "./filter-rule-editor.js";
19
+
20
+ const folders: FolderOption[] = [
21
+ { id: "mbx-inbox", label: "Inbox" },
22
+ { id: "mbx-archive", label: "Archive" },
23
+ ];
24
+
25
+ const rule: FilterRule = {
26
+ clauses: [{ id: "c1", field: "From", value: "a@example.com" }],
27
+ matchOperator: "all",
28
+ scope: "once",
29
+ };
30
+
31
+ const preview: PreviewCount = { status: "ready", count: 3 };
32
+
33
+ let dom: JSDOM;
34
+ let container: HTMLElement;
35
+ let root: Root;
36
+ let act: typeof reactAct;
37
+ let createElement: typeof reactCreateElement;
38
+ let createRoot: typeof reactCreateRoot;
39
+ let FilterRuleEditor: typeof FilterRuleEditorType;
40
+
41
+ before(async () => {
42
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
43
+ dom = new JSDOMCtor(
44
+ "<!doctype html><html><body><div id=root></div></body></html>",
45
+ { url: "http://localhost/", pretendToBeVisual: true },
46
+ );
47
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
48
+ globalThis.document = dom.window.document;
49
+ globalThis.HTMLElement = dom.window.HTMLElement;
50
+ globalThis.Element = dom.window.Element;
51
+ globalThis.Event = dom.window.Event;
52
+ Object.defineProperty(globalThis, "navigator", {
53
+ value: dom.window.navigator,
54
+ configurable: true,
55
+ });
56
+ (
57
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
58
+ ).IS_REACT_ACT_ENVIRONMENT = true;
59
+
60
+ const react = await import("react");
61
+ act = react.act;
62
+ createElement = react.createElement;
63
+ ({ createRoot } = await import("react-dom/client"));
64
+ ({ FilterRuleEditor } = await import("./filter-rule-editor.js"));
65
+ });
66
+
67
+ after(() => {
68
+ dom.window.close();
69
+ });
70
+
71
+ beforeEach(() => {
72
+ container = dom.window.document.getElementById(
73
+ "root",
74
+ ) as unknown as HTMLElement;
75
+ container.innerHTML = "";
76
+ root = createRoot(container);
77
+ });
78
+
79
+ afterEach(() => {
80
+ act(() => {
81
+ root.unmount();
82
+ });
83
+ });
84
+
85
+ function destinationSelect(): HTMLSelectElement {
86
+ return container.querySelector(
87
+ 'select[aria-label="Destination folder"]',
88
+ ) as HTMLSelectElement;
89
+ }
90
+
91
+ function createOption(): HTMLOptionElement | undefined {
92
+ return Array.from(destinationSelect().querySelectorAll("option")).find(
93
+ (option) => option.textContent?.includes("New folder"),
94
+ );
95
+ }
96
+
97
+ function chooseCreateOption() {
98
+ const select = destinationSelect();
99
+ const value = createOption()?.value ?? "";
100
+ const setter = Object.getOwnPropertyDescriptor(
101
+ dom.window.HTMLSelectElement.prototype,
102
+ "value",
103
+ )?.set;
104
+ setter?.call(select, value);
105
+ act(() => {
106
+ select.dispatchEvent(new dom.window.Event("change", { bubbles: true }));
107
+ });
108
+ }
109
+
110
+ function setInput(el: HTMLInputElement, value: string) {
111
+ const setter = Object.getOwnPropertyDescriptor(
112
+ dom.window.HTMLInputElement.prototype,
113
+ "value",
114
+ )?.set;
115
+ setter?.call(el, value);
116
+ act(() => {
117
+ el.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
118
+ });
119
+ }
120
+
121
+ function button(text: string): HTMLButtonElement | undefined {
122
+ return Array.from(container.querySelectorAll("button")).find(
123
+ (candidate) => candidate.textContent?.trim() === text,
124
+ );
125
+ }
126
+
127
+ function mount(overrides: {
128
+ onCreateFolder?: (name: string) => Promise<FolderOption>;
129
+ onChangeMove?: (id: string) => void;
130
+ }) {
131
+ act(() => {
132
+ root.render(
133
+ createElement(FilterRuleEditor, {
134
+ rule,
135
+ folders,
136
+ preview,
137
+ onCommit: () => {},
138
+ onCancel: () => {},
139
+ ...overrides,
140
+ }),
141
+ );
142
+ });
143
+ }
144
+
145
+ describe("FilterRuleEditor new-folder option", () => {
146
+ it("does not offer the create option without onCreateFolder", () => {
147
+ mount({});
148
+ assert.equal(createOption(), undefined);
149
+ });
150
+
151
+ it("offers the create option when onCreateFolder is wired", () => {
152
+ mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
153
+ assert.ok(createOption(), "the + New folder… option is present");
154
+ });
155
+
156
+ it("reveals the name field only after the option is chosen", () => {
157
+ mount({ onCreateFolder: async () => ({ id: "x", label: "x" }) });
158
+ assert.equal(
159
+ container.querySelector('input[aria-label="New folder name"]'),
160
+ null,
161
+ );
162
+ chooseCreateOption();
163
+ assert.ok(
164
+ container.querySelector('input[aria-label="New folder name"]'),
165
+ "the name field appears",
166
+ );
167
+ });
168
+
169
+ it("creates the folder and selects it as the destination", async () => {
170
+ const moved: string[] = [];
171
+ mount({
172
+ onChangeMove: (id) => moved.push(id),
173
+ onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
174
+ });
175
+ chooseCreateOption();
176
+ const nameInput = container.querySelector(
177
+ 'input[aria-label="New folder name"]',
178
+ ) as HTMLInputElement;
179
+ setInput(nameInput, "Receipts");
180
+ await act(async () => {
181
+ button("Create folder")?.click();
182
+ });
183
+ assert.deepEqual(moved, ["mbx-created"]);
184
+ });
185
+
186
+ it("keeps the name field open and surfaces the rejection message when create fails", async () => {
187
+ const moved: string[] = [];
188
+ mount({
189
+ onChangeMove: (id) => moved.push(id),
190
+ onCreateFolder: async () => {
191
+ throw new Error("A folder with that name already exists.");
192
+ },
193
+ });
194
+ chooseCreateOption();
195
+ const nameInput = container.querySelector(
196
+ 'input[aria-label="New folder name"]',
197
+ ) as HTMLInputElement;
198
+ setInput(nameInput, "Archive");
199
+ await act(async () => {
200
+ button("Create folder")?.click();
201
+ });
202
+ assert.deepEqual(moved, []);
203
+ assert.ok(
204
+ container.querySelector('input[aria-label="New folder name"]'),
205
+ "the name field stays open",
206
+ );
207
+ assert.match(
208
+ container.querySelector('[role="alert"]')?.textContent ?? "",
209
+ /already exists/,
210
+ );
211
+ });
212
+
213
+ it("falls back to the generic message for a non-Error rejection", async () => {
214
+ mount({
215
+ onCreateFolder: async () => {
216
+ throw "opaque";
217
+ },
218
+ });
219
+ chooseCreateOption();
220
+ const nameInput = container.querySelector(
221
+ 'input[aria-label="New folder name"]',
222
+ ) as HTMLInputElement;
223
+ setInput(nameInput, "Receipts");
224
+ await act(async () => {
225
+ button("Create folder")?.click();
226
+ });
227
+ assert.match(
228
+ container.querySelector('[role="alert"]')?.textContent ?? "",
229
+ /Couldn't create that folder/,
230
+ );
231
+ });
232
+
233
+ it("cancels the create field and leaves the destination unchanged", () => {
234
+ const moved: string[] = [];
235
+ mount({
236
+ onChangeMove: (id) => moved.push(id),
237
+ onCreateFolder: async (name) => ({ id: "mbx-created", label: name }),
238
+ });
239
+ chooseCreateOption();
240
+ act(() => {
241
+ button("Cancel")?.click();
242
+ });
243
+ assert.equal(
244
+ container.querySelector('input[aria-label="New folder name"]'),
245
+ null,
246
+ );
247
+ assert.deepEqual(moved, []);
248
+ });
249
+ });
@@ -10,6 +10,7 @@ import {
10
10
  demoSenderFallbackRule,
11
11
  demoVocabularyRule,
12
12
  type FilterRule,
13
+ type FolderOption,
13
14
  type PreviewCount,
14
15
  type RuleClause,
15
16
  } from "./filter-rule.js";
@@ -50,9 +51,11 @@ const READY = (count: number, stale?: boolean): PreviewCount => ({
50
51
  function LiveEditor({
51
52
  initialRule,
52
53
  semanticAvailable = true,
54
+ onCreateFolder,
53
55
  }: {
54
56
  initialRule: FilterRule;
55
57
  semanticAvailable?: boolean;
58
+ onCreateFolder?: (name: string) => Promise<FolderOption>;
56
59
  }) {
57
60
  const [rule, setRule] = useState<FilterRule>(initialRule);
58
61
  const [clauseEdit, setClauseEdit] = useState<ClauseEditState | undefined>();
@@ -132,6 +135,7 @@ function LiveEditor({
132
135
  preview={preview}
133
136
  semanticAvailable={semanticAvailable}
134
137
  clauseEdit={clauseEdit}
138
+ onCreateFolder={onCreateFolder}
135
139
  onCommit={() => {}}
136
140
  onCancel={() => {}}
137
141
  {...handlers}
@@ -144,6 +148,28 @@ export const Interactive: Story = {
144
148
  render: () => <LiveEditor initialRule={demoRule} />,
145
149
  };
146
150
 
151
+ let newFolderSeq = 0;
152
+ const mockCreateFolder = (name: string): Promise<FolderOption> =>
153
+ new Promise((resolve) => {
154
+ newFolderSeq += 1;
155
+ setTimeout(
156
+ () => resolve({ id: `mbx-new-${newFolderSeq}`, label: name }),
157
+ 400,
158
+ );
159
+ });
160
+
161
+ /**
162
+ * The move destination offers a "+ New folder…" option because `onCreateFolder`
163
+ * is wired. Choosing it reveals a name field; on resolve the folder is added to
164
+ * the select and picked as the destination. Without the prop the option never
165
+ * shows — the editor stays data-agnostic.
166
+ */
167
+ export const WithNewFolderOption: Story = {
168
+ render: () => (
169
+ <LiveEditor initialRule={demoRule} onCreateFolder={mockCreateFolder} />
170
+ ),
171
+ };
172
+
147
173
  /** Literal clauses joined with "or", including the ticket-B ListId and FromDomain fields. */
148
174
  export const AnyOfTheseClauses: Story = {
149
175
  render: () => <LiveEditor initialRule={demoVocabularyRule} />,
@@ -1,4 +1,4 @@
1
- import { Fragment, type ReactNode } from "react";
1
+ import { Fragment, type ReactNode, useMemo, useState } from "react";
2
2
  import { BottomSheet } from "./bottom-sheet.js";
3
3
  import { Button } from "./button.js";
4
4
  import { Dialog } from "./dialog.js";
@@ -77,6 +77,13 @@ export interface FilterRuleEditorProps {
77
77
  onRemoveWiden?: () => void;
78
78
  onChangeMatchOperator?: (operator: MatchOperator) => void;
79
79
  onChangeMove?: (mailboxId: string) => void;
80
+ /**
81
+ * Create a new destination folder from within the editor. Given a folder
82
+ * name, resolves to the created folder once the backend has queued it. When
83
+ * absent, the "New folder…" option is not offered — the editor stays
84
+ * data-agnostic, so stories and consumers without wiring render unchanged.
85
+ */
86
+ onCreateFolder?: (name: string) => Promise<FolderOption>;
80
87
  onChangeScope?: (scope: RuleScope) => void;
81
88
  onChangeName?: (name: string) => void;
82
89
  onChangeUntil?: (date: string) => void;
@@ -95,6 +102,149 @@ const scopeOptions: { value: RuleScope; label: string }[] = [
95
102
  { value: "until", label: "Until a date" },
96
103
  ];
97
104
 
105
+ const CREATE_FOLDER_VALUE = "__filter_create_folder__";
106
+
107
+ /**
108
+ * The move-to destination select, plus an inline "New folder…" affordance when
109
+ * the consumer wires `onCreateFolder`. Selecting the create option reveals a
110
+ * name field; on resolve the new folder is added to the local option set (so it
111
+ * is selectable even before the caller's folder list refetches) and picked as
112
+ * the destination. Without `onCreateFolder` this is the bare select.
113
+ */
114
+ function MoveDestinationField({
115
+ folders,
116
+ value,
117
+ onChangeMove,
118
+ onCreateFolder,
119
+ }: {
120
+ folders: FolderOption[];
121
+ value: string;
122
+ onChangeMove?: (mailboxId: string) => void;
123
+ onCreateFolder?: (name: string) => Promise<FolderOption>;
124
+ }) {
125
+ const [creating, setCreating] = useState(false);
126
+ const [name, setName] = useState("");
127
+ const [pending, setPending] = useState(false);
128
+ const [error, setError] = useState<string>();
129
+ const [createdFolders, setCreatedFolders] = useState<FolderOption[]>([]);
130
+
131
+ const options = useMemo(() => {
132
+ const known = new Set(folders.map((folder) => folder.id));
133
+ return [
134
+ ...folders,
135
+ ...createdFolders.filter((folder) => !known.has(folder.id)),
136
+ ];
137
+ }, [folders, createdFolders]);
138
+
139
+ const handleSelectChange = (next: string) => {
140
+ if (next === CREATE_FOLDER_VALUE) {
141
+ setError(undefined);
142
+ setCreating(true);
143
+ return;
144
+ }
145
+ onChangeMove?.(next);
146
+ };
147
+
148
+ const submit = () => {
149
+ if (!onCreateFolder) return;
150
+ const trimmed = name.trim();
151
+ if (trimmed === "") return;
152
+ setPending(true);
153
+ setError(undefined);
154
+ onCreateFolder(trimmed)
155
+ .then((folder) => {
156
+ setCreatedFolders((prev) =>
157
+ prev.some((entry) => entry.id === folder.id)
158
+ ? prev
159
+ : [...prev, folder],
160
+ );
161
+ onChangeMove?.(folder.id);
162
+ setCreating(false);
163
+ setName("");
164
+ setPending(false);
165
+ })
166
+ .catch((error: unknown) => {
167
+ setError(
168
+ error instanceof Error
169
+ ? error.message
170
+ : "Couldn't create that folder. Please try again.",
171
+ );
172
+ setPending(false);
173
+ });
174
+ };
175
+
176
+ const cancel = () => {
177
+ setCreating(false);
178
+ setName("");
179
+ setError(undefined);
180
+ };
181
+
182
+ return (
183
+ <div className="space-y-2">
184
+ <Select
185
+ aria-label="Destination folder"
186
+ value={value}
187
+ onChange={(event) => handleSelectChange(event.target.value)}
188
+ >
189
+ <option value="">Choose a folder…</option>
190
+ {options.map((folder) => (
191
+ <option key={folder.id} value={folder.id}>
192
+ {folder.label}
193
+ </option>
194
+ ))}
195
+ {onCreateFolder && (
196
+ <option value={CREATE_FOLDER_VALUE}>+ New folder…</option>
197
+ )}
198
+ </Select>
199
+ {creating && (
200
+ <div className="space-y-2 rounded-md border border-line bg-surface-sunken p-2">
201
+ <Input
202
+ value={name}
203
+ onChange={(event) => setName(event.target.value)}
204
+ placeholder="Folder name"
205
+ aria-label="New folder name"
206
+ disabled={pending}
207
+ autoFocus
208
+ onKeyDown={(event) => {
209
+ if (event.key === "Enter") {
210
+ event.preventDefault();
211
+ submit();
212
+ }
213
+ if (event.key === "Escape") {
214
+ event.preventDefault();
215
+ cancel();
216
+ }
217
+ }}
218
+ />
219
+ {error && (
220
+ <p className="text-2xs text-danger" role="alert">
221
+ {error}
222
+ </p>
223
+ )}
224
+ <div className="flex gap-2">
225
+ <Button
226
+ variant="primary"
227
+ size="sm"
228
+ onClick={submit}
229
+ disabled={pending || name.trim() === ""}
230
+ >
231
+ {pending ? "Creating…" : "Create folder"}
232
+ </Button>
233
+ <Button
234
+ variant="ghost"
235
+ size="sm"
236
+ onClick={cancel}
237
+ disabled={pending}
238
+ >
239
+ Cancel
240
+ </Button>
241
+ </div>
242
+ </div>
243
+ )}
244
+ </div>
245
+ );
246
+ }
247
+
98
248
  export function FilterRuleEditor({
99
249
  rule,
100
250
  folders,
@@ -115,6 +265,7 @@ export function FilterRuleEditor({
115
265
  onRemoveWiden,
116
266
  onChangeMatchOperator,
117
267
  onChangeMove,
268
+ onCreateFolder,
118
269
  onChangeScope,
119
270
  onChangeName,
120
271
  onChangeUntil,
@@ -204,18 +355,12 @@ export function FilterRuleEditor({
204
355
 
205
356
  <section className="space-y-2">
206
357
  <p className="text-xs font-medium text-fg-muted">Move matches to</p>
207
- <Select
208
- aria-label="Destination folder"
358
+ <MoveDestinationField
359
+ folders={folders}
209
360
  value={rule.moveMailboxId ?? ""}
210
- onChange={(e) => onChangeMove?.(e.target.value)}
211
- >
212
- <option value="">Choose a folder…</option>
213
- {folders.map((folder) => (
214
- <option key={folder.id} value={folder.id}>
215
- {folder.label}
216
- </option>
217
- ))}
218
- </Select>
361
+ onChangeMove={onChangeMove}
362
+ onCreateFolder={onCreateFolder}
363
+ />
219
364
  <div className="flex items-center gap-2 pt-0.5">
220
365
  <span className="inline-flex items-center gap-1.5 rounded-full bg-surface-sunken px-2 py-0.5 text-2xs font-medium text-fg-muted">
221
366
  label them…
@@ -0,0 +1,210 @@
1
+ /**
2
+ * The create-and-move affordance: a search that names no existing folder offers
3
+ * a create row only when `onCreateFolder` is wired; resolving it selects the new
4
+ * folder. Mounted against jsdom since it turns on typed search state and an
5
+ * async resolve. React is imported after the jsdom globals are installed so the
6
+ * controlled-input value tracker binds to jsdom's prototypes.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
10
+ import type { JSDOM } from "jsdom";
11
+ import type {
12
+ act as reactAct,
13
+ createElement as reactCreateElement,
14
+ } from "react";
15
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
16
+ import type {
17
+ MoveMailboxOption,
18
+ MoveMailboxPicker as MoveMailboxPickerType,
19
+ } from "./move-mailbox-picker.js";
20
+
21
+ const mailboxes: MoveMailboxOption[] = [
22
+ { id: "archive", label: "Archive" },
23
+ { id: "trash", label: "Trash" },
24
+ ];
25
+
26
+ let dom: JSDOM;
27
+ let container: HTMLElement;
28
+ let root: Root;
29
+ let act: typeof reactAct;
30
+ let createElement: typeof reactCreateElement;
31
+ let createRoot: typeof reactCreateRoot;
32
+ let MoveMailboxPicker: typeof MoveMailboxPickerType;
33
+
34
+ before(async () => {
35
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
36
+ dom = new JSDOMCtor(
37
+ "<!doctype html><html><body><div id=root></div></body></html>",
38
+ { url: "http://localhost/", pretendToBeVisual: true },
39
+ );
40
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
41
+ globalThis.document = dom.window.document;
42
+ globalThis.HTMLElement = dom.window.HTMLElement;
43
+ globalThis.Element = dom.window.Element;
44
+ globalThis.Event = dom.window.Event;
45
+ Object.defineProperty(globalThis, "navigator", {
46
+ value: dom.window.navigator,
47
+ configurable: true,
48
+ });
49
+ (
50
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
51
+ ).IS_REACT_ACT_ENVIRONMENT = true;
52
+
53
+ const react = await import("react");
54
+ act = react.act;
55
+ createElement = react.createElement;
56
+ ({ createRoot } = await import("react-dom/client"));
57
+ ({ MoveMailboxPicker } = await import("./move-mailbox-picker.js"));
58
+ });
59
+
60
+ after(() => {
61
+ dom.window.close();
62
+ });
63
+
64
+ beforeEach(() => {
65
+ container = dom.window.document.getElementById(
66
+ "root",
67
+ ) as unknown as HTMLElement;
68
+ container.innerHTML = "";
69
+ root = createRoot(container);
70
+ });
71
+
72
+ afterEach(() => {
73
+ act(() => {
74
+ root.unmount();
75
+ });
76
+ });
77
+
78
+ function typeSearch(value: string) {
79
+ const input = container.querySelector(
80
+ 'input[aria-label="Filter folders"]',
81
+ ) as HTMLInputElement;
82
+ const setter = Object.getOwnPropertyDescriptor(
83
+ dom.window.HTMLInputElement.prototype,
84
+ "value",
85
+ )?.set;
86
+ setter?.call(input, value);
87
+ act(() => {
88
+ input.dispatchEvent(new dom.window.Event("input", { bubbles: true }));
89
+ });
90
+ }
91
+
92
+ function createRow(): HTMLButtonElement | null {
93
+ return (
94
+ Array.from(container.querySelectorAll("button")).find((button) =>
95
+ button.textContent?.startsWith("Create "),
96
+ ) ?? null
97
+ );
98
+ }
99
+
100
+ describe("MoveMailboxPicker create-and-move", () => {
101
+ it("offers no create row without onCreateFolder, even when nothing matches", () => {
102
+ act(() => {
103
+ root.render(
104
+ createElement(MoveMailboxPicker, {
105
+ mailboxes,
106
+ onSelect: () => {},
107
+ }),
108
+ );
109
+ });
110
+ typeSearch("Taxes");
111
+ assert.equal(createRow(), null);
112
+ });
113
+
114
+ it("offers a create row when the query names no existing folder", () => {
115
+ act(() => {
116
+ root.render(
117
+ createElement(MoveMailboxPicker, {
118
+ mailboxes,
119
+ onSelect: () => {},
120
+ onCreateFolder: async () => ({ id: "new", label: "Taxes" }),
121
+ }),
122
+ );
123
+ });
124
+ typeSearch("Taxes");
125
+ const row = createRow();
126
+ assert.ok(row, "the create row is present");
127
+ assert.match(row?.textContent ?? "", /Create "Taxes"/);
128
+ });
129
+
130
+ it("hides the create row when the query exactly names an existing folder", () => {
131
+ act(() => {
132
+ root.render(
133
+ createElement(MoveMailboxPicker, {
134
+ mailboxes,
135
+ onSelect: () => {},
136
+ onCreateFolder: async () => ({ id: "new", label: "Archive" }),
137
+ }),
138
+ );
139
+ });
140
+ typeSearch("archive");
141
+ assert.equal(createRow(), null);
142
+ });
143
+
144
+ it("creates the folder and selects it in one step", async () => {
145
+ const selected: string[] = [];
146
+ act(() => {
147
+ root.render(
148
+ createElement(MoveMailboxPicker, {
149
+ mailboxes,
150
+ onSelect: (id: string) => selected.push(id),
151
+ onCreateFolder: async (name: string) => ({
152
+ id: "created-taxes",
153
+ label: name,
154
+ }),
155
+ }),
156
+ );
157
+ });
158
+ typeSearch("Taxes");
159
+ await act(async () => {
160
+ createRow()?.click();
161
+ });
162
+ assert.deepEqual(selected, ["created-taxes"]);
163
+ });
164
+
165
+ it("surfaces a validation rejection message inline without selecting anything", async () => {
166
+ const selected: string[] = [];
167
+ act(() => {
168
+ root.render(
169
+ createElement(MoveMailboxPicker, {
170
+ mailboxes,
171
+ onSelect: (id: string) => selected.push(id),
172
+ onCreateFolder: async () => {
173
+ throw new Error('A folder name can\'t contain "/".');
174
+ },
175
+ }),
176
+ );
177
+ });
178
+ typeSearch("Work/Receipts");
179
+ await act(async () => {
180
+ createRow()?.click();
181
+ });
182
+ assert.deepEqual(selected, []);
183
+ assert.match(
184
+ container.querySelector('[role="alert"]')?.textContent ?? "",
185
+ /can't contain/,
186
+ );
187
+ });
188
+
189
+ it("falls back to the generic message for a non-Error rejection", async () => {
190
+ act(() => {
191
+ root.render(
192
+ createElement(MoveMailboxPicker, {
193
+ mailboxes,
194
+ onSelect: () => {},
195
+ onCreateFolder: async () => {
196
+ throw "opaque";
197
+ },
198
+ }),
199
+ );
200
+ });
201
+ typeSearch("Taxes");
202
+ await act(async () => {
203
+ createRow()?.click();
204
+ });
205
+ assert.match(
206
+ container.querySelector('[role="alert"]')?.textContent ?? "",
207
+ /Couldn't create that folder/,
208
+ );
209
+ });
210
+ });
@@ -74,3 +74,41 @@ export const Autofocus: Story = {
74
74
  <MoveMailboxPicker mailboxes={mailboxes} onSelect={() => {}} autoFocus />
75
75
  ),
76
76
  };
77
+
78
+ let createdSeq = 0;
79
+ const mockCreateFolder = (name: string): Promise<MoveMailboxOption> =>
80
+ new Promise((resolve) => {
81
+ createdSeq += 1;
82
+ setTimeout(
83
+ () => resolve({ id: `created-${createdSeq}`, label: name }),
84
+ 400,
85
+ );
86
+ });
87
+
88
+ const CreatePicker = () => {
89
+ const [moved, setMoved] = useState<string | null>(null);
90
+ return (
91
+ <div className="flex flex-col">
92
+ <MoveMailboxPicker
93
+ mailboxes={mailboxes}
94
+ onSelect={setMoved}
95
+ onCreateFolder={mockCreateFolder}
96
+ />
97
+ {moved && (
98
+ <p className="border-t border-line px-3 py-2 text-xs text-fg-muted">
99
+ Moved to {moved}
100
+ </p>
101
+ )}
102
+ </div>
103
+ );
104
+ };
105
+
106
+ /**
107
+ * With `onCreateFolder` wired, a search that names no existing folder offers a
108
+ * create-and-move row at the bottom. Type e.g. "Taxes", then pick the create
109
+ * row — the folder is created and the message moved into it in one step.
110
+ */
111
+ export const CreateAndMove: Story = {
112
+ name: "Create folder from search",
113
+ render: () => <CreatePicker />,
114
+ };
@@ -42,6 +42,12 @@ export interface MoveMailboxPickerLabels {
42
42
  emptyMessage?: (query: string) => string;
43
43
  /** Builds the accessible label for a selectable row, e.g. `Move to X`. */
44
44
  optionLabel?: (label: string) => string;
45
+ /** Builds the label for the create-and-move row, e.g. `Create "Receipts"`. */
46
+ createLabel?: (query: string) => string;
47
+ /** Shown on the create row while the folder is being created. */
48
+ createPending?: string;
49
+ /** Shown when creating the folder fails. */
50
+ createError?: string;
45
51
  }
46
52
 
47
53
  export interface MoveMailboxPickerProps {
@@ -51,6 +57,13 @@ export interface MoveMailboxPickerProps {
51
57
  */
52
58
  mailboxes: readonly MoveMailboxOption[];
53
59
  onSelect: (mailboxId: string) => void;
60
+ /**
61
+ * Create a folder named by the current search query. When provided and the
62
+ * query names no existing folder, a create-and-move row is offered at the
63
+ * bottom of the list; resolving it yields the new folder, which is selected
64
+ * (moved into) immediately. Absent means no create affordance renders.
65
+ */
66
+ onCreateFolder?: (name: string) => Promise<MoveMailboxOption>;
54
67
  /**
55
68
  * Called when the user dismisses the picker via Escape. Trigger consumers
56
69
  * use this to close their popover/drawer; the picker never owns
@@ -78,6 +91,9 @@ const defaultLabels: Required<MoveMailboxPickerLabels> = {
78
91
  currentTag: "current",
79
92
  emptyMessage: (query) => `No folders match "${query}"`,
80
93
  optionLabel: (label) => `Move to ${label}`,
94
+ createLabel: (query) => `Create "${query}"`,
95
+ createPending: "Creating folder…",
96
+ createError: "Couldn't create that folder. Please try again.",
81
97
  };
82
98
 
83
99
  const findFirstSelectable = (options: readonly MoveMailboxOption[]): number => {
@@ -123,12 +139,15 @@ const matchesQuery = (option: MoveMailboxOption, query: string): boolean => {
123
139
  export const MoveMailboxPicker = ({
124
140
  mailboxes,
125
141
  onSelect,
142
+ onCreateFolder,
126
143
  onCancel,
127
144
  autoFocus = false,
128
145
  labels,
129
146
  }: MoveMailboxPickerProps) => {
130
147
  const text = { ...defaultLabels, ...labels };
131
148
  const [query, setQuery] = useState("");
149
+ const [creating, setCreating] = useState(false);
150
+ const [createError, setCreateError] = useState<string>();
132
151
  const [focusedIndex, setFocusedIndex] = useState<number>(() =>
133
152
  findFirstSelectable(mailboxes),
134
153
  );
@@ -176,6 +195,30 @@ export const MoveMailboxPicker = ({
176
195
  onSelect(target.id);
177
196
  }, [focusedIndex, filtered, onSelect]);
178
197
 
198
+ const showCreate =
199
+ !!onCreateFolder &&
200
+ trimmedQuery.length > 0 &&
201
+ !mailboxes.some((mailbox) => mailbox.label.toLowerCase() === trimmedQuery);
202
+
203
+ const handleCreate = useCallback(() => {
204
+ if (!onCreateFolder) return;
205
+ const name = query.trim();
206
+ if (name === "") return;
207
+ setCreating(true);
208
+ setCreateError(undefined);
209
+ onCreateFolder(name)
210
+ .then((folder) => {
211
+ onSelect(folder.id);
212
+ setCreating(false);
213
+ })
214
+ .catch((error: unknown) => {
215
+ setCreateError(
216
+ error instanceof Error ? error.message : text.createError,
217
+ );
218
+ setCreating(false);
219
+ });
220
+ }, [onCreateFolder, query, onSelect, text.createError]);
221
+
179
222
  const handleListKeyDown = useCallback(
180
223
  (event: ReactKeyboardEvent<HTMLElement>) => {
181
224
  switch (event.key) {
@@ -307,6 +350,28 @@ export const MoveMailboxPicker = ({
307
350
  })
308
351
  )}
309
352
  </div>
353
+ {showCreate && (
354
+ <div className="border-t border-line p-1">
355
+ <button
356
+ type="button"
357
+ onClick={handleCreate}
358
+ disabled={creating}
359
+ className={cn(
360
+ ROW_BASE,
361
+ "font-medium text-accent-2 hover:bg-surface-raised disabled:opacity-60",
362
+ )}
363
+ >
364
+ <span className="truncate">
365
+ {creating ? text.createPending : text.createLabel(query.trim())}
366
+ </span>
367
+ </button>
368
+ {createError && (
369
+ <p className="px-3 py-1 text-xs text-danger" role="alert">
370
+ {createError}
371
+ </p>
372
+ )}
373
+ </div>
374
+ )}
310
375
  </div>
311
376
  );
312
377
  };
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  CheckCircle2,
3
+ Clock,
3
4
  CloudOff,
4
5
  Download,
5
6
  ExternalLink,
@@ -152,6 +153,39 @@ export function SelfUpdateSection({
152
153
  </SectionRow>
153
154
  );
154
155
 
156
+ case "neverChecked":
157
+ return (
158
+ <SectionRow>
159
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
160
+ <div className="flex min-w-0 items-start gap-2">
161
+ <Clock
162
+ className="mt-0.5 size-4 shrink-0 text-fg-subtle"
163
+ aria-hidden
164
+ />
165
+ <p className="text-sm text-fg-muted">
166
+ No update check has run yet. Remit checks for updates on its
167
+ own in the background.
168
+ {state.lastCheckedAt !== undefined && (
169
+ <span className="text-fg-subtle">
170
+ {" "}
171
+ Last checked{" "}
172
+ {formatRelativeCheck(state.lastCheckedAt, now)}.
173
+ </span>
174
+ )}
175
+ </p>
176
+ </div>
177
+ <Button
178
+ variant="secondary"
179
+ size="sm"
180
+ className="shrink-0"
181
+ onClick={handleCheck}
182
+ >
183
+ Check now
184
+ </Button>
185
+ </div>
186
+ </SectionRow>
187
+ );
188
+
155
189
  case "checkFailed":
156
190
  return (
157
191
  <SectionRow>
@@ -165,6 +165,25 @@ describe("SelfUpdateSection", () => {
165
165
  assert.doesNotMatch(html, /disabled=/);
166
166
  });
167
167
 
168
+ it("states a never-checked instance without a spinner or an alert", () => {
169
+ // The version is the literal the backend emits before any check has run —
170
+ // no state file, so no authoritative version yet.
171
+ const html = section({ status: "neverChecked", version: "unknown" });
172
+ assert.match(html, /No update check has run yet/);
173
+ assert.match(html, /Check now/);
174
+ assert.doesNotMatch(html, /animate-spin/);
175
+ assert.doesNotMatch(html, /role="alert"/);
176
+ });
177
+
178
+ it("shows the last-checked time on a never-checked state when one is known", () => {
179
+ const html = section({
180
+ status: "neverChecked",
181
+ version: "unknown",
182
+ lastCheckedAt: NOW - 3_600_000,
183
+ });
184
+ assert.match(html, /Last checked/);
185
+ });
186
+
168
187
  it("renders a failed rollback verbatim and never claims a version is running", () => {
169
188
  const html = section({
170
189
  status: "rollbackFailed",
@@ -52,6 +52,16 @@ export const Checking: Story = {
52
52
  args: withState({ status: "checking", version: CURRENT }),
53
53
  };
54
54
 
55
+ /**
56
+ * A fresh, configured instance before its first automatic check has landed. The
57
+ * honest resting state, not a spinner: it says a check has not run and that Remit
58
+ * checks on its own, and offers a manual check. This is what every install shows
59
+ * until the updater writes its first result.
60
+ */
61
+ export const NeverChecked: Story = {
62
+ args: withState({ status: "neverChecked", version: "unknown" }),
63
+ };
64
+
55
65
  /**
56
66
  * An update exists. It is stated — version, date, one line of what changes, a
57
67
  * link to the full notes — and then it waits. Nothing here follows the user
@@ -28,6 +28,12 @@ export type UpdateRunId = string;
28
28
  export type SelfUpdateState =
29
29
  | { status: "upToDate"; version: string; checkedAt: number }
30
30
  | { status: "checking"; version: string }
31
+ /**
32
+ * A configured instance whose first automatic check has not landed yet. It is
33
+ * its own resting state, never a spinner: the updater checks on a cadence, and
34
+ * this is the honest account of the gap before the first result arrives.
35
+ */
36
+ | { status: "neverChecked"; version: string; lastCheckedAt?: number }
31
37
  | {
32
38
  status: "checkFailed";
33
39
  version: string;