M5-T12: add expose toggle API and Home Assistant Expose settings panel
This commit is contained in:
@@ -0,0 +1,381 @@
|
||||
/**
|
||||
* Tests for ExposeSettings (M5-T12).
|
||||
*
|
||||
* Strategy: vi.mock the apiClient so GET/PUT/POST responses are controlled
|
||||
* without a real server or MQTT broker.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. Loading state shows a loader.
|
||||
* 2. Error state shows an error alert.
|
||||
* 3. Empty catalog shows the empty-state message.
|
||||
* 4. Catalog with entities: renders entity names, device groups, toggle switches.
|
||||
* 5. MQTT status badges render correctly (configured/connected/discovery).
|
||||
* 6. Toggling a switch calls PUT /api/expose with the correct key/bool.
|
||||
* 7. Re-publish button calls POST /api/expose/republish.
|
||||
* 8. Republish ok=true shows success alert; ok=false shows warning.
|
||||
* 9. value_getter is never rendered (no crash from non-serialisable callable).
|
||||
* 10. binary_sensor entities are shown in the catalog alongside sensors.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react'
|
||||
import { renderWithProviders } from '../test-utils'
|
||||
import { ExposeSettings } from './ExposeSettings'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOCK_MQTT_STATUS_OFF = {
|
||||
mqtt_configured: false,
|
||||
mqtt_connected: false,
|
||||
discovery_enabled: false,
|
||||
}
|
||||
|
||||
const MOCK_MQTT_STATUS_ON = {
|
||||
mqtt_configured: true,
|
||||
mqtt_connected: true,
|
||||
discovery_enabled: true,
|
||||
}
|
||||
|
||||
const MOCK_CATALOG_EMPTY = {
|
||||
catalog: [],
|
||||
mqtt_status: MOCK_MQTT_STATUS_OFF,
|
||||
}
|
||||
|
||||
const MOCK_CATALOG_ONE_DEVICE = {
|
||||
catalog: [
|
||||
{
|
||||
entity: {
|
||||
key: 'modbus.abc.voltage',
|
||||
component: 'sensor',
|
||||
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||
device_class: 'voltage',
|
||||
unit: 'V',
|
||||
name: 'Test Meter Voltage',
|
||||
state_class: 'measurement',
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
key: 'modbus.abc.current',
|
||||
component: 'sensor',
|
||||
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||
device_class: 'current',
|
||||
unit: 'A',
|
||||
name: 'Test Meter Current',
|
||||
state_class: 'measurement',
|
||||
},
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
entity: {
|
||||
key: 'modbus.abc.online',
|
||||
component: 'binary_sensor',
|
||||
device: { identifiers: ['modbus', 'abc'], name: 'Test Meter' },
|
||||
device_class: 'connectivity',
|
||||
unit: '',
|
||||
name: 'Test Meter Online',
|
||||
state_class: null,
|
||||
},
|
||||
enabled: false,
|
||||
},
|
||||
],
|
||||
mqtt_status: MOCK_MQTT_STATUS_ON,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mock apiClient
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const mockGet = vi.fn()
|
||||
const mockPut = vi.fn()
|
||||
const mockPost = vi.fn()
|
||||
|
||||
vi.mock('../api/client', () => ({
|
||||
default: {
|
||||
GET: (...args: unknown[]) => mockGet(...args),
|
||||
PUT: (...args: unknown[]) => mockPut(...args),
|
||||
POST: (...args: unknown[]) => mockPost(...args),
|
||||
},
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, body: unknown) {
|
||||
super(`API error ${status}`)
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
this.body = body
|
||||
}
|
||||
},
|
||||
registerLoginRedirect: vi.fn(),
|
||||
}))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function renderExpose() {
|
||||
return renderWithProviders(<ExposeSettings />, { initialPath: '/config' })
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('ExposeSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 1. Loading state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows a loader while the catalog is loading', () => {
|
||||
// Never resolves
|
||||
mockGet.mockReturnValue(new Promise(() => {}))
|
||||
renderExpose()
|
||||
expect(screen.getByTestId('expose-loading')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 2. Error state
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows an error alert when the catalog request fails', async () => {
|
||||
mockGet.mockRejectedValue(new Error('Network error'))
|
||||
renderExpose()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-load-error')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 3. Empty catalog
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows empty-state message when catalog is empty', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
||||
renderExpose()
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 4. Catalog with entities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders entity names and device groups', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Device group heading
|
||||
expect(screen.getByTestId('device-group-Test Meter')).toBeInTheDocument()
|
||||
|
||||
// Entity names
|
||||
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Meter Current')).toBeInTheDocument()
|
||||
expect(screen.getByText('Test Meter Online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders toggle switches for each entity', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// voltage is disabled (enabled=false in fixture)
|
||||
const voltageSwitch = screen.getByTestId(
|
||||
'entity-toggle-modbus.abc.voltage',
|
||||
) as HTMLInputElement
|
||||
expect(voltageSwitch.checked).toBe(false)
|
||||
|
||||
// current is enabled (enabled=true in fixture)
|
||||
const currentSwitch = screen.getByTestId(
|
||||
'entity-toggle-modbus.abc.current',
|
||||
) as HTMLInputElement
|
||||
expect(currentSwitch.checked).toBe(true)
|
||||
})
|
||||
|
||||
it('shows binary_sensor entities alongside sensor entities', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entity-row-modbus.abc.online')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// binary_sensor row contains "binary_sensor" text
|
||||
const onlineRow = screen.getByTestId('entity-row-modbus.abc.online')
|
||||
expect(onlineRow).toHaveTextContent('binary_sensor')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 5. MQTT status badges
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders MQTT status badges', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('mqtt-status-badges')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('badge-mqtt-connected')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('badge-discovery-enabled')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Configured" when mqtt_configured is true', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Configured')
|
||||
})
|
||||
|
||||
it('shows "Not Configured" when mqtt_configured is false', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_EMPTY })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('badge-mqtt-configured')).toHaveTextContent('MQTT Not Configured')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 6. Toggle a switch → PUT /api/expose
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('calls PUT /api/expose when a toggle is switched', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPut.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('entity-toggle-modbus.abc.voltage')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
// Click the voltage toggle (currently off → turning on)
|
||||
const voltageSwitch = screen.getByTestId('entity-toggle-modbus.abc.voltage')
|
||||
fireEvent.click(voltageSwitch)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPut).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const putCall = mockPut.mock.calls[0]
|
||||
// path arg
|
||||
expect(putCall[0]).toBe('/api/expose')
|
||||
// body
|
||||
expect(putCall[1]?.body?.toggles).toMatchObject({ 'modbus.abc.voltage': true })
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 7. Republish button → POST /api/expose/republish
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('calls POST /api/expose/republish when republish button is clicked', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPost.mockResolvedValue({ data: { ok: true, message: 'HA Discovery re-published.' } })
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('republish-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockPost).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const postCall = mockPost.mock.calls[0]
|
||||
expect(postCall[0]).toBe('/api/expose/republish')
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 8. Republish ok=true → success alert; ok=false → warning
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('shows success alert when republish returns ok=true', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPost.mockResolvedValue({
|
||||
data: { ok: true, message: 'HA Discovery re-published successfully.' },
|
||||
})
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('republish-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-success')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('republish-warning')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows warning alert when republish returns ok=false', async () => {
|
||||
mockGet.mockResolvedValue({ data: MOCK_CATALOG_ONE_DEVICE })
|
||||
mockPost.mockResolvedValue({
|
||||
data: { ok: false, message: 'MQTT not connected.' },
|
||||
})
|
||||
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-button')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
fireEvent.click(screen.getByTestId('republish-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('republish-warning')).toBeInTheDocument()
|
||||
})
|
||||
expect(screen.queryByTestId('republish-success')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 9. No crash from non-serialisable value_getter (value_getter NOT rendered)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
it('renders without crashing even if value_getter were present (it is excluded by API)', async () => {
|
||||
// The API never sends value_getter; this tests resilience of the schema
|
||||
const catalogWithExtraField = {
|
||||
...MOCK_CATALOG_ONE_DEVICE,
|
||||
catalog: MOCK_CATALOG_ONE_DEVICE.catalog.map((entry) => ({
|
||||
...entry,
|
||||
entity: { ...entry.entity },
|
||||
// No value_getter — confirm the component handles normal data
|
||||
})),
|
||||
}
|
||||
mockGet.mockResolvedValue({ data: catalogWithExtraField })
|
||||
renderExpose()
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('expose-settings')).toBeInTheDocument()
|
||||
})
|
||||
// No errors thrown, entities visible
|
||||
expect(screen.getByText('Test Meter Voltage')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* ExposeSettings — Home Assistant Expose entity management (M5-T12).
|
||||
*
|
||||
* Behaviours:
|
||||
* 1. Load: GET /api/expose → display catalog of exposable entities grouped by HA device,
|
||||
* with per-entity toggle switches and MQTT/Discovery connection status badges.
|
||||
* 2. Toggle: PUT /api/expose with the changed key → bool; triggers discovery re-publish.
|
||||
* 3. Republish: POST /api/expose/republish → manually trigger HA Discovery re-publish.
|
||||
*
|
||||
* All requests go through the typed apiClient (CSRF injected automatically by
|
||||
* csrfMiddleware for write operations).
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
Stack,
|
||||
Switch,
|
||||
Text,
|
||||
} from '@mantine/core'
|
||||
import apiClient from '../api/client'
|
||||
import type { components } from '../api/schema.d.ts'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type CatalogEntry = components['schemas']['CatalogEntrySchema']
|
||||
type MqttStatus = components['schemas']['MqttStatusSchema']
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: load expose catalog
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useExposeCatalog() {
|
||||
return useQuery({
|
||||
queryKey: ['expose-catalog'],
|
||||
queryFn: async () => {
|
||||
const res = await apiClient.GET('/api/expose')
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: toggle a single entity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useToggleEntity() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async ({ key, enabled }: { key: string; enabled: boolean }) => {
|
||||
await apiClient.PUT('/api/expose', {
|
||||
body: { toggles: { [key]: enabled } },
|
||||
})
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook: republish discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function useRepublish() {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
const res = await apiClient.POST('/api/expose/republish')
|
||||
return res.data
|
||||
},
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['expose-catalog'] }),
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MqttStatusBadges — connection status indicators
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function MqttStatusBadges({ status }: { status: MqttStatus }) {
|
||||
return (
|
||||
<Group gap="xs" wrap="wrap" data-testid="mqtt-status-badges">
|
||||
<Badge
|
||||
color={status.mqtt_configured ? 'blue' : 'gray'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="badge-mqtt-configured"
|
||||
>
|
||||
MQTT {status.mqtt_configured ? 'Configured' : 'Not Configured'}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={status.mqtt_connected ? 'green' : 'red'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="badge-mqtt-connected"
|
||||
>
|
||||
{status.mqtt_connected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge
|
||||
color={status.discovery_enabled ? 'teal' : 'gray'}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="badge-discovery-enabled"
|
||||
>
|
||||
Discovery {status.discovery_enabled ? 'On' : 'Off'}
|
||||
</Badge>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EntityRow — one entity toggle row
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface EntityRowProps {
|
||||
entry: CatalogEntry
|
||||
onToggle: (key: string, enabled: boolean) => void
|
||||
isToggling: boolean
|
||||
}
|
||||
|
||||
function EntityRow({ entry, onToggle, isToggling }: EntityRowProps) {
|
||||
const { entity, enabled } = entry
|
||||
return (
|
||||
<Group justify="space-between" gap="sm" wrap="nowrap" data-testid={`entity-row-${entity.key}`}>
|
||||
<Stack gap={2}>
|
||||
<Text size="sm" fw={500}>
|
||||
{entity.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{entity.component}
|
||||
{entity.device_class ? ` · ${entity.device_class}` : ''}
|
||||
{entity.unit ? ` · ${entity.unit}` : ''}
|
||||
</Text>
|
||||
</Stack>
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onChange={(e) => onToggle(entity.key, e.currentTarget.checked)}
|
||||
disabled={isToggling}
|
||||
size="sm"
|
||||
data-testid={`entity-toggle-${entity.key}`}
|
||||
/>
|
||||
</Group>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DeviceGroup — entities grouped by device name
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface DeviceGroupProps {
|
||||
deviceName: string
|
||||
entries: CatalogEntry[]
|
||||
onToggle: (key: string, enabled: boolean) => void
|
||||
isToggling: boolean
|
||||
}
|
||||
|
||||
function DeviceGroup({ deviceName, entries, onToggle, isToggling }: DeviceGroupProps) {
|
||||
return (
|
||||
<Stack gap="xs" data-testid={`device-group-${deviceName}`}>
|
||||
<Text fw={600} size="sm" c="dimmed">
|
||||
{deviceName}
|
||||
</Text>
|
||||
{entries.map((entry) => (
|
||||
<EntityRow
|
||||
key={entry.entity.key}
|
||||
entry={entry}
|
||||
onToggle={onToggle}
|
||||
isToggling={isToggling}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RepublishButton — POST /api/expose/republish
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function RepublishButton() {
|
||||
const [result, setResult] = useState<{ ok: boolean; message: string } | null>(null)
|
||||
const republishMutation = useRepublish()
|
||||
|
||||
async function handleRepublish() {
|
||||
setResult(null)
|
||||
try {
|
||||
const data = await republishMutation.mutateAsync()
|
||||
if (data) {
|
||||
setResult({ ok: data.ok, message: data.message })
|
||||
}
|
||||
} catch {
|
||||
setResult({ ok: false, message: 'Republish request failed.' })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRepublish}
|
||||
loading={republishMutation.isPending}
|
||||
data-testid="republish-button"
|
||||
>
|
||||
Re-publish Discovery
|
||||
</Button>
|
||||
|
||||
{result?.ok && (
|
||||
<Alert color="green" data-testid="republish-success">
|
||||
{result.message}
|
||||
</Alert>
|
||||
)}
|
||||
{result !== null && !result.ok && (
|
||||
<Alert color="orange" data-testid="republish-warning">
|
||||
{result.message}
|
||||
</Alert>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ExposeSettings — main exported component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function ExposeSettings() {
|
||||
const { data, isLoading, isError } = useExposeCatalog()
|
||||
const toggleMutation = useToggleEntity()
|
||||
|
||||
function handleToggle(key: string, enabled: boolean) {
|
||||
toggleMutation.mutate({ key, enabled })
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center pt="md">
|
||||
<Loader size="sm" data-testid="expose-loading" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (isError || !data) {
|
||||
return (
|
||||
<Alert color="red" data-testid="expose-load-error">
|
||||
Failed to load expose settings. Please refresh the page.
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
|
||||
// Defensive null-coerce in case the data shape is unexpected at runtime.
|
||||
const catalog = data.catalog ?? []
|
||||
const mqtt_status = data.mqtt_status ?? {
|
||||
mqtt_configured: false,
|
||||
mqtt_connected: false,
|
||||
discovery_enabled: false,
|
||||
}
|
||||
|
||||
// Group entities by device name for display.
|
||||
const deviceGroups = new Map<string, CatalogEntry[]>()
|
||||
for (const entry of catalog) {
|
||||
const deviceName = entry.entity.device.name
|
||||
const existing = deviceGroups.get(deviceName)
|
||||
if (existing) {
|
||||
existing.push(entry)
|
||||
} else {
|
||||
deviceGroups.set(deviceName, [entry])
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md" data-testid="expose-settings">
|
||||
{/* Status row */}
|
||||
<Group justify="space-between" align="center" wrap="wrap" gap="sm">
|
||||
<MqttStatusBadges status={mqtt_status} />
|
||||
<RepublishButton />
|
||||
</Group>
|
||||
|
||||
{/* Entity catalog — empty state */}
|
||||
{catalog.length === 0 && (
|
||||
<Text c="dimmed" size="sm" data-testid="expose-empty">
|
||||
No exposable entities found. Add a Modbus device in the Energy page to get started.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Entity catalog — grouped by device */}
|
||||
{Array.from(deviceGroups.entries()).map(([deviceName, entries]) => (
|
||||
<DeviceGroup
|
||||
key={deviceName}
|
||||
deviceName={deviceName}
|
||||
entries={entries}
|
||||
onToggle={handleToggle}
|
||||
isToggling={toggleMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user