mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 08:15:38 +00:00
feat: let plugins add options to the composer's Options menu as check items
This commit is contained in:
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import React from 'react';
|
||||
import { EmailComposer } from '../email-composer';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { checkActions, resetActions, setActionDispatcher, setPluginActions } from '@/lib/plugin-sandbox/actions';
|
||||
import { clearOffers, lastSlotProps, offerSlots, slotProps } from '@/components/plugins/__tests__/fake-plugin-offer';
|
||||
import { useSettingsStore } from '@/stores/settings-store';
|
||||
import { SLOT_CATALOG } from '@/lib/plugin-slot-catalog';
|
||||
@@ -334,6 +334,50 @@ describe('composer-send-menu', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-options-menu and composer-toolbar-actions', () => {
|
||||
it('lists plugin options in Options, ticked ones as check items counted in the badge', async () => {
|
||||
act(() => setPluginActions('smime', 'S/MIME', 'composer-options-menu', [
|
||||
{ id: 'sign', label: 'Sign', checked: true, description: 'With your certificate' },
|
||||
{ id: 'encrypt', label: 'Encrypt', checked: false },
|
||||
{ id: 'zones', label: 'Insert time in other zones' },
|
||||
]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
|
||||
const options = screen.getByTestId('composer-options-menu');
|
||||
expect(options).toHaveTextContent('1');
|
||||
fireEvent.click(options);
|
||||
const sign = screen.getByRole('menuitemcheckbox', { name: /Sign/ });
|
||||
expect(sign).toHaveAttribute('aria-checked', 'true');
|
||||
expect(sign).toHaveTextContent('With your certificate');
|
||||
expect(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })).toHaveAttribute('aria-checked', 'false');
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitemcheckbox', { name: 'Encrypt' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'encrypt', { composerId: expect.any(String) });
|
||||
// A check item leaves the menu open, so several options can be set.
|
||||
expect(screen.getByRole('menuitemcheckbox', { name: /Sign/ })).toBeInTheDocument();
|
||||
|
||||
await act(async () => { fireEvent.click(screen.getByRole('menuitem', { name: 'Insert time in other zones' })); });
|
||||
expect(dispatch).toHaveBeenCalledWith('smime', 'composer-options-menu', 'zones', { composerId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('draws plugin buttons in the bottom bar like Attach, with the shortcut in the tooltip', async () => {
|
||||
act(() => setPluginActions('ai', 'Assistant', 'composer-toolbar-actions', [{ id: 'ask', label: 'Assistant', icon: 'sparkles', shortcut: 'Alt+A' }]));
|
||||
render(<EmailComposer initialData={DRAFT} onClose={vi.fn()} />);
|
||||
const button = screen.getByRole('button', { name: 'Assistant' });
|
||||
expect(button).toHaveAttribute('title', 'Assistant (Alt+A)');
|
||||
expect(button.className).toContain('h-9');
|
||||
// Not in the plugin frame row above the bar.
|
||||
expect(button.closest('[data-composer-plugin-row]')).toBeNull();
|
||||
await act(async () => { fireEvent.click(button); });
|
||||
expect(dispatch).toHaveBeenCalledWith('ai', 'composer-toolbar-actions', 'ask', { composerId: expect.any(String) });
|
||||
});
|
||||
|
||||
it('refuses odd values for checked and description', () => {
|
||||
expect(() => setPluginActions('p', 'P', 'composer-options-menu', checkActions([{ id: 'a', label: 'A', checked: 'yes' }]))).toThrow(/checked must be true or false/);
|
||||
expect(() => checkActions([{ id: 'a', label: 'A', description: 'x'.repeat(121) }])).toThrow(/description/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composer-attach-menu', () => {
|
||||
it('turns Attach into a menu for plugin entries', async () => {
|
||||
act(() => setPluginActions('p', 'Cloud', 'composer-attach-menu', [{ id: 'cloud', label: 'From the cloud' }]));
|
||||
|
||||
@@ -195,6 +195,20 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
|
||||
{separator && <div role="separator" className="my-1 h-px bg-border" />}
|
||||
{actions.map((action) => {
|
||||
const Icon = iconForName(action.icon) ?? Puzzle;
|
||||
if (action.checked !== undefined) {
|
||||
// An option like the menu's own (plain text, read receipt): stays
|
||||
// open, so several can be set in one go.
|
||||
return (
|
||||
<ComposerToolbarMenuCheckItem
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
checked={action.checked}
|
||||
description={action.description}
|
||||
onToggle={() => { void runAction(action, surface, typeof context === "function" ? context() : context); }}
|
||||
>
|
||||
{action.label}
|
||||
</ComposerToolbarMenuCheckItem>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={`${action.pluginId}:${action.id}`}
|
||||
@@ -211,7 +225,10 @@ export function ComposerPluginMenuItems({ surface, context, onSelect, separator
|
||||
)}
|
||||
>
|
||||
<Icon className="w-4 h-4 shrink-0 text-muted-foreground" />
|
||||
<span className="flex-1">{action.label}</span>
|
||||
<span className="flex-1">
|
||||
<span className="block">{action.label}</span>
|
||||
{action.description && <span className="block text-xs text-muted-foreground">{action.description}</span>}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -2086,6 +2086,8 @@ export function EmailComposer({
|
||||
// itself has nothing else to put there.
|
||||
const hasSendMenuActions = usePluginActions('composer-send-menu').length > 0;
|
||||
const hasAttachMenuActions = usePluginActions('composer-attach-menu').length > 0;
|
||||
// Ticked plugin options count in the Options button's badge like the app's own.
|
||||
const pluginOptionsOn = usePluginActions('composer-options-menu').filter((a) => a.checked).length;
|
||||
|
||||
// Files from the Files app are already blobs on the server, so they are
|
||||
// attached by blobId with no download or upload (#1179). Stalwart resolves a
|
||||
@@ -4075,7 +4077,7 @@ export function EmailComposer({
|
||||
<ComposerToolbarMenu
|
||||
icon={SlidersHorizontal}
|
||||
label={t('options')}
|
||||
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length}
|
||||
activeCount={[plainTextMode, requestReadReceipt, requestDsn, requireTls].filter(Boolean).length + pluginOptionsOn}
|
||||
testId="composer-options-menu"
|
||||
>
|
||||
{(close) => <>
|
||||
|
||||
@@ -25,6 +25,13 @@ export interface PluginAction {
|
||||
* Ctrl/Cmd+Shift or Ctrl/Cmd+Alt, like plugin shortcuts.
|
||||
*/
|
||||
shortcut?: string;
|
||||
/**
|
||||
* On menu surfaces (the composer's Options and Send menus): draw the entry
|
||||
* as a check item, ticked or not. The plugin updates it with setActions.
|
||||
*/
|
||||
checked?: boolean;
|
||||
/** A second line under the label, on menu surfaces. Up to 120 characters. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface RegisteredAction extends PluginAction {
|
||||
@@ -77,6 +84,14 @@ export function checkActions(value: unknown): PluginAction[] {
|
||||
out.mimeTypes = (a.mimeTypes as string[]).map((m) => m.toLowerCase());
|
||||
}
|
||||
if (a.danger !== undefined) out.danger = a.danger === true;
|
||||
if (a.checked !== undefined) {
|
||||
if (typeof a.checked !== 'boolean') throw new Error(`actions[${i}].checked must be true or false`);
|
||||
out.checked = a.checked;
|
||||
}
|
||||
if (a.description !== undefined) {
|
||||
if (typeof a.description !== 'string' || a.description.length > 120) throw new Error(`actions[${i}].description must be a string of up to 120 characters`);
|
||||
if (a.description.trim()) out.description = a.description.trim();
|
||||
}
|
||||
if (a.shortcut !== undefined) {
|
||||
if (typeof a.shortcut !== 'string' || a.shortcut.length > 30 || !isAllowedPluginCombo(a.shortcut)) {
|
||||
throw new Error(`actions[${i}].shortcut must use Alt, Ctrl+Shift or Ctrl+Alt`);
|
||||
|
||||
Reference in New Issue
Block a user