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);