mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 16:12:02 +00:00
fix: show the same actions in the same order in every menu of a contact, group or folder
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { ContactDetail } from '../contact-detail';
|
||||
import { ContactGroupDetail } from '../contact-group-detail';
|
||||
import { ContactList } from '../contact-list';
|
||||
import type { ContactCard } from '@/lib/jmap/types';
|
||||
|
||||
vi.mock('@/components/contacts/contact-activity', () => ({ ContactActivity: () => null }));
|
||||
|
||||
const alice: ContactCard = {
|
||||
id: '1',
|
||||
addressBookIds: {},
|
||||
name: { components: [{ kind: 'given', value: 'Alice' }], isOrdered: true },
|
||||
emails: { e0: { address: 'alice@example.com' } },
|
||||
};
|
||||
|
||||
const menuLabels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => i.textContent);
|
||||
|
||||
describe('one action list per contact (R6)', () => {
|
||||
it('offers Duplicate in the right-click menu as in the detail ⋮', () => {
|
||||
const noop = vi.fn();
|
||||
render(
|
||||
<ContactList
|
||||
contacts={[alice]} selectedContactId={null} searchQuery="" onSearchChange={noop} onSelectContact={noop}
|
||||
onCreateNew={noop} selectedContactIds={new Set()} onToggleSelection={noop} onSelectRangeContacts={noop}
|
||||
onSelectAll={noop} onClearSelection={noop} onBulkDelete={noop} onBulkAddToGroup={noop} onBulkExport={noop}
|
||||
onEditContact={noop} onDeleteContact={noop} onAddContactToGroup={noop} onDuplicateContact={noop} onComposeContact={noop}
|
||||
/>,
|
||||
);
|
||||
fireEvent.contextMenu(screen.getByText('Alice'));
|
||||
const rightClick = menuLabels();
|
||||
expect(rightClick).toContain('context_menu.duplicate');
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
|
||||
render(<ContactDetail contact={alice} onEdit={noop} onDelete={noop} onDuplicate={noop} onAddToGroup={noop} onCompose={noop} />);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
|
||||
const detail = menuLabels();
|
||||
// The detail's ⋮ is the same list, without what the page has as buttons.
|
||||
expect(detail).toEqual(rightClick.filter((l) => !['context_menu.open', 'context_menu.send_email', 'context_menu.edit'].includes(l ?? '')));
|
||||
});
|
||||
|
||||
it('says "Edit group" on a group page and keeps Delete in a ⋮ like a contact page', () => {
|
||||
const group = { id: 'g', kind: 'group', addressBookIds: {}, name: { full: 'Team' } } as ContactCard;
|
||||
const onDelete = vi.fn();
|
||||
render(<ContactGroupDetail group={group} members={[]} onEdit={vi.fn()} onDelete={onDelete} onRemoveMember={vi.fn()} onSelectMember={vi.fn()} />);
|
||||
expect(screen.getByRole('button', { name: /groups.edit/ })).toBeInTheDocument();
|
||||
expect(screen.queryByText('form.edit_title')).toBeNull();
|
||||
fireEvent.click(screen.getByRole('button', { name: 'detail.more_actions' }));
|
||||
fireEvent.click(screen.getByText('groups.delete'));
|
||||
expect(onDelete).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -45,12 +45,16 @@ interface ContactContextMenuProps {
|
||||
menuRef: React.RefObject<HTMLDivElement | null>;
|
||||
isMultiSelect?: boolean;
|
||||
selectedCount?: number;
|
||||
onOpen: () => void;
|
||||
/** Left out where the contact is already open (the detail's ⋮). */
|
||||
onOpen?: () => void;
|
||||
/** Touch: start selecting with this contact (there is no Ctrl+click). */
|
||||
onSelect?: () => void;
|
||||
onEdit: () => void;
|
||||
/** Left out where Edit is a button next to the menu. */
|
||||
onEdit?: () => void;
|
||||
onDelete: () => void;
|
||||
onAddToGroup: () => void;
|
||||
onAddToGroup?: () => void;
|
||||
/** Leave out what the detail shows as buttons of its own. */
|
||||
inDetail?: boolean;
|
||||
/** Writes to the contact in the app's composer. */
|
||||
onCompose?: () => void;
|
||||
onDuplicate?: () => void;
|
||||
@@ -79,6 +83,7 @@ export function ContactContextMenu({
|
||||
onDuplicate,
|
||||
onMoveTo,
|
||||
onBatchMoveTo,
|
||||
inDetail = false,
|
||||
onBatchExport,
|
||||
onBatchAddToGroup,
|
||||
onBatchDelete,
|
||||
@@ -155,24 +160,43 @@ export function ContactContextMenu({
|
||||
|
||||
return (
|
||||
<ContextMenu ref={menuRef} isOpen={isOpen} position={position} onClose={onClose}>
|
||||
<ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />
|
||||
{/* R6 order: Open, write, Edit, then the rest; the detail's ⋮ leaves
|
||||
out what it has as buttons (Edit, Send email, Call). */}
|
||||
{onOpen && <ContextMenuItem icon={Eye} label={t("context_menu.open")} onClick={handle(onOpen)} />}
|
||||
{onSelect && <ContextMenuItem icon={CheckSquare} label={t("filters.select")} onClick={handle(onSelect)} />}
|
||||
<ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />
|
||||
{(email || phone) && <ContextMenuSeparator />}
|
||||
{email && onCompose && (
|
||||
{email && onCompose && !inDetail && (
|
||||
<ContextMenuItem
|
||||
icon={Mail}
|
||||
label={t("context_menu.send_email")}
|
||||
onClick={handle(onCompose)}
|
||||
/>
|
||||
)}
|
||||
{phone && (
|
||||
{phone && !inDetail && (
|
||||
<ContextMenuItem
|
||||
icon={Phone}
|
||||
label={t("context_menu.call")}
|
||||
onClick={handle(handleCall)}
|
||||
/>
|
||||
)}
|
||||
{onEdit && <ContextMenuItem icon={Pencil} label={t("context_menu.edit")} onClick={handle(onEdit)} />}
|
||||
{!inDetail && <ContextMenuSeparator />}
|
||||
{onAddToGroup && (
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
)}
|
||||
{onDuplicate && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
{onMoveTo && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
|
||||
)}
|
||||
{email && (
|
||||
<ContextMenuItem
|
||||
icon={ClipboardCopy}
|
||||
@@ -187,22 +211,6 @@ export function ContactContextMenu({
|
||||
onClick={handle(() => handleCopy(phone))}
|
||||
/>
|
||||
)}
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem
|
||||
icon={Users}
|
||||
label={t("context_menu.add_to_group")}
|
||||
onClick={handle(onAddToGroup)}
|
||||
/>
|
||||
{onDuplicate && (
|
||||
<ContextMenuItem
|
||||
icon={Copy}
|
||||
label={t("context_menu.duplicate")}
|
||||
onClick={handle(onDuplicate)}
|
||||
/>
|
||||
)}
|
||||
{onMoveTo && (
|
||||
<ContextMenuItem icon={FolderInput} label={t("address_books.move_to")} onClick={handle(onMoveTo)} />
|
||||
)}
|
||||
<ContextMenuItem
|
||||
icon={Download}
|
||||
label={t("context_menu.export_vcard")}
|
||||
|
||||
@@ -1,36 +1,25 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useEffect, useRef, useMemo } from "react";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import { contactDateParts, formatContactDate } from "@/lib/contact-dates";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, Trash2, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, Download, MoreVertical, Printer, FolderInput } from "@/components/icons";
|
||||
import { Mail, Phone, Building, MapPin, StickyNote, Pencil, BookUser, Copy, Send, Globe, Cake, KeyRound, Users, Briefcase, Heart, Languages, Calendar, UserCircle, MoreVertical } from "@/components/icons";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { MailtoLink } from "@/components/ui/mailto-link";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ContactCard, AnniversaryDate } from "@/lib/jmap/types";
|
||||
import { getContactDisplayName, getContactPrimaryEmail, getContactPhotoRef } from "@/stores/contact-store";
|
||||
import { ContactActivity } from "./contact-activity";
|
||||
import { toast } from "@/stores/toast-store";
|
||||
import { exportContact, useContactExportBusy } from "./contact-export";
|
||||
import { printContact, printLabels } from "./contact-print";
|
||||
import { ContactContextMenu } from "./contact-context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
import { usePluginSlotOffers } from "@/hooks/use-plugin-slot-offers";
|
||||
import { PluginSlot } from "@/components/plugins/plugin-slot";
|
||||
import { contactHooks } from "@/lib/plugin-hooks";
|
||||
import { contactToView } from "@/lib/plugin-projection";
|
||||
|
||||
type MoreItem =
|
||||
| {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
destructive?: boolean;
|
||||
disabled?: boolean;
|
||||
separator?: false;
|
||||
}
|
||||
| { separator: true };
|
||||
|
||||
interface ContactDetailProps {
|
||||
contact: ContactCard | null;
|
||||
onEdit: () => void;
|
||||
@@ -85,12 +74,13 @@ function getCompletedYears(dateInput: AnniversaryDate): number | null {
|
||||
export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDuplicate, onMoveTo, onCompose, isMobile, className }: ContactDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const locale = useLocale();
|
||||
// The ⋮ shows the contact's menu, the one right-click shows in the list (R6).
|
||||
const moreMenu = useContextMenu<ContactCard>();
|
||||
const contextLabel = (key: string) => (CONTEXT_LABEL_KEYS[key] ? t(CONTEXT_LABEL_KEYS[key]) : key);
|
||||
const featureLabel = (key: string) => (PHONE_FEATURE_LABEL_KEYS[key] ? t(PHONE_FEATURE_LABEL_KEYS[key]) : key);
|
||||
|
||||
const cryptoKeys = contact?.cryptoKeys ? Object.values(contact.cryptoKeys) : [];
|
||||
const hasCryptoKeysSlotOffers = usePluginSlotOffers('contact-cryptokeys').length > 0;
|
||||
const exporting = useContactExportBusy();
|
||||
const pluginContactProps = useMemo(() => (contact ? { contact: contactToView(contact) } : null), [contact]);
|
||||
|
||||
// Tell plugins once per contact shown here, not on every edit of that card.
|
||||
@@ -117,28 +107,6 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
const photo = getContactPhotoRef(contact) ?? null;
|
||||
const phone = contact.phones ? Object.values(contact.phones)[0]?.number : undefined;
|
||||
|
||||
const handleExport = async () => {
|
||||
if (await exportContact(contact)) toast.success(t("export.success", { count: 1 }));
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
void printContact(contact, printLabels(t, locale), name);
|
||||
};
|
||||
|
||||
const moreItems: MoreItem[] = [];
|
||||
if (onAddToGroup) {
|
||||
moreItems.push({ icon: Users, label: t("context_menu.add_to_group"), onClick: onAddToGroup });
|
||||
}
|
||||
if (onDuplicate) {
|
||||
moreItems.push({ icon: Copy, label: t("context_menu.duplicate"), onClick: onDuplicate });
|
||||
}
|
||||
if (onMoveTo) {
|
||||
moreItems.push({ icon: FolderInput, label: t("address_books.move_to"), onClick: onMoveTo });
|
||||
}
|
||||
moreItems.push({ icon: Download, label: t("context_menu.export_vcard"), onClick: handleExport, disabled: exporting });
|
||||
moreItems.push({ icon: Printer, label: t("context_menu.print"), onClick: handlePrint });
|
||||
moreItems.push({ separator: true });
|
||||
moreItems.push({ icon: Trash2, label: t("context_menu.delete"), onClick: onDelete, destructive: true });
|
||||
const emails = contact.emails ? Object.values(contact.emails) : [];
|
||||
const phones = contact.phones ? Object.values(contact.phones) : [];
|
||||
const orgs = contact.organizations ? Object.values(contact.organizations) : [];
|
||||
@@ -206,7 +174,32 @@ export function ContactDetail({ contact, onEdit, onDelete, onAddToGroup, onDupli
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("form.edit_title")}
|
||||
</Button>
|
||||
<MoreActionsMenu items={moreItems} label={t("detail.more_actions")} />
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
label={t("detail.more_actions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={moreMenu.contextMenu.isOpen}
|
||||
onClick={(e) => moreMenu.openContextMenuFrom(e.currentTarget, contact, "end")}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
{moreMenu.contextMenu.data && (
|
||||
<ContactContextMenu
|
||||
contact={moreMenu.contextMenu.data}
|
||||
position={moreMenu.contextMenu.position}
|
||||
isOpen={moreMenu.contextMenu.isOpen}
|
||||
onClose={moreMenu.closeContextMenu}
|
||||
menuRef={moreMenu.menuRef}
|
||||
inDetail
|
||||
onCompose={onCompose}
|
||||
onDelete={onDelete}
|
||||
onAddToGroup={onAddToGroup}
|
||||
onDuplicate={onDuplicate}
|
||||
onMoveTo={onMoveTo}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -529,71 +522,6 @@ function RowActions({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
function MoreActionsMenu({ items, label }: { items: MoreItem[]; label: string }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onDocMouseDown = (e: MouseEvent) => {
|
||||
if (!ref.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onDocMouseDown);
|
||||
document.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", onDocMouseDown);
|
||||
document.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setOpen((o) => !o)}
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
{open && (
|
||||
<div
|
||||
role="menu"
|
||||
className="absolute end-0 top-full mt-1 z-30 min-w-[200px] bg-background rounded-md shadow-lg border border-border py-1"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
if (item.separator) {
|
||||
return <ContextMenuSeparator key={i} />;
|
||||
}
|
||||
return (
|
||||
<ContextMenuItem
|
||||
key={i}
|
||||
icon={item.icon}
|
||||
label={item.label}
|
||||
destructive={item.destructive}
|
||||
disabled={item.disabled}
|
||||
onClick={() => {
|
||||
item.onClick();
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyButton({ value, label, successMsg, failMsg, className }: { value: string; label: string; successMsg: string; failMsg: string; className?: string }) {
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Users, Pencil, Trash2, UserMinus, Mail } from "@/components/icons";
|
||||
import { Users, Pencil, Trash2, UserMinus, Mail, MoreVertical } from "@/components/icons";
|
||||
import { ContextMenu, ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu";
|
||||
import { useContextMenu } from "@/hooks/use-context-menu";
|
||||
import { Avatar } from "@/components/ui/avatar";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IconButton } from "@/components/ui/icon-button";
|
||||
@@ -35,6 +37,8 @@ export function ContactGroupDetail({
|
||||
}: ContactGroupDetailProps) {
|
||||
const t = useTranslations("contacts");
|
||||
const groupName = getContactDisplayName(group);
|
||||
// Delete sits in a ⋮, as on a contact's page (R6).
|
||||
const menu = useContextMenu<null>();
|
||||
const hasEmailMembers = members.some((m) => getContactPrimaryEmail(m).trim());
|
||||
|
||||
return (
|
||||
@@ -55,18 +59,24 @@ export function ContactGroupDetail({
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onEdit} className="touch-manipulation">
|
||||
<Pencil className="w-4 h-4 me-1" />
|
||||
{t("form.edit_title")}
|
||||
{t("groups.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onDelete}
|
||||
aria-label={t("groups.delete")}
|
||||
title={t("groups.delete")}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10 touch-manipulation"
|
||||
label={t("detail.more_actions")}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={menu.contextMenu.isOpen}
|
||||
onClick={(e) => menu.openContextMenuFrom(e.currentTarget, null, "end")}
|
||||
className="touch-manipulation"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
<MoreVertical />
|
||||
</IconButton>
|
||||
<ContextMenu ref={menu.menuRef} isOpen={menu.contextMenu.isOpen} position={menu.contextMenu.position} onClose={menu.closeContextMenu}>
|
||||
<ContextMenuItem icon={Pencil} label={t("groups.edit")} onClick={() => { menu.closeContextMenu(); onEdit(); }} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("groups.delete")} destructive onClick={() => { menu.closeContextMenu(); onDelete(); }} />
|
||||
</ContextMenu>
|
||||
</div>
|
||||
</div>
|
||||
{onComposeGroup && hasEmailMembers && (
|
||||
|
||||
@@ -117,6 +117,7 @@ interface ContactListProps {
|
||||
onAddContactToGroup: (id: string) => void;
|
||||
/** Opens the app's composer to the contact. */
|
||||
onComposeContact?: (contact: ContactCard) => void;
|
||||
onDuplicateContact?: (contact: ContactCard) => void;
|
||||
/** Move contacts to another address book; undefined where there is none. */
|
||||
onMoveContacts?: (ids: string[]) => void;
|
||||
/** Show a burger button at the start that opens the (overlay) categories sidebar. */
|
||||
@@ -145,6 +146,7 @@ export function ContactList({
|
||||
onDeleteContact,
|
||||
onAddContactToGroup,
|
||||
onComposeContact,
|
||||
onDuplicateContact,
|
||||
onMoveContacts,
|
||||
onMenuClick,
|
||||
isLoading = false,
|
||||
@@ -750,6 +752,7 @@ export function ContactList({
|
||||
onDelete={() => onDeleteContact(contextMenu.data!)}
|
||||
onAddToGroup={() => onAddContactToGroup(contextMenu.data!.id)}
|
||||
onCompose={onComposeContact ? () => onComposeContact(contextMenu.data!) : undefined}
|
||||
onDuplicate={onDuplicateContact ? () => onDuplicateContact(contextMenu.data!) : undefined}
|
||||
onMoveTo={onMoveContacts ? () => onMoveContacts([contextMenu.data!.id]) : undefined}
|
||||
onBatchMoveTo={onMoveContacts ? () => onMoveContacts([...selectedContactIds]) : undefined}
|
||||
onBatchExport={onBulkExport}
|
||||
|
||||
@@ -1290,6 +1290,7 @@ function ContactsAppContent({ linkSegments: routeSegments }: ContactsAppProps =
|
||||
onDeleteContact={handleDeleteContact}
|
||||
onAddContactToGroup={guard(handleAddContactToGroup)}
|
||||
onComposeContact={handleComposeContact}
|
||||
onDuplicateContact={(contact) => void handleDuplicateContact(contact)}
|
||||
onMoveContacts={canMoveContacts ? handleMoveContacts : undefined}
|
||||
onMenuClick={isNarrow ? () => setNarrowSidebarOpen(true) : undefined}
|
||||
isLoading={supportsSync && !contactsLoaded}
|
||||
|
||||
@@ -449,4 +449,17 @@ describe('FileBrowser', () => {
|
||||
fireEvent.keyDown(document.body, { key: '/' });
|
||||
expect(document.activeElement).toHaveAttribute('role', 'searchbox');
|
||||
});
|
||||
|
||||
it("lists a folder's actions in the same order in the list and in the tree", () => {
|
||||
renderBrowser({ props: { onDownloadZip: vi.fn(async () => {}), onMoveNode: vi.fn(async () => {}) } });
|
||||
const labels = () => within(screen.getByRole('menu')).getAllByRole('menuitem').map((i) => (i.textContent ?? '').replace(/(F2|Del|Ctrl\+[A-Z]|⌘[A-Z])$/, ''));
|
||||
fireEvent.contextMenu(within(list()).getByText('Documents'));
|
||||
const inList = labels();
|
||||
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
||||
fireEvent.contextMenu(within(tree()).getByText('Documents'));
|
||||
const inTree = labels();
|
||||
const shared = inTree.filter((l) => inList.includes(l));
|
||||
expect(shared).toEqual(inList.filter((l) => inTree.includes(l)));
|
||||
expect(shared).toEqual(expect.arrayContaining(['open', 'star', 'rename', 'move_to', 'download_zip', 'delete']));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -876,22 +876,18 @@ export function FileBrowser({
|
||||
const starred = starredPaths.has(path);
|
||||
return (
|
||||
<>
|
||||
{/* R6 order, the same as the folder tree's menu: Open, Edit, Share,
|
||||
Star, Rename, Duplicate, Move to…, Cut, Copy, Download, Details, Delete. */}
|
||||
{resource.isDirectory ? (
|
||||
<>
|
||||
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
|
||||
{onDownloadZip && (
|
||||
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
|
||||
)}
|
||||
</>
|
||||
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => { closeMenu(); openResource(resource); }} />
|
||||
) : resource.isSymlink ? null : (
|
||||
<>
|
||||
{isOfficeEditable?.(resource.name) && onEditFile && (
|
||||
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
|
||||
)}
|
||||
{isPreviewable(resource.name) && (
|
||||
<ContextMenuItem icon={Eye} label={t("preview")} onClick={() => { closeMenu(); previewResource(resource); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
|
||||
{isOfficeEditable?.(resource.name) && onEditFile && (
|
||||
<ContextMenuItem icon={SquarePen} label={t("office_edit")} onClick={() => { closeMenu(); onEditFile(resource.name); }} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
|
||||
@@ -917,6 +913,12 @@ export function FileBrowser({
|
||||
{!resource.isSymlink && (
|
||||
<ContextMenuItem icon={Copy} label={t("copy")} shortcut={formatShortcutCombo("Mod + C")} onClick={() => { closeMenu(); onCopy([resource.name]); }} />
|
||||
)}
|
||||
{resource.isDirectory && onDownloadZip && (
|
||||
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => { closeMenu(); downloadNames([resource.name]); }} />
|
||||
)}
|
||||
{!resource.isDirectory && !resource.isSymlink && (
|
||||
<ContextMenuItem icon={Download} label={t("download")} onClick={() => { closeMenu(); void onDownload(resource.name); }} />
|
||||
)}
|
||||
<ContextMenuItem icon={Info} label={t("details")} onClick={() => { closeMenu(); toggleDetails(true); }} />
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem icon={Trash2} label={t("delete")} shortcut="Del" destructive onClick={() => { closeMenu(); deleteNames([resource.name]); }} />
|
||||
|
||||
@@ -464,7 +464,12 @@ export function FilesSidebar({
|
||||
<>
|
||||
<ContextMenuItem icon={FolderOpen} label={t("open")} onClick={() => run(() => onNavigate(path, folder.id))} />
|
||||
{writable && createItems(path, folder.id)}
|
||||
{/* The list's folder menu in the same order; Cut, Copy and
|
||||
Details act on the list and are left out here (R6). */}
|
||||
{(!folder.isShared || canShare(folder)) && <ContextMenuSeparator />}
|
||||
{canShare(folder) && (
|
||||
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
|
||||
)}
|
||||
{!folder.isShared && (
|
||||
<ContextMenuItem
|
||||
icon={starred ? StarOff : Star}
|
||||
@@ -472,10 +477,7 @@ export function FilesSidebar({
|
||||
onClick={() => run(() => onToggleFavorite(path))}
|
||||
/>
|
||||
)}
|
||||
{canShare(folder) && (
|
||||
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => run(() => onShare(folder))} />
|
||||
)}
|
||||
{(mayRename || mayDelete) && <ContextMenuSeparator />}
|
||||
<ContextMenuSeparator />
|
||||
{mayRename && (
|
||||
<ContextMenuItem icon={Pencil} label={t("rename")} onClick={() => run(() => onRenameFolder(folder))} />
|
||||
)}
|
||||
@@ -485,6 +487,7 @@ export function FilesSidebar({
|
||||
{onDownloadFolder && (
|
||||
<ContextMenuItem icon={Download} label={t("download_zip")} onClick={() => run(() => onDownloadFolder(folder))} />
|
||||
)}
|
||||
{mayDelete && <ContextMenuSeparator />}
|
||||
{mayDelete && (
|
||||
<ContextMenuItem icon={Trash2} label={t("delete")} destructive onClick={() => run(() => onDeleteFolder(folder))} />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user