@remit/web-client 0.0.291 → 0.0.293
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/calendar/CalendarComposePane.stories.tsx +19 -0
- package/src/components/calendar/CalendarComposePane.tsx +30 -10
- package/src/components/calendar/OpenCalendarEvent.tsx +7 -0
- package/src/components/calendar/WriteCalendarEvent.tsx +4 -0
- package/src/components/onboarding/OnboardingWizard.flow.test.ts +45 -0
- package/src/components/onboarding/OnboardingWizard.tsx +2 -8
- package/src/components/onboarding/onboarding-servers.stories.tsx +49 -0
- package/src/components/settings/CalendarCreateCard.stories.tsx +33 -0
- package/src/components/settings/CalendarCreateCard.tsx +149 -0
- package/src/components/settings/CalendarDetailsForm.stories.tsx +57 -0
- package/src/components/settings/CalendarDetailsForm.tsx +136 -0
- package/src/components/settings/CalendarFeedCard.stories.tsx +18 -0
- package/src/components/settings/CalendarFeedCard.tsx +7 -1
- package/src/components/settings/CalendarFeedPanel.tsx +4 -0
- package/src/components/settings/CalendarSettingsPanel.tsx +54 -0
- package/src/components/settings/TimeZoneSelect.tsx +43 -0
- package/src/components/settings/calendar-collection-cards.render.test.ts +149 -0
- package/src/hooks/calendar/calendar-collections.render.test.ts +259 -0
- package/src/hooks/calendar/index.ts +6 -0
- package/src/hooks/calendar/useCalendarCollectionWrites.ts +130 -0
- package/src/hooks/calendar/useDraftClashes.ts +91 -0
- package/src/lib/calendar-collection-copy.test.ts +23 -0
- package/src/lib/calendar-collection-copy.ts +21 -0
- package/src/lib/provider-presets.test.ts +33 -2
- package/src/lib/provider-presets.ts +14 -1
- package/src/routes/settings/calendars.tsx +28 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.293",
|
|
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": {
|
|
@@ -130,3 +130,22 @@ export const Conflicted: Story = {
|
|
|
130
130
|
"This event changed somewhere else — over CalDAV, or in another tab. Nothing was saved. Close it and open it again to see the version that's stored now.",
|
|
131
131
|
},
|
|
132
132
|
};
|
|
133
|
+
|
|
134
|
+
/** The span on the form runs into something already booked, named before Save. */
|
|
135
|
+
export const Clashing: Story = {
|
|
136
|
+
args: {
|
|
137
|
+
clashes: [
|
|
138
|
+
{
|
|
139
|
+
id: "evt_board",
|
|
140
|
+
label: "Board prep, Wed 10 June, 10:30 – 11:00",
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
id: "evt_vendor",
|
|
144
|
+
label: "Vendor call, Wed 10 June, 11:00 – 12:00",
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/** Checked, and clear. Silence here would read as "not checked". */
|
|
151
|
+
export const Clear: Story = { args: { clashes: [] } };
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
|
+
type CalendarClash,
|
|
3
|
+
CalendarClashStrip,
|
|
2
4
|
type CalendarDescriptor,
|
|
3
5
|
type EventDraft,
|
|
4
6
|
EventEditor,
|
|
@@ -44,6 +46,11 @@ export interface CalendarComposePaneProps {
|
|
|
44
46
|
* part of its address and the patch has no field for it.
|
|
45
47
|
*/
|
|
46
48
|
calendarEditable?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* What the span on the form runs into. Undefined while there is nothing to
|
|
51
|
+
* check or the answer has not landed, which draws no strip at all.
|
|
52
|
+
*/
|
|
53
|
+
clashes?: CalendarClash[];
|
|
47
54
|
onSave: () => void;
|
|
48
55
|
onCancel: () => void;
|
|
49
56
|
}
|
|
@@ -59,6 +66,7 @@ export function CalendarComposePane({
|
|
|
59
66
|
isSaving,
|
|
60
67
|
repeatEditable = true,
|
|
61
68
|
calendarEditable = true,
|
|
69
|
+
clashes,
|
|
62
70
|
onSave,
|
|
63
71
|
onCancel,
|
|
64
72
|
}: CalendarComposePaneProps) {
|
|
@@ -75,16 +83,28 @@ export function CalendarComposePane({
|
|
|
75
83
|
onSave={onSave}
|
|
76
84
|
onCancel={onCancel}
|
|
77
85
|
header={
|
|
78
|
-
problem === "" ? undefined : (
|
|
79
|
-
<div
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
problem === "" && clashes === undefined ? undefined : (
|
|
87
|
+
<div className="space-y-2">
|
|
88
|
+
{problem !== "" && (
|
|
89
|
+
<div
|
|
90
|
+
role="alert"
|
|
91
|
+
className="flex items-start gap-2 rounded-md border border-danger/40 bg-danger-soft px-3 py-2 text-sm"
|
|
92
|
+
>
|
|
93
|
+
<AlertCircle
|
|
94
|
+
className="mt-0.5 size-4 shrink-0 text-danger"
|
|
95
|
+
aria-hidden="true"
|
|
96
|
+
/>
|
|
97
|
+
<p className="min-w-0 flex-1 break-words text-fg">
|
|
98
|
+
{problem}
|
|
99
|
+
</p>
|
|
100
|
+
</div>
|
|
101
|
+
)}
|
|
102
|
+
{clashes !== undefined && (
|
|
103
|
+
<CalendarClashStrip
|
|
104
|
+
clashes={clashes}
|
|
105
|
+
clearText="Nothing else is booked at this time."
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
88
108
|
</div>
|
|
89
109
|
)
|
|
90
110
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type CalendarWriteOutcome,
|
|
11
11
|
calendarInstanceId,
|
|
12
12
|
draftFromEvent,
|
|
13
|
+
emptyDraft,
|
|
13
14
|
patchFromDrafts,
|
|
14
15
|
rruleFromIcalData,
|
|
15
16
|
type ScopedWrite,
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
useCalendarEvent,
|
|
20
21
|
useCalendars,
|
|
21
22
|
useCalendarWrites,
|
|
23
|
+
useDraftClashes,
|
|
22
24
|
} from "@/hooks/calendar";
|
|
23
25
|
import { useCalendarData } from "@/hooks/useCalendarData";
|
|
24
26
|
import { formatEventWhen } from "@/lib/calendar-format";
|
|
@@ -77,6 +79,10 @@ export function OpenCalendarEvent({
|
|
|
77
79
|
>(undefined);
|
|
78
80
|
const [draft, setDraft] = useState<EventDraft | undefined>(undefined);
|
|
79
81
|
const [problem, setProblem] = useState("");
|
|
82
|
+
const clashes = useDraftClashes(
|
|
83
|
+
draft ?? emptyDraft("", ""),
|
|
84
|
+
calendarObjectId,
|
|
85
|
+
);
|
|
80
86
|
|
|
81
87
|
const stored = resource ? textFromIcalData(resource.icalData) : undefined;
|
|
82
88
|
const rrule = resource ? rruleFromIcalData(resource.icalData) : "";
|
|
@@ -182,6 +188,7 @@ export function OpenCalendarEvent({
|
|
|
182
188
|
isSaving={isWriting}
|
|
183
189
|
repeatEditable={editing.scope === undefined || editing.scope === "all"}
|
|
184
190
|
calendarEditable={false}
|
|
191
|
+
clashes={clashes}
|
|
185
192
|
onSave={saveEdit}
|
|
186
193
|
onCancel={() => {
|
|
187
194
|
setEditing(undefined);
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
UNZONED_CALENDAR,
|
|
9
9
|
useCalendars,
|
|
10
10
|
useCalendarWrites,
|
|
11
|
+
useDraftClashes,
|
|
11
12
|
} from "@/hooks/calendar";
|
|
12
13
|
import { useCalendarAddress } from "@/routing";
|
|
13
14
|
|
|
@@ -42,6 +43,8 @@ export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
|
|
|
42
43
|
};
|
|
43
44
|
});
|
|
44
45
|
|
|
46
|
+
const clashes = useDraftClashes(draft);
|
|
47
|
+
|
|
45
48
|
// The default calendar is provisioned by the listing itself, so the picker
|
|
46
49
|
// has one the moment the calendars arrive rather than making the reader
|
|
47
50
|
// choose before they can type anything.
|
|
@@ -88,6 +91,7 @@ export function WriteCalendarEvent({ onClose }: WriteCalendarEventProps) {
|
|
|
88
91
|
problem={problem}
|
|
89
92
|
saveLabel="Add"
|
|
90
93
|
isSaving={isWriting}
|
|
94
|
+
clashes={clashes}
|
|
91
95
|
onSave={save}
|
|
92
96
|
onCancel={onClose}
|
|
93
97
|
/>
|
|
@@ -169,6 +169,51 @@ describe("OnboardingWizard — the first-run path", () => {
|
|
|
169
169
|
assert.equal(imapHostField(dom).value, "imap.fastmail.com");
|
|
170
170
|
});
|
|
171
171
|
|
|
172
|
+
it("picks the Gmail preset from a Gmail address and locks both hosts", async () => {
|
|
173
|
+
const dom = start();
|
|
174
|
+
await walkToServers(dom, "alice@gmail.com");
|
|
175
|
+
|
|
176
|
+
const provider = dom.query<HTMLSelectElement>("#provider-select");
|
|
177
|
+
assert.ok(provider);
|
|
178
|
+
assert.equal(provider.value, "gmail");
|
|
179
|
+
const imap = imapHostField(dom);
|
|
180
|
+
const smtp = dom.query<HTMLInputElement>(
|
|
181
|
+
'input[placeholder="smtp.example.com"]',
|
|
182
|
+
);
|
|
183
|
+
assert.ok(smtp, "expected the SMTP host field");
|
|
184
|
+
assert.equal(imap.value, "imap.gmail.com");
|
|
185
|
+
assert.equal(smtp.value, "smtp.gmail.com");
|
|
186
|
+
assert.equal(imap.readOnly, true);
|
|
187
|
+
assert.equal(smtp.readOnly, true);
|
|
188
|
+
const security = [
|
|
189
|
+
...dom.document.querySelectorAll<HTMLSelectElement>("fieldset select"),
|
|
190
|
+
];
|
|
191
|
+
assert.deepEqual(
|
|
192
|
+
security.map((select) => [select.value, select.disabled]),
|
|
193
|
+
[
|
|
194
|
+
["tls", true],
|
|
195
|
+
["starttls", true],
|
|
196
|
+
],
|
|
197
|
+
);
|
|
198
|
+
assert.match(dom.text(), /2-Step Verification/);
|
|
199
|
+
assert.match(dom.text(), /create an app password/);
|
|
200
|
+
const link = dom.byText("a", "Get an app password");
|
|
201
|
+
assert.equal(
|
|
202
|
+
link.getAttribute("href"),
|
|
203
|
+
"https://support.google.com/accounts/answer/185833",
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("offers no Gmail sign-in on the connector step", () => {
|
|
208
|
+
const dom = start();
|
|
209
|
+
clickText(dom, "Add your first account");
|
|
210
|
+
assert.match(dom.text(), /How does this account connect/);
|
|
211
|
+
assert.doesNotMatch(
|
|
212
|
+
dom.text(),
|
|
213
|
+
/Gmail|Sign in with Google|No app passwords/,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
|
|
172
217
|
it("falls back to a guess for a domain nobody has heard of", async () => {
|
|
173
218
|
const dom = start();
|
|
174
219
|
await walkToServers(dom, "alice@unheard-of.example");
|
|
@@ -257,7 +257,7 @@ function StepConnector({
|
|
|
257
257
|
</>
|
|
258
258
|
}
|
|
259
259
|
>
|
|
260
|
-
<div className="grid grid-cols-1 gap-3 sm:grid-cols-
|
|
260
|
+
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
|
261
261
|
<ConnectorTile
|
|
262
262
|
name="IMAP / SMTP"
|
|
263
263
|
description="Any mail provider — Fastmail, iCloud, your own server."
|
|
@@ -265,12 +265,6 @@ function StepConnector({
|
|
|
265
265
|
selected={selected === "imap"}
|
|
266
266
|
onSelect={() => setSelected("imap")}
|
|
267
267
|
/>
|
|
268
|
-
<ConnectorTile
|
|
269
|
-
name="Gmail"
|
|
270
|
-
description="Sign in with Google. No app passwords."
|
|
271
|
-
icon={<AtSign className="size-5" />}
|
|
272
|
-
comingSoon
|
|
273
|
-
/>
|
|
274
268
|
<ConnectorTile
|
|
275
269
|
name="Outlook / Microsoft 365"
|
|
276
270
|
description="Sign in with Microsoft. Works with Outlook.com and work accounts."
|
|
@@ -588,7 +582,7 @@ function StepAddress({
|
|
|
588
582
|
);
|
|
589
583
|
}
|
|
590
584
|
|
|
591
|
-
function StepServers({
|
|
585
|
+
export function StepServers({
|
|
592
586
|
email,
|
|
593
587
|
imapConfig,
|
|
594
588
|
smtpConfig,
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { expect, within } from "storybook/test";
|
|
3
|
+
import { getPresetById } from "@/lib/provider-presets";
|
|
4
|
+
import { StepServers } from "./OnboardingWizard";
|
|
5
|
+
|
|
6
|
+
const gmail = getPresetById("gmail");
|
|
7
|
+
if (!gmail) throw new Error("the gmail preset is missing");
|
|
8
|
+
|
|
9
|
+
const meta: Meta<typeof StepServers> = {
|
|
10
|
+
title: "Playground/Shipped/Onboarding/Servers",
|
|
11
|
+
component: StepServers,
|
|
12
|
+
parameters: { layout: "fullscreen" },
|
|
13
|
+
args: {
|
|
14
|
+
email: "alice@gmail.com",
|
|
15
|
+
imapConfig: gmail.imap,
|
|
16
|
+
smtpConfig: gmail.smtp,
|
|
17
|
+
discovered: true,
|
|
18
|
+
onContinue: () => {},
|
|
19
|
+
onBack: () => {},
|
|
20
|
+
onChange: () => {},
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
export default meta;
|
|
24
|
+
|
|
25
|
+
type Story = StoryObj<typeof StepServers>;
|
|
26
|
+
|
|
27
|
+
export const GmailPreset: Story = {
|
|
28
|
+
play: async ({ canvasElement }) => {
|
|
29
|
+
const canvas = within(canvasElement);
|
|
30
|
+
await expect(canvas.getByLabelText("Provider")).toHaveValue("gmail");
|
|
31
|
+
await expect(canvas.getByDisplayValue("imap.gmail.com")).toHaveAttribute(
|
|
32
|
+
"readonly",
|
|
33
|
+
);
|
|
34
|
+
await expect(canvas.getByDisplayValue("smtp.gmail.com")).toHaveAttribute(
|
|
35
|
+
"readonly",
|
|
36
|
+
);
|
|
37
|
+
for (const security of canvas.getAllByLabelText("Security")) {
|
|
38
|
+
await expect(security).toBeDisabled();
|
|
39
|
+
}
|
|
40
|
+
await expect(canvas.getByText(/2-Step Verification/)).toBeVisible();
|
|
41
|
+
await expect(
|
|
42
|
+
canvas.getByRole("link", { name: "Get an app password" }),
|
|
43
|
+
).toHaveAttribute("href", gmail.passwordHelp.url);
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export const GmailPresetPhone: Story = {
|
|
48
|
+
globals: { viewport: { value: "mobile" } },
|
|
49
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CalendarCreateCard } from "./CalendarCreateCard";
|
|
3
|
+
|
|
4
|
+
/** Settings › Calendars: adding a calendar, and the server refusing one. */
|
|
5
|
+
const meta: Meta<typeof CalendarCreateCard> = {
|
|
6
|
+
title: "Playground/Shipped/Settings/Calendars/New calendar",
|
|
7
|
+
component: CalendarCreateCard,
|
|
8
|
+
parameters: { layout: "padded" },
|
|
9
|
+
args: {
|
|
10
|
+
isBusy: false,
|
|
11
|
+
problem: "",
|
|
12
|
+
onCreate: () => Promise.resolve(true),
|
|
13
|
+
},
|
|
14
|
+
};
|
|
15
|
+
export default meta;
|
|
16
|
+
|
|
17
|
+
type Story = StoryObj<typeof CalendarCreateCard>;
|
|
18
|
+
|
|
19
|
+
export const Empty: Story = {};
|
|
20
|
+
|
|
21
|
+
/** The create is out. */
|
|
22
|
+
export const Adding: Story = { args: { isBusy: true } };
|
|
23
|
+
|
|
24
|
+
/** The address is already taken, said in the server's words. */
|
|
25
|
+
export const AddressTaken: Story = {
|
|
26
|
+
args: {
|
|
27
|
+
problem:
|
|
28
|
+
'"work" already addresses a calendar on this account — pick another',
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** The same card on the dark theme. */
|
|
33
|
+
export const Dark: Story = { parameters: { theme: "dark" } };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Calendars: adding a calendar.
|
|
3
|
+
*
|
|
4
|
+
* The address is the collection's identity and the path a CalDAV client
|
|
5
|
+
* bookmarks, so it is asked for rather than hidden. It follows the name until
|
|
6
|
+
* the reader edits it, and the server decides whether it is free — a taken one
|
|
7
|
+
* comes back in the server's words where the button is.
|
|
8
|
+
*/
|
|
9
|
+
import {
|
|
10
|
+
Banner,
|
|
11
|
+
Button,
|
|
12
|
+
Card,
|
|
13
|
+
CardBody,
|
|
14
|
+
CardHeader,
|
|
15
|
+
CardTitle,
|
|
16
|
+
Input,
|
|
17
|
+
} from "@remit/ui";
|
|
18
|
+
import { Plus } from "lucide-react";
|
|
19
|
+
import { useId, useState } from "react";
|
|
20
|
+
import type { CalendarDetails } from "@/components/settings/CalendarDetailsForm";
|
|
21
|
+
import { TimeZoneSelect } from "@/components/settings/TimeZoneSelect";
|
|
22
|
+
import { UNZONED_CALENDAR } from "@/hooks/calendar";
|
|
23
|
+
import { calendarUrlSegmentFor } from "@/lib/calendar-collection-copy";
|
|
24
|
+
|
|
25
|
+
export interface NewCalendar extends CalendarDetails {
|
|
26
|
+
urlSegment: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface CalendarCreateCardProps {
|
|
30
|
+
isBusy: boolean;
|
|
31
|
+
/** The server's words for the last create it turned down; empty when none. */
|
|
32
|
+
problem: string;
|
|
33
|
+
/** Resolves true once the server has the calendar, so the form can clear. */
|
|
34
|
+
onCreate: (calendar: NewCalendar) => Promise<boolean>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function CalendarCreateCard({
|
|
38
|
+
isBusy,
|
|
39
|
+
problem,
|
|
40
|
+
onCreate,
|
|
41
|
+
}: CalendarCreateCardProps) {
|
|
42
|
+
const [displayName, setDisplayName] = useState("");
|
|
43
|
+
const [typedSegment, setTypedSegment] = useState<string | undefined>(
|
|
44
|
+
undefined,
|
|
45
|
+
);
|
|
46
|
+
const [timezone, setTimezone] = useState(UNZONED_CALENDAR);
|
|
47
|
+
const titleId = useId();
|
|
48
|
+
const nameId = useId();
|
|
49
|
+
const segmentId = useId();
|
|
50
|
+
const zoneId = useId();
|
|
51
|
+
|
|
52
|
+
const urlSegment = typedSegment ?? calendarUrlSegmentFor(displayName);
|
|
53
|
+
const ready = displayName.trim() !== "" && urlSegment.trim() !== "";
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<Card className="max-w-xl" role="region" aria-labelledby={titleId}>
|
|
57
|
+
<CardHeader>
|
|
58
|
+
<CardTitle id={titleId}>New calendar</CardTitle>
|
|
59
|
+
</CardHeader>
|
|
60
|
+
<CardBody>
|
|
61
|
+
<form
|
|
62
|
+
className="space-y-3"
|
|
63
|
+
onSubmit={(event) => {
|
|
64
|
+
event.preventDefault();
|
|
65
|
+
if (!ready) return;
|
|
66
|
+
void onCreate({
|
|
67
|
+
displayName: displayName.trim(),
|
|
68
|
+
urlSegment: urlSegment.trim(),
|
|
69
|
+
timezone,
|
|
70
|
+
}).then((created) => {
|
|
71
|
+
if (!created) return;
|
|
72
|
+
setDisplayName("");
|
|
73
|
+
setTypedSegment(undefined);
|
|
74
|
+
setTimezone(UNZONED_CALENDAR);
|
|
75
|
+
});
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
79
|
+
<div className="space-y-1">
|
|
80
|
+
<label
|
|
81
|
+
htmlFor={nameId}
|
|
82
|
+
className="text-xs font-medium text-fg-muted"
|
|
83
|
+
>
|
|
84
|
+
Name
|
|
85
|
+
</label>
|
|
86
|
+
<Input
|
|
87
|
+
id={nameId}
|
|
88
|
+
value={displayName}
|
|
89
|
+
maxLength={140}
|
|
90
|
+
disabled={isBusy}
|
|
91
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
<div className="space-y-1">
|
|
95
|
+
<label
|
|
96
|
+
htmlFor={segmentId}
|
|
97
|
+
className="text-xs font-medium text-fg-muted"
|
|
98
|
+
>
|
|
99
|
+
Address
|
|
100
|
+
</label>
|
|
101
|
+
<Input
|
|
102
|
+
id={segmentId}
|
|
103
|
+
value={urlSegment}
|
|
104
|
+
maxLength={64}
|
|
105
|
+
disabled={isBusy}
|
|
106
|
+
onChange={(event) => setTypedSegment(event.target.value)}
|
|
107
|
+
/>
|
|
108
|
+
</div>
|
|
109
|
+
<div className="space-y-1 sm:col-span-2">
|
|
110
|
+
<label
|
|
111
|
+
htmlFor={zoneId}
|
|
112
|
+
className="text-xs font-medium text-fg-muted"
|
|
113
|
+
>
|
|
114
|
+
Time zone
|
|
115
|
+
</label>
|
|
116
|
+
<TimeZoneSelect
|
|
117
|
+
id={zoneId}
|
|
118
|
+
value={timezone}
|
|
119
|
+
disabled={isBusy}
|
|
120
|
+
onChange={setTimezone}
|
|
121
|
+
/>
|
|
122
|
+
</div>
|
|
123
|
+
</div>
|
|
124
|
+
<p className="text-xs text-fg-muted">
|
|
125
|
+
The address names the calendar for CalDAV clients and cannot be
|
|
126
|
+
changed later. The name and zone can.
|
|
127
|
+
</p>
|
|
128
|
+
|
|
129
|
+
{problem !== "" && (
|
|
130
|
+
<Banner tone="danger" variant="soft">
|
|
131
|
+
<p className="font-medium">The calendar was not added.</p>
|
|
132
|
+
<p className="mt-0.5 break-words">{problem}</p>
|
|
133
|
+
</Banner>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
<Button
|
|
137
|
+
type="submit"
|
|
138
|
+
variant="primary"
|
|
139
|
+
size="sm"
|
|
140
|
+
disabled={isBusy || !ready}
|
|
141
|
+
icon={<Plus className="size-3.5" />}
|
|
142
|
+
>
|
|
143
|
+
{isBusy ? "Adding…" : "Add calendar"}
|
|
144
|
+
</Button>
|
|
145
|
+
</form>
|
|
146
|
+
</CardBody>
|
|
147
|
+
</Card>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CalendarDetailsForm } from "./CalendarDetailsForm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Settings › Calendars: one calendar's name and zone, and deleting it. Every
|
|
6
|
+
* state the server can leave the form in, including its refusal to delete the
|
|
7
|
+
* default calendar.
|
|
8
|
+
*/
|
|
9
|
+
const meta: Meta<typeof CalendarDetailsForm> = {
|
|
10
|
+
title: "Playground/Shipped/Settings/Calendars/Calendar details",
|
|
11
|
+
component: CalendarDetailsForm,
|
|
12
|
+
parameters: { layout: "padded" },
|
|
13
|
+
args: {
|
|
14
|
+
calendarName: "Work",
|
|
15
|
+
timezone: "Europe/Amsterdam",
|
|
16
|
+
isBusy: false,
|
|
17
|
+
problem: "",
|
|
18
|
+
onSave: () => undefined,
|
|
19
|
+
onDelete: () => undefined,
|
|
20
|
+
},
|
|
21
|
+
render: (args) => (
|
|
22
|
+
<div className="max-w-xl">
|
|
23
|
+
<CalendarDetailsForm {...args} />
|
|
24
|
+
</div>
|
|
25
|
+
),
|
|
26
|
+
};
|
|
27
|
+
export default meta;
|
|
28
|
+
|
|
29
|
+
type Story = StoryObj<typeof CalendarDetailsForm>;
|
|
30
|
+
|
|
31
|
+
export const Default: Story = {};
|
|
32
|
+
|
|
33
|
+
/** No zone of its own, which reads as UTC. */
|
|
34
|
+
export const Unzoned: Story = { args: { timezone: "" } };
|
|
35
|
+
|
|
36
|
+
/** A save or delete is out; every control waits. */
|
|
37
|
+
export const Busy: Story = { args: { isBusy: true } };
|
|
38
|
+
|
|
39
|
+
/** The server refused to delete the default calendar, in its own words. */
|
|
40
|
+
export const DeleteRefused: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
calendarName: "Default",
|
|
43
|
+
problem:
|
|
44
|
+
'"default" is the calendar this account files events into and cannot be removed',
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Deleting asks first, because every event in the calendar goes with it. */
|
|
49
|
+
export const DeleteConfirm: Story = {
|
|
50
|
+
play: async ({ canvasElement }) => {
|
|
51
|
+
const buttons = [...canvasElement.querySelectorAll("button")];
|
|
52
|
+
buttons.find((button) => button.textContent === "Delete calendar")?.click();
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** The same form on the dark theme. */
|
|
57
|
+
export const Dark: Story = { parameters: { theme: "dark" } };
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings › Calendars: one calendar's name and zone, and deleting it.
|
|
3
|
+
*
|
|
4
|
+
* Presentational. The draft starts from what the server holds and the parent
|
|
5
|
+
* remounts it when that changes, so a saved rename reads back from the listing
|
|
6
|
+
* rather than from what was typed. A delete always asks first: the calendar
|
|
7
|
+
* goes with every event in it, and nothing on this page can tell an empty one
|
|
8
|
+
* from a full one without reading all of its time.
|
|
9
|
+
*/
|
|
10
|
+
import { Banner, Button, ConfirmDialog, Input } from "@remit/ui";
|
|
11
|
+
import { Save, Trash2 } from "lucide-react";
|
|
12
|
+
import { useId, useState } from "react";
|
|
13
|
+
import { TimeZoneSelect } from "@/components/settings/TimeZoneSelect";
|
|
14
|
+
import { deleteCalendarConfirmCopy } from "@/lib/calendar-collection-copy";
|
|
15
|
+
|
|
16
|
+
export interface CalendarDetails {
|
|
17
|
+
displayName: string;
|
|
18
|
+
timezone: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface CalendarDetailsFormProps {
|
|
22
|
+
/** What the server holds now. */
|
|
23
|
+
calendarName: string;
|
|
24
|
+
timezone: string;
|
|
25
|
+
isBusy: boolean;
|
|
26
|
+
/** The server's words for the last write it turned down; empty when none. */
|
|
27
|
+
problem: string;
|
|
28
|
+
onSave: (details: CalendarDetails) => void;
|
|
29
|
+
onDelete: () => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function CalendarDetailsForm({
|
|
33
|
+
calendarName,
|
|
34
|
+
timezone,
|
|
35
|
+
isBusy,
|
|
36
|
+
problem,
|
|
37
|
+
onSave,
|
|
38
|
+
onDelete,
|
|
39
|
+
}: CalendarDetailsFormProps) {
|
|
40
|
+
const [displayName, setDisplayName] = useState(calendarName);
|
|
41
|
+
const [zone, setZone] = useState(timezone);
|
|
42
|
+
const [confirming, setConfirming] = useState(false);
|
|
43
|
+
const nameId = useId();
|
|
44
|
+
const zoneId = useId();
|
|
45
|
+
|
|
46
|
+
const trimmed = displayName.trim();
|
|
47
|
+
const changed = trimmed !== calendarName || zone !== timezone;
|
|
48
|
+
const copy = deleteCalendarConfirmCopy(calendarName);
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<>
|
|
52
|
+
<form
|
|
53
|
+
className="space-y-3"
|
|
54
|
+
onSubmit={(event) => {
|
|
55
|
+
event.preventDefault();
|
|
56
|
+
if (!changed || trimmed === "") return;
|
|
57
|
+
onSave({ displayName: trimmed, timezone: zone });
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
61
|
+
<div className="space-y-1">
|
|
62
|
+
<label
|
|
63
|
+
htmlFor={nameId}
|
|
64
|
+
className="text-xs font-medium text-fg-muted"
|
|
65
|
+
>
|
|
66
|
+
Name
|
|
67
|
+
</label>
|
|
68
|
+
<Input
|
|
69
|
+
id={nameId}
|
|
70
|
+
value={displayName}
|
|
71
|
+
maxLength={140}
|
|
72
|
+
disabled={isBusy}
|
|
73
|
+
onChange={(event) => setDisplayName(event.target.value)}
|
|
74
|
+
/>
|
|
75
|
+
</div>
|
|
76
|
+
<div className="space-y-1">
|
|
77
|
+
<label
|
|
78
|
+
htmlFor={zoneId}
|
|
79
|
+
className="text-xs font-medium text-fg-muted"
|
|
80
|
+
>
|
|
81
|
+
Time zone
|
|
82
|
+
</label>
|
|
83
|
+
<TimeZoneSelect
|
|
84
|
+
id={zoneId}
|
|
85
|
+
value={zone}
|
|
86
|
+
disabled={isBusy}
|
|
87
|
+
onChange={setZone}
|
|
88
|
+
/>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{problem !== "" && (
|
|
93
|
+
<Banner tone="danger" variant="soft">
|
|
94
|
+
<p className="font-medium">{calendarName} was not changed.</p>
|
|
95
|
+
<p className="mt-0.5 break-words">{problem}</p>
|
|
96
|
+
</Banner>
|
|
97
|
+
)}
|
|
98
|
+
|
|
99
|
+
<div className="flex flex-wrap gap-2">
|
|
100
|
+
<Button
|
|
101
|
+
type="submit"
|
|
102
|
+
variant="secondary"
|
|
103
|
+
size="sm"
|
|
104
|
+
disabled={isBusy || !changed || trimmed === ""}
|
|
105
|
+
icon={<Save className="size-3.5" />}
|
|
106
|
+
>
|
|
107
|
+
Save changes
|
|
108
|
+
</Button>
|
|
109
|
+
<Button
|
|
110
|
+
type="button"
|
|
111
|
+
variant="danger"
|
|
112
|
+
size="sm"
|
|
113
|
+
disabled={isBusy}
|
|
114
|
+
icon={<Trash2 className="size-3.5" />}
|
|
115
|
+
onClick={() => setConfirming(true)}
|
|
116
|
+
>
|
|
117
|
+
Delete calendar
|
|
118
|
+
</Button>
|
|
119
|
+
</div>
|
|
120
|
+
</form>
|
|
121
|
+
|
|
122
|
+
<ConfirmDialog
|
|
123
|
+
isOpen={confirming}
|
|
124
|
+
title={copy.title}
|
|
125
|
+
description={copy.description}
|
|
126
|
+
confirmLabel="Delete calendar"
|
|
127
|
+
destructive
|
|
128
|
+
onConfirm={() => {
|
|
129
|
+
setConfirming(false);
|
|
130
|
+
onDelete();
|
|
131
|
+
}}
|
|
132
|
+
onCancel={() => setConfirming(false)}
|
|
133
|
+
/>
|
|
134
|
+
</>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { CalendarDetailsForm } from "./CalendarDetailsForm";
|
|
2
3
|
import { CalendarFeedCard } from "./CalendarFeedCard";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -132,3 +133,20 @@ export const Unreadable: Story = {
|
|
|
132
133
|
},
|
|
133
134
|
},
|
|
134
135
|
};
|
|
136
|
+
|
|
137
|
+
/** The whole card as the settings page draws it: details above the address. */
|
|
138
|
+
export const WithDetails: Story = {
|
|
139
|
+
args: {
|
|
140
|
+
state: { status: "active", createdAt: CREATED, rotatedAt: 0 },
|
|
141
|
+
details: (
|
|
142
|
+
<CalendarDetailsForm
|
|
143
|
+
calendarName="Work"
|
|
144
|
+
timezone="Europe/Amsterdam"
|
|
145
|
+
isBusy={false}
|
|
146
|
+
problem=""
|
|
147
|
+
onSave={() => undefined}
|
|
148
|
+
onDelete={() => undefined}
|
|
149
|
+
/>
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
};
|