Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/settings-template-revamp.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -20,6 +22,7 @@ export type SettingsGroupPageProps = {
i18nDescription?: string;
tabs?: ReactNode;
isCustom?: boolean;
sections?: string[];
};

const SettingsGroupPage = ({
Expand All @@ -31,6 +34,7 @@ const SettingsGroupPage = ({
i18nDescription = undefined,
tabs = undefined,
isCustom = false,
sections = [],
}: SettingsGroupPageProps) => {
const { t, i18n } = useTranslation();
const dispatch = useSettingsDispatch();
Expand Down Expand Up @@ -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 (
<Page is='form' action='#' method='post' onSubmit={handleSubmit}>
<PageHeader onClickBack={onClickBack} title={i18nLabel && isTranslationKey(i18nLabel) && t(i18nLabel)}>
<ButtonGroup>{headerButtons}</ButtonGroup>
<PageHeader onClickBack={onClickBack} title={groupTitle}>
<ButtonGroup>
{headerButtons}
<Button type='reset' disabled={!isDirty} onClick={handleCancelClick}>
{t('Reset')}
</Button>
<Button className='save' disabled={isSaveDisabled} primary type='submit' onClick={handleSaveClick}>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageHeader>
{tabs}
{isCustom ? (
children
) : (
<PageScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
{i18nDescription && isTranslationKey(i18nDescription) && i18n.exists(i18nDescription) && (
<Box is='p' color='hint' fontScale='p2'>
{t(i18nDescription)}
</Box>
)}

<Accordion>{children}</Accordion>
</Box>
</PageScrollableContentWithShadow>
<Box display='flex' flexGrow={1} height='full' overflow='hidden'>
<PageScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x640'>
{i18nDescription && isTranslationKey(i18nDescription) && i18n.exists(i18nDescription) && (
<Box is='p' color='hint' fontScale='p2' mbe={16}>
{t(i18nDescription)}
</Box>
)}

{children}
</Box>
</PageScrollableContentWithShadow>
{tableOfContentsEntries.length > 1 && <SettingsPageTableOfContents title={groupTitle} entries={tableOfContentsEntries} />}
</Box>
)}
<PageFooter isDirty={!(changedEditableSettings.length === 0)}>
<ButtonGroup>
{changedEditableSettings.length > 0 && (
<Button type='reset' onClick={handleCancelClick}>
{t('Cancel')}
</Button>
)}
<Button className='save' disabled={isSaveDisabled} primary type='submit' onClick={handleSaveClick}>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string | undefined>(ids[0]);

useEffect(() => {
setActiveId(ids[0]);

if (typeof IntersectionObserver === 'undefined') {
return;
}

const visible = new Set<string>();

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 (
<Box is='nav' aria-label={title} width='x260' flexShrink={0} pi={24} pbs={24} display='flex' flexDirection='column'>
<Box mbe={12} pi={12} fontScale='micro' color='hint' style={{ textTransform: 'uppercase' }}>
{title}
</Box>
{entries.map(({ id, label }) => {
const isActive = id === activeId;
return (
<Box
key={id}
is='button'
type='button'
aria-current={isActive ? 'true' : undefined}
onClick={() => handleSelect(id)}
pi={12}
pb={8}
mbe={2}
borderRadius={4}
fontScale='p2'
color={isActive ? 'font-info' : 'font-default'}
className={itemStyle}
>
{label}
</Box>
);
})}
</Box>
);
};

export default SettingsPageTableOfContents;
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -71,13 +72,25 @@ function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, so
};

return (
<AccordionItem
<Box
is='section'
id={getSectionAnchorId(groupId, sectionName)}
data-qa-section={sectionName}
noncollapsible={solo || !sectionName}
title={sectionName && t(sectionName as TranslationKey)}
mbe={24}
p={24}
borderWidth={1}
borderStyle='solid'
borderColor='extra-light'
borderRadius={8}
backgroundColor='surface-tint'
>
{sectionName && !solo && (
<Box is='h3' fontScale='h4' mbe={help ? 4 : 16}>
{t(sectionName as TranslationKey)}
</Box>
)}
{help && (
<Box is='p' color='hint' fontScale='p2'>
<Box is='p' color='hint' fontScale='p2' mbe={16}>
{help}
</Box>
)}
Expand All @@ -93,7 +106,7 @@ function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, so
{t('Reset_section_settings')}
</Button>
)}
</AccordionItem>
</Box>
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function GenericGroupPage({ _id, i18nLabel, sections, tabs, currentTab, hasReset
const solo = sections.length === 1;

return (
<SettingsGroupPage _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} tabs={tabs} {...props}>
<SettingsGroupPage _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} tabs={tabs} sections={sections} {...props}>
{sections.map((sectionName) => (
<Section key={sectionName || ''} hasReset={hasReset} groupId={_id} sectionName={sectionName} currentTab={currentTab} solo={solo} />
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
_id={_id}
{...group}
onClickBack={onClickBack}
sections={settingSections}
headerButtons={
<>
<Button onClick={handleRefreshOAuthServicesButtonClick}>{t('Refresh_oauth_services')}</Button>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const getSectionAnchorId = (groupId: string, sectionName: string): string =>
`settings-section-${groupId}-${sectionName || 'default'}`.replace(/[^a-zA-Z0-9-_]/g, '-');
2 changes: 0 additions & 2 deletions apps/meteor/tests/e2e/administration-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading