mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 08:13:50 +00:00
fix: list a plugin with both a settings page and a settings section once, with the section on its page
This commit is contained in:
@@ -94,6 +94,29 @@ describe('Settings plugin pages', () => {
|
||||
expect(opts.plugin.id).toBe('kanban');
|
||||
});
|
||||
|
||||
it('gives a plugin with both a page and a section one entry, with the section on its page', async () => {
|
||||
activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
|
||||
const { container } = render(<SettingsApp linkSegments={[]} />);
|
||||
|
||||
const entries = screen.getAllByRole('button', { name: /Showcase/ });
|
||||
expect(entries).toHaveLength(1);
|
||||
|
||||
act(() => { fireEvent.click(entries[0]); });
|
||||
await waitFor(() => expect(createSlotInstance).toHaveBeenCalledTimes(2));
|
||||
const slots = createSlotInstance.mock.calls.map((call) => (call[0] as { slot: string }).slot);
|
||||
expect(slots.sort()).toEqual(['settings-page', 'settings-section']);
|
||||
const section = container.querySelector('[data-plugin-slot="settings-section"]')!;
|
||||
const page = container.querySelector('[data-plugin-slot="settings-page"]')!;
|
||||
expect(section.compareDocumentPosition(page) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it('opens the page of such a plugin from an old link to its section', async () => {
|
||||
activate('showcase', 'Showcase', ['settings-section', 'settings-page']);
|
||||
const { container } = render(<SettingsApp linkSegments={['plugin:showcase']} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector('[data-plugin-slot="settings-page"]')).not.toBeNull());
|
||||
});
|
||||
|
||||
it('opens the page from a /settings/plugin-page:<id> link', async () => {
|
||||
activate('kanban', 'Kanban', ['settings-page']);
|
||||
render(<SettingsApp linkSegments={['plugin-page:kanban']} />);
|
||||
|
||||
@@ -254,6 +254,8 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
() => pluginOffersForSlot('settings-page'),
|
||||
);
|
||||
const tPluginPages = useTranslations('plugin_pages');
|
||||
const pluginPageIds = useMemo(() => new Set(pluginPageOffers.map((offer) => offer.pluginId)), [pluginPageOffers]);
|
||||
const pluginSectionIds = useMemo(() => new Set(pluginSettingsOffers.map((offer) => offer.pluginId)), [pluginSettingsOffers]);
|
||||
// Consume the one-shot deep-link key so a section gear only steers this one
|
||||
// open, never the persisted default for future Settings-button clicks.
|
||||
useEffect(() => {
|
||||
@@ -584,7 +586,9 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
...(isFeatureEnabled('sidebarAppsEnabled') ? [{ id: 'sidebar_apps' as Tab, label: t('tabs.sidebar_apps'), icon: tabIcons.sidebar_apps, group: 'apps' as TabGroup }] : []),
|
||||
// Plugin-contributed settings pages: one entry per active plugin that
|
||||
// offers a `settings-section` slot (e.g. S/MIME key & certificate manager).
|
||||
...pluginSettingsOffers.map((offer): TabDef => ({
|
||||
// A plugin that also has a page of its own gets that entry only, with the
|
||||
// section on its page, instead of two entries with the same name.
|
||||
...pluginSettingsOffers.filter((offer) => !pluginPageIds.has(offer.pluginId)).map((offer): TabDef => ({
|
||||
id: `plugin:${offer.pluginId}` as PluginTabId,
|
||||
label: getActivePlugin(offer.pluginId)?.plugin.name ?? offer.pluginId,
|
||||
icon: Puzzle,
|
||||
@@ -657,9 +661,15 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
// If active tab is not in the visible list (e.g., feature disabled, or scoped
|
||||
// mode hides it), fall back. In scoped mode fall back to the first scoped tab;
|
||||
// otherwise the usual 'appearance' default.
|
||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === activeTab);
|
||||
// An old link or remembered tab to the section of a plugin that now has a
|
||||
// page of its own opens that page, where the section is.
|
||||
const sectionPluginId = activeTab.startsWith('plugin:') ? activeTab.slice('plugin:'.length) : null;
|
||||
const requestedTab: SettingsTabId = sectionPluginId && pluginPageIds.has(sectionPluginId)
|
||||
? (`${PLUGIN_PAGE_PREFIX}${sectionPluginId}` as PluginPageTabId)
|
||||
: activeTab;
|
||||
const isActiveVisible = visibleTabs.some((tab) => tab.id === requestedTab);
|
||||
const effectiveActiveTab: SettingsTabId = isActiveVisible
|
||||
? activeTab
|
||||
? requestedTab
|
||||
: (managedAccountId ? (visibleTabs[0]?.id ?? 'appearance') : 'appearance');
|
||||
|
||||
const handleTabSelect = (tabId: SettingsTabId) => {
|
||||
@@ -736,13 +746,26 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =
|
||||
/>
|
||||
)}
|
||||
{effectiveActiveTab.startsWith(PLUGIN_PAGE_PREFIX) && (
|
||||
<div className="w-full" data-plugin-slot="settings-page">
|
||||
<PluginIframeSlot
|
||||
key={effectiveActiveTab}
|
||||
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
|
||||
slot="settings-page"
|
||||
/>
|
||||
</div>
|
||||
<>
|
||||
{/* The plugin's settings-section, on its page rather than as a
|
||||
second entry with the same name. */}
|
||||
{pluginSectionIds.has(effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)) && (
|
||||
<div className="max-w-3xl mb-6" data-plugin-slot="settings-section">
|
||||
<PluginIframeSlot
|
||||
key={`${effectiveActiveTab}:section`}
|
||||
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
|
||||
slot="settings-section"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full" data-plugin-slot="settings-page">
|
||||
<PluginIframeSlot
|
||||
key={effectiveActiveTab}
|
||||
pluginId={effectiveActiveTab.slice(PLUGIN_PAGE_PREFIX.length)}
|
||||
slot="settings-page"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -106,7 +106,7 @@ export const SLOT_CATALOG = {
|
||||
'file-preview-section': S('files', 'frame', ['ui:files'], { file: 'files:read' }, 'a section in the file preview'),
|
||||
|
||||
// ─── Settings and admin ────────────────────────────────────
|
||||
'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings'),
|
||||
'settings-section': S('settings', 'frame', ['ui:settings-section'], {}, 'a section on the plugin\'s own page in Settings (above its settings-page, when it has one: one entry per plugin)'),
|
||||
'settings-page': S('settings', 'frame', ['ui:settings-section'], {}, 'a page of its own in Settings, listed under the plugin\'s name'),
|
||||
'admin-plugin-page': S('admin', 'frame', ['ui:admin-page', 'admin:config'], {}, 'the plugin\'s page in the admin dashboard'),
|
||||
} as const satisfies Record<string, SlotSpec>;
|
||||
|
||||
Reference in New Issue
Block a user