diff --git a/.changeset/settings-template-revamp.md b/.changeset/settings-template-revamp.md new file mode 100644 index 0000000000000..755eda0036916 --- /dev/null +++ b/.changeset/settings-template-revamp.md @@ -0,0 +1,5 @@ +--- +'@rocket.chat/meteor': minor +--- + +Revamps the admin settings group template so every settings group renders with the new layout: sections are shown as always-visible cards instead of an accordion, the Reset and Save changes actions moved into the page header, and an in-page table of contents lists the group's sections with scroll-to and active-section highlighting. Setting names and descriptions are unchanged. diff --git a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx index 576b84ea6d286..81483f4c338b5 100644 --- a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsGroupPage.tsx @@ -1,15 +1,17 @@ import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; -import { Accordion, Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; +import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; -import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client'; +import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import { useToastMessageDispatch, useSettingsDispatch, useSettings } from '@rocket.chat/ui-contexts'; import type { ReactNode, MouseEvent, SubmitEvent } from 'react'; import { useMemo, memo } from 'react'; import { useTranslation } from 'react-i18next'; +import SettingsPageTableOfContents from './SettingsPageTableOfContents'; import type { EditableSetting } from '../../EditableSettingsContext'; import { useEditableSettingsDispatch, useEditableSettings } from '../../EditableSettingsContext'; +import { getSectionAnchorId } from '../lib/sectionAnchor'; export type SettingsGroupPageProps = { children: ReactNode; @@ -20,6 +22,7 @@ export type SettingsGroupPageProps = { i18nDescription?: string; tabs?: ReactNode; isCustom?: boolean; + sections?: string[]; }; const SettingsGroupPage = ({ @@ -31,6 +34,7 @@ const SettingsGroupPage = ({ i18nDescription = undefined, tabs = undefined, isCustom = false, + sections = [], }: SettingsGroupPageProps) => { const { t, i18n } = useTranslation(); const dispatch = useSettingsDispatch(); @@ -137,39 +141,48 @@ const SettingsGroupPage = ({ const isTranslationKey = (key: string): key is TranslationKey => (key as TranslationKey) !== undefined; + const isDirty = changedEditableSettings.length > 0; + const groupTitle = (i18nLabel && isTranslationKey(i18nLabel) && t(i18nLabel)) || ''; + + const tableOfContentsEntries = sections + .filter((sectionName) => Boolean(sectionName)) + .map((sectionName) => ({ + id: getSectionAnchorId(_id, sectionName), + label: t(sectionName as TranslationKey), + })); + return ( - - {headerButtons} + + + {headerButtons} + + + {tabs} {isCustom ? ( children ) : ( - - - {i18nDescription && isTranslationKey(i18nDescription) && i18n.exists(i18nDescription) && ( - - {t(i18nDescription)} - - )} - - {children} - - + + + + {i18nDescription && isTranslationKey(i18nDescription) && i18n.exists(i18nDescription) && ( + + {t(i18nDescription)} + + )} + + {children} + + + {tableOfContentsEntries.length > 1 && } + )} - - - {changedEditableSettings.length > 0 && ( - - )} - - - ); }; diff --git a/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsPageTableOfContents.tsx b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsPageTableOfContents.tsx new file mode 100644 index 0000000000000..644001b37090e --- /dev/null +++ b/apps/meteor/client/views/admin/settings/SettingsGroupPage/SettingsPageTableOfContents.tsx @@ -0,0 +1,111 @@ +import { css } from '@rocket.chat/css-in-js'; +import { Box, Palette } from '@rocket.chat/fuselage'; +import { useEffect, useMemo, useState } from 'react'; + +export type TableOfContentsEntry = { + id: string; + label: string; +}; + +const useScrollSpy = (ids: string[]): string | undefined => { + const [activeId, setActiveId] = useState(ids[0]); + + useEffect(() => { + setActiveId(ids[0]); + + if (typeof IntersectionObserver === 'undefined') { + return; + } + + const visible = new Set(); + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + if (entry.isIntersecting) { + visible.add(entry.target.id); + } else { + visible.delete(entry.target.id); + } + } + + const firstVisible = ids.find((id) => visible.has(id)); + if (firstVisible) { + setActiveId(firstVisible); + } + }, + { rootMargin: '0px 0px -70% 0px', threshold: 0 }, + ); + + const elements = ids.map((id) => document.getElementById(id)).filter((element): element is HTMLElement => element !== null); + elements.forEach((element) => observer.observe(element)); + + return () => observer.disconnect(); + }, [ids]); + + return activeId; +}; + +const itemStyle = css` + appearance: none; + border: none; + background: transparent; + width: 100%; + text-align: start; + font: inherit; + cursor: pointer; + text-decoration: none; + + &:hover { + background-color: ${Palette.surface['surface-hover'].toString()}; + } + + &[aria-current='true'] { + background-color: ${Palette.surface['surface-tint'].toString()}; + } +`; + +type SettingsPageTableOfContentsProps = { + title: string; + entries: TableOfContentsEntry[]; +}; + +const SettingsPageTableOfContents = ({ title, entries }: SettingsPageTableOfContentsProps) => { + const ids = useMemo(() => entries.map((entry) => entry.id), [entries]); + const activeId = useScrollSpy(ids); + + const handleSelect = (id: string): void => { + document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + return ( + + + {title} + + {entries.map(({ id, label }) => { + const isActive = id === activeId; + return ( + handleSelect(id)} + pi={12} + pb={8} + mbe={2} + borderRadius={4} + fontScale='p2' + color={isActive ? 'font-info' : 'font-default'} + className={itemStyle} + > + {label} + + ); + })} + + ); +}; + +export default SettingsPageTableOfContents; diff --git a/apps/meteor/client/views/admin/settings/SettingsSection/SettingsSection.tsx b/apps/meteor/client/views/admin/settings/SettingsSection/SettingsSection.tsx index 08aa89928063b..7ea1f3dc3c83b 100644 --- a/apps/meteor/client/views/admin/settings/SettingsSection/SettingsSection.tsx +++ b/apps/meteor/client/views/admin/settings/SettingsSection/SettingsSection.tsx @@ -1,5 +1,5 @@ import { isSetting, isSettingColor } from '@rocket.chat/core-typings'; -import { AccordionItem, Box, Button, FieldGroup } from '@rocket.chat/fuselage'; +import { Box, Button, FieldGroup } from '@rocket.chat/fuselage'; import { useStableCallback } from '@rocket.chat/fuselage-hooks'; import type { TranslationKey } from '@rocket.chat/ui-contexts'; import type { ReactNode } from 'react'; @@ -8,6 +8,7 @@ import { useTranslation } from 'react-i18next'; import { useEditableSettings, useEditableSettingsDispatch } from '../../EditableSettingsContext'; import Setting from '../Setting'; +import { getSectionAnchorId } from '../lib/sectionAnchor'; export type SettingsSectionProps = { groupId: string; @@ -71,13 +72,25 @@ function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, so }; return ( - + {sectionName && !solo && ( + + {t(sectionName as TranslationKey)} + + )} {help && ( - + {help} )} @@ -93,7 +106,7 @@ function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, so {t('Reset_section_settings')} )} - + ); } diff --git a/apps/meteor/client/views/admin/settings/groups/GenericGroupPage.tsx b/apps/meteor/client/views/admin/settings/groups/GenericGroupPage.tsx index 965f4a7bd749a..6199881de0dc6 100644 --- a/apps/meteor/client/views/admin/settings/groups/GenericGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/groups/GenericGroupPage.tsx @@ -19,7 +19,7 @@ function GenericGroupPage({ _id, i18nLabel, sections, tabs, currentTab, hasReset const solo = sections.length === 1; return ( - + {sections.map((sectionName) => (
))} diff --git a/apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx b/apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx index c06b747ee3d8a..edf16eaa4d86b 100644 --- a/apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx +++ b/apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx @@ -98,6 +98,7 @@ function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) { _id={_id} {...group} onClickBack={onClickBack} + sections={settingSections} headerButtons={ <> diff --git a/apps/meteor/client/views/admin/settings/lib/sectionAnchor.ts b/apps/meteor/client/views/admin/settings/lib/sectionAnchor.ts new file mode 100644 index 0000000000000..04e363b0577c2 --- /dev/null +++ b/apps/meteor/client/views/admin/settings/lib/sectionAnchor.ts @@ -0,0 +1,2 @@ +export const getSectionAnchorId = (groupId: string, sectionName: string): string => + `settings-section-${groupId}-${sectionName || 'default'}`.replace(/[^a-zA-Z0-9-_]/g, '-'); diff --git a/apps/meteor/tests/e2e/administration-settings.spec.ts b/apps/meteor/tests/e2e/administration-settings.spec.ts index 6c6df34536b2b..64a8b06454511 100644 --- a/apps/meteor/tests/e2e/administration-settings.spec.ts +++ b/apps/meteor/tests/e2e/administration-settings.spec.ts @@ -76,8 +76,6 @@ test.describe.parallel('administration-settings', () => { test.afterAll(async ({ api }) => setSettingValueById(api, 'theme-custom-css', '')); test('should display the code mirror correctly', async ({ page, api }) => { - await poAdminSettings.getAccordionBtnByName('Custom CSS').click(); - await test.step('should render only one code mirror element', async () => { const codeMirrorParent = page.getByRole('code'); await expect(codeMirrorParent.locator('.CodeMirror')).toHaveCount(1);