M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion

This commit is contained in:
2026-06-22 20:11:54 +02:00
parent 75d685f04d
commit 35cdc7f22a
11 changed files with 502 additions and 59 deletions
@@ -201,6 +201,7 @@ function RepublishButton() {
return (
<Stack gap="xs">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRepublish}
+162
View File
@@ -399,6 +399,168 @@ describe('ConfigPage', () => {
})
})
// ---------------------------------------------------------------------------
// M5-fix2: MqttTestButton — Issue 1 (frontend)
// ---------------------------------------------------------------------------
describe('ConfigPage — MQTT test button', () => {
beforeEach(() => {
vi.clearAllMocks()
// Use the MOCK_CONFIG_WITH_CHECKBOX fixture which includes an MQTT section
mockGet.mockResolvedValue({ data: MOCK_CONFIG_WITH_CHECKBOX, response: { status: 200, ok: true } })
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders MQTT test button when MQTT section is present', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
})
})
it('does not render MQTT test button when no MQTT section present', async () => {
// Use a config fixture without MQTT section
mockGet.mockResolvedValueOnce({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-form')).toBeInTheDocument()
})
// No MQTT section in MOCK_CONFIG → button absent
// Wait a bit for any async renders
await waitFor(() => {
expect(screen.queryByTestId('mqtt-test-button')).not.toBeInTheDocument()
})
})
it('shows success alert after MQTT test succeeds', async () => {
mockPost.mockResolvedValueOnce({
data: { result: 'success', message: 'Test message published.' },
response: { status: 200, ok: true },
})
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('mqtt-test-button'))
await waitFor(() => {
expect(screen.getByTestId('mqtt-result-success')).toBeInTheDocument()
})
expect(screen.queryByTestId('mqtt-result-config-error')).not.toBeInTheDocument()
expect(screen.queryByTestId('mqtt-result-failed')).not.toBeInTheDocument()
})
it('shows config-error alert when MQTT test returns config-error', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(
new ApiError(400, { result: 'config-error', message: 'Broker host not configured.' }),
)
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('mqtt-test-button'))
await waitFor(() => {
expect(screen.getByTestId('mqtt-result-config-error')).toBeInTheDocument()
})
expect(screen.queryByTestId('mqtt-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('mqtt-result-failed')).not.toBeInTheDocument()
})
it('shows failed alert when MQTT test returns failed (502)', async () => {
const { ApiError } = await import('../api/client')
mockPost.mockRejectedValueOnce(
new ApiError(502, { result: 'failed', message: 'Connection refused.' }),
)
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
})
fireEvent.click(screen.getByTestId('mqtt-test-button'))
await waitFor(() => {
expect(screen.getByTestId('mqtt-result-failed')).toBeInTheDocument()
})
expect(screen.queryByTestId('mqtt-result-success')).not.toBeInTheDocument()
expect(screen.queryByTestId('mqtt-result-config-error')).not.toBeInTheDocument()
})
it('MQTT test button has type="button" and does not submit the config form', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('mqtt-test-button')).toBeInTheDocument()
})
const mqttBtn = screen.getByTestId('mqtt-test-button')
// The rendered button element should have type="button"
expect(mqttBtn.getAttribute('type')).toBe('button')
})
})
// ---------------------------------------------------------------------------
// M5-fix2: ExposeSettings in config accordion — Issue 3 (frontend)
// ---------------------------------------------------------------------------
describe('ConfigPage — ExposeSettings in main accordion', () => {
beforeEach(() => {
vi.clearAllMocks()
mockGet.mockResolvedValue({ data: MOCK_CONFIG, response: { status: 200, ok: true } })
})
afterEach(() => {
vi.clearAllMocks()
})
it('renders Home Assistant Expose accordion item inside the config accordion', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('config-accordion')).toBeInTheDocument()
})
// The Expose accordion item should be inside the main config-accordion
const configAccordion = screen.getByTestId('config-accordion')
const exposeControl = screen.getByTestId('accordion-control-expose')
expect(configAccordion.contains(exposeControl)).toBe(true)
})
it('Expose accordion item appears before the Save button in DOM order', async () => {
renderConfig()
await waitFor(() => {
expect(screen.getByTestId('accordion-control-expose')).toBeInTheDocument()
})
const exposeControl = screen.getByTestId('accordion-control-expose')
const saveButton = screen.getByTestId('config-save-button')
// compareDocumentPosition: if expose comes before save, position & Node.DOCUMENT_POSITION_FOLLOWING === true
const position = exposeControl.compareDocumentPosition(saveButton)
// DOCUMENT_POSITION_FOLLOWING = 4 means saveButton comes after exposeControl
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
})
})
// ---------------------------------------------------------------------------
// Area B: checkbox (Switch) rendering and value round-trip
// ---------------------------------------------------------------------------
+102 -17
View File
@@ -57,6 +57,13 @@ type SmtpResult =
| { kind: 'failed'; message: string }
| null
/** MQTT test result tri-state. */
type MqttResult =
| { kind: 'success'; message: string }
| { kind: 'config-error'; message: string }
| { kind: 'failed'; message: string }
| null
// ---------------------------------------------------------------------------
// Hook: load config
// ---------------------------------------------------------------------------
@@ -263,6 +270,75 @@ function SmtpTestButton({ smtpResult, setSmtpResult }: SmtpTestButtonProps) {
)
}
// ---------------------------------------------------------------------------
// MqttTestButton — sends POST /api/config/mqtt/test and displays tri-state result
// ---------------------------------------------------------------------------
interface MqttTestButtonProps {
mqttResult: MqttResult
setMqttResult: (r: MqttResult) => void
}
function MqttTestButton({ mqttResult, setMqttResult }: MqttTestButtonProps) {
const [testing, setTesting] = useState(false)
async function handleTest() {
setMqttResult(null)
setTesting(true)
try {
const res = await apiClient.POST('/api/config/mqtt/test')
if (res.data) {
setMqttResult({ kind: 'success', message: res.data.message })
}
} catch (err) {
if (err instanceof ApiError) {
const body = err.body as { result?: string; message?: string } | null
const result = body?.result
const message = body?.message ?? 'Unknown error'
if (result === 'config-error') {
setMqttResult({ kind: 'config-error', message })
} else {
setMqttResult({ kind: 'failed', message })
}
} else {
setMqttResult({ kind: 'failed', message: 'Unexpected error sending test message.' })
}
} finally {
setTesting(false)
}
}
return (
<Stack gap="xs">
<Button
type="button"
variant="outline"
onClick={handleTest}
loading={testing}
data-testid="mqtt-test-button"
>
Send Test Message
</Button>
{mqttResult?.kind === 'success' && (
<Alert color="green" data-testid="mqtt-result-success">
MQTT test message sent successfully. {mqttResult.message}
</Alert>
)}
{mqttResult?.kind === 'config-error' && (
<Alert color="orange" data-testid="mqtt-result-config-error">
MQTT configuration error check your MQTT settings. {mqttResult.message}
</Alert>
)}
{mqttResult?.kind === 'failed' && (
<Alert color="red" data-testid="mqtt-result-failed">
MQTT test failed. {mqttResult.message}
</Alert>
)}
</Stack>
)
}
// ---------------------------------------------------------------------------
// ConfigPage — main component
// ---------------------------------------------------------------------------
@@ -295,6 +371,9 @@ export function ConfigPage() {
// SMTP test tri-state
const [smtpResult, setSmtpResult] = useState<SmtpResult>(null)
// MQTT test tri-state
const [mqttResult, setMqttResult] = useState<MqttResult>(null)
function handleChange(envName: string, value: string) {
setLocalValues((prev) => ({ ...prev, [envName]: value }))
setSaveStatus(null)
@@ -350,6 +429,9 @@ export function ConfigPage() {
s.name.toLowerCase().includes('smtp') || s.name.toLowerCase().includes('email'),
)
// Detect if there is an MQTT section (to show the MQTT test button).
const hasMqttSection = data.sections.some((s) => s.name.toLowerCase() === 'mqtt')
// Default: open the first section so users immediately see content.
const defaultAccordionValue = data.sections[0]?.name ?? null
@@ -387,6 +469,18 @@ export function ConfigPage() {
</Accordion.Panel>
</Accordion.Item>
))}
{/* M5-fix2: Home Assistant Expose panel merged into the config accordion,
above Save/Test buttons. All interactive elements inside ExposeSettings
use type="button" so they do not trigger the config form submit. */}
<Accordion.Item value="expose" data-testid="expose-accordion-item">
<Accordion.Control data-testid="accordion-control-expose">
<Text fw={500}>Home Assistant Expose</Text>
</Accordion.Control>
<Accordion.Panel>
<ExposeSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Divider />
@@ -411,9 +505,14 @@ export function ConfigPage() {
Save Configuration
</Button>
{hasSmtpSection && (
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
)}
<Group gap="sm" wrap="wrap">
{hasSmtpSection && (
<SmtpTestButton smtpResult={smtpResult} setSmtpResult={setSmtpResult} />
)}
{hasMqttSection && (
<MqttTestButton mqttResult={mqttResult} setMqttResult={setMqttResult} />
)}
</Group>
</Group>
</Stack>
</form>
@@ -426,20 +525,6 @@ export function ConfigPage() {
</Stack>
)}
{/* M5-T12: Home Assistant Expose — entity toggle panel as an Accordion.Item
below the config form. Kept outside <form> because it manages its own
mutations (PUT /api/expose, POST /api/expose/republish). */}
<Accordion variant="separated" radius="md" mt="xl" data-testid="expose-accordion">
<Accordion.Item value="expose">
<Accordion.Control data-testid="accordion-control-expose">
<Text fw={500}>Home Assistant Expose</Text>
</Accordion.Control>
<Accordion.Panel>
<ExposeSettings />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{/* TOTP two-factor auth management */}
<Stack mt="xl">
<TotpSettings />