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/admin-settings-card-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@rocket.chat/meteor': minor
---

Refreshes the look of every admin settings group. The shared settings template now renders each section as a card with an in-page section navigation ("Settings in …") on the right, instead of a single accordion. All existing setting groups, sections, names, and descriptions are preserved — this is a presentational refactor of the settings rendering components, so it applies uniformly across the admin without reorganizing any settings.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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 type { TranslationKey } from '@rocket.chat/ui-contexts';
Expand All @@ -8,6 +8,8 @@ import type { ReactNode, MouseEvent, SubmitEvent } from 'react';
import { useMemo, memo } from 'react';
import { useTranslation } from 'react-i18next';

import SettingsSectionNav from './SettingsSectionNav';
import type { SettingsSectionNavItem } from './SettingsSectionNav';
import type { EditableSetting } from '../../EditableSettingsContext';
import { useEditableSettingsDispatch, useEditableSettings } from '../../EditableSettingsContext';

Expand All @@ -19,6 +21,7 @@ export type SettingsGroupPageProps = {
i18nLabel: string;
i18nDescription?: string;
tabs?: ReactNode;
navItems?: SettingsSectionNavItem[];
isCustom?: boolean;
};

Expand All @@ -30,6 +33,7 @@ const SettingsGroupPage = ({
i18nLabel,
i18nDescription = undefined,
tabs = undefined,
navItems = undefined,
isCustom = false,
}: SettingsGroupPageProps) => {
const { t, i18n } = useTranslation();
Expand Down Expand Up @@ -146,17 +150,24 @@ const SettingsGroupPage = ({
{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 display='flex' flexDirection='row' flexGrow={1} minHeight={0} overflow='hidden'>
<Box display='flex' flexDirection='column' flexGrow={1} minWidth={0}>
<PageScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x600' pbe={24}>
{i18nDescription && isTranslationKey(i18nDescription) && i18n.exists(i18nDescription) && (
<Box is='p' color='hint' fontScale='p2' mbe={24}>
{t(i18nDescription)}
</Box>
)}

{children}
</Box>
)}

<Accordion>{children}</Accordion>
</PageScrollableContentWithShadow>
</Box>
</PageScrollableContentWithShadow>
{navItems && navItems.length > 1 && (
<SettingsSectionNav groupLabel={isTranslationKey(i18nLabel) ? t(i18nLabel) : i18nLabel} items={navItems} />
)}
</Box>
)}
<PageFooter isDirty={!(changedEditableSettings.length === 0)}>
<ButtonGroup>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Accordion, Box, Skeleton } from '@rocket.chat/fuselage';
import { Box, Skeleton } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useMemo } from 'react';

