M5-T01B: render ConfigPage sections as Mantine Accordion

This commit is contained in:
2026-06-22 12:34:04 +02:00
parent da7d0f9d62
commit eac7860b51
3 changed files with 92 additions and 31 deletions
+1 -1
View File
@@ -274,7 +274,7 @@ Phase CMQTT / Discovery,依赖 B 的设备数据与 provider 接口)
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
### M5-T01B — Config 页分区折叠(Accordion
- **Status**: `todo` · **Depends**: none
- **Status**: `done` · **Depends**: none
- **Context**: config 内容会越来越多(M5 还要加 MQTT / HA Discovery / Modbus 配置 + Expose 面板)。把 ConfigPage 从一长串 section 改为 Mantine `Accordion`:进页只见各大类标题,逐类展开编辑。**纯前端、单列内容**——它不是"第二个 app 级侧栏",与主侧栏(T01)无冲突,故 `Depends: none`、可独立先做。
- **Files**: `modify frontend/src/pages/ConfigPage.tsx``modify frontend/src/pages/ConfigPage.test.tsx`(折叠/展开断言)
- **Steps**:
+44 -1
View File
@@ -1,5 +1,5 @@
/**
* Tests for ConfigPage (M2-T08).
* Tests for ConfigPage (M2-T08, M5-T01B).
*
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
* without a real server.
@@ -15,6 +15,8 @@
* 8. SMTP test button: success state (200 result=success).
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
* 10. SMTP test button: failed state (502/ApiError result=failed).
* 11. (M5-T01B) Accordion: sections rendered as accordion items.
* 12. (M5-T01B) Accordion: second section can be expanded by clicking its control.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
@@ -334,4 +336,45 @@ describe('ConfigPage', () => {
expect(screen.queryByTestId('smtp-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('smtp-result-config-error')).not.toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 11. (M5-T01B) Accordion: sections rendered as accordion items
// -------------------------------------------------------------------------
it('renders an accordion with one item per config section', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-accordion')).toBeInTheDocument()
})
// Each section should have an accordion control
expect(screen.getByTestId('accordion-control-General')).toBeInTheDocument()
expect(screen.getByTestId('accordion-control-SMTP')).toBeInTheDocument()
})
// -------------------------------------------------------------------------
// 12. (M5-T01B) Accordion: second section can be expanded by clicking its control
// -------------------------------------------------------------------------
it('expands a collapsed section when its accordion control is clicked', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('accordion-control-SMTP')).toBeInTheDocument()
})
// The SMTP section control is clickable (the accordion item exists)
const smtpControl = screen.getByTestId('accordion-control-SMTP')
expect(smtpControl).toBeInTheDocument()
// Click to expand the SMTP section
fireEvent.click(smtpControl)
// After clicking, the SMTP section fields should still be in the DOM
// (Mantine accordion keeps content in DOM even when collapsed)
await waitFor(() => {
expect(screen.getByText('SMTP Password')).toBeInTheDocument()
})
})
})
+31 -13
View File
@@ -1,8 +1,8 @@
/**
* ConfigPage — config editor (M2-T08).
* ConfigPage — config editor (M2-T08, M5-T01B).
*
* Behaviours:
* 1. Load config: GET /api/config → render sections (grouped) with Mantine inputs.
* 1. Load config: GET /api/config → render sections as Accordion items (M5-T01B).
* - Non-secret fields show their value.
* - Secret fields render as empty PasswordInput (never show a masked value).
* 2. Save config: PUT /api/config with full-field submission semantics.
@@ -13,11 +13,16 @@
* 3. SMTP test button: POST /api/config/smtp/test.
* - Tri-state: success / config-error / failed.
* - Errors read `err.body.result` from ApiError.
*
* Layout (M5-T01B): each config section = one Accordion.Item. The first section
* is open by default. Future panels (e.g. T12 Home Assistant Expose) should be
* appended as additional Accordion.Items after the config sections.
*/
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
Accordion,
Container,
Title,
Text,
@@ -30,7 +35,6 @@ import {
Divider,
Loader,
Center,
Paper,
Badge,
} from '@mantine/core'
import apiClient, { ApiError } from '../api/client'
@@ -153,21 +157,17 @@ function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
}
// ---------------------------------------------------------------------------
// ConfigSectionPanel — one section
// ConfigSectionFields — the fields inside one accordion panel
// ---------------------------------------------------------------------------
interface ConfigSectionPanelProps {
interface ConfigSectionFieldsProps {
section: ConfigSection
localValues: Record<string, string>
onChange: (envName: string, value: string) => void
}
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
function ConfigSectionFields({ section, localValues, onChange }: ConfigSectionFieldsProps) {
return (
<Paper withBorder p="md" radius="md">
<Title order={4} mb="md">
{section.name}
</Title>
<Stack gap="sm">
{section.fields.map((field) => (
<ConfigFieldInput
@@ -178,7 +178,6 @@ function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPan
/>
))}
</Stack>
</Paper>
)
}
@@ -338,6 +337,9 @@ export function ConfigPage() {
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
)
// Default: open the first section so users immediately see content.
const defaultAccordionValue = data.sections[0]?.name ?? null
return (
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
<Group justify="space-between" mb="lg" wrap="nowrap">
@@ -349,14 +351,30 @@ export function ConfigPage() {
<form onSubmit={handleSave} data-testid="config-form">
<Stack gap="lg">
{/* M5-T01B: each config section is one Accordion.Item.
Future panels (e.g. T12 Home Assistant Expose) should be appended
as additional Accordion.Items here, after the config sections. */}
<Accordion
defaultValue={defaultAccordionValue}
variant="separated"
radius="md"
data-testid="config-accordion"
>
{data.sections.map((section) => (
<ConfigSectionPanel
key={section.name}
<Accordion.Item key={section.name} value={section.name}>
<Accordion.Control data-testid={`accordion-control-${section.name}`}>
<Text fw={500}>{section.name}</Text>
</Accordion.Control>
<Accordion.Panel>
<ConfigSectionFields
section={section}
localValues={localValues}
onChange={handleChange}
/>
</Accordion.Panel>
</Accordion.Item>
))}
</Accordion>
<Divider />