♻️ refactor(portals): вынести меню разделов в shared

This commit is contained in:
Andrey committed 2026-09-29 13:09:19 +03:00
1 parent 21a7626f95
commit 36c95199a0
8 files changed
+181 -101

No files matched your search

@@ -0,0 +1,30 @@
---
id: T-007
title: Вынести меню разделов настроек в общий компонент
milestone: M01
status: done
depends_on: []
order: 1
spec: "0002"
created: 2026-09-29
branch: skaro/T-007-vynesti-menyu-razdelov-nastroe
---
## Цель
Страница подключения и настройки портала должны пользоваться одним меню разделов, а не двумя копиями. Меню портала выносится в общий компонент без изменения вида.
## Критерии приёмки
- [x] Меню разделов (пункты с иконкой, подсказкой, разделителем, опасный пункт) — общий компонент в shared/, настройки портала используют его
- [x] Вид настроек портала (кадры PT4–PT6 из «Порталы Baseline») не изменился
- [x] Классы portal-settings-nav* в новой странице не дублируются
- [x] Тесты и tsc по затронутым файлам проходят
## Заметки
Сейчас меню собрано в features/support-portals/PortalSettings.tsx и sections.ts. Правило «один стандарт на элемент» из AGENTS.md.
## Итог
Меню настроек портала вынесено в общий SectionMenu и CSS в shared, сохранены пункты с иконкой, подсказкой, разделителем и опасным пунктом. На штатном Docker dev-контуре установлен демо-набор, портал проверен в Chromium на кадрах PT4–PT6; целевой тест, tsc и сборка прошли.
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Icon } from "../../shared/icons";
import { SectionMenu } from "../../shared/SectionMenu";
import {
listPortalWidgets,
type PortalAddressConfig,
@@ -47,32 +47,19 @@ export function PortalSettings({
const current = PORTAL_SETTINGS_SECTIONS.find((item) => item.key === section)
?? PORTAL_SETTINGS_SECTIONS[0];
const domainLive = Boolean(portal.customDomain && portal.customDomainVerifiedAt);
const hints: Partial<Record<PortalSettingsSectionKey, { text: string; tone: "ok" | "muted" }>> = {
domain: domainLive ? { text: t("portals.working"), tone: "ok" } : undefined,
};
const menuItems = PORTAL_SETTINGS_SECTIONS.map((item) => ({
...item,
hint: item.key === "domain" && domainLive ? { text: t("portals.working"), tone: "ok" as const } : undefined,
}));
return (
<div className="portal-settings-layout">
<nav className="portal-settings-nav">
{PORTAL_SETTINGS_SECTIONS.map((item) => (
<span key={item.key}>
<button
className={`portal-settings-nav-item${item.key === current.key ? " is-active" : ""}`}
type="button"
onClick={() => openSection(item.key)}
>
<Icon name={item.icon} size={16} strokeWidth={1.9} />
<span>{item.label}</span>
{hints[item.key] && (
<small className={hints[item.key]!.tone === "ok" ? "is-ok" : ""}>{hints[item.key]!.text}</small>
)}
</button>
{item.divider && <i className="portal-settings-nav-divider" />}
</span>
))}
<span className="portal-settings-nav-gap" />
<p>{t("portals.changes_reach_public_pages_as")}</p>
</nav>
<SectionMenu
items={menuItems}
activeKey={current.key}
note={t("portals.changes_reach_public_pages_as")}
onSelect={openSection}
/>
<div className="portal-settings-content">
<div className="portal-settings-inner">
@@ -1,8 +1,6 @@
import type { Icon } from "../../shared/icons";
import type { SectionMenuItem } from "../../shared/SectionMenu";
import { t } from "../../i18n";
type IconName = Parameters<typeof Icon>[0]["name"];
export type PortalSettingsSectionKey =
| "basics"
| "domain"
@@ -10,14 +8,9 @@ export type PortalSettingsSectionKey =
| "widget"
| "danger";
export type PortalSettingsSection = {
key: PortalSettingsSectionKey;
label: string;
icon: IconName;
export type PortalSettingsSection = SectionMenuItem<PortalSettingsSectionKey> & {
heading: string;
lead: string;
// Разделитель под пунктом — как в макете, перед сноской субменю.
divider?: boolean;
};
// Субменю настроек портала (дизайн-базлайн v2, кадры PT4–PT6). Модалки нет:
@@ -58,6 +51,7 @@ export const PORTAL_SETTINGS_SECTIONS: PortalSettingsSection[] = [
heading: t("portals.publishing_archive"),
lead: t("portals.publishing_opens_material_visitors_archive"),
divider: true,
danger: true,
},
];
@@ -8,74 +8,6 @@
min-height: 0;
}
.portal-settings-nav {
display: flex;
width: 250px;
flex: none;
flex-direction: column;
gap: 1px;
padding: 14px 10px;
border-right: 1px solid var(--n-8);
background: var(--surface-card);
}
.portal-settings-nav > span {
display: block;
}
.portal-settings-nav-item {
display: flex;
width: 100%;
min-height: 34px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.portal-settings-nav-item:hover { background: var(--n-9); }
.portal-settings-nav-item.is-active {
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
}
.portal-settings-nav-item > span { flex: 1; }
.portal-settings-nav-item small {
color: var(--n-5);
font-size: 11px;
font-weight: 600;
}
.portal-settings-nav-item small.is-ok { color: var(--success-text); }
.portal-settings-nav-divider {
display: block;
height: 1px;
margin: 8px 6px;
background: var(--n-8);
}
.portal-settings-nav-gap { flex: 1; }
.portal-settings-nav p {
margin: 0;
padding: 0 10px;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.5;
}
.portal-settings-content {
min-width: 0;
min-height: 0;
@@ -0,0 +1,32 @@
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { t } from "../i18n";
import { PORTAL_SETTINGS_SECTIONS } from "../features/support-portals/sections";
import { SectionMenu } from "./SectionMenu";
describe("SectionMenu", () => {
it("renders the portal sections, hint, divider, and dangerous item", () => {
const items = PORTAL_SETTINGS_SECTIONS.map((item) => ({
...item,
hint: item.key === "domain" ? { text: t("portals.working"), tone: "ok" as const } : undefined,
}));
const markup = renderToStaticMarkup(
<SectionMenu
items={items}
activeKey="domain"
note={t("portals.changes_reach_public_pages_as")}
onSelect={() => undefined}
/>,
);
expect(markup).toContain('class="section-menu"');
expect(markup.match(/class="section-menu-item/g)).toHaveLength(5);
expect(markup).toContain('class="section-menu-item is-active"');
expect(markup).toContain('class="section-menu-item is-danger"');
expect(markup).toContain('class="section-menu-divider"');
expect(markup).toContain('class="is-ok">' + t("portals.working"));
expect(markup).toContain(t("portals.changes_reach_public_pages_as"));
expect(markup).toContain("<svg");
});
});
@@ -0,0 +1,37 @@
import { Icon } from "./icons";
export type SectionMenuItem<Key extends string> = {
key: Key;
label: string;
icon: Parameters<typeof Icon>[0]["name"];
hint?: { text: string; tone?: "ok" | "muted" };
divider?: boolean;
danger?: boolean;
};
export function SectionMenu<Key extends string>({ items, activeKey, note, onSelect }: {
items: readonly SectionMenuItem<Key>[];
activeKey: Key;
note?: string;
onSelect: (key: Key) => void;
}) {
return (
<nav className="section-menu">
{items.map((item) => (
<span key={item.key}>
<button
className={`section-menu-item${item.key === activeKey ? " is-active" : ""}${item.danger ? " is-danger" : ""}`}
type="button"
onClick={() => onSelect(item.key)}
>
<Icon name={item.icon} size={16} strokeWidth={1.9} />
<span>{item.label}</span>
{item.hint && <small className={item.hint.tone === "ok" ? "is-ok" : ""}>{item.hint.text}</small>}
</button>
{item.divider && <i className="section-menu-divider" />}
</span>
))}
{note && <><span className="section-menu-gap" /><p>{note}</p></>}
</nav>
);
}
@@ -0,0 +1,67 @@
.section-menu {
display: flex;
width: 250px;
flex: none;
flex-direction: column;
gap: 1px;
padding: 14px 10px;
border-right: 1px solid var(--n-8);
background: var(--surface-card);
}
.section-menu > span {
display: block;
}
.section-menu-item {
display: flex;
width: 100%;
min-height: 34px;
align-items: center;
gap: 10px;
padding: 0 10px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--n-2);
font-family: inherit;
font-size: 13px;
font-weight: 500;
text-align: left;
cursor: pointer;
}
.section-menu-item:hover { background: var(--n-9); }
.section-menu-item.is-active {
background: var(--primary-bg);
color: var(--primary-text);
font-weight: 600;
}
.section-menu-item > span { flex: 1; }
.section-menu-item small {
color: var(--n-5);
font-size: 11px;
font-weight: 600;
}
.section-menu-item small.is-ok { color: var(--success-text); }
.section-menu-divider {
display: block;
height: 1px;
margin: 8px 6px;
background: var(--n-8);
}
.section-menu-gap { flex: 1; }
.section-menu p {
margin: 0;
padding: 0 10px;
color: var(--n-4);
font-size: 11.5px;
line-height: 1.5;
}
+1
View File
@@ -12,6 +12,7 @@
@import "./features/conversations/contact-edit.css";
@import "./shared/table-controls.css";
@import "./shared/menu.css";
@import "./shared/section-menu.css";
@import "./shared/content-library/styles.css";
@import "./shared/links.css";
@import "./features/employees/styles.css";