M5-T01B: render ConfigPage sections as Mantine Accordion
This commit is contained in:
@@ -274,7 +274,7 @@ Phase C(MQTT / Discovery,依赖 B 的设备数据与 provider 接口)
|
|||||||
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
|
- **Reviewer checklist**: 布局只在 `App.tsx`/新组件内变动,未误改页面或鉴权;无死链导航项。
|
||||||
|
|
||||||
### M5-T01B — Config 页分区折叠(Accordion)
|
### 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`、可独立先做。
|
- **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`(折叠/展开断言)
|
- **Files**: `modify frontend/src/pages/ConfigPage.tsx`;`modify frontend/src/pages/ConfigPage.test.tsx`(折叠/展开断言)
|
||||||
- **Steps**:
|
- **Steps**:
|
||||||
|
|||||||
@@ -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
|
* Strategy: vi.mock the apiClient module so we can control GET/PUT/POST responses
|
||||||
* without a real server.
|
* without a real server.
|
||||||
@@ -15,6 +15,8 @@
|
|||||||
* 8. SMTP test button: success state (200 result=success).
|
* 8. SMTP test button: success state (200 result=success).
|
||||||
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
|
* 9. SMTP test button: config-error state (400/ApiError result=config-error).
|
||||||
* 10. SMTP test button: failed state (502/ApiError result=failed).
|
* 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'
|
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-success')).not.toBeInTheDocument()
|
||||||
expect(screen.queryByTestId('smtp-result-config-error')).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()
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* ConfigPage — config editor (M2-T08).
|
* ConfigPage — config editor (M2-T08, M5-T01B).
|
||||||
*
|
*
|
||||||
* Behaviours:
|
* 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.
|
* - Non-secret fields show their value.
|
||||||
* - Secret fields render as empty PasswordInput (never show a masked value).
|
* - Secret fields render as empty PasswordInput (never show a masked value).
|
||||||
* 2. Save config: PUT /api/config with full-field submission semantics.
|
* 2. Save config: PUT /api/config with full-field submission semantics.
|
||||||
@@ -13,11 +13,16 @@
|
|||||||
* 3. SMTP test button: POST /api/config/smtp/test.
|
* 3. SMTP test button: POST /api/config/smtp/test.
|
||||||
* - Tri-state: success / config-error / failed.
|
* - Tri-state: success / config-error / failed.
|
||||||
* - Errors read `err.body.result` from ApiError.
|
* - 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 { useState } from 'react'
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
import {
|
import {
|
||||||
|
Accordion,
|
||||||
Container,
|
Container,
|
||||||
Title,
|
Title,
|
||||||
Text,
|
Text,
|
||||||
@@ -30,7 +35,6 @@ import {
|
|||||||
Divider,
|
Divider,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Paper,
|
|
||||||
Badge,
|
Badge,
|
||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import apiClient, { ApiError } from '../api/client'
|
import apiClient, { ApiError } from '../api/client'
|
||||||
@@ -153,32 +157,27 @@ function ConfigFieldInput({ field, value, onChange }: ConfigFieldInputProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// ConfigSectionPanel — one section
|
// ConfigSectionFields — the fields inside one accordion panel
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface ConfigSectionPanelProps {
|
interface ConfigSectionFieldsProps {
|
||||||
section: ConfigSection
|
section: ConfigSection
|
||||||
localValues: Record<string, string>
|
localValues: Record<string, string>
|
||||||
onChange: (envName: string, value: string) => void
|
onChange: (envName: string, value: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigSectionPanel({ section, localValues, onChange }: ConfigSectionPanelProps) {
|
function ConfigSectionFields({ section, localValues, onChange }: ConfigSectionFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" radius="md">
|
<Stack gap="sm">
|
||||||
<Title order={4} mb="md">
|
{section.fields.map((field) => (
|
||||||
{section.name}
|
<ConfigFieldInput
|
||||||
</Title>
|
key={field.env_name}
|
||||||
<Stack gap="sm">
|
field={field}
|
||||||
{section.fields.map((field) => (
|
value={localValues[field.env_name] ?? ''}
|
||||||
<ConfigFieldInput
|
onChange={onChange}
|
||||||
key={field.env_name}
|
/>
|
||||||
field={field}
|
))}
|
||||||
value={localValues[field.env_name] ?? ''}
|
</Stack>
|
||||||
onChange={onChange}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Paper>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,6 +337,9 @@ export function ConfigPage() {
|
|||||||
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
|
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 (
|
return (
|
||||||
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
|
<Container size="md" pt="xl" pb="xl" data-testid="config-page">
|
||||||
<Group justify="space-between" mb="lg" wrap="nowrap">
|
<Group justify="space-between" mb="lg" wrap="nowrap">
|
||||||
@@ -349,14 +351,30 @@ export function ConfigPage() {
|
|||||||
|
|
||||||
<form onSubmit={handleSave} data-testid="config-form">
|
<form onSubmit={handleSave} data-testid="config-form">
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{data.sections.map((section) => (
|
{/* M5-T01B: each config section is one Accordion.Item.
|
||||||
<ConfigSectionPanel
|
Future panels (e.g. T12 Home Assistant Expose) should be appended
|
||||||
key={section.name}
|
as additional Accordion.Items here, after the config sections. */}
|
||||||
section={section}
|
<Accordion
|
||||||
localValues={localValues}
|
defaultValue={defaultAccordionValue}
|
||||||
onChange={handleChange}
|
variant="separated"
|
||||||
/>
|
radius="md"
|
||||||
))}
|
data-testid="config-accordion"
|
||||||
|
>
|
||||||
|
{data.sections.map((section) => (
|
||||||
|
<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 />
|
<Divider />
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user