mirror of
https://github.com/suitenumerique/messages.git
synced 2026-09-22 17:55:01 +02:00
💅(frontend) refine mailbox dropdown menu (#705)
Customize mailbox dropdown menu to make it more 'interactible'. Distinguish shared vs personal mailbox through avatar shape and display Name + email.
This commit is contained in:
@@ -71,8 +71,12 @@ test.describe("Mailbox settings modal", () => {
|
||||
// The user administers two mailboxes (their own + the import mailbox) and is
|
||||
// only a sender on the shared mailbox, so the switcher is rendered and lists
|
||||
// exactly the two administered mailboxes.
|
||||
//
|
||||
// The switcher trigger is the shared MailboxSelector card: its accessible
|
||||
// name is the currently-configured mailbox (the user's own administered
|
||||
// mailbox) rather than a fixed label.
|
||||
await modal
|
||||
.getByRole("button", { name: "Select the mailbox to configure" })
|
||||
.getByRole("button", { name: `user.e2e.${browserName}@example.local` })
|
||||
.click();
|
||||
|
||||
// The switcher renders a single-select dropdown, so its entries expose a
|
||||
|
||||
@@ -23,7 +23,7 @@ test.describe("Import Message", () => {
|
||||
|
||||
// Go the import mailbox
|
||||
await page.getByRole("button", { name: getMailboxEmail('user', browserName) }).click();
|
||||
await page.getByRole("menuitem", { name: getMailboxEmail('import') }).click();
|
||||
await page.getByRole("menuitem").filter({ hasText: getMailboxEmail('import') }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// As the database is fresh, there should be no threads and the Import messages button should be visible
|
||||
@@ -117,7 +117,8 @@ test.describe("Import Message", () => {
|
||||
// Go to the shared mailbox where the user only has sender rights
|
||||
await page.getByRole("button", { name: email }).click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: getMailboxEmail("shared") })
|
||||
.getByRole("menuitem")
|
||||
.filter({ hasText: getMailboxEmail("shared") })
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ test.describe("Inline Image in Composer", () => {
|
||||
// Switch to shared mailbox and verify the message is received
|
||||
// Message delivery is async (Celery task), so we poll until the message appears
|
||||
await page.getByTestId('panel-main-left').getByRole("button", { name: getMailboxEmail('user', browserName) }).click();
|
||||
await page.getByRole("menuitem", { name: getMailboxEmail('shared') }).click();
|
||||
await page.getByRole("menuitem").filter({ hasText: getMailboxEmail('shared') }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.getByRole("link", { name: "Inbox" }).click();
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ test.describe("Send Message", () => {
|
||||
|
||||
// Go the shared mailbox and check if the message is there
|
||||
await page.getByRole("button", { name: getMailboxEmail('user', browserName) }).click();
|
||||
await page.getByRole("menuitem", { name: getMailboxEmail('shared') }).click();
|
||||
await page.getByRole("menuitem").filter({ hasText: getMailboxEmail('shared') }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await page.getByRole("link", { name: "Inbox" }).click();
|
||||
|
||||
@@ -33,7 +33,8 @@ async function navigateToSharedThread(page: Page, browserName: BrowserName) {
|
||||
.getByRole("button", { name: getMailboxEmail("user", browserName) })
|
||||
.click();
|
||||
await page
|
||||
.getByRole("menuitem", { name: getMailboxEmail("shared") })
|
||||
.getByRole("menuitem")
|
||||
.filter({ hasText: getMailboxEmail("shared") })
|
||||
.click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
|
||||
@@ -68,9 +68,17 @@ export const signInKeycloakIfNeeded = async ({ page, username, navigateTo = "/"
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForURL(`/`, { waitUntil: 'networkidle' });
|
||||
|
||||
expect(proConnectButton).not.toBeVisible();
|
||||
const mailboxName = await page.getByRole('button', { name: email });
|
||||
expect(mailboxName).toBeVisible();
|
||||
await expect(proConnectButton).not.toBeVisible();
|
||||
|
||||
// Confirm the authenticated app shell rendered before snapshotting storage
|
||||
// state. The sidebar mailbox selector shows the signed-in address, but it
|
||||
// renders as a switcher *button* only for multi-mailbox users; single-mailbox
|
||||
// fixtures (e.g. domain_admin) get a static card instead. Match on the
|
||||
// address text within the selector rather than a button role, which covers
|
||||
// both variants.
|
||||
await expect(
|
||||
page.locator('.mailbox-selector').getByText(email),
|
||||
).toBeVisible();
|
||||
|
||||
await page.context().storageState({ path: storageStatePath });
|
||||
};
|
||||
|
||||
@@ -23,33 +23,11 @@
|
||||
flex-shrink: 0;
|
||||
padding-bottom: var(--c--globals--spacings--xs);
|
||||
|
||||
& > .c__dropdown-menu-trigger {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button.mailbox-panel__mailbox-title__dropdown-button {
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
font-weight: 700;
|
||||
padding-block: var(--c--globals--spacings--sm);
|
||||
// The trigger card itself lives in the shared MailboxSelector component; here
|
||||
// we only nudge its trailing padding so the unfold chevron lines up with the
|
||||
// action icons sitting above it in the sidebar header.
|
||||
.mailbox-selector__trigger {
|
||||
// 1.1rem hardcoded to be aligned with other icon in the sidebar
|
||||
padding-inline: var(--c--globals--spacings--base) 1.1rem;
|
||||
width: 100%;
|
||||
height: inherit;
|
||||
|
||||
& > .button__label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
color: inherit;
|
||||
}
|
||||
padding-inline-end: 1.1rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,68 +1,40 @@
|
||||
import { DropdownMenu, HorizontalSeparator, Icon, Spinner } from "@gouvfr-lasuite/ui-kit"
|
||||
import { ChevronDown, ChevronUp } from "@gouvfr-lasuite/ui-kit/icons";
|
||||
import { HorizontalSeparator, Spinner } from "@gouvfr-lasuite/ui-kit"
|
||||
import { MailboxPanelActions } from "./components/mailbox-actions"
|
||||
import { MailboxList } from "./components/mailbox-list"
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useUrlSearchParams } from "@/hooks/use-url-search-params";
|
||||
import { useLayoutContext } from "@/features/layouts/components/layout-context";
|
||||
import { MailboxLabels } from "./components/mailbox-labels";
|
||||
import { useState } from "react";
|
||||
import { Group, Panel, Separator, useDefaultLayout } from "react-resizable-panels";
|
||||
import MailboxHelper from "@/features/utils/mailbox-helper";
|
||||
import { MailboxSelector } from "@/features/layouts/components/mailbox-selector";
|
||||
|
||||
export const MailboxPanel = () => {
|
||||
const navigate = useNavigate();
|
||||
const searchParams = useUrlSearchParams();
|
||||
const { selectedMailbox, mailboxes, queryStates } = useMailboxContext();
|
||||
const { closeLeftPanel } = useLayoutContext();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { defaultLayout, onLayoutChange } = useDefaultLayout({
|
||||
groupId: "mailbox-panel-sections",
|
||||
storage: typeof window !== "undefined" ? localStorage : undefined,
|
||||
});
|
||||
|
||||
const getMailboxOptions = () => {
|
||||
if (!mailboxes) return [];
|
||||
const sortedMailboxes = MailboxHelper.sortByKind(mailboxes);
|
||||
return sortedMailboxes.map((mailbox, index) => ({
|
||||
label: mailbox.email,
|
||||
value: mailbox.id,
|
||||
icon: mailbox.is_identity ? <Icon name="person" /> : <Icon name="group" />,
|
||||
showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index)
|
||||
}));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mailbox-panel">
|
||||
<div className="mailbox-panel__header">
|
||||
<MailboxPanelActions />
|
||||
<HorizontalSeparator withPadding={false} />
|
||||
{ selectedMailbox && (
|
||||
<div className="mailbox-panel__mailbox-title">
|
||||
<DropdownMenu
|
||||
options={getMailboxOptions()}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedValues={[selectedMailbox.id]}
|
||||
onSelectValue={(value) => {
|
||||
closeLeftPanel();
|
||||
navigate({ to: '/mailbox/$mailboxId', params: { mailboxId: value }, search: Object.fromEntries(searchParams) });
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="mailbox-panel__mailbox-title__dropdown-button"
|
||||
color="neutral"
|
||||
variant="tertiary"
|
||||
icon={isOpen ? <ChevronUp size="small" /> : <ChevronDown size="small" />}
|
||||
iconPosition="right"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
<span className="button__label">{selectedMailbox.email}</span>
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
{ selectedMailbox && mailboxes && (
|
||||
<div className="mailbox-panel__mailbox-title">
|
||||
<MailboxSelector
|
||||
mailboxes={mailboxes}
|
||||
selectedMailbox={selectedMailbox}
|
||||
onSelect={(mailboxId) => {
|
||||
closeLeftPanel();
|
||||
navigate({ to: '/mailbox/$mailboxId', params: { mailboxId }, search: Object.fromEntries(searchParams) });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!selectedMailbox || queryStates.mailboxes.isLoading ? <Spinner /> :
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
.mailbox-selector {
|
||||
// Make the dropdown trigger wrapper span the full available width so the
|
||||
// card aligns with the surrounding sidebar/header content.
|
||||
& > .c__dropdown-menu-trigger {
|
||||
justify-content: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// Card shared by the switchable (button) and the static (single-mailbox)
|
||||
// variants: avatar on the left, bold name and the address underneath, with
|
||||
// the unfold chevron pushed to the trailing edge.
|
||||
.mailbox-selector__trigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--xs);
|
||||
width: 100%;
|
||||
height: inherit;
|
||||
padding-block: var(--c--globals--spacings--base) var(--c--globals--spacings--sm);
|
||||
padding-inline: var(--c--globals--spacings--base);
|
||||
.c__button__icon {
|
||||
transition: transform 0.25s ease;
|
||||
backface-visibility: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.mailbox-selector__trigger[aria-expanded="true"] {
|
||||
.c__button__icon {
|
||||
transform: rotateX(-180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.mailbox-selector__trigger--static {
|
||||
// Nothing to unfold for a single mailbox: drop the interactive cursor.
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
// Shared (non-identity) mailboxes get a rounded-square avatar to set them
|
||||
// apart from the circular personal one.
|
||||
.mailbox-selector__avatar[data-shared="true"] .c__avatar {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.mailbox-selector__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mailbox-selector__name {
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
line-height: 1.2;
|
||||
font-weight: 700;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mailbox-selector__email {
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
opacity: 0.8;
|
||||
font-weight: 500;
|
||||
line-height: 1.2;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
// Same rounded-square treatment for shared mailboxes in the dropdown options.
|
||||
// The popover renders in a portal outside `.mailbox-selector`, so this rule is
|
||||
// intentionally global rather than nested under it.
|
||||
.mailbox-selector__option-avatar {
|
||||
// Layout-transparent wrapper: the avatar sits exactly where the dropdown
|
||||
// expects its icon, we only need the element as a styling hook.
|
||||
display: contents;
|
||||
|
||||
&[data-shared="true"] .c__avatar {
|
||||
border-radius: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
// The dropdown caps its width at 320px, so a long mailbox name would otherwise
|
||||
// wrap onto several lines. Truncate it with an ellipsis instead. Scoped to our
|
||||
// items (which carry the avatar hook) since the popover lives in a portal.
|
||||
.c__dropdown-menu-item:has(.mailbox-selector__option-avatar) {
|
||||
.c__dropdown-menu-item__label-container {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.c__dropdown-menu-item__label,
|
||||
.c__dropdown-menu-item__label-subtext {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { DropdownMenu, UserAvatar } from "@gouvfr-lasuite/ui-kit";
|
||||
import { ChevronDown } from "@gouvfr-lasuite/ui-kit/icons";
|
||||
import { Button } from "@gouvfr-lasuite/cunningham-react";
|
||||
import { useState } from "react";
|
||||
import { Mailbox } from "@/features/api/gen";
|
||||
import MailboxHelper from "@/features/utils/mailbox-helper";
|
||||
|
||||
/** Display name of a mailbox, falling back to its address when no contact name
|
||||
* is set (a mailbox may legitimately have a null/blank name). */
|
||||
const getMailboxLabel = (mailbox: Mailbox) => mailbox.name?.trim() || mailbox.email;
|
||||
|
||||
type MailboxSelectorProps = {
|
||||
/** Mailboxes the user can switch to (already the eligible subset). */
|
||||
mailboxes: readonly Mailbox[];
|
||||
/** Currently displayed mailbox. */
|
||||
selectedMailbox: Mailbox;
|
||||
/** Called with the picked mailbox id; never fired for the current one. */
|
||||
onSelect: (mailboxId: string) => void | Promise<void>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mailbox switcher shared by the sidebar header and the settings modal: an
|
||||
* avatar + bold name + address card that unfolds a dropdown of the eligible
|
||||
* mailboxes. When the user owns a single mailbox there is nothing to switch to,
|
||||
* so the card renders as static content (not a disabled button) to keep the
|
||||
* name/address legible and avoid exposing a bogus "button, unavailable" control.
|
||||
*/
|
||||
export const MailboxSelector = ({
|
||||
mailboxes,
|
||||
selectedMailbox,
|
||||
onSelect,
|
||||
}: MailboxSelectorProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const label = getMailboxLabel(selectedMailbox);
|
||||
const sublabel = selectedMailbox.name?.trim() ? selectedMailbox.email : null;
|
||||
const canSwitch = mailboxes.length > 1;
|
||||
|
||||
// Avatar is decorative: the name it encodes is already shown next to it as
|
||||
// text, so hide it from assistive tech to avoid a duplicate announcement.
|
||||
const content = (
|
||||
<>
|
||||
<span
|
||||
className="mailbox-selector__avatar"
|
||||
data-shared={!selectedMailbox.is_identity}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<UserAvatar fullName={label} size="small" />
|
||||
</span>
|
||||
<span className="mailbox-selector__text">
|
||||
<span className="mailbox-selector__name">{label}</span>
|
||||
{sublabel && <span className="mailbox-selector__email">{sublabel}</span>}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
if (!canSwitch) {
|
||||
return (
|
||||
<div className="mailbox-selector">
|
||||
<div className="mailbox-selector__trigger mailbox-selector__trigger--static">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sortedMailboxes = MailboxHelper.sortByKind(mailboxes);
|
||||
const options = sortedMailboxes.map((mailbox, index) => ({
|
||||
label: getMailboxLabel(mailbox),
|
||||
subText: mailbox.name?.trim() ? mailbox.email : undefined,
|
||||
value: mailbox.id,
|
||||
icon: (
|
||||
<span
|
||||
className="mailbox-selector__option-avatar"
|
||||
data-shared={!mailbox.is_identity}
|
||||
>
|
||||
<UserAvatar fullName={getMailboxLabel(mailbox)} size="small" />
|
||||
</span>
|
||||
),
|
||||
showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index),
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="mailbox-selector">
|
||||
<DropdownMenu
|
||||
options={options}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
selectedValues={[selectedMailbox.id]}
|
||||
onSelectValue={(value) => {
|
||||
setIsOpen(false);
|
||||
if (value !== selectedMailbox.id) {
|
||||
void onSelect(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="mailbox-selector__trigger"
|
||||
color="brand"
|
||||
variant="tertiary"
|
||||
icon={<ChevronDown />}
|
||||
iconPosition="right"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={isOpen}
|
||||
fullWidth
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{content}
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+3
-67
@@ -36,78 +36,14 @@
|
||||
// macOS-style account card sitting at the very top of the settings sidebar:
|
||||
// mailbox avatar on the left, name in bold and (when set) the address in a
|
||||
// muted line underneath.
|
||||
// Wrapper around the shared MailboxSelector in the sidebar header. Full width
|
||||
// with min-width:0 so the selector's text column can shrink and ellipsize the
|
||||
// long address instead of overflowing the narrow sidebar.
|
||||
&__identity {
|
||||
/* gap: var(--c--globals--spacings--xs); */
|
||||
width: 100%;
|
||||
// Min-width:0 lets the flexible text column actually shrink so the long
|
||||
// address can ellipsize instead of overflowing the narrow sidebar.
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
&__identity-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 0;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
&__identity-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
font-size: var(--c--globals--font--sizes--sm);
|
||||
font-weight: 600;
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
}
|
||||
|
||||
&__identity-email {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: var(--c--globals--font--sizes--xs);
|
||||
font-weight: 400;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
color: var(--c--contextuals--content--semantic--neutral--secondary);
|
||||
}
|
||||
|
||||
// Shared box for both card variants so the switchable (button) and the
|
||||
// static (single-mailbox) renderings keep the exact same footprint.
|
||||
&__dropdown-button,
|
||||
&__identity-static {
|
||||
padding: var(--c--globals--spacings--sm) var(--c--globals--spacings--xs);
|
||||
}
|
||||
|
||||
// Switchable variant: the whole card becomes a borderless button with an
|
||||
// unfold chevron and a subtle hover fill, mirroring a macOS popup button.
|
||||
&__dropdown-button {
|
||||
border: none;
|
||||
height: inherit;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
&__dropdown-button__content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--c--globals--spacings--s);
|
||||
// Let the content shrink inside the (full-width) card so the text column
|
||||
// can ellipsize instead of pushing the card wider than the sidebar.
|
||||
min-width: 0;
|
||||
|
||||
// The avatar keeps its size; only the text column absorbs the shrink.
|
||||
& > :first-child {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__identity-chevron {
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
color: var(--c--contextuals--content--semantic--neutral--tertiary);
|
||||
}
|
||||
|
||||
&__section {
|
||||
margin-bottom: var(--c--globals--spacings--lg);
|
||||
}
|
||||
|
||||
+14
-96
@@ -1,16 +1,14 @@
|
||||
import {
|
||||
Button,
|
||||
Modal,
|
||||
ModalSize,
|
||||
ModalTab,
|
||||
} from "@gouvfr-lasuite/cunningham-react";
|
||||
import { DropdownMenu, HorizontalSeparator, UserAvatar } from "@gouvfr-lasuite/ui-kit";
|
||||
import { HorizontalSeparator } from "@gouvfr-lasuite/ui-kit";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Mailbox } from "@/features/api/gen";
|
||||
import { useMailboxContext } from "@/features/providers/mailbox";
|
||||
import { FEATURE_KEYS, useFeatureFlag } from "@/hooks/use-feature";
|
||||
import MailboxHelper from "@/features/utils/mailbox-helper";
|
||||
import { MailboxSelector } from "@/features/layouts/components/mailbox-selector";
|
||||
import {
|
||||
useConfirmBeforeClose,
|
||||
useConfirmUnsavedChanges,
|
||||
@@ -21,7 +19,6 @@ import { MailboxSettingsSignaturesTab } from "./signatures-tab";
|
||||
import { MailboxSettingsMessageTemplatesTab } from "./message-templates-tab";
|
||||
import { MailboxSettingsAutorepliesTab } from "./autoreplies-tab";
|
||||
import { MailboxSettingsIntegrationsTab } from "./integrations-tab";
|
||||
import { ChevronDown, ChevronUp } from "@gouvfr-lasuite/ui-kit/icons";
|
||||
|
||||
export type SettingsTabId =
|
||||
| "general"
|
||||
@@ -45,11 +42,6 @@ export const MODAL_MAILBOX_SETTINGS_ID = "modal-mailbox-settings";
|
||||
// the sidebar↔content view itself can't be driven from outside the component.
|
||||
const COMPACT_MODAL_MEDIA_QUERY = "(max-width: 576px)";
|
||||
|
||||
/** Display name shown on the identity card; falls back to the address when the
|
||||
* mailbox has no contact name set. */
|
||||
const getMailboxName = (mailbox: Mailbox) =>
|
||||
mailbox.name?.trim() || mailbox.email;
|
||||
|
||||
/**
|
||||
* Settings modal for a mailbox the user can configure. Built on Cunningham's
|
||||
* "tab" modal layout: the sidebar lists setting categories (General — rename —,
|
||||
@@ -91,7 +83,6 @@ export const ModalMailboxSettings = ({
|
||||
const [selectedMailboxId, setSelectedMailboxId] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const [isMailboxDropdownOpen, setIsMailboxDropdownOpen] = useState(false);
|
||||
|
||||
const [isActiveTabDirty, setIsActiveTabDirty] = useState(false);
|
||||
const confirmUnsavedChanges = useConfirmUnsavedChanges();
|
||||
@@ -130,18 +121,6 @@ export const ModalMailboxSettings = ({
|
||||
: "general";
|
||||
});
|
||||
|
||||
const mailboxOptions = useMemo(() => {
|
||||
const sortedMailboxes = MailboxHelper.sortByKind(settingsMailboxes);
|
||||
|
||||
return sortedMailboxes.map((mailbox: Mailbox, index) => ({
|
||||
label: getMailboxName(mailbox),
|
||||
subText: mailbox.name?.trim() ? mailbox.email : undefined,
|
||||
value: mailbox.id,
|
||||
icon: <UserAvatar fullName={getMailboxName(mailbox)} size="small" />,
|
||||
showSeparator: MailboxHelper.showSeparatorAfter(sortedMailboxes, index),
|
||||
}));
|
||||
}, [settingsMailboxes]);
|
||||
|
||||
// Ordered ids of the tabs the selected mailbox exposes, gated by its abilities
|
||||
// (and the integrations feature flag). Single source of truth: it drives both
|
||||
// the rendered `tabs` below and the active-tab synchronisation on mailbox
|
||||
@@ -191,81 +170,20 @@ export const ModalMailboxSettings = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasMultipleMailboxes = settingsMailboxes.length > 1;
|
||||
const mailboxName = getMailboxName(settingsMailbox);
|
||||
const mailboxSubtitle = settingsMailbox.name?.trim()
|
||||
? settingsMailbox.email
|
||||
: null;
|
||||
|
||||
// The macOS-style account card: avatar, name and (when distinct) the address
|
||||
// underneath. Shared between the static and the switchable variants below.
|
||||
const identityCardContent = (
|
||||
<>
|
||||
<UserAvatar fullName={mailboxName} size="medium" />
|
||||
<span className="mailbox-settings__identity-text">
|
||||
<span className="mailbox-settings__identity-name">{mailboxName}</span>
|
||||
{mailboxSubtitle && (
|
||||
<span className="mailbox-settings__identity-email">
|
||||
{mailboxSubtitle}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
// macOS-style account card switching the configured mailbox, shared with the
|
||||
// sidebar header switcher. Switching remounts the General tab and discards any
|
||||
// unsaved rename, so confirm first when there are pending edits.
|
||||
const sidebarHeader = (
|
||||
<>
|
||||
{hasMultipleMailboxes ? (
|
||||
<DropdownMenu
|
||||
options={mailboxOptions}
|
||||
isOpen={isMailboxDropdownOpen}
|
||||
onOpenChange={setIsMailboxDropdownOpen}
|
||||
selectedValues={[settingsMailbox.id]}
|
||||
onSelectValue={async (value) => {
|
||||
setIsMailboxDropdownOpen(false);
|
||||
if (value === settingsMailbox.id) {
|
||||
return;
|
||||
}
|
||||
// Switching the configured mailbox remounts the General tab and
|
||||
// discards any unsaved rename, just like a tab switch does.
|
||||
if (await confirmUnsavedChanges(isActiveTabDirty)) {
|
||||
setSelectedMailboxId(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
className="mailbox-settings__dropdown-button"
|
||||
variant="tertiary"
|
||||
color="neutral"
|
||||
aria-label={t("Select the mailbox to configure")}
|
||||
aria-expanded={isMailboxDropdownOpen}
|
||||
fullWidth
|
||||
onClick={() => setIsMailboxDropdownOpen(!isMailboxDropdownOpen)}
|
||||
icon={
|
||||
isMailboxDropdownOpen ? (
|
||||
<ChevronUp size="small" />
|
||||
) : (
|
||||
<ChevronDown size="small" />
|
||||
)
|
||||
}
|
||||
iconPosition="right"
|
||||
>
|
||||
<div className="mailbox-settings__dropdown-button__content">
|
||||
{identityCardContent}
|
||||
</div>
|
||||
</Button>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
// Single mailbox: nothing to switch to, so the card is purely informative.
|
||||
// Rendered as static content (not a disabled button) to avoid exposing a
|
||||
// bogus "button, unavailable" control and to keep the name/address fully
|
||||
// legible instead of greyed out by the disabled state.
|
||||
<div className="mailbox-settings__identity-static">
|
||||
<div className="mailbox-settings__dropdown-button__content">
|
||||
{identityCardContent}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<MailboxSelector
|
||||
mailboxes={settingsMailboxes}
|
||||
selectedMailbox={settingsMailbox}
|
||||
onSelect={async (value) => {
|
||||
if (await confirmUnsavedChanges(isActiveTabDirty)) {
|
||||
setSelectedMailboxId(value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<HorizontalSeparator width="double" />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,7 @@ body {
|
||||
margin: 0;
|
||||
background-color: var(--c--contextuals--background--surface--tertiary);
|
||||
color: var(--c--contextuals--content--semantic--neutral--primary);
|
||||
perspective: 9000px;
|
||||
}
|
||||
|
||||
* {
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
@use "./../features/ui/components/suggestion-input";
|
||||
@use "./../features/ui/components/transient-tooltip";
|
||||
@use "./../features/ui/components/assignees-avatar-group";
|
||||
@use "./../features/layouts/components/mailbox-selector";
|
||||
@use "./../features/layouts/components/mailbox-panel";
|
||||
@use "./../features/layouts/components/mailbox-panel/components/mailbox-actions";
|
||||
@use "./../features/layouts/components/mailbox-panel/components/mailbox-list";
|
||||
|
||||
Reference in New Issue
Block a user