@remit/ui 0.0.143 → 0.0.145
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/attendee-row.render.test.ts +107 -0
- package/src/components/attendee-row.stories.tsx +48 -0
- package/src/components/attendee-row.tsx +107 -9
- package/src/components/event-detail.render.test.ts +22 -0
- package/src/components/event-detail.tsx +21 -1
- package/src/components/filter-rule-editor.create.test.ts +24 -1
- package/src/components/filter-rule-editor.stories.tsx +31 -1
- package/src/components/filter-rule-editor.tsx +2 -2
- package/src/components/folder-tree-picker.stories.tsx +23 -0
- package/src/lib/folder-tree.test.ts +48 -0
- package/src/lib/folder-tree.ts +24 -9
package/package.json
CHANGED
|
@@ -54,6 +54,52 @@ describe("AttendeeRow", () => {
|
|
|
54
54
|
);
|
|
55
55
|
assert.doesNotMatch(html, /Organiser/);
|
|
56
56
|
});
|
|
57
|
+
|
|
58
|
+
it("renders exactly what it always did for a caller with nothing to open", () => {
|
|
59
|
+
const html = renderToString(
|
|
60
|
+
createElement(AttendeeRow, { attendee: attendees[1] }),
|
|
61
|
+
);
|
|
62
|
+
assert.match(html, /^<div class="flex min-h-9 items-center gap-2\.5">/);
|
|
63
|
+
assert.match(html, /<span class="min-w-0 flex-1">/);
|
|
64
|
+
assert.doesNotMatch(
|
|
65
|
+
html,
|
|
66
|
+
/<button|aria-expanded|w-full|text-left|relative/,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("becomes a control where a name leads somewhere", () => {
|
|
71
|
+
const html = renderToString(
|
|
72
|
+
createElement(AttendeeRow, {
|
|
73
|
+
attendee: attendees[0],
|
|
74
|
+
onActivate: () => undefined,
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
assert.match(html, /<button/);
|
|
78
|
+
assert.match(html, /aria-expanded="false"/);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("says the row is the one standing open", () => {
|
|
82
|
+
const html = renderToString(
|
|
83
|
+
createElement(AttendeeRow, {
|
|
84
|
+
attendee: attendees[0],
|
|
85
|
+
active: true,
|
|
86
|
+
onActivate: () => undefined,
|
|
87
|
+
}),
|
|
88
|
+
);
|
|
89
|
+
assert.match(html, /aria-expanded="true"/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("gives a thumb a row it can hit", () => {
|
|
93
|
+
const html = renderToString(
|
|
94
|
+
createElement(AttendeeRow, {
|
|
95
|
+
attendee: attendees[0],
|
|
96
|
+
onActivate: () => undefined,
|
|
97
|
+
touch: true,
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
assert.match(html, /min-h-11/);
|
|
101
|
+
assert.doesNotMatch(html, /min-h-9/);
|
|
102
|
+
});
|
|
57
103
|
});
|
|
58
104
|
|
|
59
105
|
describe("AttendeeList", () => {
|
|
@@ -70,4 +116,65 @@ describe("AttendeeList", () => {
|
|
|
70
116
|
"",
|
|
71
117
|
);
|
|
72
118
|
});
|
|
119
|
+
|
|
120
|
+
it("leaves the markup it always had when nothing can be opened", () => {
|
|
121
|
+
const html = renderToString(createElement(AttendeeList, { attendees }));
|
|
122
|
+
assert.doesNotMatch(html, /<button|aria-expanded|w-full/);
|
|
123
|
+
assert.doesNotMatch(html, /<div>/);
|
|
124
|
+
assert.equal(
|
|
125
|
+
html.match(/<div class="flex min-h-9 items-center gap-2\.5">/g)?.length,
|
|
126
|
+
attendees.length,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("makes every row a control when a guest can be opened", () => {
|
|
131
|
+
const html = renderToString(
|
|
132
|
+
createElement(AttendeeList, { attendees, onActivate: () => undefined }),
|
|
133
|
+
);
|
|
134
|
+
assert.equal(html.match(/<button/g)?.length, attendees.length);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("puts the context under the guest it is about and under no other", () => {
|
|
138
|
+
const html = renderToString(
|
|
139
|
+
createElement(AttendeeList, {
|
|
140
|
+
attendees,
|
|
141
|
+
activeEmail: "dana@northwind.example",
|
|
142
|
+
onActivate: () => undefined,
|
|
143
|
+
renderContext: (attendee) =>
|
|
144
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
assert.match(html, /Recent mail · Dana Okafor/);
|
|
148
|
+
assert.doesNotMatch(html, /Recent mail · Priya Natarajan/);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("names the disclosure the open row controls", () => {
|
|
152
|
+
const html = renderToString(
|
|
153
|
+
createElement(AttendeeList, {
|
|
154
|
+
attendees,
|
|
155
|
+
activeEmail: "dana@northwind.example",
|
|
156
|
+
onActivate: () => undefined,
|
|
157
|
+
renderContext: (attendee) =>
|
|
158
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
159
|
+
}),
|
|
160
|
+
);
|
|
161
|
+
const controls = html.match(/aria-controls="([^"]+)"/g) ?? [];
|
|
162
|
+
assert.equal(controls.length, 1);
|
|
163
|
+
const id = /aria-controls="([^"]+)"/.exec(html)?.[1];
|
|
164
|
+
assert.ok(id);
|
|
165
|
+
assert.ok(html.includes(`<div id="${id}"`));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("opens no context for a guest who is not on the list", () => {
|
|
169
|
+
const html = renderToString(
|
|
170
|
+
createElement(AttendeeList, {
|
|
171
|
+
attendees,
|
|
172
|
+
activeEmail: "nobody@northwind.example",
|
|
173
|
+
onActivate: () => undefined,
|
|
174
|
+
renderContext: (attendee) =>
|
|
175
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
assert.doesNotMatch(html, /Recent mail/);
|
|
179
|
+
});
|
|
73
180
|
});
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { expect, userEvent, waitFor, within } from "storybook/test";
|
|
2
4
|
import { AttendeeList, RsvpBadge } from "./attendee-row.js";
|
|
3
5
|
import type { CalendarAttendee } from "./calendar-types.js";
|
|
4
6
|
|
|
@@ -56,6 +58,52 @@ export const List: Story = {
|
|
|
56
58
|
),
|
|
57
59
|
};
|
|
58
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The same list where the surface has something to say about the person behind
|
|
63
|
+
* a row. A row is then a disclosure: it opens under the guest, the same
|
|
64
|
+
* activation closes it, and what opens is the caller's — the kit places it and
|
|
65
|
+
* knows nothing else about it.
|
|
66
|
+
*/
|
|
67
|
+
export const WithContext: Story = {
|
|
68
|
+
render: () => {
|
|
69
|
+
const [active, setActive] = useState("");
|
|
70
|
+
return (
|
|
71
|
+
<div className="max-w-sm rounded-lg border border-line bg-surface p-3">
|
|
72
|
+
<AttendeeList
|
|
73
|
+
attendees={attendees}
|
|
74
|
+
activeEmail={active}
|
|
75
|
+
onActivate={setActive}
|
|
76
|
+
renderContext={(attendee) => (
|
|
77
|
+
<p className="w-64 rounded-lg border border-line bg-surface-raised p-3 text-xs text-fg-muted">
|
|
78
|
+
{`Everything ${attendee.name} has written lately would go here.`}
|
|
79
|
+
</p>
|
|
80
|
+
)}
|
|
81
|
+
/>
|
|
82
|
+
</div>
|
|
83
|
+
);
|
|
84
|
+
},
|
|
85
|
+
play: async ({ canvasElement }) => {
|
|
86
|
+
const canvas = within(canvasElement);
|
|
87
|
+
const guest = canvas.getByRole("button", { name: /Aisha Khan/ });
|
|
88
|
+
|
|
89
|
+
await userEvent.click(guest);
|
|
90
|
+
|
|
91
|
+
const opened = await waitFor(() =>
|
|
92
|
+
canvas.getByText(/Everything Aisha Khan has written/),
|
|
93
|
+
);
|
|
94
|
+
await expect(guest).toHaveAttribute("aria-expanded", "true");
|
|
95
|
+
await expect(guest).toHaveAttribute(
|
|
96
|
+
"aria-controls",
|
|
97
|
+
opened.parentElement?.id,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
await userEvent.click(opened);
|
|
101
|
+
|
|
102
|
+
await waitFor(() => expect(opened).toBeInTheDocument());
|
|
103
|
+
await expect(guest).toHaveAttribute("aria-expanded", "true");
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
|
|
59
107
|
export const EveryReply: Story = {
|
|
60
108
|
render: () => (
|
|
61
109
|
<div className="flex gap-4">
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Check, Clock, Minus, X } from "lucide-react";
|
|
2
|
+
import { Fragment, type ReactNode, useId } from "react";
|
|
2
3
|
import { cn } from "../lib/cn.js";
|
|
3
4
|
import { Avatar } from "./avatar.js";
|
|
4
5
|
import type { CalendarAttendee, RsvpState } from "./calendar-types.js";
|
|
@@ -48,12 +49,31 @@ export function RsvpBadge({ rsvp, className }: RsvpBadgeProps) {
|
|
|
48
49
|
|
|
49
50
|
export interface AttendeeRowProps {
|
|
50
51
|
attendee: CalendarAttendee;
|
|
52
|
+
/** Marks the row whose context is open. */
|
|
53
|
+
active?: boolean;
|
|
54
|
+
/**
|
|
55
|
+
* Called with the guest to open and with "" to close, so a row that is
|
|
56
|
+
* already open closes on the next activation. Absent leaves the row inert,
|
|
57
|
+
* as today.
|
|
58
|
+
*/
|
|
59
|
+
onActivate?: (email: string) => void;
|
|
60
|
+
/** The disclosure this row opens, so the button can name what it controls. */
|
|
61
|
+
contextId?: string;
|
|
62
|
+
/** A thumb needs a bigger row than a pointer does. */
|
|
63
|
+
touch?: boolean;
|
|
51
64
|
className?: string;
|
|
52
65
|
}
|
|
53
66
|
|
|
54
|
-
export function AttendeeRow({
|
|
55
|
-
|
|
56
|
-
|
|
67
|
+
export function AttendeeRow({
|
|
68
|
+
attendee,
|
|
69
|
+
active = false,
|
|
70
|
+
onActivate,
|
|
71
|
+
contextId,
|
|
72
|
+
touch = false,
|
|
73
|
+
className,
|
|
74
|
+
}: AttendeeRowProps) {
|
|
75
|
+
const face = (
|
|
76
|
+
<>
|
|
57
77
|
<Avatar name={attendee.name} email={attendee.email} size="sm" />
|
|
58
78
|
<span className="min-w-0 flex-1">
|
|
59
79
|
<span className="block truncate text-sm text-fg">{attendee.name}</span>
|
|
@@ -62,17 +82,74 @@ export function AttendeeRow({ attendee, className }: AttendeeRowProps) {
|
|
|
62
82
|
)}
|
|
63
83
|
</span>
|
|
64
84
|
<RsvpBadge rsvp={attendee.rsvp} />
|
|
65
|
-
|
|
85
|
+
</>
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
if (!onActivate)
|
|
89
|
+
return (
|
|
90
|
+
<div className={cn("flex min-h-9 items-center gap-2.5", className)}>
|
|
91
|
+
{face}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
type="button"
|
|
98
|
+
aria-expanded={active}
|
|
99
|
+
aria-controls={active && contextId !== undefined ? contextId : undefined}
|
|
100
|
+
onClick={() => onActivate(active ? "" : attendee.email)}
|
|
101
|
+
onKeyDown={(event) => {
|
|
102
|
+
if (event.key === "Escape") onActivate("");
|
|
103
|
+
}}
|
|
104
|
+
onBlur={(event) => {
|
|
105
|
+
if (!active) return;
|
|
106
|
+
// Only focus landing on something else is focus leaving the guest.
|
|
107
|
+
// Clicking the text inside the disclosure focuses nothing at all, and
|
|
108
|
+
// what the row opened is rendered beside it, so neither closes it.
|
|
109
|
+
if (event.relatedTarget === null) return;
|
|
110
|
+
if (event.currentTarget.parentElement?.contains(event.relatedTarget))
|
|
111
|
+
return;
|
|
112
|
+
onActivate("");
|
|
113
|
+
}}
|
|
114
|
+
className={cn(
|
|
115
|
+
"flex w-full items-center gap-2.5 rounded-md px-1.5 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
|
116
|
+
touch ? "min-h-11" : "min-h-9",
|
|
117
|
+
active ? "bg-accent-2-soft" : "hover:bg-surface-sunken",
|
|
118
|
+
className,
|
|
119
|
+
)}
|
|
120
|
+
>
|
|
121
|
+
{face}
|
|
122
|
+
</button>
|
|
66
123
|
);
|
|
67
124
|
}
|
|
68
125
|
|
|
69
126
|
export interface AttendeeListProps {
|
|
70
127
|
attendees: CalendarAttendee[];
|
|
128
|
+
/** The guest whose context is open. Empty for none. */
|
|
129
|
+
activeEmail?: string;
|
|
130
|
+
/** Called with a guest to open and with "" to close. */
|
|
131
|
+
onActivate?: (email: string) => void;
|
|
132
|
+
/** Rendered under the open row. The kit knows nothing about mail. */
|
|
133
|
+
renderContext?: (attendee: CalendarAttendee) => ReactNode;
|
|
134
|
+
touch?: boolean;
|
|
71
135
|
className?: string;
|
|
72
136
|
}
|
|
73
137
|
|
|
74
|
-
/**
|
|
75
|
-
|
|
138
|
+
/**
|
|
139
|
+
* The full guest list with a one-line tally above it. A row is a control only
|
|
140
|
+
* where the surface has something to say about the person behind it, and what
|
|
141
|
+
* it opens is a disclosure under the row rather than a card over the pane: the
|
|
142
|
+
* same gesture closes it, so do Escape and taking focus elsewhere.
|
|
143
|
+
*/
|
|
144
|
+
export function AttendeeList({
|
|
145
|
+
attendees,
|
|
146
|
+
activeEmail = "",
|
|
147
|
+
onActivate,
|
|
148
|
+
renderContext,
|
|
149
|
+
touch = false,
|
|
150
|
+
className,
|
|
151
|
+
}: AttendeeListProps) {
|
|
152
|
+
const listId = useId();
|
|
76
153
|
if (attendees.length === 0) return null;
|
|
77
154
|
const tally = (["accepted", "tentative", "declined", "noReply"] as const)
|
|
78
155
|
.map((state) => ({
|
|
@@ -88,9 +165,30 @@ export function AttendeeList({ attendees, className }: AttendeeListProps) {
|
|
|
88
165
|
<p className="pb-1 text-2xs uppercase tracking-wider text-fg-subtle">
|
|
89
166
|
{`${attendees.length} guests · ${tally}`}
|
|
90
167
|
</p>
|
|
91
|
-
{attendees.map((attendee) =>
|
|
92
|
-
|
|
93
|
-
|
|
168
|
+
{attendees.map((attendee, index) => {
|
|
169
|
+
const active = attendee.email === activeEmail;
|
|
170
|
+
const contextId = `${listId}-guest-${index}`;
|
|
171
|
+
const row = (
|
|
172
|
+
<AttendeeRow
|
|
173
|
+
attendee={attendee}
|
|
174
|
+
active={active}
|
|
175
|
+
onActivate={onActivate}
|
|
176
|
+
contextId={renderContext ? contextId : undefined}
|
|
177
|
+
touch={touch}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
if (!onActivate) return <Fragment key={attendee.email}>{row}</Fragment>;
|
|
181
|
+
return (
|
|
182
|
+
<div key={attendee.email}>
|
|
183
|
+
{row}
|
|
184
|
+
{active && renderContext && (
|
|
185
|
+
<div id={contextId} className="py-1">
|
|
186
|
+
{renderContext(attendee)}
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
})}
|
|
94
192
|
</div>
|
|
95
193
|
);
|
|
96
194
|
}
|
|
@@ -101,4 +101,26 @@ describe("EventDetail", () => {
|
|
|
101
101
|
it("names the calendar and its account", () => {
|
|
102
102
|
assert.match(render({}), /Northwind/);
|
|
103
103
|
});
|
|
104
|
+
|
|
105
|
+
it("leaves the guest list inert where a name leads nowhere", () => {
|
|
106
|
+
assert.doesNotMatch(render({}), /aria-expanded/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("opens the caller's context under the guest it is about", () => {
|
|
110
|
+
const html = renderToString(
|
|
111
|
+
createElement(EventDetail, {
|
|
112
|
+
event,
|
|
113
|
+
calendar,
|
|
114
|
+
whenText: "Wednesday 10 June · 10:00 – 11:30",
|
|
115
|
+
onEdit: () => undefined,
|
|
116
|
+
onDelete: () => undefined,
|
|
117
|
+
activeAttendee: "priya@northwind.example",
|
|
118
|
+
onActivateAttendee: () => undefined,
|
|
119
|
+
renderAttendeeContext: (attendee) =>
|
|
120
|
+
createElement("p", null, `Recent mail · ${attendee.name}`),
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
assert.match(html, /aria-expanded="true"/);
|
|
124
|
+
assert.match(html, /Recent mail · Priya Natarajan/);
|
|
125
|
+
});
|
|
104
126
|
});
|
|
@@ -13,6 +13,7 @@ import { cn } from "../lib/cn.js";
|
|
|
13
13
|
import { AttendeeList, RsvpBadge } from "./attendee-row.js";
|
|
14
14
|
import { Button } from "./button.js";
|
|
15
15
|
import type {
|
|
16
|
+
CalendarAttendee,
|
|
16
17
|
CalendarDescriptor,
|
|
17
18
|
CalendarEventData,
|
|
18
19
|
} from "./calendar-types.js";
|
|
@@ -28,6 +29,14 @@ export interface EventDetailProps {
|
|
|
28
29
|
onOpenThread?: () => void;
|
|
29
30
|
/** Puts the pane back to whatever it shows with nothing selected. */
|
|
30
31
|
onClose?: () => void;
|
|
32
|
+
/** The guest whose context is open. Empty for none. */
|
|
33
|
+
activeAttendee?: string;
|
|
34
|
+
/** Called with a guest to open and with "" to close. Absent leaves the list inert. */
|
|
35
|
+
onActivateAttendee?: (email: string) => void;
|
|
36
|
+
/** Rendered under the open guest. The kit knows nothing about mail. */
|
|
37
|
+
renderAttendeeContext?: (attendee: CalendarAttendee) => React.ReactNode;
|
|
38
|
+
/** A thumb needs a bigger row than a pointer does. */
|
|
39
|
+
touch?: boolean;
|
|
31
40
|
/**
|
|
32
41
|
* `bare` drops the header and the scrolling frame, for a surface that already
|
|
33
42
|
* carries both — a full-screen flow whose footer holds the same actions.
|
|
@@ -64,6 +73,10 @@ export function EventDetail({
|
|
|
64
73
|
onDelete,
|
|
65
74
|
onOpenThread,
|
|
66
75
|
onClose,
|
|
76
|
+
activeAttendee = "",
|
|
77
|
+
onActivateAttendee,
|
|
78
|
+
renderAttendeeContext,
|
|
79
|
+
touch = false,
|
|
67
80
|
chrome = "header",
|
|
68
81
|
className,
|
|
69
82
|
}: EventDetailProps) {
|
|
@@ -128,7 +141,14 @@ export function EventDetail({
|
|
|
128
141
|
)}
|
|
129
142
|
|
|
130
143
|
{event.attendees.length > 0 && (
|
|
131
|
-
<AttendeeList
|
|
144
|
+
<AttendeeList
|
|
145
|
+
attendees={event.attendees}
|
|
146
|
+
activeEmail={activeAttendee}
|
|
147
|
+
onActivate={onActivateAttendee}
|
|
148
|
+
renderContext={renderAttendeeContext}
|
|
149
|
+
touch={touch}
|
|
150
|
+
className="mt-3"
|
|
151
|
+
/>
|
|
132
152
|
)}
|
|
133
153
|
|
|
134
154
|
{event.notes !== "" && (
|
|
@@ -29,6 +29,13 @@ const folders: FolderTreeNode[] = [
|
|
|
29
29
|
},
|
|
30
30
|
];
|
|
31
31
|
|
|
32
|
+
// A server that reports no hierarchy delimiter has a flat namespace, where a
|
|
33
|
+
// path carries no separator to read a trail out of.
|
|
34
|
+
const flatFolders: FolderTreeNode[] = [
|
|
35
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
36
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
37
|
+
];
|
|
38
|
+
|
|
32
39
|
const rule: FilterRule = {
|
|
33
40
|
clauses: [{ id: "c1", field: "From", value: "a@example.com" }],
|
|
34
41
|
matchOperator: "all",
|
|
@@ -61,6 +68,8 @@ interface MountOptions {
|
|
|
61
68
|
signal?: AbortSignal,
|
|
62
69
|
) => Promise<FolderTreeNode>;
|
|
63
70
|
onChangeMove?: (id: string) => void;
|
|
71
|
+
options?: readonly FolderTreeNode[];
|
|
72
|
+
delimiter?: string;
|
|
64
73
|
}
|
|
65
74
|
|
|
66
75
|
/** Holds the destination the way the app does, so a pick shows on screen. */
|
|
@@ -68,6 +77,8 @@ const mount = async ({
|
|
|
68
77
|
initialDestination,
|
|
69
78
|
onCreateFolder,
|
|
70
79
|
onChangeMove,
|
|
80
|
+
options = folders,
|
|
81
|
+
delimiter,
|
|
71
82
|
}: MountOptions = {}) => {
|
|
72
83
|
const Controlled = () => {
|
|
73
84
|
const [moveMailboxId, setMoveMailboxId] = useState<string | undefined>(
|
|
@@ -75,7 +86,8 @@ const mount = async ({
|
|
|
75
86
|
);
|
|
76
87
|
return createElement(FilterRuleEditor, {
|
|
77
88
|
rule: { ...rule, moveMailboxId },
|
|
78
|
-
folders,
|
|
89
|
+
folders: options,
|
|
90
|
+
delimiter,
|
|
79
91
|
preview,
|
|
80
92
|
onChangeMove: (id: string) => {
|
|
81
93
|
setMoveMailboxId(id || undefined);
|
|
@@ -235,6 +247,17 @@ describe("FilterRuleEditor move destination", () => {
|
|
|
235
247
|
assert.deepEqual(picked, [""]);
|
|
236
248
|
});
|
|
237
249
|
|
|
250
|
+
it("reads a flat-namespace destination as its whole path, not per character", async () => {
|
|
251
|
+
await mount({ options: flatFolders, delimiter: "" });
|
|
252
|
+
await openTree();
|
|
253
|
+
await click(byAriaLabel("Move to Work"));
|
|
254
|
+
assert.ok(
|
|
255
|
+
byText("Move matches to Work"),
|
|
256
|
+
"the whole path is one segment when nothing nests",
|
|
257
|
+
);
|
|
258
|
+
assert.equal(byText("Move matches to W / o / r / k"), undefined);
|
|
259
|
+
});
|
|
260
|
+
|
|
238
261
|
it("offers no create affordance without onCreateFolder", async () => {
|
|
239
262
|
await mount();
|
|
240
263
|
await openTree();
|
|
@@ -60,10 +60,15 @@ function LiveEditor({
|
|
|
60
60
|
propertyRule,
|
|
61
61
|
labels = demoLabels,
|
|
62
62
|
initialClauseEdit,
|
|
63
|
+
folders = demoFolders,
|
|
64
|
+
delimiter,
|
|
63
65
|
onCreateFolder,
|
|
64
66
|
onCreateLabel,
|
|
65
67
|
}: {
|
|
66
68
|
initialRule: FilterRule;
|
|
69
|
+
folders?: FolderTreeNode[];
|
|
70
|
+
/** The provider's hierarchy separator; `""` is a flat namespace. */
|
|
71
|
+
delimiter?: string;
|
|
67
72
|
semanticAvailable?: boolean;
|
|
68
73
|
/** Offers the match-mode control; omit to render the editor without one. */
|
|
69
74
|
initialMatchMode?: RuleMatchMode;
|
|
@@ -179,7 +184,8 @@ function LiveEditor({
|
|
|
179
184
|
return (
|
|
180
185
|
<FilterRuleEditor
|
|
181
186
|
rule={rule}
|
|
182
|
-
folders={
|
|
187
|
+
folders={folders}
|
|
188
|
+
delimiter={delimiter}
|
|
183
189
|
labels={labels}
|
|
184
190
|
preview={preview}
|
|
185
191
|
semanticAvailable={semanticAvailable}
|
|
@@ -459,6 +465,30 @@ export const DestinationNestedFolders: Story = {
|
|
|
459
465
|
},
|
|
460
466
|
};
|
|
461
467
|
|
|
468
|
+
// A server that reports no hierarchy delimiter has a flat namespace: every
|
|
469
|
+
// folder sits at the top level and a path is a name, not a trail.
|
|
470
|
+
const flatFolders: FolderTreeNode[] = [
|
|
471
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX" },
|
|
472
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
473
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
474
|
+
{ id: "mbx-workshop", label: "Workshop", path: "Workshop" },
|
|
475
|
+
];
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A flat namespace, where the chosen destination reads as its whole path —
|
|
479
|
+
* `Work`, not one segment per character.
|
|
480
|
+
*/
|
|
481
|
+
export const DestinationFlatNamespace: Story = {
|
|
482
|
+
name: "Destination — a flat namespace (server reports no delimiter)",
|
|
483
|
+
render: () => (
|
|
484
|
+
<LiveEditor
|
|
485
|
+
initialRule={{ ...demoRule, moveMailboxId: "mbx-work" }}
|
|
486
|
+
folders={flatFolders}
|
|
487
|
+
delimiter=""
|
|
488
|
+
/>
|
|
489
|
+
),
|
|
490
|
+
};
|
|
491
|
+
|
|
462
492
|
/**
|
|
463
493
|
* A new folder is made inside the folder the tree is looking at, so a filter can
|
|
464
494
|
* point at `Travel/Car hire` without leaving the editor.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Fragment, type ReactNode, useMemo, useState } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type FolderTreeNode, folderPathSegments } from "../lib/folder-tree.js";
|
|
3
3
|
import { Button } from "./button.js";
|
|
4
4
|
import {
|
|
5
5
|
AddChipButton,
|
|
@@ -216,7 +216,7 @@ function MoveDestinationField({
|
|
|
216
216
|
|
|
217
217
|
/** Two folders can share a leaf name, so a destination reads as its trail. */
|
|
218
218
|
const trail = (folder: FolderTreeNode): string => {
|
|
219
|
-
const segments = folder.path
|
|
219
|
+
const segments = folderPathSegments(folder.path, delimiter);
|
|
220
220
|
return segments
|
|
221
221
|
.map((segment, index) => {
|
|
222
222
|
const path = segments.slice(0, index + 1).join(delimiter);
|
|
@@ -47,6 +47,17 @@ const folders: FolderTreeNode[] = [
|
|
|
47
47
|
{ id: "mbx-work-recruiting", label: "Recruiting", path: "Work/Recruiting" },
|
|
48
48
|
];
|
|
49
49
|
|
|
50
|
+
// A server that reports no hierarchy delimiter has a flat namespace: nothing
|
|
51
|
+
// nests, and `Work` is not a parent of `Workshop`.
|
|
52
|
+
const flatFolders: FolderTreeNode[] = [
|
|
53
|
+
{ id: "mbx-inbox", label: "Inbox", path: "INBOX", isCurrent: true },
|
|
54
|
+
{ id: "mbx-archive", label: "Archive", path: "Archive" },
|
|
55
|
+
{ id: "mbx-work", label: "Work", path: "Work" },
|
|
56
|
+
{ id: "mbx-workshop", label: "Workshop", path: "Workshop" },
|
|
57
|
+
{ id: "mbx-sent", label: "Sent", path: "Sent Items" },
|
|
58
|
+
{ id: "mbx-trash", label: "Trash", path: "Deleted Messages" },
|
|
59
|
+
];
|
|
60
|
+
|
|
50
61
|
const longFolders: FolderTreeNode[] = [
|
|
51
62
|
...folders,
|
|
52
63
|
...Array.from({ length: 36 }, (_, i) => ({
|
|
@@ -100,6 +111,7 @@ const rejects = (message: string) => (): Promise<FolderTreeNode> =>
|
|
|
100
111
|
function Picker({
|
|
101
112
|
options = folders,
|
|
102
113
|
onCreateFolder = createFolder,
|
|
114
|
+
delimiter,
|
|
103
115
|
}: {
|
|
104
116
|
options?: FolderTreeNode[];
|
|
105
117
|
onCreateFolder?: (
|
|
@@ -107,6 +119,7 @@ function Picker({
|
|
|
107
119
|
parentPath: string,
|
|
108
120
|
signal?: AbortSignal,
|
|
109
121
|
) => Promise<FolderTreeNode>;
|
|
122
|
+
delimiter?: string;
|
|
110
123
|
}) {
|
|
111
124
|
const [selected, setSelected] = useState<string>();
|
|
112
125
|
const [known, setKnown] = useState(options);
|
|
@@ -115,6 +128,7 @@ function Picker({
|
|
|
115
128
|
<FolderTreePicker
|
|
116
129
|
folders={known}
|
|
117
130
|
selectedId={selected}
|
|
131
|
+
delimiter={delimiter}
|
|
118
132
|
onSelect={setSelected}
|
|
119
133
|
onCreateFolder={(name, parentPath, signal) =>
|
|
120
134
|
onCreateFolder(name, parentPath, signal).then((created) => {
|
|
@@ -160,6 +174,15 @@ export const LongList: Story = {
|
|
|
160
174
|
render: () => <Picker options={longFolders} />,
|
|
161
175
|
};
|
|
162
176
|
|
|
177
|
+
/**
|
|
178
|
+
* A flat namespace: every folder sits at the top level and none of them opens,
|
|
179
|
+
* so a new folder can only be made at the top.
|
|
180
|
+
*/
|
|
181
|
+
export const FlatNamespace: Story = {
|
|
182
|
+
name: "Flat namespace (server reports no delimiter)",
|
|
183
|
+
render: () => <Picker options={flatFolders} delimiter="" />,
|
|
184
|
+
};
|
|
185
|
+
|
|
163
186
|
/** An account with nothing to list: the message states that, not a filter. */
|
|
164
187
|
export const Empty: Story = {
|
|
165
188
|
name: "No folders",
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
folderDepth,
|
|
9
9
|
folderLeaf,
|
|
10
10
|
folderParent,
|
|
11
|
+
folderPathSegments,
|
|
11
12
|
matchesQuery,
|
|
12
13
|
orderFolderNodes,
|
|
13
14
|
queryExpandedPaths,
|
|
@@ -298,3 +299,50 @@ describe("folderLeaf", () => {
|
|
|
298
299
|
assert.equal(folderLeaf("INBOX", "."), "INBOX");
|
|
299
300
|
});
|
|
300
301
|
});
|
|
302
|
+
|
|
303
|
+
describe("a flat namespace", () => {
|
|
304
|
+
const flat: FolderTreeNode[] = [
|
|
305
|
+
node("work", "Work", "Work"),
|
|
306
|
+
node("workshop", "Workshop", "Workshop"),
|
|
307
|
+
node("inbox", "Inbox", "INBOX"),
|
|
308
|
+
];
|
|
309
|
+
|
|
310
|
+
it("keeps a path whole rather than splitting it into characters", () => {
|
|
311
|
+
assert.deepEqual(folderPathSegments("Projects/Q3", ""), ["Projects/Q3"]);
|
|
312
|
+
assert.deepEqual(folderPathSegments("Projects/Q3", "/"), [
|
|
313
|
+
"Projects",
|
|
314
|
+
"Q3",
|
|
315
|
+
]);
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
it("makes every folder a root with no parent, depth or ancestors", () => {
|
|
319
|
+
assert.equal(folderParent("Projects/Q3", ""), "");
|
|
320
|
+
assert.equal(folderDepth("Projects/Q3", ""), 0);
|
|
321
|
+
assert.deepEqual(folderAncestors("Projects/Q3", ""), []);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("puts the whole list on screen at the top level", () => {
|
|
325
|
+
const rows = collapseFolderTree(orderFolderNodes(flat, ""), new Set(), "");
|
|
326
|
+
assert.deepEqual(paths(rows), ["Work", "Workshop", "INBOX"]);
|
|
327
|
+
assert.deepEqual(
|
|
328
|
+
rows.map((row) => row.depth),
|
|
329
|
+
[0, 0, 0],
|
|
330
|
+
);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
it("offers no create action inside a folder, not even a prefix match", () => {
|
|
334
|
+
const rows = collapseFolderTree(
|
|
335
|
+
orderFolderNodes(flat, ""),
|
|
336
|
+
new Set(["Work"]),
|
|
337
|
+
"",
|
|
338
|
+
);
|
|
339
|
+
assert.deepEqual(
|
|
340
|
+
withCreateRows(rows, "").map((entry) =>
|
|
341
|
+
entry.kind === "create"
|
|
342
|
+
? `new inside ${entry.parent.path}`
|
|
343
|
+
: entry.row.folder.path,
|
|
344
|
+
),
|
|
345
|
+
["Work", "Workshop", "INBOX"],
|
|
346
|
+
);
|
|
347
|
+
});
|
|
348
|
+
});
|
package/src/lib/folder-tree.ts
CHANGED
|
@@ -38,28 +38,39 @@ export type FolderTreeDisplayRow =
|
|
|
38
38
|
| { kind: "folder"; row: FolderTreeRow; index: number }
|
|
39
39
|
| { kind: "create"; parent: FolderTreeNode; depth: number };
|
|
40
40
|
|
|
41
|
-
// A server that reports no delimiter has a flat namespace
|
|
42
|
-
//
|
|
41
|
+
// A server that reports no delimiter has a flat namespace: the path is its own
|
|
42
|
+
// leaf and every folder is a root. Splitting on "" would return single
|
|
43
|
+
// characters, and `"Inbox".lastIndexOf("")` is 5 rather than -1, so each of
|
|
44
|
+
// these answers the flat case before it touches the path.
|
|
45
|
+
export const folderPathSegments = (
|
|
46
|
+
path: string,
|
|
47
|
+
delimiter: string,
|
|
48
|
+
): string[] => (delimiter.length === 0 ? [path] : path.split(delimiter));
|
|
49
|
+
|
|
43
50
|
export const folderLeaf = (path: string, delimiter: string): string => {
|
|
44
|
-
|
|
45
|
-
const parts = path.split(delimiter);
|
|
51
|
+
const parts = folderPathSegments(path, delimiter);
|
|
46
52
|
return parts[parts.length - 1] || path;
|
|
47
53
|
};
|
|
48
54
|
|
|
49
55
|
export const folderParent = (path: string, delimiter: string): string => {
|
|
56
|
+
if (delimiter.length === 0) return "";
|
|
50
57
|
const cut = path.lastIndexOf(delimiter);
|
|
51
58
|
return cut === -1 ? "" : path.slice(0, cut);
|
|
52
59
|
};
|
|
53
60
|
|
|
54
61
|
export const folderDepth = (path: string, delimiter: string): number =>
|
|
55
|
-
path
|
|
62
|
+
folderPathSegments(path, delimiter).length - 1;
|
|
56
63
|
|
|
64
|
+
// Every step up is strictly shorter than the path below it, so the walk is
|
|
65
|
+
// bounded by the length of the path whatever a parent comes back as.
|
|
57
66
|
export const folderAncestors = (path: string, delimiter: string): string[] => {
|
|
58
67
|
const out: string[] = [];
|
|
59
|
-
let
|
|
60
|
-
|
|
68
|
+
let child = path;
|
|
69
|
+
let parent = folderParent(child, delimiter);
|
|
70
|
+
while (parent && parent.length < child.length) {
|
|
61
71
|
out.push(parent);
|
|
62
|
-
|
|
72
|
+
child = parent;
|
|
73
|
+
parent = folderParent(child, delimiter);
|
|
63
74
|
}
|
|
64
75
|
return out;
|
|
65
76
|
};
|
|
@@ -182,12 +193,16 @@ export const collapseFolderTree = (
|
|
|
182
193
|
|
|
183
194
|
/**
|
|
184
195
|
* Drops a create action at the end of every open folder's children, so "New
|
|
185
|
-
* folder" reads as the last folder inside the one you opened.
|
|
196
|
+
* folder" reads as the last folder inside the one you opened. A flat namespace
|
|
197
|
+
* has no inside, so it gets the rows on their own and creates at the top level.
|
|
186
198
|
*/
|
|
187
199
|
export const withCreateRows = (
|
|
188
200
|
rows: readonly FolderTreeRow[],
|
|
189
201
|
delimiter: string,
|
|
190
202
|
): FolderTreeDisplayRow[] => {
|
|
203
|
+
if (delimiter.length === 0)
|
|
204
|
+
return rows.map((row, index) => ({ kind: "folder", row, index }));
|
|
205
|
+
|
|
191
206
|
const out: FolderTreeDisplayRow[] = [];
|
|
192
207
|
const open: FolderTreeRow[] = [];
|
|
193
208
|
|