From 742f18690c0119acf6d5672703546d1a2642b579 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Mon, 10 Aug 2026 17:51:27 +0200 Subject: [PATCH] feat(webui): add show details action for charging stations (#2072) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(webui): add show details action for charging stations Add a read-only "Show details" action in both Web UI skins displaying a charging station's stationInfo, OCPP configuration parameters and other relevant ChargingStationData fields. Frontend only: the data is already shipped to the client via the charging station data payload. - Shared pure util `stationDetails.ts` (single source of truth for section selection, field formatting and OCPP key visibility filtering), matching the existing `stationStatus.ts` pure-util convention. - Classic skin: `show-details` router action + `ShowDetails.vue` panel, triggered by a shared ToggleButton in the station actions cell. - Modern skin: `StationDetailsDialog.vue` modal, triggered by a footer "Details" button on the station card, wired through ModernLayout. - Supervision password is always masked; supervision URL is host-only. Closes #993 * [autofix.ci] apply automated fixes * refactor(webui): centralize OCPP row formatting and address review Review-round-1 fixes for the "Show details" action: - Move OCPP cell formatting (readonly/reboot/value) into the shared `buildConfigurationRows` util so both skins render identical, single- sourced rows instead of duplicating the ternaries. - Format Boot Notification "Current Time" via toLocaleString (consistent with "Last Update") and drop the unsafe double cast. - Give the modern OCPP table an accessible name (aria-labelledby). - Tests: assert password masking in the classic rendered panel, cover the OCPP readonly/reboot/value cell formatting in both skins and the util. * test(webui): tighten details tests and unify empty-value formatting Review-round-2 fixes for the "Show details" action: - Format the OCPP parameter value via the shared `formatValue` so an empty-string value renders as the empty placeholder, consistent with every other field (was `value ?? Ø`, which left '' blank). - Strengthen the modern password-masking test to also assert the masked placeholder, add coverage for the empty-string value case, and assert the modern OCPP table's aria-labelledby accessible name. * refactor(webui): address initial-review findings for show details - M1 (DRY): extract shared `useStationDetails(hashId)` composable consumed by both skins, removing the duplicated station/sections/configurationRows computed (mirrors the shared `useSetUrlForm` precedent). - M2 (terminology): unify the feature label on "Show Details" across the classic header, the modern dialog title and the README; the modern card keeps its terse "Details" button per the card convention; route id unchanged. - M3: drop the duplicated Boot Notification "Status" entry (kept as "Registration Status" under General). - N1: factor a private `formatDate` helper and guard both dates. - N3: give each modern detail `
` section an accessible name via useId(). - N2 (OCPP terminology) intentionally left as "OCPP Parameters" to match the issue wording; documented in review. - Tests: new `useStationDetails` composable tests; assert the modern section aria-labelledby wiring; update the "Show Details" heading/title assertions. * style(webui): harmonize modern show-details with skin conventions - H3: extract the shared `.modern-section-label` primitive (renamed from `.modern-card__section-label`, single consumer migrated) and use it for the modern dialog section headings instead of a one-off `.station-details__title`. - H1: restyle the detail key/value list to the modern spec typography (uppercase muted `dt`, strong `dd`) in a left-aligned two-column grid fit for the wider dialog (drops the ad-hoc space-between/right-align/hairlines). - H2: restyle the OCPP table to the modern low-chrome table aesthetic (border-collapse, no per-cell borders, muted uppercase headers, subtle row separators) matching the existing `.modern-connector__tx-table` precedent; scope word-break to values/keys so column headers no longer break mid-word. Classic skin unchanged (already reuses the shared data-table system). No test changes: DOM hooks (.station-details__list/__table), aria-labelledby and the OCPP row formatting are preserved. Card rendering is visually unchanged. * style(webui): resolve exhaustive-review nits for show details - MIN-1: left-align the classic detail table cells (target th/td so the shared `.data-table` center rule no longer wins), fixing centered values. - MIN-2: modern dialog title to sentence-case "Show details — {id}" to match the sibling dialog titles (classic header keeps Title Case per its convention). - NIT-1: derive the OCPP heading id from useId() instead of a hardcoded id, consistent with the section headings. - NIT-2: extract a private `formatBoolean` helper reused by buildConfigurationRows and formatValue's boolean branch. - NIT-3: align the shared "Supervision Url" label with the existing majority spelling used across the classic surface. - NIT-4: rename StationDetailsDialog.vue -> ShowDetailsDialog.vue (matches the classic ShowDetails.vue and the feature name); update wiring + tests. "OCPP Parameters" kept (issue #993 wording). Tests updated for the new title, useId-based aria and the rename. 567 tests green. * style(webui): order ShowDetailsDialog async import alphabetically * docs(webui): fix show details JSDoc wording and uniformize empty-state punctuation --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- ui/web/README.md | 2 +- ui/web/src/core/Constants.ts | 2 + ui/web/src/core/index.ts | 1 + ui/web/src/router/index.ts | 10 + .../shared/composables/useStationDetails.ts | 46 ++++ ui/web/src/shared/utils/index.ts | 6 + ui/web/src/shared/utils/stationDetails.ts | 185 ++++++++++++++++ .../components/actions/ShowDetails.vue | 118 ++++++++++ .../components/charging-stations/CSData.vue | 25 +++ ui/web/src/skins/modern/ModernLayout.vue | 17 ++ .../skins/modern/components/StationCard.vue | 16 +- .../components/dialogs/ShowDetailsDialog.vue | 209 ++++++++++++++++++ ui/web/src/skins/modern/modern.css | 6 +- ui/web/tests/unit/router.test.ts | 5 + .../shared/composables/stationDetails.test.ts | 201 +++++++++++++++++ .../composables/useStationDetails.test.ts | 72 ++++++ .../tests/unit/skins/classic/Actions.test.ts | 111 +++++++++- .../tests/unit/skins/modern/Dialogs.test.ts | 127 ++++++++++- .../unit/skins/modern/ModernLayout.test.ts | 8 +- .../unit/skins/modern/StationCard.test.ts | 10 + 20 files changed, 1166 insertions(+), 11 deletions(-) create mode 100644 ui/web/src/shared/composables/useStationDetails.ts create mode 100644 ui/web/src/shared/utils/stationDetails.ts create mode 100644 ui/web/src/skins/classic/components/actions/ShowDetails.vue create mode 100644 ui/web/src/skins/modern/components/dialogs/ShowDetailsDialog.vue create mode 100644 ui/web/tests/unit/shared/composables/stationDetails.test.ts create mode 100644 ui/web/tests/unit/shared/composables/useStationDetails.test.ts diff --git a/ui/web/README.md b/ui/web/README.md index 3e7a385e..0864a249 100644 --- a/ui/web/README.md +++ b/ui/web/README.md @@ -9,7 +9,7 @@ Vue.js dashboard for monitoring and controlling the e-mobility charging stations ![Web UI](./src/assets/screenshot.png) 1. The top bar lets you switch between UI servers, start/stop the simulator, add charging stations, and select themes and skins. -2. Each charging station is a card with status indicators, connector details, and actions: start, stop, open/close connection, start/stop transaction, and more. +2. Each charging station is a card with status indicators, connector details, and actions: start, stop, open/close connection, start/stop transaction, show details, and more. ## Table of contents diff --git a/ui/web/src/core/Constants.ts b/ui/web/src/core/Constants.ts index ec997102..6be8f005 100644 --- a/ui/web/src/core/Constants.ts +++ b/ui/web/src/core/Constants.ts @@ -7,6 +7,7 @@ export const DEFAULT_SKIN: (typeof SKIN_IDS)[number] = 'modern' export const DEFAULT_THEME: (typeof THEME_IDS)[number] = 'tokyo-night-storm' export const ASYNC_COMPONENT_TIMEOUT_MS = 10_000 export const EMPTY_VALUE_PLACEHOLDER = 'Ø' +export const MASKED_VALUE_PLACEHOLDER = '••••••••' export const MAX_SKIN_ERROR_RELOADS = 2 export const MAX_STATIONS_PER_ADD = 100 export const SKIN_ERROR_RELOAD_COUNT_KEY = 'skin-error-reload-count' @@ -19,6 +20,7 @@ export const ROUTE_NAMES = { CHARGING_STATIONS: 'charging-stations', NOT_FOUND: 'not-found', SET_SUPERVISION_URL: 'set-supervision-url', + SHOW_DETAILS: 'show-details', START_TRANSACTION: 'start-transaction', } as const diff --git a/ui/web/src/core/index.ts b/ui/web/src/core/index.ts index 2e428eb5..fafbf594 100644 --- a/ui/web/src/core/index.ts +++ b/ui/web/src/core/index.ts @@ -5,6 +5,7 @@ export { DEFAULT_THEME, EMPTY_VALUE_PLACEHOLDER, LEGACY_UI_SERVER_CONFIG_KEY, + MASKED_VALUE_PLACEHOLDER, MAX_SKIN_ERROR_RELOADS, MAX_STATIONS_PER_ADD, ROUTE_NAMES, diff --git a/ui/web/src/router/index.ts b/ui/web/src/router/index.ts index 43d1d58a..fadadfc6 100644 --- a/ui/web/src/router/index.ts +++ b/ui/web/src/router/index.ts @@ -80,6 +80,16 @@ export const router = createRouter({ path: '/start-transaction/:hashId/:chargingStationId/:connectorId', props: { action: true }, }, + { + beforeEnter: skinGuard, + components: { + action: () => import('@/skins/classic/components/actions/ShowDetails.vue'), + }, + meta: { skinOnly: 'classic' }, + name: ROUTE_NAMES.SHOW_DETAILS, + path: '/show-details/:hashId/:chargingStationId', + props: { action: true }, + }, { component: { render: () => diff --git a/ui/web/src/shared/composables/useStationDetails.ts b/ui/web/src/shared/composables/useStationDetails.ts new file mode 100644 index 00000000..d1a3b7f4 --- /dev/null +++ b/ui/web/src/shared/composables/useStationDetails.ts @@ -0,0 +1,46 @@ +import { type ChargingStationData } from 'ui-common' +import { computed, type ComputedRef } from 'vue' + +import { useChargingStations } from '@/core/index.js' +import { + buildConfigurationRows, + buildStationDetailSections, + type ConfigurationRow, + type DetailSection, +} from '@/shared/utils/index.js' + +export interface StationDetailsView { + configurationRows: ComputedRef + sections: ComputedRef + station: ComputedRef +} + +/** + * Resolves a charging station from the store by hash id and derives its read-only + * "Show details" view model (detail sections + OCPP configuration rows). Shared by both + * skins so the reactive lookup and view-model wiring stay single-sourced. The view stays + * reactive to store updates and degrades to `undefined`/empty when the station is removed. + * @param hashId - The charging station hash identifier + * @returns The resolved station and its derived detail sections and configuration rows + */ +export function useStationDetails (hashId: string): StationDetailsView { + const $chargingStations = useChargingStations() + + const station = computed(() => + $chargingStations.value.find(entry => entry.stationInfo.hashId === hashId) + ) + + const sections = computed(() => + station.value != null ? buildStationDetailSections(station.value) : [] + ) + + const configurationRows = computed(() => + station.value != null ? buildConfigurationRows(station.value) : [] + ) + + return { + configurationRows, + sections, + station, + } +} diff --git a/ui/web/src/shared/utils/index.ts b/ui/web/src/shared/utils/index.ts index c2d4ea4d..97220381 100644 --- a/ui/web/src/shared/utils/index.ts +++ b/ui/web/src/shared/utils/index.ts @@ -1,6 +1,12 @@ export { getSelectValue } from './dom.js' export { formatSupervisionUrl } from './formatSupervisionUrl.js' export { nonEmptyStringOrUndefined } from './nonEmptyString.js' +export type { ConfigurationRow, DetailEntry, DetailSection } from './stationDetails.js' +export { + buildConfigurationRows, + buildStationDetailSections, + getVisibleConfigurationKeys, +} from './stationDetails.js' export type { StatusVariant } from './stationStatus.js' export { getATGStatus, diff --git a/ui/web/src/shared/utils/stationDetails.ts b/ui/web/src/shared/utils/stationDetails.ts new file mode 100644 index 00000000..97f183b3 --- /dev/null +++ b/ui/web/src/shared/utils/stationDetails.ts @@ -0,0 +1,185 @@ +/** + * @file stationDetails.ts + * @description Pure utility functions building the read-only "Show details" view model + * from `ChargingStationData`. These are not Vue composables (no reactive state) — they are + * pure utility functions consumed by both skins via the shared layer, so field selection, + * ordering, labelling and formatting stay single-sourced. + * + * Security invariant: `supervisionPassword` is always masked (never rendered raw); the + * supervision URL is passed through {@link formatSupervisionUrl}, which strips any embedded + * userinfo credentials (protocol, host and path are preserved). + */ +import { type ChargingStationData, type ConfigurationKey, getWebSocketStateName } from 'ui-common' + +import { EMPTY_VALUE_PLACEHOLDER, MASKED_VALUE_PLACEHOLDER } from '@/core/index.js' + +import { formatSupervisionUrl } from './formatSupervisionUrl.js' +import { getConnectorEntries } from './stationStatus.js' + +export interface ConfigurationRow { + key: string + readonly: string + reboot: string + value: string +} + +export interface DetailEntry { + label: string + value: string +} + +export interface DetailSection { + entries: DetailEntry[] + title: string +} + +/** + * Formats a boolean flag for display; undefined renders as "No". + * @param value - The raw boolean flag + * @returns "Yes" or "No" + */ +const formatBoolean = (value: boolean | undefined): string => (value === true ? 'Yes' : 'No') + +/** + * Formats a date-like station field for display. + * Nullish values render as the empty placeholder; otherwise the localized date-time string. + * @param value - The raw date, epoch millisecond timestamp, or ISO string + * @returns A display string + */ +const formatDate = (value: Date | number | string | undefined): string => + value == null ? EMPTY_VALUE_PLACEHOLDER : new Date(value).toLocaleString() + +/** + * Formats a scalar station field for display. + * Booleans render as Yes/No; nullish or empty values render as the empty placeholder. + * @param value - The raw field value + * @returns A display string + */ +const formatValue = (value: boolean | number | string | undefined): string => { + if (typeof value === 'boolean') { + return formatBoolean(value) + } + if (value == null || value === '') { + return EMPTY_VALUE_PLACEHOLDER + } + return String(value) +} + +/** + * Builds display rows for the visible OCPP configuration keys. + * @param station - The charging station data + * @returns Formatted configuration rows (key, value, readonly, reboot) + */ +export function buildConfigurationRows (station: ChargingStationData): ConfigurationRow[] { + return getVisibleConfigurationKeys(station).map(key => ({ + key: key.key, + readonly: formatBoolean(key.readonly), + reboot: formatBoolean(key.reboot), + value: formatValue(key.value), + })) +} + +/** + * Builds the ordered detail sections for a charging station. + * @param station - The charging station data + * @returns Ordered sections of labelled key/value entries + */ +export function buildStationDetailSections (station: ChargingStationData): DetailSection[] { + const { stationInfo } = station + const sections: DetailSection[] = [ + { + entries: [ + { label: 'Charging Station Id', value: formatValue(stationInfo.chargingStationId) }, + { label: 'Started', value: formatValue(station.started) }, + { label: 'Supervision Url', value: formatSupervisionUrl(station.supervisionUrl) }, + { + label: 'WebSocket State', + value: formatValue(getWebSocketStateName(station.wsState)), + }, + { + label: 'Registration Status', + value: formatValue(station.bootNotificationResponse?.status), + }, + { label: 'Connectors', value: formatValue(getConnectorEntries(station).length) }, + { label: 'Last Update', value: formatDate(station.timestamp) }, + ], + title: 'General', + }, + { + entries: [ + { label: 'Base Name', value: formatValue(stationInfo.baseName) }, + { label: 'Template', value: formatValue(stationInfo.templateName) }, + { label: 'Template Index', value: formatValue(stationInfo.templateIndex) }, + { label: 'Vendor', value: formatValue(stationInfo.chargePointVendor) }, + { label: 'Model', value: formatValue(stationInfo.chargePointModel) }, + { label: 'Firmware Version', value: formatValue(stationInfo.firmwareVersion) }, + { label: 'OCPP Version', value: formatValue(stationInfo.ocppVersion) }, + { label: 'OCPP Protocol', value: formatValue(stationInfo.ocppProtocol) }, + { label: 'Current Out Type', value: formatValue(stationInfo.currentOutType) }, + { label: 'Number Of Phases', value: formatValue(stationInfo.numberOfPhases) }, + { label: 'Voltage Out', value: formatValue(stationInfo.voltageOut) }, + { label: 'Maximum Power (W)', value: formatValue(stationInfo.maximumPower) }, + { label: 'Maximum Amperage (A)', value: formatValue(stationInfo.maximumAmperage) }, + { label: 'Auto Register', value: formatValue(stationInfo.autoRegister) }, + { label: 'Auto Start', value: formatValue(stationInfo.autoStart) }, + { + label: 'OCPP Strict Compliance', + value: formatValue(stationInfo.ocppStrictCompliance), + }, + ], + title: 'Station Info', + }, + { + entries: [ + { label: 'Supervision User', value: formatValue(stationInfo.supervisionUser) }, + { + label: 'Supervision Password', + value: + stationInfo.supervisionPassword == null || stationInfo.supervisionPassword === '' + ? EMPTY_VALUE_PLACEHOLDER + : MASKED_VALUE_PLACEHOLDER, + }, + ], + title: 'Credentials', + }, + ] + + if (station.bootNotificationResponse != null) { + sections.push({ + entries: [ + { label: 'Interval', value: formatValue(station.bootNotificationResponse.interval) }, + { + label: 'Current Time', + value: formatDate(station.bootNotificationResponse.currentTime), + }, + ], + title: 'Boot Notification', + }) + } + + if (station.automaticTransactionGenerator?.automaticTransactionGenerator != null) { + sections.push({ + entries: [ + { + label: 'Enabled', + value: formatValue( + station.automaticTransactionGenerator.automaticTransactionGenerator.enable + ), + }, + ], + title: 'Automatic Transaction Generator', + }) + } + + return sections +} + +/** + * Returns the visible OCPP configuration keys for a charging station. + * Keys explicitly marked `visible: false` are excluded; a missing configuration yields []. + * @param station - The charging station data + * @returns The visible configuration keys + */ +export function getVisibleConfigurationKeys (station: ChargingStationData): ConfigurationKey[] { + return station.ocppConfiguration.configurationKey?.filter(key => key.visible !== false) ?? [] +} diff --git a/ui/web/src/skins/classic/components/actions/ShowDetails.vue b/ui/web/src/skins/classic/components/actions/ShowDetails.vue new file mode 100644 index 00000000..9557b19f --- /dev/null +++ b/ui/web/src/skins/classic/components/actions/ShowDetails.vue @@ -0,0 +1,118 @@ + + + + + diff --git a/ui/web/src/skins/classic/components/charging-stations/CSData.vue b/ui/web/src/skins/classic/components/charging-stations/CSData.vue index cdd05664..5a473f3b 100644 --- a/ui/web/src/skins/classic/components/charging-stations/CSData.vue +++ b/ui/web/src/skins/classic/components/charging-stations/CSData.vue @@ -70,6 +70,31 @@ > Set Supervision Url + + Show Details + diff --git a/ui/web/src/skins/modern/ModernLayout.vue b/ui/web/src/skins/modern/ModernLayout.vue index 5d2c300b..56873599 100644 --- a/ui/web/src/skins/modern/ModernLayout.vue +++ b/ui/web/src/skins/modern/ModernLayout.vue @@ -32,6 +32,7 @@ :key="station.stationInfo.hashId" :charging-station="station" @open-authorize="openAuthorizeDialog" + @open-details="openDetailsDialog" @open-set-url="openSetUrlDialog" @open-start-tx="openStartTxDialog" /> @@ -71,6 +72,12 @@ :ocpp-version="showAuthorizeDialog.ocppVersion" @close="showAuthorizeDialog = null" /> + @@ -124,6 +131,9 @@ const AuthorizeDialog = defineAsyncDialog(() => import('./components/dialogs/Aut const SetSupervisionUrlDialog = defineAsyncDialog( () => import('./components/dialogs/SetSupervisionUrlDialog.vue') ) +const ShowDetailsDialog = defineAsyncDialog( + () => import('./components/dialogs/ShowDetailsDialog.vue') +) const StartTransactionDialog = defineAsyncDialog( () => import('./components/dialogs/StartTransactionDialog.vue') ) @@ -165,6 +175,10 @@ const showAuthorizeDialog = ref(null) +const showDetailsDialog = ref(null) const confirmStopSimulator = (): void => { stopSimulator() @@ -182,6 +196,9 @@ const toggleSimulator = (): void => { const openAuthorizeDialog = (data: typeof showAuthorizeDialog.value): void => { showAuthorizeDialog.value = data } +const openDetailsDialog = (data: typeof showDetailsDialog.value): void => { + showDetailsDialog.value = data +} const openSetUrlDialog = (data: typeof showSetUrlDialog.value): void => { showSetUrlDialog.value = data } diff --git a/ui/web/src/skins/modern/components/StationCard.vue b/ui/web/src/skins/modern/components/StationCard.vue index 48ce8b78..75df4266 100644 --- a/ui/web/src/skins/modern/components/StationCard.vue +++ b/ui/web/src/skins/modern/components/StationCard.vue @@ -84,7 +84,7 @@ -
Authorize + + Details +
{ }) } +const emitOpenDetails = (): void => { + emit('open-details', { + chargingStationId: props.chargingStation.stationInfo.chargingStationId, + hashId: props.chargingStation.stationInfo.hashId, + }) +} + const handleDeleteStation = (): void => { const hashId = props.chargingStation.stationInfo.hashId deleteStation(hashId, () => { diff --git a/ui/web/src/skins/modern/components/dialogs/ShowDetailsDialog.vue b/ui/web/src/skins/modern/components/dialogs/ShowDetailsDialog.vue new file mode 100644 index 00000000..d74124b0 --- /dev/null +++ b/ui/web/src/skins/modern/components/dialogs/ShowDetailsDialog.vue @@ -0,0 +1,209 @@ + + + + + diff --git a/ui/web/src/skins/modern/modern.css b/ui/web/src/skins/modern/modern.css index 9f77516a..25abb100 100644 --- a/ui/web/src/skins/modern/modern.css +++ b/ui/web/src/skins/modern/modern.css @@ -393,8 +393,8 @@ html[data-skin='modern'] #app { /* Section label + the content right under it belong together, so pull * them in tight. The body `gap` still separates distinct sections. */ -.modern-card__section-label + .modern-card__connectors, -.modern-card__section-label + .modern-card__empty-connectors { +.modern-section-label + .modern-card__connectors, +.modern-section-label + .modern-card__empty-connectors { margin-top: calc(var(--skin-space-3) * -1 + var(--skin-space-1)); } @@ -568,7 +568,7 @@ html[data-skin='modern'] #app { box-shadow: 0 4px 14px -4px color-mix(in srgb, var(--color-state-err) 40%, transparent); } -.modern-card__section-label { +.modern-section-label { font-size: 0.6875rem; font-weight: 600; color: var(--color-text-muted); diff --git a/ui/web/tests/unit/router.test.ts b/ui/web/tests/unit/router.test.ts index 7c34806a..69f35359 100644 --- a/ui/web/tests/unit/router.test.ts +++ b/ui/web/tests/unit/router.test.ts @@ -29,6 +29,11 @@ describe('router', () => { expect(router.currentRoute.value.name).toBe(ROUTE_NAMES.CHARGING_STATIONS) }) + it('should redirect show-details to charging-stations when active skin is not classic', async () => { + await router.push('/show-details/test-hash/CS-1') + expect(router.currentRoute.value.name).toBe(ROUTE_NAMES.CHARGING_STATIONS) + }) + it('should allow non-guarded routes regardless of skin', async () => { await router.push('/') expect(router.currentRoute.value.name).toBe(ROUTE_NAMES.CHARGING_STATIONS) diff --git a/ui/web/tests/unit/shared/composables/stationDetails.test.ts b/ui/web/tests/unit/shared/composables/stationDetails.test.ts new file mode 100644 index 00000000..08783740 --- /dev/null +++ b/ui/web/tests/unit/shared/composables/stationDetails.test.ts @@ -0,0 +1,201 @@ +/** + * @file Tests for the shared station details view-model builder + * @description buildStationDetailSections field selection/formatting + password masking, + * and getVisibleConfigurationKeys visibility filtering. + */ +import { describe, expect, it } from 'vitest' + +import { EMPTY_VALUE_PLACEHOLDER, MASKED_VALUE_PLACEHOLDER } from '@/core/index.js' +import { + buildConfigurationRows, + buildStationDetailSections, + type DetailSection, + getVisibleConfigurationKeys, +} from '@/shared/utils/index.js' + +import { createChargingStationData, createStationInfo } from '../../constants.js' + +/** + * Reads a formatted entry value from built sections. + * @param sections - Built detail sections + * @param title - Section title + * @param label - Entry label + * @returns The entry value, or undefined if not present + */ +function readValue (sections: DetailSection[], title: string, label: string): string | undefined { + return sections.find(section => section.title === title)?.entries.find(e => e.label === label) + ?.value +} + +describe('stationDetails', () => { + describe('buildStationDetailSections', () => { + it('should render core sections', () => { + const sections = buildStationDetailSections(createChargingStationData()) + const titles = sections.map(section => section.title) + expect(titles).toContain('General') + expect(titles).toContain('Station Info') + expect(titles).toContain('Credentials') + }) + + it('should format booleans as Yes/No', () => { + const started = readValue( + buildStationDetailSections(createChargingStationData({ started: true })), + 'General', + 'Started' + ) + const stopped = readValue( + buildStationDetailSections(createChargingStationData({ started: false })), + 'General', + 'Started' + ) + expect(started).toBe('Yes') + expect(stopped).toBe('No') + }) + + it('should render the empty placeholder for missing scalar fields', () => { + const sections = buildStationDetailSections( + createChargingStationData({ + stationInfo: createStationInfo({ firmwareVersion: undefined }), + }) + ) + expect(readValue(sections, 'Station Info', 'Firmware Version')).toBe(EMPTY_VALUE_PLACEHOLDER) + }) + + it('should mask the supervision password and never expose the raw value', () => { + const sections = buildStationDetailSections( + createChargingStationData({ + stationInfo: createStationInfo({ supervisionPassword: 'super-secret' }), + }) + ) + const value = readValue(sections, 'Credentials', 'Supervision Password') + expect(value).toBe(MASKED_VALUE_PLACEHOLDER) + expect(value).not.toContain('super-secret') + }) + + it('should render the empty placeholder for an unset supervision password', () => { + const sections = buildStationDetailSections( + createChargingStationData({ + stationInfo: createStationInfo({ supervisionPassword: undefined }), + }) + ) + expect(readValue(sections, 'Credentials', 'Supervision Password')).toBe( + EMPTY_VALUE_PLACEHOLDER + ) + }) + + it('should include the Boot Notification section only when present', () => { + const withBoot = buildStationDetailSections(createChargingStationData()) + expect(withBoot.map(section => section.title)).toContain('Boot Notification') + const withoutBoot = buildStationDetailSections( + createChargingStationData({ bootNotificationResponse: undefined }) + ) + expect(withoutBoot.map(section => section.title)).not.toContain('Boot Notification') + }) + + it('should include the ATG section only when an ATG configuration is present', () => { + const withoutATG = buildStationDetailSections(createChargingStationData()) + expect(withoutATG.map(section => section.title)).not.toContain( + 'Automatic Transaction Generator' + ) + const withATG = buildStationDetailSections( + createChargingStationData({ + automaticTransactionGenerator: { + automaticTransactionGenerator: { + enable: true, + maxDelayBetweenTwoTransactions: 0, + maxDuration: 0, + minDelayBetweenTwoTransactions: 0, + minDuration: 0, + probabilityOfStart: 1, + stopAbsoluteDuration: false, + stopAfterHours: 0, + }, + }, + }) + ) + const atg = withATG.find(section => section.title === 'Automatic Transaction Generator') + expect(atg?.entries.find(e => e.label === 'Enabled')?.value).toBe('Yes') + }) + }) + + describe('getVisibleConfigurationKeys', () => { + it('should exclude keys explicitly marked not visible', () => { + const keys = getVisibleConfigurationKeys( + createChargingStationData({ + ocppConfiguration: { + configurationKey: [ + { key: 'Visible', readonly: false, value: 'a' }, + { key: 'Shown', readonly: true, value: 'b', visible: true }, + { key: 'Hidden', readonly: false, value: 'c', visible: false }, + ], + }, + }) + ) + expect(keys.map(key => key.key)).toEqual(['Visible', 'Shown']) + }) + + it('should return an empty array when no configuration keys are reported', () => { + expect( + getVisibleConfigurationKeys(createChargingStationData({ ocppConfiguration: {} })) + ).toEqual([]) + }) + }) + + describe('buildConfigurationRows', () => { + it('should format readonly, reboot and missing value for display', () => { + const rows = buildConfigurationRows( + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'HeartbeatInterval', readonly: true, reboot: true }], + }, + }) + ) + expect(rows).toEqual([ + { + key: 'HeartbeatInterval', + readonly: 'Yes', + reboot: 'Yes', + value: EMPTY_VALUE_PLACEHOLDER, + }, + ]) + }) + + it('should format non-readonly, non-reboot keys with their value', () => { + const rows = buildConfigurationRows( + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'MeterValueSampleInterval', readonly: false, value: '60' }], + }, + }) + ) + expect(rows).toEqual([ + { key: 'MeterValueSampleInterval', readonly: 'No', reboot: 'No', value: '60' }, + ]) + }) + + it('should render an empty-string value as the empty placeholder', () => { + const rows = buildConfigurationRows( + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'BlankValue', readonly: false, value: '' }], + }, + }) + ) + expect(rows[0].value).toBe(EMPTY_VALUE_PLACEHOLDER) + }) + + it('should exclude keys marked not visible', () => { + const rows = buildConfigurationRows( + createChargingStationData({ + ocppConfiguration: { + configurationKey: [ + { key: 'Shown', readonly: false, value: 'a' }, + { key: 'Hidden', readonly: false, value: 'b', visible: false }, + ], + }, + }) + ) + expect(rows.map(row => row.key)).toEqual(['Shown']) + }) + }) +}) diff --git a/ui/web/tests/unit/shared/composables/useStationDetails.test.ts b/ui/web/tests/unit/shared/composables/useStationDetails.test.ts new file mode 100644 index 00000000..5924695c --- /dev/null +++ b/ui/web/tests/unit/shared/composables/useStationDetails.test.ts @@ -0,0 +1,72 @@ +/** + * @file Tests for useStationDetails composable + * @description Store resolution by hash id and derivation of the read-only detail + * sections + OCPP configuration rows shared by both skins. + */ +import { mount } from '@vue/test-utils' +import { type ChargingStationData } from 'ui-common' +import { describe, expect, it } from 'vitest' +import { defineComponent, type Ref, ref } from 'vue' + +import { chargingStationsKey } from '@/core/index.js' +import { + type StationDetailsView, + useStationDetails, +} from '@/shared/composables/useStationDetails.js' + +import { createChargingStationData, TEST_HASH_ID } from '../../constants.js' + +/** + * Mounts a throwaway component that runs useStationDetails with the provided store. + * @param stations - Reactive charging-station store + * @param hashId - Hash id to resolve + * @returns The composable's return value + */ +function runComposable (stations: Ref, hashId: string): StationDetailsView { + let api!: StationDetailsView + mount( + defineComponent({ + setup () { + api = useStationDetails(hashId) + return () => null + }, + }), + { global: { provide: { [chargingStationsKey as symbol]: stations } } } + ) + return api +} + +describe('useStationDetails', () => { + it('should resolve the station and derive its sections and configuration rows', () => { + const stations = ref([ + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'HeartbeatInterval', readonly: false, value: '30' }], + }, + }), + ]) + const { configurationRows, sections, station } = runComposable(stations, TEST_HASH_ID) + expect(station.value?.stationInfo.hashId).toBe(TEST_HASH_ID) + expect(sections.value.map(section => section.title)).toContain('General') + expect(configurationRows.value).toEqual([ + { key: 'HeartbeatInterval', readonly: 'No', reboot: 'No', value: '30' }, + ]) + }) + + it('should return an undefined station and empty derived data for an unknown hashId', () => { + const stations = ref([createChargingStationData()]) + const { configurationRows, sections, station } = runComposable(stations, 'unknown-hash') + expect(station.value).toBeUndefined() + expect(sections.value).toEqual([]) + expect(configurationRows.value).toEqual([]) + }) + + it('should reactively resolve a station added to the store after mount', () => { + const stations = ref([]) + const { sections, station } = runComposable(stations, TEST_HASH_ID) + expect(station.value).toBeUndefined() + stations.value = [createChargingStationData()] + expect(station.value?.stationInfo.hashId).toBe(TEST_HASH_ID) + expect(sections.value.map(section => section.title)).toContain('General') + }) +}) diff --git a/ui/web/tests/unit/skins/classic/Actions.test.ts b/ui/web/tests/unit/skins/classic/Actions.test.ts index 7ea5b821..1a2d18f1 100644 --- a/ui/web/tests/unit/skins/classic/Actions.test.ts +++ b/ui/web/tests/unit/skins/classic/Actions.test.ts @@ -7,13 +7,27 @@ import { OCPPVersion } from 'ui-common' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ref, shallowRef } from 'vue' -import { chargingStationsKey, configurationKey, templatesKey, uiClientKey } from '@/core/index.js' +import { + chargingStationsKey, + configurationKey, + EMPTY_VALUE_PLACEHOLDER, + MASKED_VALUE_PLACEHOLDER, + templatesKey, + uiClientKey, +} from '@/core/index.js' import AddChargingStations from '@/skins/classic/components/actions/AddChargingStations.vue' import SetSupervisionUrl from '@/skins/classic/components/actions/SetSupervisionUrl.vue' +import ShowDetails from '@/skins/classic/components/actions/ShowDetails.vue' import StartTransaction from '@/skins/classic/components/actions/StartTransaction.vue' import { toastMock } from '../../../setup.js' -import { createUIServerConfig, TEST_HASH_ID, TEST_STATION_ID } from '../../constants.js' +import { + createChargingStationData, + createStationInfo, + createUIServerConfig, + TEST_HASH_ID, + TEST_STATION_ID, +} from '../../constants.js' import { ButtonStub, createMockUIClient, type MockUIClient } from '../../helpers.js' const mockPush = vi.fn().mockResolvedValue(undefined) @@ -406,4 +420,97 @@ describe('Actions', () => { expect(toastMock.error).toHaveBeenCalledWith('Error at starting transaction') }) }) + + describe('ShowDetails', () => { + beforeEach(() => { + mockPush.mockClear() + }) + + afterEach(() => { + vi.clearAllMocks() + vi.restoreAllMocks() + }) + + /** + * Mounts ShowDetails with a provided store. + * @param stations - Charging stations to seed the store with + * @returns Mounted ShowDetails wrapper + */ + function mountShowDetails (stations = [createChargingStationData()]) { + return mount(ShowDetails, { + global: { + provide: { + [chargingStationsKey as symbol]: shallowRef(stations), + }, + stubs: { Button: ButtonStub }, + }, + props: { + chargingStationId: TEST_STATION_ID, + hashId: TEST_HASH_ID, + }, + }) + } + + it('should render the heading and station id', () => { + const wrapper = mountShowDetails() + expect(wrapper.find('h1').text()).toBe('Show Details') + expect(wrapper.find('h2').text()).toBe(TEST_STATION_ID) + }) + + it('should render the detail sections and OCPP parameters table', () => { + const wrapper = mountShowDetails() + const captions = wrapper.findAll('caption').map(c => c.text()) + expect(captions).toContain('General') + expect(captions).toContain('Station Info') + expect(captions).toContain('OCPP Parameters') + }) + + it('should render the empty message when no OCPP parameters are reported', () => { + const wrapper = mountShowDetails([ + createChargingStationData({ ocppConfiguration: { configurationKey: [] } }), + ]) + expect(wrapper.text()).toContain('No OCPP parameters reported') + }) + + it('should mask the supervision password in the rendered panel', () => { + const wrapper = mountShowDetails([ + createChargingStationData({ + stationInfo: createStationInfo({ supervisionPassword: 'super-secret' }), + }), + ]) + expect(wrapper.text()).not.toContain('super-secret') + expect(wrapper.text()).toContain(MASKED_VALUE_PLACEHOLDER) + }) + + it('should format OCPP readonly, reboot and missing value cells', () => { + const wrapper = mountShowDetails([ + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'RebootKey', readonly: true, reboot: true }], + }, + }), + ]) + const tables = wrapper.findAll('table.data-table') + const ocppTable = tables[tables.length - 1] + const row = ocppTable.findAll('tbody tr').find(tr => tr.find('th').text() === 'RebootKey') + expect(row).toBeDefined() + expect(row?.findAll('td').map(td => td.text())).toEqual([ + EMPTY_VALUE_PLACEHOLDER, + 'Yes', + 'Yes', + ]) + }) + + it('should render a not-found message when the station is absent from the store', () => { + const wrapper = mountShowDetails([]) + expect(wrapper.text()).toContain('Charging station not found') + }) + + it('should navigate to charging-stations on close', async () => { + const wrapper = mountShowDetails() + await wrapper.findComponent(ButtonStub).trigger('click') + await flushPromises() + expect(mockPush).toHaveBeenCalledWith({ name: 'charging-stations' }) + }) + }) }) diff --git a/ui/web/tests/unit/skins/modern/Dialogs.test.ts b/ui/web/tests/unit/skins/modern/Dialogs.test.ts index 2762f1e9..3b8c4b37 100644 --- a/ui/web/tests/unit/skins/modern/Dialogs.test.ts +++ b/ui/web/tests/unit/skins/modern/Dialogs.test.ts @@ -15,7 +15,13 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { defineComponent, ref } from 'vue' -import { chargingStationsKey, templatesKey, uiClientKey } from '@/core/index.js' +import { + chargingStationsKey, + EMPTY_VALUE_PLACEHOLDER, + MASKED_VALUE_PLACEHOLDER, + templatesKey, + uiClientKey, +} from '@/core/index.js' // Mock Modal to render slots inline (no Teleport), so `wrapper.find()` works. vi.mock('@/skins/modern/components/ModernModal.vue', () => ({ @@ -35,10 +41,16 @@ import AddStationsDialog from '@/skins/modern/components/dialogs/AddStationsDial import AuthorizeDialog from '@/skins/modern/components/dialogs/AuthorizeDialog.vue' import SetConnectorStatusDialog from '@/skins/modern/components/dialogs/SetConnectorStatusDialog.vue' import SetSupervisionUrlDialog from '@/skins/modern/components/dialogs/SetSupervisionUrlDialog.vue' +import ShowDetailsDialog from '@/skins/modern/components/dialogs/ShowDetailsDialog.vue' import StartTransactionDialog from '@/skins/modern/components/dialogs/StartTransactionDialog.vue' import { toastMock } from '../../../setup.js' -import { createChargingStationData, TEST_HASH_ID, TEST_STATION_ID } from '../../constants.js' +import { + createChargingStationData, + createStationInfo, + TEST_HASH_ID, + TEST_STATION_ID, +} from '../../constants.js' import { createMockUIClient, type MockUIClient } from '../../helpers.js' let mockClient: MockUIClient @@ -538,4 +550,115 @@ describe('Dialogs', () => { expect(wrapper.emitted('close')).toHaveLength(1) }) }) + + describe('ShowDetailsDialog', () => { + /** + * @param stations - Charging station data to provide to the dialog + * @returns Mounted wrapper for ShowDetailsDialog + */ + function mountDialog (stations = [createChargingStationData()]) { + return mount(ShowDetailsDialog, { + global: { + provide: { + [chargingStationsKey as symbol]: ref(stations), + }, + }, + props: { chargingStationId: TEST_STATION_ID, hashId: TEST_HASH_ID }, + }) + } + + it('should render the title with the station id', () => { + const wrapper = mountDialog() + expect(wrapper.text()).toContain(`Show details — ${TEST_STATION_ID}`) + }) + + it('should render detail sections', () => { + const wrapper = mountDialog() + expect(wrapper.text()).toContain('General') + expect(wrapper.text()).toContain('Station Info') + }) + + it('should render OCPP parameter rows from the configuration keys', () => { + const wrapper = mountDialog([ + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'HeartbeatInterval', readonly: false, value: '30' }], + }, + }), + ]) + expect(wrapper.text()).toContain('HeartbeatInterval') + expect(wrapper.text()).toContain('30') + }) + + it('should format OCPP readonly, reboot and missing value cells', () => { + const wrapper = mountDialog([ + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'RebootKey', readonly: true, reboot: true }], + }, + }), + ]) + const row = wrapper + .findAll('.station-details__table tbody tr') + .find(tr => tr.find('th').text() === 'RebootKey') + expect(row).toBeDefined() + expect(row?.findAll('td').map(td => td.text())).toEqual([ + EMPTY_VALUE_PLACEHOLDER, + 'Yes', + 'Yes', + ]) + }) + + it('should render the empty message when no OCPP parameters are reported', () => { + const wrapper = mountDialog([ + createChargingStationData({ ocppConfiguration: { configurationKey: [] } }), + ]) + expect(wrapper.text()).toContain('No OCPP parameters reported') + }) + + it('should mask the supervision password', () => { + const wrapper = mountDialog([ + createChargingStationData({ + stationInfo: createStationInfo({ supervisionPassword: 'super-secret' }), + }), + ]) + expect(wrapper.text()).not.toContain('super-secret') + expect(wrapper.text()).toContain(MASKED_VALUE_PLACEHOLDER) + }) + + it('should give the OCPP parameters table an accessible name', () => { + const wrapper = mountDialog([ + createChargingStationData({ + ocppConfiguration: { + configurationKey: [{ key: 'HeartbeatInterval', readonly: false, value: '30' }], + }, + }), + ]) + const labelledBy = wrapper.find('.station-details__table').attributes('aria-labelledby') ?? '' + expect(labelledBy).not.toBe('') + expect(wrapper.find(`#${labelledBy}`).exists()).toBe(true) + }) + + it('should give each detail section an accessible name pointing at its heading', () => { + const wrapper = mountDialog() + const lists = wrapper.findAll('.station-details__list') + expect(lists.length).toBeGreaterThan(0) + for (const list of lists) { + const labelledBy = list.attributes('aria-labelledby') ?? '' + expect(labelledBy).not.toBe('') + expect(wrapper.find(`#${labelledBy}`).exists()).toBe(true) + } + }) + + it('should render a not-found message when the station is absent', () => { + const wrapper = mountDialog([]) + expect(wrapper.text()).toContain('Charging station not found') + }) + + it('should emit close when the Close button is clicked', async () => { + const wrapper = mountDialog() + await wrapper.findAll('.stub-modal__foot button')[0].trigger('click') + expect(wrapper.emitted('close')).toHaveLength(1) + }) + }) }) diff --git a/ui/web/tests/unit/skins/modern/ModernLayout.test.ts b/ui/web/tests/unit/skins/modern/ModernLayout.test.ts index dc9ed509..c45d0448 100644 --- a/ui/web/tests/unit/skins/modern/ModernLayout.test.ts +++ b/ui/web/tests/unit/skins/modern/ModernLayout.test.ts @@ -315,7 +315,7 @@ describe('ModernLayout', () => { wrapper.unmount() }) - it('should open authorize dialog when station card emits open-authorize', async () => { + it('should open the matching dialog when station card emits an open-* event', async () => { const station = createChargingStationData({ stationInfo: { baseName: 'CS-1', @@ -343,13 +343,15 @@ describe('ModernLayout', () => { AuthorizeDialog: true, ConfirmDialog: true, SetSupervisionUrlDialog: true, + ShowDetailsDialog: true, SimulatorBar: true, StartTransactionDialog: true, StationCard: { - emits: ['open-authorize', 'open-set-url', 'open-start-tx'], + emits: ['open-authorize', 'open-details', 'open-set-url', 'open-start-tx'], props: ['chargingStation'], template: `
+
`, @@ -359,6 +361,7 @@ describe('ModernLayout', () => { }) await flushPromises() await wrapper.find('.stub-authorize').trigger('click') + await wrapper.find('.stub-details').trigger('click') await wrapper.find('.stub-set-url').trigger('click') await wrapper.find('.stub-start-tx').trigger('click') await flushPromises() @@ -366,6 +369,7 @@ describe('ModernLayout', () => { expect(wrapper.findComponent({ name: 'AuthorizeDialog' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'SetSupervisionUrlDialog' }).exists()).toBe(true) expect(wrapper.findComponent({ name: 'StartTransactionDialog' }).exists()).toBe(true) + expect(wrapper.findComponent({ name: 'ShowDetailsDialog' }).exists()).toBe(true) wrapper.unmount() }) }) diff --git a/ui/web/tests/unit/skins/modern/StationCard.test.ts b/ui/web/tests/unit/skins/modern/StationCard.test.ts index 984e9cb7..a688e1f2 100644 --- a/ui/web/tests/unit/skins/modern/StationCard.test.ts +++ b/ui/web/tests/unit/skins/modern/StationCard.test.ts @@ -239,6 +239,16 @@ describe('StationCard', () => { ]) }) + it('should emit open-details from footer', async () => { + wrapper = mountCard() + const buttons = wrapper.findAll('.modern-card__foot-group .modern-btn') + const btn = buttons.find(b => b.text() === 'Details') + await btn?.trigger('click') + expect(wrapper.emitted('open-details')).toEqual([ + [{ chargingStationId: TEST_STATION_ID, hashId: TEST_HASH_ID }], + ]) + }) + it('should open delete confirm dialog and cancel without an API call', async () => { wrapper = mountCard() const delBtn = wrapper.find('.modern-card__foot .modern-btn--danger') -- 2.53.0