]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(webui): add show details action for charging stations (#2072)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Mon, 10 Aug 2026 15:51:27 +0000 (17:51 +0200)
committerGitHub <noreply@github.com>
Mon, 10 Aug 2026 15:51:27 +0000 (17:51 +0200)
* 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 `<dl>` 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>
20 files changed:
ui/web/README.md
ui/web/src/core/Constants.ts
ui/web/src/core/index.ts
ui/web/src/router/index.ts
ui/web/src/shared/composables/useStationDetails.ts [new file with mode: 0644]
ui/web/src/shared/utils/index.ts
ui/web/src/shared/utils/stationDetails.ts [new file with mode: 0644]
ui/web/src/skins/classic/components/actions/ShowDetails.vue [new file with mode: 0644]
ui/web/src/skins/classic/components/charging-stations/CSData.vue
ui/web/src/skins/modern/ModernLayout.vue
ui/web/src/skins/modern/components/StationCard.vue
ui/web/src/skins/modern/components/dialogs/ShowDetailsDialog.vue [new file with mode: 0644]
ui/web/src/skins/modern/modern.css
ui/web/tests/unit/router.test.ts
ui/web/tests/unit/shared/composables/stationDetails.test.ts [new file with mode: 0644]
ui/web/tests/unit/shared/composables/useStationDetails.test.ts [new file with mode: 0644]
ui/web/tests/unit/skins/classic/Actions.test.ts
ui/web/tests/unit/skins/modern/Dialogs.test.ts
ui/web/tests/unit/skins/modern/ModernLayout.test.ts
ui/web/tests/unit/skins/modern/StationCard.test.ts

index 3e7a385e52f188b26729f5663d71ab9dd536d8cd..0864a24958f323165c1a9ce5adfff27027da8e2a 100644 (file)
@@ -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
 
index ec997102d3d33091bc40b7aeda95f7df0da98bc3..6be8f0053fc87468ec98c25e29d65d2116fd7f36 100644 (file)
@@ -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
 
index 2e428eb5199ee3c02188513bc0e217ada8c4974a..fafbf5947fea9b00a5ea499e441b855ac2329d6a 100644 (file)
@@ -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,
index 43d1d58a90047af0792691f6f83e91377c070e17..fadadfc64589ec6559a6a9646c0e2cb7670cea71 100644 (file)
@@ -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 (file)
index 0000000..d1a3b7f
--- /dev/null
@@ -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<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,
+  }
+}
index c2d4ea4d677e6c5b2fd9e36f75a3dacc8acd6803..9722038176f8fd13d2fe216d99aa987835da1989 100644 (file)
@@ -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 (file)
index 0000000..97f183b
--- /dev/null
@@ -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 (file)
index 0000000..9557b19
--- /dev/null
@@ -0,0 +1,118 @@
+<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>
index cdd05664b540748aa584fb57294537ec3854516b..5a473f3b6ac03a585ad9dee406e7679b2fb6e32f 100644 (file)
       >
         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>
index 5d2c300b773c9cd764b50d7896c1bb3f8755a2d9..568735993bdf85e063338b4515cede4b83aaa34c 100644 (file)
@@ -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"
       />
       :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>
 
@@ -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 | {
   hashId: string
   ocppVersion?: OCPPVersion
 }>(null)
+const showDetailsDialog = ref<null | {
+  chargingStationId: string
+  hashId: string
+}>(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
 }
index 48ce8b7832e6799045d35e3b4f8a8a87e5bc7945..75df4266bc5faa634734fb3d85b60c6376ab8298 100644 (file)
@@ -84,7 +84,7 @@
           </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"
@@ -187,6 +193,7 @@ const props = defineProps<{
 
 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: {
@@ -255,6 +262,13 @@ const emitOpenAuthorize = (): void => {
   })
 }
 
+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 (file)
index 0000000..d74124b
--- /dev/null
@@ -0,0 +1,209 @@
+<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>
index 9f77516a921487307765d27b15aa875ed6ae8aef..25abb1000884a24b5aa747fe5abbcf34fa78cfb6 100644 (file)
@@ -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);
index 7c34806aad823304fb8fff5d57fe5a308d673778..69f3535931e3c44bc39354a734348f8bb5079cfc 100644 (file)
@@ -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 (file)
index 0000000..0878374
--- /dev/null
@@ -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 (file)
index 0000000..5924695
--- /dev/null
@@ -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<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')
+  })
+})
index 7ea5b8214c96f34b3d15e205ddfea614fb051e53..1a2d18f198da0b33b12ee27574ac1c354059a595 100644 (file)
@@ -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' })
+    })
+  })
 })
index 2762f1e979993d9d82e560729fd2d60c9f8c5d82..3b8c4b374c8b2cc5670cb9f5821f516c75110786 100644 (file)
@@ -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)
+    })
+  })
 })
index dc9ed509cabcebc3b22e9d5223e3559adfe61956..c45d0448541aa06e7f7a1a2e99ceaeb51b3aef42 100644 (file)
@@ -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: `<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>`,
@@ -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()
   })
 })
index 984e9cb76d3e5bb9d408052b3485cff4f8b98e2c..a688e1f2cb9c2b79df26cc5cec66e311f5f687e9 100644 (file)
@@ -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')