M5: use DB-merged runtime settings for MQTT/discovery; add MQTT test button; move Expose panel into config accordion
This commit is contained in:
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user