diff --git a/components/settings/__tests__/settings-plugin-page.test.tsx b/components/settings/__tests__/settings-plugin-page.test.tsx index 40887a75e..622628fe3 100644 --- a/components/settings/__tests__/settings-plugin-page.test.tsx +++ b/components/settings/__tests__/settings-plugin-page.test.tsx @@ -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(); + + 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(); + + await waitFor(() => expect(container.querySelector('[data-plugin-slot="settings-page"]')).not.toBeNull()); + }); + it('opens the page from a /settings/plugin-page: link', async () => { activate('kanban', 'Kanban', ['settings-page']); render(); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 42c26226e..be232a5e9 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -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) && ( -
- -
+ <> + {/* 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)) && ( +
+ +
+ )} +
+ +
+ )} ); diff --git a/lib/plugin-slot-catalog.ts b/lib/plugin-slot-catalog.ts index f6bece96c..f6e307bdf 100644 --- a/lib/plugin-slot-catalog.ts +++ b/lib/plugin-slot-catalog.ts @@ -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;