mirror of
https://github.com/dartdavros/chatballs.git
synced 2026-10-05 09:14:58 +03:00
♻️ refactor(portals): вынести меню разделов в shared
This commit is contained in:
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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user