fix(settings): remove the duplicated section heading on 11 tabs
A generic shell heading stacked on top of each tab component's own internal
heading. The report named five tabs "at least"; auditing all 28 found 11:
downloads, sso, apiTokens, webhooks, businessProfile, crm, accounting,
whatsapp, slideshow, moderation, styling. On the first eight the two headings
resolve to the identical string -- sso and businessProfile literally render
the same key twice. The other three were near-identical stacked titles
("Moderation"/"Word Filters", "Custom CSS"/"Custom CSS Templates",
"CRM behaviour"/"CRM settings").
Clean, and left alone: general, events, categories, thumbnails, security, seo,
imageSecurity, status, analytics (its first heading is a genuine sub-section),
plus the eight already in TABS_WITH_OWN_HEADER.
Removed the component side and kept the shell heading: the shell heading is
the consistent one (icon + label + divider on ~20 tabs) and always matches the
nav item the admin clicked, and none of these components are mounted outside
SettingsPage, so nothing loses a title. Subtitles and intro copy preserved
throughout; orphaned icon imports removed.
The guard test was checked against the pre-fix blobs and does fail on them.
Refs testplan REPORT.md, "duplicate H2 section heading" warning.
This commit is contained in:
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, RotateCcw, Code, AlertTriangle, Check } from 'lucide-react';
|
||||
import { Save, RotateCcw, AlertTriangle, Check } from 'lucide-react';
|
||||
import { Button, Card, Loading } from '../common';
|
||||
import { cssTemplatesService, CssTemplate } from '../../services/cssTemplates.service';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
@@ -90,12 +90,10 @@ export const CssTemplateEditor: React.FC = () => {
|
||||
return (
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<Code className="w-5 h-5" />
|
||||
{t('cssTemplates.title', 'Custom CSS Templates')}
|
||||
</h2>
|
||||
</div>
|
||||
{/* No title here — this component IS the Settings → Custom CSS
|
||||
tab, and the Settings shell already renders that section
|
||||
heading (icon + label + divider). A second, near-identical H2
|
||||
stacked directly under it (QA warning). */}
|
||||
|
||||
{/* Tab Navigation */}
|
||||
<div className="flex border-b border-neutral-200 dark:border-neutral-700 mb-6">
|
||||
|
||||
@@ -176,10 +176,11 @@ export const WordFilterManager: React.FC = () => {
|
||||
<>
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
{/* No title here — this component IS the Settings → Moderation
|
||||
tab, and the Settings shell already renders that section
|
||||
heading (icon + label + divider). A second H2 stacked
|
||||
directly under it (QA warning). */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
{t('settings.moderation.wordFilters', 'Word Filters')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
|
||||
</p>
|
||||
|
||||
@@ -95,10 +95,12 @@ export const AccountingTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('settings.accounting.title', 'Accounting')}</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}</p>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the same
|
||||
`settings.accounting.title` key, so repeating it stacked two
|
||||
identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-neutral-600 dark:text-neutral-400">{t('settings.accounting.subtitle', 'Default rates for internal expenses and the proof requirement.')}</p>
|
||||
|
||||
<Card><CardContent className="p-5 space-y-4">
|
||||
<div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { KeyRound, Trash2, Copy, AlertTriangle } from 'lucide-react';
|
||||
import { Trash2, Copy, AlertTriangle } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import { api } from '../../../config/api';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
@@ -83,10 +83,10 @@ export const ApiTokensTab: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5" />
|
||||
{t('settings.apiTokens.title', 'API Tokens')}
|
||||
</h2>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and repeating it stacked
|
||||
two identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.apiTokens.subtitle', 'Long-lived bearer tokens for the public /api/v1 surface — n8n integrations, custom apps, scripts. Tokens act as the admin user that minted them, intersected with the chosen scopes.')}
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Save, Download, Plus, Trash2 } from 'lucide-react';
|
||||
import { Save, Plus, Trash2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
@@ -107,12 +107,10 @@ export const DownloadsTab: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Download className="w-5 h-5 text-neutral-500" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.downloads.title', 'Download resolutions')}
|
||||
</h2>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and repeating it stacked
|
||||
two identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-5">
|
||||
{t('settings.downloads.intro',
|
||||
'The standard size is what every gallery hands out by default. Individual galleries can override this.')}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Save, KeyRound, PlugZap, Copy, Check, UserCog, ShieldAlert, Plus, Trash2 } from 'lucide-react';
|
||||
import { Save, PlugZap, Copy, Check, UserCog, ShieldAlert, Plus, Trash2 } from 'lucide-react';
|
||||
import type { AxiosError } from 'axios';
|
||||
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
@@ -127,12 +127,11 @@ export const SsoTab: React.FC = () => {
|
||||
<div className="space-y-6">
|
||||
<Card>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound className="w-5 h-5 text-neutral-500" />
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.sso.title', 'Single Sign-On (OIDC)')}
|
||||
</h2>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the
|
||||
same `settings.sso.title` key, so repeating it stacked two
|
||||
identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.sso.intro', 'Let admins sign in through your identity provider (Keycloak, Authentik, Pocket ID, or any OIDC-compliant IdP). Local email/password login stays available as a fallback.')}
|
||||
</p>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Webhook as WebhookIcon, Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Trash2, Copy, AlertTriangle, Activity, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||
import { api } from '../../../config/api';
|
||||
import { useLocalizedDate } from '../../../hooks/useLocalizedDate';
|
||||
@@ -124,10 +124,10 @@ export const WebhooksTab: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2 flex items-center gap-2">
|
||||
<WebhookIcon className="w-5 h-5" />
|
||||
{t('settings.webhooks.title', 'Webhooks')}
|
||||
</h2>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and repeating it stacked
|
||||
two identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('settings.webhooks.subtitle', 'POST event notifications to your URL the moment something happens — gallery published, photo uploaded, event archived, etc. Signed with HMAC-SHA256 in the X-PicPeak-Signature header.')}
|
||||
</p>
|
||||
|
||||
@@ -119,17 +119,17 @@ export const WhatsAppTab: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.whatsapp.title', 'WhatsApp')}
|
||||
</h2>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t(
|
||||
'settings.whatsapp.subtitle',
|
||||
'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.',
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the same
|
||||
`settings.whatsapp.title` key, so repeating it stacked two
|
||||
identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-neutral-600 dark:text-neutral-400">
|
||||
{t(
|
||||
'settings.whatsapp.subtitle',
|
||||
'Configure Meta Business credentials to deliver the gallery-ready notification via WhatsApp alongside email.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-5 space-y-4">
|
||||
|
||||
@@ -12,14 +12,14 @@ export const SlideshowSettingsPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
|
||||
{t('settings.slideshow.title', 'Slideshow')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('settings.slideshow.subtitle', 'Global defaults for the Live Slideshow. Events and event types can override these.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and it reads from the same
|
||||
`settings.slideshow.title` key, so repeating it stacked two
|
||||
identical H2s on top of each other (QA warning). */}
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('settings.slideshow.subtitle', 'Global defaults for the Live Slideshow. Events and event types can override these.')}
|
||||
</p>
|
||||
<SlideshowGlobalDefaultsCard />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* SettingsPage renders a section heading (icon + nav label + divider) for
|
||||
* every tab that isn't listed in its own TABS_WITH_OWN_HEADER escape hatch.
|
||||
* Several tab components ALSO rendered a page-level title of their own, so
|
||||
* the admin saw the same heading twice, stacked (QA warning).
|
||||
*
|
||||
* The duplicate was removed on the component side — the shell heading is the
|
||||
* one that is consistent across all ~20 tabs and always matches the nav item
|
||||
* the admin just clicked. Source-inspection guard so it doesn't come back.
|
||||
*/
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import en from '../../../i18n/locales/en.json';
|
||||
|
||||
const SRC = path.resolve(__dirname, '../../..');
|
||||
|
||||
const read = (rel: string) => fs.readFileSync(path.join(SRC, rel), 'utf8');
|
||||
|
||||
const translate = (key: string): string | undefined => {
|
||||
let node: unknown = en;
|
||||
for (const part of key.split('.')) {
|
||||
if (typeof node !== 'object' || node === null || !(part in node)) return undefined;
|
||||
node = (node as Record<string, unknown>)[part];
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined;
|
||||
};
|
||||
|
||||
const normalize = (value: string) => value.toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
|
||||
/** Tabs that lean on the shell heading, and the component each one mounts. */
|
||||
const SHELL_HEADING_TABS = [
|
||||
{ tab: 'downloads', labelKey: 'settings.downloads.title', file: 'features/settings/tabs/DownloadsTab.tsx' },
|
||||
{ tab: 'sso', labelKey: 'settings.sso.title', file: 'features/settings/tabs/SsoTab.tsx' },
|
||||
{ tab: 'apiTokens', labelKey: 'settings.apiTokens.title', file: 'features/settings/tabs/ApiTokensTab.tsx' },
|
||||
{ tab: 'webhooks', labelKey: 'settings.webhooks.title', file: 'features/settings/tabs/WebhooksTab.tsx' },
|
||||
{ tab: 'accounting', labelKey: 'settings.accounting.title', file: 'features/settings/tabs/AccountingTab.tsx' },
|
||||
{ tab: 'whatsapp', labelKey: 'settings.whatsapp.title', file: 'features/settings/tabs/WhatsAppTab.tsx' },
|
||||
{ tab: 'moderation', labelKey: 'settings.moderation.title', file: 'components/admin/WordFilterManager.tsx' },
|
||||
{ tab: 'styling', labelKey: 'settings.styling.title', file: 'components/admin/CssTemplateEditor.tsx' },
|
||||
{ tab: 'slideshow', labelKey: 'settings.slideshow.title', file: 'pages/admin/SlideshowSettingsPage.tsx' },
|
||||
{ tab: 'businessProfile', labelKey: 'settings.businessProfile.title', file: 'pages/admin/settings/SettingsBusinessProfilePage.tsx' },
|
||||
{ tab: 'crm', labelKey: 'settings.crm.title', file: 'pages/admin/settings/CrmSettingsPage.tsx' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The three tabs whose own title merely paraphrased the nav label ("Word
|
||||
* Filters" under "Moderation") rather than repeating it verbatim, so the
|
||||
* label comparison above can't catch a regression on them.
|
||||
*/
|
||||
const PARAPHRASED_TITLE_KEYS = [
|
||||
{ file: 'components/admin/WordFilterManager.tsx', key: 'settings.moderation.wordFilters' },
|
||||
{ file: 'components/admin/CssTemplateEditor.tsx', key: 'cssTemplates.title' },
|
||||
{ file: 'pages/admin/settings/CrmSettingsPage.tsx', key: 'crmSettings.title' },
|
||||
];
|
||||
|
||||
const HEADING = /<(h[12])\b[^>]*>([\s\S]*?)<\/\1>/g;
|
||||
// Captures the key and, when present, the inline English default. Several of
|
||||
// these keys live only as defaults (`businessProfile.title` is not in en.json
|
||||
// at all), so resolving against the locale file alone would miss them.
|
||||
const T_CALL = /\bt\(\s*'([^']+)'(?:\s*,\s*'((?:[^'\\]|\\.)*)')?/g;
|
||||
|
||||
const resolve = (key: string, fallback?: string) => translate(key) ?? fallback;
|
||||
|
||||
describe('settings tabs render exactly one section heading', () => {
|
||||
it.each(SHELL_HEADING_TABS)(
|
||||
'$tab does not repeat its nav label inside the tab body',
|
||||
({ labelKey, file }) => {
|
||||
const label = translate(labelKey);
|
||||
expect(label, `${labelKey} missing from en.json`).toBeTruthy();
|
||||
|
||||
const offenders: string[] = [];
|
||||
for (const heading of read(file).matchAll(HEADING)) {
|
||||
for (const call of heading[2].matchAll(T_CALL)) {
|
||||
const resolved = resolve(call[1], call[2]);
|
||||
if (resolved && normalize(resolved) === normalize(label as string)) {
|
||||
offenders.push(call[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(PARAPHRASED_TITLE_KEYS)('$file no longer renders $key as a title', ({ file, key }) => {
|
||||
expect(read(file)).not.toContain(`t('${key}'`);
|
||||
});
|
||||
|
||||
it('keeps these tabs out of SettingsPage.TABS_WITH_OWN_HEADER', () => {
|
||||
// The fix must stay on the component side: adding the tabs to the skip
|
||||
// list would drop the shell heading and lose the icon + divider that
|
||||
// every other tab shows.
|
||||
const declaration = /TABS_WITH_OWN_HEADER: TabType\[\] = \[([^\]]*)\]/
|
||||
.exec(read('pages/admin/SettingsPage.tsx'))?.[1];
|
||||
expect(declaration).toBeTruthy();
|
||||
|
||||
const skipped = Array.from((declaration as string).matchAll(/'([^']+)'/g), (m) => m[1]);
|
||||
for (const { tab } of SHELL_HEADING_TABS) {
|
||||
expect(skipped).not.toContain(tab);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -171,13 +171,15 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('crmSettings.title', 'CRM settings')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, so a page title repeated
|
||||
the nav label the admin just clicked (QA warning). The subtitle
|
||||
stays. */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('crmSettings.subtitle', 'Fine-tune quote and invoice behaviour.')}
|
||||
</p>
|
||||
<Button onClick={() => saveAll.mutate()} disabled={saveAll.isPending || !anySection}>
|
||||
<SaveIcon className="w-4 h-4 mr-1" />{t('common.save', 'Save')}
|
||||
</Button>
|
||||
|
||||
@@ -63,13 +63,15 @@ export const SettingsBusinessProfilePage: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('businessProfile.title', 'Business profile')}</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* No tab title here — the Settings shell renders the section
|
||||
heading (icon + label + divider) for every tab that isn't in
|
||||
SettingsPage's TABS_WITH_OWN_HEADER, and its label resolves to
|
||||
the same string, so repeating it stacked two identical H2s on
|
||||
top of each other (QA warning). The subtitle stays. */}
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
{t('businessProfile.subtitle', 'Issuer block shown on every quote and invoice PDF.')}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => saveProfile.mutate()}
|
||||
disabled={saveProfile.isPending}
|
||||
|
||||
Reference in New Issue
Block a user