@rapidmx/web-client 0.2.0
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/LICENSE +382 -0
- package/apps/admin/_404.tsx +9 -0
- package/apps/admin/_500.tsx +9 -0
- package/apps/admin/_layout.tsx +41 -0
- package/apps/admin/audit-log/index.tsx +187 -0
- package/apps/admin/branding/index.tsx +404 -0
- package/apps/admin/distribution-lists/[uid].tsx +70 -0
- package/apps/admin/distribution-lists/index.tsx +115 -0
- package/apps/admin/distribution-lists/new/index.tsx +114 -0
- package/apps/admin/domains/[uid].tsx +259 -0
- package/apps/admin/domains/index.tsx +125 -0
- package/apps/admin/domains/new/index.tsx +84 -0
- package/apps/admin/index.tsx +82 -0
- package/apps/admin/ingest-queue/index.tsx +116 -0
- package/apps/admin/mailboxes/[uid].tsx +126 -0
- package/apps/admin/mailboxes/new/index.tsx +291 -0
- package/apps/admin/quarantine/index.tsx +171 -0
- package/apps/admin/transport-rules/[uid].tsx +143 -0
- package/apps/admin/transport-rules/_transportRuleConfig.tsx +75 -0
- package/apps/admin/transport-rules/index.tsx +117 -0
- package/apps/admin/transport-rules/new/index.tsx +105 -0
- package/apps/shared/components/admin/distributionLists/MemberListCard.tsx +117 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +186 -0
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +73 -0
- package/apps/shared/components/admin/mailboxes/ResourceSettingsCard.tsx +154 -0
- package/apps/shared/components/admin/mailboxes/ShareAccessCard.tsx +124 -0
- package/apps/shared/components/booking/AvailabilityEditor.tsx +118 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +119 -0
- package/apps/shared/components/calendar/EventModal.tsx +513 -0
- package/apps/shared/components/calendar/MonthView.tsx +139 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +180 -0
- package/apps/shared/components/calendar/ResourcePicker.tsx +80 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +137 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +198 -0
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +211 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +99 -0
- package/apps/shared/components/contacts/ContactForm.tsx +314 -0
- package/apps/shared/components/contacts/ContactsSidebar.tsx +242 -0
- package/apps/shared/components/contacts/ContactsToolbar.tsx +115 -0
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +197 -0
- package/apps/shared/components/layout/AppShell.tsx +165 -0
- package/apps/shared/components/layout/BrandingChrome.tsx +25 -0
- package/apps/shared/components/layout/MailboxProvisioning.tsx +110 -0
- package/apps/shared/components/layout/UserMenu.tsx +150 -0
- package/apps/shared/components/mail/ConversationList.tsx +61 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +174 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +390 -0
- package/apps/shared/components/mail/compose/ComposeContext.tsx +114 -0
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +418 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +518 -0
- package/apps/shared/components/mail/compose/EmojiPicker.tsx +64 -0
- package/apps/shared/components/mail/compose/GifPicker.tsx +82 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +83 -0
- package/apps/shared/components/mail/compose/ScheduleSendPicker.tsx +64 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +287 -0
- package/apps/shared/components/rules/RuleBuilder.tsx +276 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +223 -0
- package/apps/shared/components/tasks/TasksSidebar.tsx +201 -0
- package/apps/shared/components/tasks/TasksToolbar.tsx +68 -0
- package/apps/shared/components/tasks/layout/TasksShell.tsx +199 -0
- package/apps/shared/styles/app.css +265 -0
- package/apps/www/_404.tsx +9 -0
- package/apps/www/_500.tsx +9 -0
- package/apps/www/_layout.tsx +45 -0
- package/apps/www/calendar/index.tsx +407 -0
- package/apps/www/contacts/[uid].tsx +88 -0
- package/apps/www/contacts/index.tsx +436 -0
- package/apps/www/index.tsx +375 -0
- package/apps/www/messages/[uid].tsx +70 -0
- package/apps/www/settings/auto-reply/index.tsx +129 -0
- package/apps/www/settings/booking-types/[uid].tsx +300 -0
- package/apps/www/settings/booking-types/index.tsx +98 -0
- package/apps/www/settings/booking-types/new/index.tsx +212 -0
- package/apps/www/settings/filters/[uid].tsx +155 -0
- package/apps/www/settings/filters/_mailFilterRuleConfig.tsx +102 -0
- package/apps/www/settings/filters/index.tsx +99 -0
- package/apps/www/settings/filters/new/index.tsx +126 -0
- package/apps/www/settings/focused-inbox/index.tsx +148 -0
- package/apps/www/settings/read-receipts/index.tsx +125 -0
- package/apps/www/settings/signatures/[uid].tsx +167 -0
- package/apps/www/settings/signatures/index.tsx +85 -0
- package/apps/www/settings/signatures/new/index.tsx +129 -0
- package/apps/www/settings/signatures/signatureDefaults.ts +36 -0
- package/apps/www/tasks/index.tsx +530 -0
- package/dist/apps/admin/_404.js +4 -0
- package/dist/apps/admin/_500.js +4 -0
- package/dist/apps/admin/_layout.js +10 -0
- package/dist/apps/admin/audit-log/index.js +66 -0
- package/dist/apps/admin/branding/index.js +126 -0
- package/dist/apps/admin/distribution-lists/[uid].js +34 -0
- package/dist/apps/admin/distribution-lists/index.js +31 -0
- package/dist/apps/admin/distribution-lists/new/index.js +51 -0
- package/dist/apps/admin/domains/[uid].js +102 -0
- package/dist/apps/admin/domains/index.js +31 -0
- package/dist/apps/admin/domains/new/index.js +41 -0
- package/dist/apps/admin/index.js +32 -0
- package/dist/apps/admin/ingest-queue/index.js +48 -0
- package/dist/apps/admin/mailboxes/[uid].js +59 -0
- package/dist/apps/admin/mailboxes/new/index.js +93 -0
- package/dist/apps/admin/quarantine/index.js +66 -0
- package/dist/apps/admin/transport-rules/[uid].js +87 -0
- package/dist/apps/admin/transport-rules/_transportRuleConfig.js +37 -0
- package/dist/apps/admin/transport-rules/index.js +32 -0
- package/dist/apps/admin/transport-rules/new/index.js +50 -0
- package/dist/apps/shared/components/admin/distributionLists/MemberListCard.js +64 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.js +94 -0
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +16 -0
- package/dist/apps/shared/components/admin/mailboxes/ResourceSettingsCard.js +59 -0
- package/dist/apps/shared/components/admin/mailboxes/ShareAccessCard.js +67 -0
- package/dist/apps/shared/components/booking/AvailabilityEditor.js +45 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +44 -0
- package/dist/apps/shared/components/calendar/EventModal.js +167 -0
- package/dist/apps/shared/components/calendar/MonthView.js +38 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +55 -0
- package/dist/apps/shared/components/calendar/ResourcePicker.js +36 -0
- package/dist/apps/shared/components/calendar/SplitDayView.js +44 -0
- package/dist/apps/shared/components/calendar/TimeGridView.js +58 -0
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +96 -0
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +11 -0
- package/dist/apps/shared/components/contacts/ContactForm.js +85 -0
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +105 -0
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +32 -0
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +93 -0
- package/dist/apps/shared/components/layout/AppShell.js +68 -0
- package/dist/apps/shared/components/layout/BrandingChrome.js +18 -0
- package/dist/apps/shared/components/layout/MailboxProvisioning.js +75 -0
- package/dist/apps/shared/components/layout/UserMenu.js +59 -0
- package/dist/apps/shared/components/mail/ConversationList.js +17 -0
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +112 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +153 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +55 -0
- package/dist/apps/shared/components/mail/compose/ComposeToolbar.js +88 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +261 -0
- package/dist/apps/shared/components/mail/compose/EmojiPicker.js +25 -0
- package/dist/apps/shared/components/mail/compose/GifPicker.js +36 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +60 -0
- package/dist/apps/shared/components/mail/compose/ScheduleSendPicker.js +33 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +139 -0
- package/dist/apps/shared/components/rules/RuleBuilder.js +78 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +100 -0
- package/dist/apps/shared/components/tasks/TasksSidebar.js +81 -0
- package/dist/apps/shared/components/tasks/TasksToolbar.js +15 -0
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +92 -0
- package/dist/apps/shared/styles/app.css +265 -0
- package/dist/apps/www/_404.js +4 -0
- package/dist/apps/www/_500.js +4 -0
- package/dist/apps/www/_layout.js +8 -0
- package/dist/apps/www/calendar/index.js +228 -0
- package/dist/apps/www/contacts/[uid].js +62 -0
- package/dist/apps/www/contacts/index.js +262 -0
- package/dist/apps/www/index.js +228 -0
- package/dist/apps/www/messages/[uid].js +47 -0
- package/dist/apps/www/settings/auto-reply/index.js +54 -0
- package/dist/apps/www/settings/booking-types/[uid].js +129 -0
- package/dist/apps/www/settings/booking-types/index.js +29 -0
- package/dist/apps/www/settings/booking-types/new/index.js +86 -0
- package/dist/apps/www/settings/filters/[uid].js +94 -0
- package/dist/apps/www/settings/filters/_mailFilterRuleConfig.js +65 -0
- package/dist/apps/www/settings/filters/index.js +30 -0
- package/dist/apps/www/settings/filters/new/index.js +61 -0
- package/dist/apps/www/settings/focused-inbox/index.js +69 -0
- package/dist/apps/www/settings/read-receipts/index.js +51 -0
- package/dist/apps/www/settings/signatures/[uid].js +96 -0
- package/dist/apps/www/settings/signatures/index.js +34 -0
- package/dist/apps/www/settings/signatures/new/index.js +63 -0
- package/dist/apps/www/settings/signatures/signatureDefaults.js +30 -0
- package/dist/apps/www/tasks/index.js +280 -0
- package/package.json +87 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { createContext, PropsWithChildren, ReactNode, useContext, useEffect, useMemo, useState } from "react";
|
|
6
|
+
import { HiOutlineBars3 } from "react-icons/hi2";
|
|
7
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
8
|
+
import Drawer from "@rapidmx/react-shared/components/overlays/Drawer.js";
|
|
9
|
+
import { Folder, Mailbox, listFolders, listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
11
|
+
import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedback/Skeleton.js";
|
|
12
|
+
import AppShell, { AppShellProps } from "../../layout/AppShell.js";
|
|
13
|
+
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
14
|
+
|
|
15
|
+
export type CalendarShellProps = Omit<AppShellProps, "active">;
|
|
16
|
+
|
|
17
|
+
export interface CalendarShellContextValue {
|
|
18
|
+
/** The mailbox currently selected (`?mailboxUid=`, or the caller's first accessible mailbox). */
|
|
19
|
+
mailboxUid?: string;
|
|
20
|
+
/** The selected mailbox's first `calendar`-type folder — kept for callers that only care about a
|
|
21
|
+
* single calendar (e.g. as the default target for "+ New event"). */
|
|
22
|
+
folderUid?: string;
|
|
23
|
+
/** Every `calendar`-type folder for the selected mailbox — usually just one, but a mailbox can have
|
|
24
|
+
* several (see `CalendarListSidebar`). */
|
|
25
|
+
calendarFolders: Folder[];
|
|
26
|
+
mailboxes: Mailbox[];
|
|
27
|
+
/** Re-fetches this mailbox's folders (e.g. after creating a new calendar) without a full mailbox reload. */
|
|
28
|
+
reloadFolders: () => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CalendarShellContext = createContext<CalendarShellContextValue>({ calendarFolders: [], mailboxes: [], reloadFolders: () => undefined });
|
|
32
|
+
|
|
33
|
+
/** Reads the mailbox/folder the Calendar is currently showing, as resolved by the enclosing `CalendarShell`. */
|
|
34
|
+
export function useCalendarShell(): CalendarShellContextValue {
|
|
35
|
+
return useContext(CalendarShellContext);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
type Status = "checking" | "error" | "ready";
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The Calendar app's shell — structurally identical to `ContactsShell`/`TasksShell` (a mailbox has
|
|
42
|
+
* exactly one well-known `calendar` folder, guaranteed to exist by `BaseMailboxRoute.create()`'s
|
|
43
|
+
* eager provisioning in `@rapidmx/restapi`), just bound to a different folder type. Unlike Contacts/
|
|
44
|
+
* Tasks, the actual view being looked at (month/week/day, and which one) is *not* shell state — it
|
|
45
|
+
* lives in `apps/www/calendar/index.tsx`'s own local state, since navigating between views/dates must
|
|
46
|
+
* be instant (no full page reload) and this framework has no client-side router to make a URL-driven
|
|
47
|
+
* approach for that free.
|
|
48
|
+
*/
|
|
49
|
+
export default function CalendarShell({
|
|
50
|
+
userUid,
|
|
51
|
+
authServerUrl,
|
|
52
|
+
impersonating,
|
|
53
|
+
impersonationBaseUrl,
|
|
54
|
+
trusted,
|
|
55
|
+
children,
|
|
56
|
+
}: PropsWithChildren<CalendarShellProps>) {
|
|
57
|
+
const [status, setStatus] = useState<Status>("checking");
|
|
58
|
+
const [error, setError] = useState<string | null>(null);
|
|
59
|
+
const [mailboxes, setMailboxes] = useState<Mailbox[]>([]);
|
|
60
|
+
const [folders, setFolders] = useState<Folder[]>([]);
|
|
61
|
+
const [folderError, setFolderError] = useState<string | null>(null);
|
|
62
|
+
const [requestedMailboxUid, setRequestedMailboxUid] = useState<string | null>(null);
|
|
63
|
+
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
64
|
+
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
setRequestedMailboxUid(new URLSearchParams(window.location.search).get("mailboxUid"));
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (!userUid) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
listMailboxes({ limit: 100 })
|
|
74
|
+
.then((result) => {
|
|
75
|
+
setMailboxes(result);
|
|
76
|
+
setStatus("ready");
|
|
77
|
+
})
|
|
78
|
+
.catch((err) => {
|
|
79
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not load your mailboxes.");
|
|
80
|
+
setStatus("error");
|
|
81
|
+
});
|
|
82
|
+
}, [userUid]);
|
|
83
|
+
|
|
84
|
+
const mailboxUid: string | undefined =
|
|
85
|
+
(requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
86
|
+
mailboxes[0]?.uid;
|
|
87
|
+
|
|
88
|
+
const [folderRefreshToken, setFolderRefreshToken] = useState(0);
|
|
89
|
+
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!mailboxUid) {
|
|
92
|
+
setFolders([]);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
setFolderError(null);
|
|
96
|
+
listFolders(mailboxUid)
|
|
97
|
+
.then(setFolders)
|
|
98
|
+
.catch((err) => setFolderError(err instanceof ApiRequestError ? err.message : "Could not load this mailbox's calendar folder."));
|
|
99
|
+
}, [mailboxUid, folderRefreshToken]);
|
|
100
|
+
|
|
101
|
+
const calendarFolders: Folder[] = useMemo(() => folders.filter((f) => f.type === "calendar"), [folders]);
|
|
102
|
+
const folderUid: string | undefined = calendarFolders[0]?.uid;
|
|
103
|
+
|
|
104
|
+
const contextValue = useMemo<CalendarShellContextValue>(
|
|
105
|
+
() => ({ mailboxUid, folderUid, calendarFolders, mailboxes, reloadFolders: () => setFolderRefreshToken((t) => t + 1) }),
|
|
106
|
+
[mailboxUid, folderUid, calendarFolders, mailboxes],
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// A full-screen takeover, not nested inside the rest of the app's chrome — there's nothing else
|
|
110
|
+
// for a mailbox-less caller to do here yet, so the icon rail/header don't render at all.
|
|
111
|
+
if (userUid && status === "ready" && !mailboxUid) {
|
|
112
|
+
return <MailboxProvisioning />;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let inner: ReactNode = null;
|
|
116
|
+
if (userUid && status === "checking") {
|
|
117
|
+
// Renders immediately (no network round trip needed) so switching into Calendar never shows a
|
|
118
|
+
// blank pane while `listMailboxes()` is in flight — the actual sidebar (mini date-picker +
|
|
119
|
+
// calendar list) lives in `apps/www/calendar/index.tsx`'s own content, so this mimics its rough
|
|
120
|
+
// shape rather than the (minimal, mailbox-switcher-only) shape of this shell's own sidebar.
|
|
121
|
+
inner = (
|
|
122
|
+
<div className="w-56 shrink-0 bg-surface border-r border-border p-3 flex flex-col gap-4">
|
|
123
|
+
<Skeleton height="h-40" className="rounded-md" />
|
|
124
|
+
<SkeletonList count={4} />
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
} else if (userUid && status === "error") {
|
|
128
|
+
inner = (
|
|
129
|
+
<div className="flex-1 flex items-center justify-center p-8">
|
|
130
|
+
<div className="w-full max-w-md">
|
|
131
|
+
<Alert>{error}</Alert>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
} else if (userUid && status === "ready") {
|
|
136
|
+
// A function, not a plain JSX constant — see MailShell/ContactsShell's identical comment: it's
|
|
137
|
+
// rendered twice (desktop `<aside>` + mobile `Drawer`), possibly simultaneously mounted, so the
|
|
138
|
+
// `<select>`'s `id`/its `<label>`'s `htmlFor` need a distinct value per instance.
|
|
139
|
+
const mailboxSwitcher = (idPrefix: string) =>
|
|
140
|
+
mailboxes.length > 1 && (
|
|
141
|
+
<div>
|
|
142
|
+
<label
|
|
143
|
+
className="block text-xs font-bold uppercase tracking-wide text-text-muted mb-1"
|
|
144
|
+
htmlFor={`${idPrefix}-calendar-mailbox-switcher`}
|
|
145
|
+
>
|
|
146
|
+
Mailbox
|
|
147
|
+
</label>
|
|
148
|
+
<select
|
|
149
|
+
id={`${idPrefix}-calendar-mailbox-switcher`}
|
|
150
|
+
className="w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface"
|
|
151
|
+
value={mailboxUid}
|
|
152
|
+
onChange={(e) => {
|
|
153
|
+
window.location.href = `/calendar?mailboxUid=${encodeURIComponent(e.target.value)}`;
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{mailboxes.map((mb) => (
|
|
157
|
+
<option key={mb.uid} value={mb.uid}>
|
|
158
|
+
{mb.displayName}
|
|
159
|
+
{mb.ownerUserUid ? "" : " (shared)"}
|
|
160
|
+
</option>
|
|
161
|
+
))}
|
|
162
|
+
</select>
|
|
163
|
+
</div>
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
inner = (
|
|
167
|
+
<>
|
|
168
|
+
{mailboxes.length > 1 && (
|
|
169
|
+
<div className="hidden md:block w-56 shrink-0 bg-surface border-r border-border p-3">{mailboxSwitcher("desktop")}</div>
|
|
170
|
+
)}
|
|
171
|
+
<Drawer open={drawerOpen} onClose={() => setDrawerOpen(false)} title="Mailbox">
|
|
172
|
+
<div className="flex flex-col gap-3">
|
|
173
|
+
{mailboxSwitcher("mobile")}
|
|
174
|
+
{folderError && <Alert>{folderError}</Alert>}
|
|
175
|
+
</div>
|
|
176
|
+
</Drawer>
|
|
177
|
+
<div className="flex-1 min-w-0 flex flex-col">
|
|
178
|
+
{(mailboxes.length > 1 || folderError) && (
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
className="md:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text"
|
|
182
|
+
aria-label="Open mailbox switcher"
|
|
183
|
+
onClick={() => setDrawerOpen(true)}
|
|
184
|
+
>
|
|
185
|
+
<HiOutlineBars3 size={20} aria-hidden="true" />
|
|
186
|
+
</button>
|
|
187
|
+
)}
|
|
188
|
+
{folderError && (
|
|
189
|
+
<div className="hidden md:block p-3">
|
|
190
|
+
<Alert>{folderError}</Alert>
|
|
191
|
+
</div>
|
|
192
|
+
)}
|
|
193
|
+
<CalendarShellContext.Provider value={contextValue}>{children}</CalendarShellContext.Provider>
|
|
194
|
+
</div>
|
|
195
|
+
</>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return (
|
|
200
|
+
<AppShell
|
|
201
|
+
active="calendar"
|
|
202
|
+
userUid={userUid}
|
|
203
|
+
authServerUrl={authServerUrl}
|
|
204
|
+
impersonating={impersonating}
|
|
205
|
+
impersonationBaseUrl={impersonationBaseUrl}
|
|
206
|
+
trusted={trusted}
|
|
207
|
+
>
|
|
208
|
+
{inner}
|
|
209
|
+
</AppShell>
|
|
210
|
+
);
|
|
211
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { Contact } from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
7
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
import ContactAvatar from "@rapidmx/react-shared/components/avatar/ContactAvatar.js";
|
|
9
|
+
|
|
10
|
+
export interface ContactDetailPaneProps {
|
|
11
|
+
contact: Contact;
|
|
12
|
+
onEdit: () => void;
|
|
13
|
+
onDelete: () => void;
|
|
14
|
+
/** Present only on the mobile detail route — renders a "back to contacts" link above the header. Absent
|
|
15
|
+
* on the desktop inline pane, which never navigates away (selecting a different contact just swaps
|
|
16
|
+
* `contact` in place). */
|
|
17
|
+
backHref?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A contact's read-only detail view. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`,
|
|
22
|
+
* always visible alongside the contact list) and the mobile detail route
|
|
23
|
+
* (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
|
|
24
|
+
*/
|
|
25
|
+
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref }: ContactDetailPaneProps) {
|
|
26
|
+
return (
|
|
27
|
+
<div role="region" aria-label="Contact details" className="max-w-xl flex flex-col gap-5">
|
|
28
|
+
{backHref && (
|
|
29
|
+
<a href={backHref} className="text-sm text-primary-dark hover:underline">
|
|
30
|
+
← Back to contacts
|
|
31
|
+
</a>
|
|
32
|
+
)}
|
|
33
|
+
<div className="flex items-start justify-between gap-4">
|
|
34
|
+
<div className="flex items-center gap-3">
|
|
35
|
+
<ContactAvatar displayName={contact.displayName} size={48} />
|
|
36
|
+
<div>
|
|
37
|
+
<h1 className="text-xl font-bold tracking-tight">{contact.displayName}</h1>
|
|
38
|
+
{contact.jobTitle && contact.company && (
|
|
39
|
+
<p className="text-sm text-text-muted mt-0.5">
|
|
40
|
+
{contact.jobTitle} at {contact.company}
|
|
41
|
+
</p>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
</div>
|
|
45
|
+
<div className="flex gap-2 shrink-0">
|
|
46
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={onEdit}>
|
|
47
|
+
Edit
|
|
48
|
+
</Button>
|
|
49
|
+
<Button type="button" variant="secondary" className="!w-auto text-danger" onClick={onDelete}>
|
|
50
|
+
Delete
|
|
51
|
+
</Button>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<div className="bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm">
|
|
56
|
+
{contact.emails.length > 0 && (
|
|
57
|
+
<div>
|
|
58
|
+
<div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Email</div>
|
|
59
|
+
{contact.emails.map((e, i) => (
|
|
60
|
+
<div key={i}>
|
|
61
|
+
{e.address} <span className="text-text-muted">({e.type})</span>
|
|
62
|
+
</div>
|
|
63
|
+
))}
|
|
64
|
+
</div>
|
|
65
|
+
)}
|
|
66
|
+
{contact.phones.length > 0 && (
|
|
67
|
+
<div>
|
|
68
|
+
<div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Phone</div>
|
|
69
|
+
{contact.phones.map((p, i) => (
|
|
70
|
+
<div key={i}>
|
|
71
|
+
{p.phoneNumber} <span className="text-text-muted">({p.type})</span>
|
|
72
|
+
</div>
|
|
73
|
+
))}
|
|
74
|
+
</div>
|
|
75
|
+
)}
|
|
76
|
+
{contact.addresses.length > 0 && (
|
|
77
|
+
<div>
|
|
78
|
+
<div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Address</div>
|
|
79
|
+
{contact.addresses.map((a, i) => (
|
|
80
|
+
<div key={i}>{[a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ")}</div>
|
|
81
|
+
))}
|
|
82
|
+
</div>
|
|
83
|
+
)}
|
|
84
|
+
{contact.categories && contact.categories.length > 0 && (
|
|
85
|
+
<div>
|
|
86
|
+
<div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Categories</div>
|
|
87
|
+
<div>{contact.categories.join(", ")}</div>
|
|
88
|
+
</div>
|
|
89
|
+
)}
|
|
90
|
+
{contact.notes && (
|
|
91
|
+
<div>
|
|
92
|
+
<div className="text-text-muted text-xs font-bold uppercase tracking-wide mb-1">Notes</div>
|
|
93
|
+
<div>{contact.notes}</div>
|
|
94
|
+
</div>
|
|
95
|
+
)}
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { FormEvent, useState } from "react";
|
|
6
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
7
|
+
import {
|
|
8
|
+
Contact,
|
|
9
|
+
ContactAddressKind,
|
|
10
|
+
ContactEmail,
|
|
11
|
+
ContactPhone,
|
|
12
|
+
ContactPostalAddress,
|
|
13
|
+
createContact,
|
|
14
|
+
updateContact,
|
|
15
|
+
} from "@rapidmx/react-shared/contacts/contactsApi.js";
|
|
16
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
17
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
18
|
+
import FormField from "@rapidmx/react-shared/components/forms/FormField.js";
|
|
19
|
+
|
|
20
|
+
const INPUT_CLASS =
|
|
21
|
+
"w-full text-sm py-2 px-3 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
22
|
+
const SELECT_CLASS =
|
|
23
|
+
"text-sm py-2 px-2 border border-border rounded-sm bg-surface text-text focus:outline-none focus:border-primary";
|
|
24
|
+
|
|
25
|
+
export interface ContactFormProps {
|
|
26
|
+
contact?: Contact;
|
|
27
|
+
mailboxUid?: string;
|
|
28
|
+
folderUid?: string;
|
|
29
|
+
onSaved: (contact: Contact) => void;
|
|
30
|
+
onCancel: () => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const ADDRESS_KINDS: ContactAddressKind[] = ["home", "work", "other"];
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A contact's create/edit form. Shared by the desktop inline pane (`apps/www/contacts/index.tsx`) and the
|
|
37
|
+
* mobile detail route's own edit mode (`apps/www/contacts/[uid].tsx`) — creation itself stays
|
|
38
|
+
* desktop-and-mobile-inline (see that route's own doc comment on why "new" never gets a dedicated route).
|
|
39
|
+
*/
|
|
40
|
+
export default function ContactForm({ contact, mailboxUid, folderUid, onSaved, onCancel }: ContactFormProps) {
|
|
41
|
+
const [displayName, setDisplayName] = useState(contact?.displayName ?? "");
|
|
42
|
+
const [givenName, setGivenName] = useState(contact?.givenName ?? "");
|
|
43
|
+
const [surname, setSurname] = useState(contact?.surname ?? "");
|
|
44
|
+
const [company, setCompany] = useState(contact?.company ?? "");
|
|
45
|
+
const [jobTitle, setJobTitle] = useState(contact?.jobTitle ?? "");
|
|
46
|
+
const [notes, setNotes] = useState(contact?.notes ?? "");
|
|
47
|
+
const [favorite, setFavorite] = useState(contact?.favorite ?? false);
|
|
48
|
+
const [categoriesText, setCategoriesText] = useState((contact?.categories ?? []).join(", "));
|
|
49
|
+
const [emails, setEmails] = useState<ContactEmail[]>(contact?.emails ?? []);
|
|
50
|
+
const [phones, setPhones] = useState<ContactPhone[]>(contact?.phones ?? []);
|
|
51
|
+
const [address, setAddress] = useState<ContactPostalAddress | null>(contact?.addresses[0] ?? null);
|
|
52
|
+
const [error, setError] = useState<string | null>(null);
|
|
53
|
+
const [saving, setSaving] = useState(false);
|
|
54
|
+
|
|
55
|
+
function updateEmail(index: number, patch: Partial<ContactEmail>) {
|
|
56
|
+
setEmails((prev) => prev.map((e, i) => (i === index ? { ...e, ...patch } : e)));
|
|
57
|
+
}
|
|
58
|
+
function removeEmail(index: number) {
|
|
59
|
+
setEmails((prev) => prev.filter((_, i) => i !== index));
|
|
60
|
+
}
|
|
61
|
+
function updatePhone(index: number, patch: Partial<ContactPhone>) {
|
|
62
|
+
setPhones((prev) => prev.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
|
63
|
+
}
|
|
64
|
+
function removePhone(index: number) {
|
|
65
|
+
setPhones((prev) => prev.filter((_, i) => i !== index));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleSubmit(e: FormEvent) {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
setError(null);
|
|
71
|
+
|
|
72
|
+
if (!displayName.trim()) {
|
|
73
|
+
setError("A display name is required.");
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
setSaving(true);
|
|
78
|
+
try {
|
|
79
|
+
const categories = categoriesText
|
|
80
|
+
.split(",")
|
|
81
|
+
.map((c) => c.trim())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
const input = {
|
|
84
|
+
displayName: displayName.trim(),
|
|
85
|
+
givenName: givenName.trim() || undefined,
|
|
86
|
+
surname: surname.trim() || undefined,
|
|
87
|
+
company: company.trim() || undefined,
|
|
88
|
+
jobTitle: jobTitle.trim() || undefined,
|
|
89
|
+
notes: notes.trim() || undefined,
|
|
90
|
+
favorite,
|
|
91
|
+
categories,
|
|
92
|
+
emails,
|
|
93
|
+
phones,
|
|
94
|
+
addresses: address ? [address] : [],
|
|
95
|
+
};
|
|
96
|
+
const saved = contact
|
|
97
|
+
? await updateContact({ uid: contact.uid, version: contact.version, mailboxUid: contact.mailboxUid, folderUid: contact.folderUid, ...input })
|
|
98
|
+
: await createContact({ mailboxUid: mailboxUid as string, folderUid: folderUid as string, ...input });
|
|
99
|
+
onSaved(saved);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not save this contact.");
|
|
102
|
+
} finally {
|
|
103
|
+
setSaving(false);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<form onSubmit={handleSubmit} className="max-w-xl flex flex-col gap-1">
|
|
109
|
+
<h1 className="text-xl font-bold uppercase tracking-wide mb-3">{contact ? "Edit contact" : "New contact"}</h1>
|
|
110
|
+
|
|
111
|
+
{error && <Alert>{error}</Alert>}
|
|
112
|
+
|
|
113
|
+
<FormField label="Display name" htmlFor="contact-displayName">
|
|
114
|
+
<input
|
|
115
|
+
id="contact-displayName"
|
|
116
|
+
type="text"
|
|
117
|
+
className={INPUT_CLASS}
|
|
118
|
+
value={displayName}
|
|
119
|
+
onChange={(e) => setDisplayName(e.target.value)}
|
|
120
|
+
/>
|
|
121
|
+
</FormField>
|
|
122
|
+
|
|
123
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
124
|
+
<FormField label="First name" htmlFor="contact-givenName">
|
|
125
|
+
<input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
|
|
126
|
+
</FormField>
|
|
127
|
+
<FormField label="Last name" htmlFor="contact-surname">
|
|
128
|
+
<input id="contact-surname" type="text" className={INPUT_CLASS} value={surname} onChange={(e) => setSurname(e.target.value)} />
|
|
129
|
+
</FormField>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
133
|
+
<FormField label="Company" htmlFor="contact-company">
|
|
134
|
+
<input id="contact-company" type="text" className={INPUT_CLASS} value={company} onChange={(e) => setCompany(e.target.value)} />
|
|
135
|
+
</FormField>
|
|
136
|
+
<FormField label="Job title" htmlFor="contact-jobTitle">
|
|
137
|
+
<input id="contact-jobTitle" type="text" className={INPUT_CLASS} value={jobTitle} onChange={(e) => setJobTitle(e.target.value)} />
|
|
138
|
+
</FormField>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<label className="flex items-center gap-2 text-sm mb-4">
|
|
142
|
+
<input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
|
|
143
|
+
Favorite
|
|
144
|
+
</label>
|
|
145
|
+
|
|
146
|
+
<FormField label="Categories (comma-separated)" htmlFor="contact-categories">
|
|
147
|
+
<input
|
|
148
|
+
id="contact-categories"
|
|
149
|
+
type="text"
|
|
150
|
+
className={INPUT_CLASS}
|
|
151
|
+
value={categoriesText}
|
|
152
|
+
onChange={(e) => setCategoriesText(e.target.value)}
|
|
153
|
+
placeholder="VIP, Work"
|
|
154
|
+
/>
|
|
155
|
+
</FormField>
|
|
156
|
+
|
|
157
|
+
<FormField label="Email" htmlFor="contact-emails">
|
|
158
|
+
<div id="contact-emails" className="flex flex-col gap-2">
|
|
159
|
+
{emails.map((email, i) => (
|
|
160
|
+
<div key={i} className="flex gap-2">
|
|
161
|
+
<input
|
|
162
|
+
type="email"
|
|
163
|
+
className={`${INPUT_CLASS} flex-1`}
|
|
164
|
+
value={email.address}
|
|
165
|
+
onChange={(e) => updateEmail(i, { address: e.target.value })}
|
|
166
|
+
aria-label={`Email address ${i + 1}`}
|
|
167
|
+
/>
|
|
168
|
+
<select
|
|
169
|
+
className={SELECT_CLASS}
|
|
170
|
+
value={email.type}
|
|
171
|
+
onChange={(e) => updateEmail(i, { type: e.target.value as ContactAddressKind })}
|
|
172
|
+
aria-label={`Email type ${i + 1}`}
|
|
173
|
+
>
|
|
174
|
+
{ADDRESS_KINDS.map((kind) => (
|
|
175
|
+
<option key={kind} value={kind}>
|
|
176
|
+
{kind}
|
|
177
|
+
</option>
|
|
178
|
+
))}
|
|
179
|
+
</select>
|
|
180
|
+
<button type="button" onClick={() => removeEmail(i)} className="text-sm text-danger px-2" aria-label={`Remove email ${i + 1}`}>
|
|
181
|
+
×
|
|
182
|
+
</button>
|
|
183
|
+
</div>
|
|
184
|
+
))}
|
|
185
|
+
<button
|
|
186
|
+
type="button"
|
|
187
|
+
onClick={() => setEmails((prev) => [...prev, { address: "", type: "work" }])}
|
|
188
|
+
className="self-start text-xs font-medium text-primary-dark hover:underline"
|
|
189
|
+
>
|
|
190
|
+
+ Add email
|
|
191
|
+
</button>
|
|
192
|
+
</div>
|
|
193
|
+
</FormField>
|
|
194
|
+
|
|
195
|
+
<FormField label="Phone" htmlFor="contact-phones">
|
|
196
|
+
<div id="contact-phones" className="flex flex-col gap-2">
|
|
197
|
+
{phones.map((phone, i) => (
|
|
198
|
+
<div key={i} className="flex gap-2">
|
|
199
|
+
<input
|
|
200
|
+
type="tel"
|
|
201
|
+
className={`${INPUT_CLASS} flex-1`}
|
|
202
|
+
value={phone.phoneNumber}
|
|
203
|
+
onChange={(e) => updatePhone(i, { phoneNumber: e.target.value })}
|
|
204
|
+
aria-label={`Phone number ${i + 1}`}
|
|
205
|
+
/>
|
|
206
|
+
<select
|
|
207
|
+
className={SELECT_CLASS}
|
|
208
|
+
value={phone.type}
|
|
209
|
+
onChange={(e) => updatePhone(i, { type: e.target.value as ContactAddressKind })}
|
|
210
|
+
aria-label={`Phone type ${i + 1}`}
|
|
211
|
+
>
|
|
212
|
+
{ADDRESS_KINDS.map((kind) => (
|
|
213
|
+
<option key={kind} value={kind}>
|
|
214
|
+
{kind}
|
|
215
|
+
</option>
|
|
216
|
+
))}
|
|
217
|
+
</select>
|
|
218
|
+
<button type="button" onClick={() => removePhone(i)} className="text-sm text-danger px-2" aria-label={`Remove phone ${i + 1}`}>
|
|
219
|
+
×
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
))}
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
onClick={() => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }])}
|
|
226
|
+
className="self-start text-xs font-medium text-primary-dark hover:underline"
|
|
227
|
+
>
|
|
228
|
+
+ Add phone
|
|
229
|
+
</button>
|
|
230
|
+
</div>
|
|
231
|
+
</FormField>
|
|
232
|
+
|
|
233
|
+
<FormField label="Address" htmlFor="contact-address">
|
|
234
|
+
{address ? (
|
|
235
|
+
<div className="flex flex-col gap-2">
|
|
236
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
|
237
|
+
<input
|
|
238
|
+
type="text"
|
|
239
|
+
placeholder="Street"
|
|
240
|
+
className={INPUT_CLASS}
|
|
241
|
+
value={address.street ?? ""}
|
|
242
|
+
onChange={(e) => setAddress({ ...address, street: e.target.value })}
|
|
243
|
+
/>
|
|
244
|
+
<input
|
|
245
|
+
type="text"
|
|
246
|
+
placeholder="City"
|
|
247
|
+
className={INPUT_CLASS}
|
|
248
|
+
value={address.city ?? ""}
|
|
249
|
+
onChange={(e) => setAddress({ ...address, city: e.target.value })}
|
|
250
|
+
/>
|
|
251
|
+
<input
|
|
252
|
+
type="text"
|
|
253
|
+
placeholder="State/Province"
|
|
254
|
+
className={INPUT_CLASS}
|
|
255
|
+
value={address.state ?? ""}
|
|
256
|
+
onChange={(e) => setAddress({ ...address, state: e.target.value })}
|
|
257
|
+
/>
|
|
258
|
+
<input
|
|
259
|
+
type="text"
|
|
260
|
+
placeholder="Postal code"
|
|
261
|
+
className={INPUT_CLASS}
|
|
262
|
+
value={address.postalCode ?? ""}
|
|
263
|
+
onChange={(e) => setAddress({ ...address, postalCode: e.target.value })}
|
|
264
|
+
/>
|
|
265
|
+
<input
|
|
266
|
+
type="text"
|
|
267
|
+
placeholder="Country"
|
|
268
|
+
className={INPUT_CLASS}
|
|
269
|
+
value={address.country ?? ""}
|
|
270
|
+
onChange={(e) => setAddress({ ...address, country: e.target.value })}
|
|
271
|
+
/>
|
|
272
|
+
<select
|
|
273
|
+
className={SELECT_CLASS}
|
|
274
|
+
value={address.type}
|
|
275
|
+
onChange={(e) => setAddress({ ...address, type: e.target.value as ContactAddressKind })}
|
|
276
|
+
aria-label="Address type"
|
|
277
|
+
>
|
|
278
|
+
{ADDRESS_KINDS.map((kind) => (
|
|
279
|
+
<option key={kind} value={kind}>
|
|
280
|
+
{kind}
|
|
281
|
+
</option>
|
|
282
|
+
))}
|
|
283
|
+
</select>
|
|
284
|
+
</div>
|
|
285
|
+
<button type="button" onClick={() => setAddress(null)} className="self-start text-xs font-medium text-danger hover:underline">
|
|
286
|
+
Remove address
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
) : (
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
onClick={() => setAddress({ type: "home" })}
|
|
293
|
+
className="self-start text-xs font-medium text-primary-dark hover:underline"
|
|
294
|
+
>
|
|
295
|
+
+ Add address
|
|
296
|
+
</button>
|
|
297
|
+
)}
|
|
298
|
+
</FormField>
|
|
299
|
+
|
|
300
|
+
<FormField label="Notes" htmlFor="contact-notes">
|
|
301
|
+
<textarea id="contact-notes" rows={3} className={INPUT_CLASS} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
|
302
|
+
</FormField>
|
|
303
|
+
|
|
304
|
+
<div className="flex gap-3 mt-2">
|
|
305
|
+
<Button type="submit" loading={saving} disabled={saving} className="!w-auto">
|
|
306
|
+
Save
|
|
307
|
+
</Button>
|
|
308
|
+
<Button type="button" variant="secondary" className="!w-auto" onClick={onCancel}>
|
|
309
|
+
Cancel
|
|
310
|
+
</Button>
|
|
311
|
+
</div>
|
|
312
|
+
</form>
|
|
313
|
+
);
|
|
314
|
+
}
|