
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
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'
CHARGING_STATIONS: 'charging-stations',
NOT_FOUND: 'not-found',
SET_SUPERVISION_URL: 'set-supervision-url',
+ SHOW_DETAILS: 'show-details',
START_TRANSACTION: 'start-transaction',
} as const
DEFAULT_THEME,
EMPTY_VALUE_PLACEHOLDER,
LEGACY_UI_SERVER_CONFIG_KEY,
+ MASKED_VALUE_PLACEHOLDER,
MAX_SKIN_ERROR_RELOADS,
MAX_STATIONS_PER_ADD,
ROUTE_NAMES,
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: () =>
--- /dev/null
+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<ConfigurationRow[]>
+ sections: ComputedRef<DetailSection[]>
+ station: ComputedRef<ChargingStationData | undefined>
+}
+
+/**
+ * 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,
+ }
+}
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,
--- /dev/null
+/**
+ * @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) ?? []
+}
--- /dev/null
+<template>
+ <h1 class="classic-action-header">
+ Show Details
+ </h1>
+ <h2>{{ chargingStationId }}</h2>
+ <p
+ v-if="station == null"
+ class="show-details__empty"
+ >
+ Charging station not found
+ </p>
+ <template v-else>
+ <table
+ v-for="section in sections"
+ :key="section.title"
+ class="data-table data-table--bordered show-details__section"
+ >
+ <caption class="data-table__caption">
+ {{
+ section.title
+ }}
+ </caption>
+ <tbody>
+ <tr
+ v-for="entry in section.entries"
+ :key="entry.label"
+ >
+ <th scope="row">
+ {{ entry.label }}
+ </th>
+ <td>{{ entry.value }}</td>
+ </tr>
+ </tbody>
+ </table>
+ <table class="data-table data-table--bordered show-details__section">
+ <caption class="data-table__caption">
+ OCPP Parameters
+ </caption>
+ <thead class="data-table__head">
+ <tr>
+ <th scope="col">
+ Key
+ </th>
+ <th scope="col">
+ Value
+ </th>
+ <th scope="col">
+ Readonly
+ </th>
+ <th scope="col">
+ Reboot
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr v-if="configurationRows.length === 0">
+ <td colspan="4">
+ No OCPP parameters reported
+ </td>
+ </tr>
+ <tr
+ v-for="row in configurationRows"
+ :key="row.key"
+ >
+ <th scope="row">
+ {{ row.key }}
+ </th>
+ <td>{{ row.value }}</td>
+ <td>{{ row.readonly }}</td>
+ <td>{{ row.reboot }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </template>
+ <Button
+ id="action-button"
+ @click="close()"
+ >
+ Close
+ </Button>
+</template>
+
+<script setup lang="ts">
+import { useRouter } from 'vue-router'
+
+import { resetToggleButtonState, ROUTE_NAMES } from '@/core/index.js'
+import { useStationDetails } from '@/shared/composables/useStationDetails.js'
+
+import Button from '../buttons/ClassicButton.vue'
+
+const props = defineProps<{
+ chargingStationId: string
+ hashId: string
+}>()
+
+const $router = useRouter()
+
+const { configurationRows, sections, station } = useStationDetails(props.hashId)
+
+const close = (): void => {
+ resetToggleButtonState(`${props.hashId}-show-details`, true)
+ $router.push({ name: ROUTE_NAMES.CHARGING_STATIONS }).catch(() => undefined)
+}
+</script>
+
+<style scoped>
+.show-details__section {
+ margin-bottom: var(--spacing-md);
+}
+
+.show-details__section :is(th, td) {
+ text-align: left;
+}
+
+.show-details__empty {
+ text-align: center;
+}
+</style>
>
Set Supervision Url
</ToggleButton>
+ <ToggleButton
+ :id="`${chargingStation.stationInfo.hashId}-show-details`"
+ :off="
+ () => {
+ $router.push({ name: ROUTE_NAMES.CHARGING_STATIONS }).catch(() => undefined)
+ }
+ "
+ :on="
+ () => {
+ $router
+ .push({
+ name: ROUTE_NAMES.SHOW_DETAILS,
+ params: {
+ hashId: chargingStation.stationInfo.hashId,
+ chargingStationId: chargingStation.stationInfo.chargingStationId,
+ },
+ })
+ .catch(() => undefined)
+ }
+ "
+ :shared="true"
+ @clicked="$emit('need-refresh')"
+ >
+ Show Details
+ </ToggleButton>
<Button @click="deleteChargingStation()">
Delete Charging Station
</Button>
:key="station.stationInfo.hashId"
:charging-station="station"
@open-authorize="openAuthorizeDialog"
+ @open-details="openDetailsDialog"
@open-set-url="openSetUrlDialog"
@open-start-tx="openStartTxDialog"
/>
:ocpp-version="showAuthorizeDialog.ocppVersion"
@close="showAuthorizeDialog = null"
/>
+ <ShowDetailsDialog
+ v-if="showDetailsDialog"
+ :hash-id="showDetailsDialog.hashId"
+ :charging-station-id="showDetailsDialog.chargingStationId"
+ @close="showDetailsDialog = null"
+ />
</main>
</template>
const SetSupervisionUrlDialog = defineAsyncDialog(
() => import('./components/dialogs/SetSupervisionUrlDialog.vue')
)
+const ShowDetailsDialog = defineAsyncDialog(
+ () => import('./components/dialogs/ShowDetailsDialog.vue')
+)
const StartTransactionDialog = defineAsyncDialog(
() => import('./components/dialogs/StartTransactionDialog.vue')
)
hashId: string
ocppVersion?: OCPPVersion
}>(null)
+const showDetailsDialog = ref<null | {
+ chargingStationId: string
+ hashId: string
+}>(null)
const confirmStopSimulator = (): void => {
stopSimulator()
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
}
</svg>
</button>
</div>
- <p class="modern-card__section-label">
+ <p class="modern-section-label">
Connectors
</p>
<div
>
Authorize
</ActionButton>
+ <ActionButton
+ variant="ghost"
+ @click="emitOpenDetails"
+ >
+ Details
+ </ActionButton>
</div>
<ActionButton
variant="danger"
const emit = defineEmits<{
'open-authorize': [data: { chargingStationId: string; hashId: string; ocppVersion?: OCPPVersion }]
+ 'open-details': [data: { chargingStationId: string; hashId: string }]
'open-set-url': [data: { chargingStationId: string; hashId: string }]
'open-start-tx': [
data: {
})
}
+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, () => {
--- /dev/null
+<template>
+ <Modal
+ :title="`Show details — ${chargingStationId}`"
+ @close="close"
+ >
+ <p
+ v-if="station == null"
+ class="station-details__empty"
+ >
+ Charging station not found
+ </p>
+ <div
+ v-else
+ class="station-details"
+ >
+ <section
+ v-for="(section, index) in sections"
+ :key="section.title"
+ class="station-details__section"
+ >
+ <h3
+ :id="`${sectionsBaseId}-section-${index}`"
+ class="modern-section-label"
+ >
+ {{ section.title }}
+ </h3>
+ <dl
+ class="station-details__list"
+ :aria-labelledby="`${sectionsBaseId}-section-${index}`"
+ >
+ <div
+ v-for="entry in section.entries"
+ :key="entry.label"
+ class="station-details__row"
+ >
+ <dt>{{ entry.label }}</dt>
+ <dd>{{ entry.value }}</dd>
+ </div>
+ </dl>
+ </section>
+ <section class="station-details__section">
+ <h3
+ :id="`${sectionsBaseId}-ocpp`"
+ class="modern-section-label"
+ >
+ OCPP Parameters
+ </h3>
+ <p
+ v-if="configurationRows.length === 0"
+ class="station-details__empty"
+ >
+ No OCPP parameters reported
+ </p>
+ <table
+ v-else
+ class="station-details__table"
+ :aria-labelledby="`${sectionsBaseId}-ocpp`"
+ >
+ <thead>
+ <tr>
+ <th scope="col">
+ Key
+ </th>
+ <th scope="col">
+ Value
+ </th>
+ <th scope="col">
+ Readonly
+ </th>
+ <th scope="col">
+ Reboot
+ </th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr
+ v-for="row in configurationRows"
+ :key="row.key"
+ >
+ <th scope="row">
+ {{ row.key }}
+ </th>
+ <td>{{ row.value }}</td>
+ <td>{{ row.readonly }}</td>
+ <td>{{ row.reboot }}</td>
+ </tr>
+ </tbody>
+ </table>
+ </section>
+ </div>
+ <template #footer>
+ <ActionButton
+ variant="ghost"
+ @click="close"
+ >
+ Close
+ </ActionButton>
+ </template>
+ </Modal>
+</template>
+
+<script setup lang="ts">
+import { useId } from 'vue'
+
+import { useStationDetails } from '@/shared/composables/useStationDetails.js'
+
+import ActionButton from '../ActionButton.vue'
+import Modal from '../ModernModal.vue'
+
+const props = defineProps<{
+ chargingStationId: string
+ hashId: string
+}>()
+
+const emit = defineEmits<{ close: [] }>()
+
+const sectionsBaseId = useId()
+
+const { configurationRows, sections, station } = useStationDetails(props.hashId)
+
+const close = (): void => {
+ emit('close')
+}
+</script>
+
+<style scoped>
+.station-details {
+ display: flex;
+ flex-direction: column;
+ gap: var(--skin-space-4);
+}
+
+.station-details__section {
+ display: flex;
+ flex-direction: column;
+ gap: var(--skin-space-2);
+}
+
+.station-details__list {
+ margin: 0;
+ display: grid;
+ gap: var(--skin-space-2);
+}
+
+.station-details__row {
+ display: grid;
+ grid-template-columns: minmax(9rem, 12rem) 1fr;
+ gap: var(--skin-space-1) var(--skin-space-3);
+ align-items: baseline;
+}
+
+.station-details__row dt {
+ margin: 0;
+ font-size: 0.6875rem;
+ font-weight: 500;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+}
+
+.station-details__row dd {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 500;
+ color: var(--color-text-strong);
+ word-break: break-word;
+}
+
+.station-details__table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 0.8125rem;
+}
+
+.station-details__table th,
+.station-details__table td {
+ padding: var(--skin-space-1) var(--skin-space-2);
+ text-align: left;
+}
+
+.station-details__table td,
+.station-details__table th[scope='row'] {
+ word-break: break-word;
+}
+
+.station-details__table thead th {
+ font-size: 0.6875rem;
+ font-weight: 600;
+ color: var(--color-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ white-space: nowrap;
+ border-bottom: 1px solid var(--skin-border);
+}
+
+.station-details__table th[scope='row'] {
+ font-weight: 600;
+ color: var(--color-text-strong);
+}
+
+.station-details__table tbody tr {
+ border-bottom: 1px solid var(--skin-border);
+}
+
+.station-details__empty {
+ margin: 0;
+ color: var(--color-text-muted);
+}
+</style>
/* 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));
}
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);
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)
--- /dev/null
+/**
+ * @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'])
+ })
+ })
+})
--- /dev/null
+/**
+ * @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<ChargingStationData[]>, 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<ChargingStationData[]>([])
+ 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')
+ })
+})
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)
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' })
+ })
+ })
})
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', () => ({
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
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)
+ })
+ })
})
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',
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: `<article class="stub-station-card">
<button class="stub-authorize" @click="$emit('open-authorize', { chargingStationId: 'CS-1', hashId: 'h1' })">auth</button>
+ <button class="stub-details" @click="$emit('open-details', { chargingStationId: 'CS-1', hashId: 'h1' })">details</button>
<button class="stub-set-url" @click="$emit('open-set-url', { chargingStationId: 'CS-1', hashId: 'h1' })">url</button>
<button class="stub-start-tx" @click="$emit('open-start-tx', { chargingStationId: 'CS-1', connectorId: '1', hashId: 'h1' })">tx</button>
</article>`,
})
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()
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()
})
})
])
})
+ 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')