Expand All @@ -8,15 +8,13 @@ const SettingsGroupPageSkeleton = () => (
<Page>
<PageHeader title={<Skeleton style={{ width: '20rem' }} />} />
<PageContent>
<Box style={useMemo(() => ({ margin: '0 auto', width: '100%', maxWidth: '590px' }), [])}>
<Box is='p' color='hint' fontScale='p2'>
<Box style={useMemo(() => ({ margin: '0 auto', width: '100%', maxWidth: '600px' }), [])}>
<Box is='p' color='hint' fontScale='p2' mbe={24}>
<Skeleton />
<Skeleton />
<Skeleton width='75%' />
</Box>
<Accordion>
<SettingsSectionSkeleton />
</Accordion>
<SettingsSectionSkeleton />
</Box>
</PageContent>
</Page>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { Box } from '@rocket.chat/fuselage';
import type { MouseEvent } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';

export type SettingsSectionNavItem = {
id: string;
label: string;
};

type SettingsSectionNavProps = {
groupLabel: string;
items: SettingsSectionNavItem[];
};

const SettingsSectionNav = ({ groupLabel, items }: SettingsSectionNavProps) => {
const { t } = useTranslation();
const [activeId, setActiveId] = useState(items[0]?.id);

useEffect(() => {
setActiveId((current) => (items.some((item) => item.id === current) ? current : items[0]?.id));
}, [items]);

const handleClick = (event: MouseEvent<HTMLAnchorElement>, id: string): void => {
event.preventDefault();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Section navigation does not update the URL fragment, so a selected Accounts section cannot be bookmarked/shared and browser history has no section state. Preserve/update the hash while retaining smooth scrolling.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/views/admin/settings/groups/AccountsGroupPage/AccountsSettingsNav.tsx, line 21:

<comment>Section navigation does not update the URL fragment, so a selected Accounts section cannot be bookmarked/shared and browser history has no section state. Preserve/update the hash while retaining smooth scrolling.</comment>

<file context>
@@ -0,0 +1,65 @@
+	const [activeId, setActiveId] = useState(items[0]?.id);
+
+	const handleClick = (event: MouseEvent<HTMLAnchorElement>, id: string): void => {
+		event.preventDefault();
+		setActiveId(id);
+		document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
</file context>

setActiveId(id);
document.getElementById(id)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};

return (
<Box
is='nav'
width='x240'
flexShrink={0}
pi={24}
pbs={24}
display='flex'
flexDirection='column'
overflowY='auto'
borderInlineStartWidth='default'
borderInlineStartColor='light'
>
<Box is='h3' mbe={16} color='hint' fontScale='micro' textTransform='uppercase'>
{t('Settings_in_group', { group: groupLabel })}
</Box>
{items.map(({ id, label }) => {
const isActive = id === activeId;
return (
<Box
key={id}
is='a'
href={`#${id}`}
onClick={(event: MouseEvent<HTMLAnchorElement>) => handleClick(event, id)}
pb={8}
pis={12}
mbe={4}
fontScale='p2'
color={isActive ? 'info' : 'hint'}
fontWeight={isActive ? 700 : 400}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The current section is conveyed only by color/weight. Expose the active navigation item with aria-current="location" so assistive technology receives the same state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/meteor/client/views/admin/settings/groups/AccountsGroupPage/AccountsSettingsNav.tsx, line 54:

<comment>The current section is conveyed only by color/weight. Expose the active navigation item with `aria-current="location"` so assistive technology receives the same state.</comment>

<file context>
@@ -0,0 +1,65 @@
+						mbe={4}
+						fontScale='p2'
+						color={isActive ? 'info' : 'hint'}
+						fontWeight={isActive ? 700 : 400}
+						style={{ cursor: 'pointer', textDecoration: 'none' }}
+					>
</file context>

style={{ cursor: 'pointer', textDecoration: 'none' }}
>
{label}
</Box>
);
Comment on lines +46 to +64

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

Add aria-current to the active nav link.

The active link is visually distinguished by color='info' and fontWeight={700}, but screen reader users have no programmatic indication of which section is active. Add aria-current={isActive ? 'true' : undefined} to the anchor element.

♿ Proposed fix
 					fontScale='p2'
 					color={isActive ? 'info' : 'hint'}
 					fontWeight={isActive ? 700 : 400}
+					aria-current={isActive ? 'true' : undefined}
 					style={{ cursor: 'pointer', textDecoration: 'none' }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{items.map(({ id, label }) => {
const isActive = id === activeId;
return (
<Box
key={id}
is='a'
href={`#${id}`}
onClick={(event: MouseEvent<HTMLAnchorElement>) => handleClick(event, id)}
pb={8}
pis={12}
mbe={4}
fontScale='p2'
color={isActive ? 'info' : 'hint'}
fontWeight={isActive ? 700 : 400}
style={{ cursor: 'pointer', textDecoration: 'none' }}
>
{label}
</Box>
);
{items.map(({ id, label }) => {
const isActive = id === activeId;
return (
<Box
key={id}
is='a'
href={`#${id}`}
onClick={(event: MouseEvent<HTMLAnchorElement>) => handleClick(event, id)}
pb={8}
pis={12}
mbe={4}
fontScale='p2'
color={isActive ? 'info' : 'hint'}
fontWeight={isActive ? 700 : 400}
aria-current={isActive ? 'true' : undefined}
style={{ cursor: 'pointer', textDecoration: 'none' }}
>
{label}
</Box>
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/meteor/client/views/admin/settings/groups/AccountsGroupPage/AccountsSettingsNav.tsx`
around lines 41 - 59, Update the anchor-rendering Box in the items map to add
aria-current, setting it to 'true' when isActive and leaving it undefined
otherwise.

})}
</Box>
);
};

export default SettingsSectionNav;
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 @@ -14,12 +14,12 @@ export type SettingsSectionProps = {
hasReset?: boolean;
sectionName: string;
currentTab?: string;
solo: boolean;
id?: string;
help?: ReactNode;
children?: ReactNode;
};

function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, solo, help, children }: SettingsSectionProps) {
function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, id, help, children }: SettingsSectionProps) {
const { t } = useTranslation();

const editableSettings = useEditableSettings(
Expand Down Expand Up @@ -71,29 +71,47 @@ function SettingsSection({ groupId, hasReset = true, sectionName, currentTab, so
};

return (
<AccordionItem
data-qa-section={sectionName}
noncollapsible={solo || !sectionName}
title={sectionName && t(sectionName as TranslationKey)}
>
{help && (
<Box is='p' color='hint' fontScale='p2'>
{help}
<Box is='section' data-qa-section={sectionName} id={id} mbe={24}>
{sectionName && (
<Box is='h2' fontScale='h4' mbe={12}>
{t(sectionName as TranslationKey)}
</Box>
)}
<FieldGroup>
{editableSettings.map(
(setting) => isSetting(setting) && <Setting key={setting._id} settingId={setting._id} sectionChanged={changed} />,
<Box
p={24}
borderWidth='default'
borderColor='light'
borderRadius='x8'
backgroundColor='surface-light'
display='flex'
flexDirection='column'
>
{help && (
<Box is='p' color='hint' fontScale='p2' mbe={16}>
{help}
</Box>
)}
<FieldGroup>
{editableSettings.map(
(setting) => isSetting(setting) && <Setting key={setting._id} settingId={setting._id} sectionChanged={changed} />,
)}

{children}
</FieldGroup>
{hasReset && canReset && (
<Button secondary danger marginBlockStart={16} data-section={sectionName} onClick={handleResetSectionClick}>
{t('Reset_section_settings')}
</Button>
)}
</AccordionItem>
{children}
</FieldGroup>
{hasReset && canReset && (
<Button
secondary
danger
marginBlockStart={16}
alignSelf='flex-start'
data-section={sectionName}
onClick={handleResetSectionClick}
>
{t('Reset_section_settings')}
</Button>
)}
</Box>
</Box>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import { AccordionItem, Box, FieldGroup, Skeleton } from '@rocket.chat/fuselage';
import { Box, FieldGroup, Skeleton } from '@rocket.chat/fuselage';

import SettingSkeleton from '../Setting/SettingSkeleton';

function SettingsSectionSkeleton() {
return (
<AccordionItem noncollapsible title={<Skeleton />}>
<Box is='p' color='hint' fontScale='p2'>
<Box is='section' mbe={24}>
<Box mbe={12} width='x160'>
<Skeleton />
</Box>

<FieldGroup>
{Array.from({ length: 10 }).map((_, i) => (
<SettingSkeleton key={i} />
))}
</FieldGroup>
</AccordionItem>
<Box p={24} borderWidth='default' borderColor='light' borderRadius='x8' backgroundColor='surface-light'>
<FieldGroup>
{Array.from({ length: 10 }).map((_, i) => (
<SettingSkeleton key={i} />
))}
</FieldGroup>
</Box>
</Box>
);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { memo } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';

import { getSettingsSectionAnchorId } from './getSettingsSectionAnchorId';
import SettingsGroupPage from '../SettingsGroupPage';
import Section from '../SettingsSection';

Expand All @@ -16,12 +19,27 @@ export type GenericGroupPageProps = {
};

function GenericGroupPage({ _id, i18nLabel, sections, tabs, currentTab, hasReset, onClickBack, ...props }: GenericGroupPageProps) {
const solo = sections.length === 1;
const { t } = useTranslation();

const navItems = useMemo(
() =>
sections
.filter(Boolean)
.map((sectionName) => ({ id: getSettingsSectionAnchorId(_id, sectionName), label: t(sectionName as TranslationKey) })),
[sections, _id, t],
);

return (
<SettingsGroupPage _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} tabs={tabs} {...props}>
<SettingsGroupPage _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} tabs={tabs} navItems={navItems} {...props}>
{sections.map((sectionName) => (
<Section key={sectionName || ''} hasReset={hasReset} groupId={_id} sectionName={sectionName} currentTab={currentTab} solo={solo} />
<Section
key={sectionName || ''}
id={getSettingsSectionAnchorId(_id, sectionName)}
hasReset={hasReset}
groupId={_id}
sectionName={sectionName}
currentTab={currentTab}
/>
))}
</SettingsGroupPage>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,31 @@ import { capitalize } from '@rocket.chat/string-helpers';
import { GenericModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useAbsoluteUrl, useMethod, useTranslation, useSetModal } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { memo, useEffect, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';

import CreateOAuthModal from './CreateOAuthModal';
import { strRight } from '../../../../../../lib/utils/stringUtils';
import { useEditableSettingsGroupSections } from '../../../EditableSettingsContext';
import SettingsGroupPage from '../../SettingsGroupPage';
import SettingsSection from '../../SettingsSection';
import { getSettingsSectionAnchorId } from '../getSettingsSectionAnchorId';

export type OAuthGroupPageProps = ISetting & {
onClickBack?: () => void;
};

function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
const sections = useEditableSettingsGroupSections(_id);
const solo = sections.length === 1;
const t = useTranslation();

const [settingSections, setSettingSections] = useState(sections);

const navItems = useMemo(
() =>
settingSections.filter(Boolean).map((sectionName) => ({ id: getSettingsSectionAnchorId(_id, sectionName), label: t(sectionName) })),
[settingSections, _id, t],
);
Comment on lines +26 to +30

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the useTranslation import/type used in OAuthGroupPage.tsx and its t() signature.
sed -n '1,10p' apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx
rg -n "export.*useTranslation" packages/ui-contexts/src 2>/dev/null

Repository: RocketChat/Rocket.Chat

Length of output: 980


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' packages/ui-contexts/src/hooks/useTranslation.ts
printf '\n---\n'
rg -n "type TranslationContextValue|interface TranslationContextValue|TranslationKey" packages/ui-contexts/src packages/ui-contexts -g '!**/dist/**'

Repository: RocketChat/Rocket.Chat

Length of output: 2467


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx
printf '\n---\n'
rg -n "useEditableSettingsGroupSections|settingSections" apps/meteor/client/views/admin/settings -g '!**/dist/**'

Repository: RocketChat/Rocket.Chat

Length of output: 6871


🏁 Script executed:

#!/bin/bash
sed -n '1,220p' apps/meteor/client/views/admin/settings/EditableSettingsContext.tsx
printf '\n---\n'
rg -n "useEditableSettingsGroupSections" apps/meteor/client/views/admin/settings -g '!**/dist/**'

Repository: RocketChat/Rocket.Chat

Length of output: 1180


🏁 Script executed:

#!/bin/bash
fd -a "EditableSettingsContext.*" apps/meteor/client/views/admin/settings
printf '\n---\n'
fd -a "EditableSettingsContext.*" apps/meteor/client/views/admin
printf '\n---\n'
git ls-files | rg "EditableSettingsContext\.(ts|tsx)$"

Repository: RocketChat/Rocket.Chat

Length of output: 388


🏁 Script executed:

#!/bin/bash
sed -n '1,240p' apps/meteor/client/views/admin/EditableSettingsContext.ts
printf '\n---\n'
sed -n '1,220p' apps/meteor/client/views/admin/settings/groups/GenericGroupPage.tsx
printf '\n---\n'
sed -n '1,220p' apps/meteor/client/views/admin/settings/groups/SettingsSection.tsx

Repository: RocketChat/Rocket.Chat

Length of output: 5995


Add the TranslationKey cast here too. useEditableSettingsGroupSections() returns string[], but t() only accepts TranslationKey, so t(sectionName) here is a type mismatch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@apps/meteor/client/views/admin/settings/groups/OAuthGroupPage/OAuthGroupPage.tsx`
around lines 26 - 30, Update the navItems useMemo mapping in OAuthGroupPage to
cast each sectionName to TranslationKey before passing it to t(). Preserve the
existing filtering, anchor ID generation, and dependency array.


const sectionIsCustomOAuth = (sectionName: string): string | boolean => sectionName && /^Custom OAuth:\s.+/.test(sectionName);

const getAbsoluteUrl = useAbsoluteUrl();
Expand Down Expand Up @@ -98,6 +104,7 @@ function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
_id={_id}
{...group}
onClickBack={onClickBack}
navItems={navItems}
headerButtons={
<>
<Button onClick={handleRefreshOAuthServicesButtonClick}>{t('Refresh_oauth_services')}</Button>
Expand All @@ -114,6 +121,7 @@ function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
return (
<SettingsSection
key={sectionName}
id={getSettingsSectionAnchorId(_id, sectionName)}
groupId={_id}
help={
<span
Expand All @@ -123,7 +131,6 @@ function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
/>
}
sectionName={sectionName}
solo={solo}
>
<div className='submit'>
<Button secondary danger onClick={handleRemoveCustomOAuthButtonClick}>
Expand All @@ -134,7 +141,9 @@ function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
);
}

return <SettingsSection key={sectionName} groupId={_id} sectionName={sectionName} solo={solo} />;
return (
<SettingsSection key={sectionName} id={getSettingsSectionAnchorId(_id, sectionName)} groupId={_id} sectionName={sectionName} />
);
})}
</SettingsGroupPage>
);
Expand Down
Loading
Loading