]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(ui): move generic utilities to ui-common and add useFetchData composable
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 00:53:23 +0000 (02:53 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 00:53:23 +0000 (02:53 +0200)
- Move convertToBoolean(), convertToInt() to ui-common/src/utils/converters.ts
- Move getWebSocketStateName() to ui-common/src/utils/websocket.ts using
  portable WebSocketReadyState enum instead of browser WebSocket constants
- Add useFetchData() composable to deduplicate fetch-with-loading-guard
  pattern in ChargingStationsView (3 instances collapsed)
- All consumers import directly from ui-common, no re-exports

ui/common/src/index.ts
ui/common/src/utils/converters.ts [new file with mode: 0644]
ui/common/src/utils/websocket.ts [new file with mode: 0644]
ui/web/src/components/actions/AddChargingStations.vue
ui/web/src/components/actions/StartTransaction.vue
ui/web/src/components/charging-stations/CSData.vue
ui/web/src/composables/Utils.ts
ui/web/src/composables/index.ts
ui/web/src/views/ChargingStationsView.vue
ui/web/tests/unit/Utils.test.ts

index c18620e2881a9443fc681c21035b03a7d755267f..d79e9be3cc182c9a24baf57b5692c366940bf653 100644 (file)
@@ -10,4 +10,6 @@ export * from './types/ConfigurationType.js'
 export * from './types/JsonType.js'
 export * from './types/UIProtocol.js'
 export * from './types/UUID.js'
+export * from './utils/converters.js'
 export * from './utils/UUID.js'
+export * from './utils/websocket.js'
diff --git a/ui/common/src/utils/converters.ts b/ui/common/src/utils/converters.ts
new file mode 100644 (file)
index 0000000..f3c4e0a
--- /dev/null
@@ -0,0 +1,35 @@
+export const convertToBoolean = (value: unknown): boolean => {
+  let result = false
+  if (value != null) {
+    if (typeof value === 'boolean') {
+      return value
+    } else if (typeof value === 'string') {
+      const normalized = value.trim().toLowerCase()
+      result = normalized === 'true' || normalized === '1'
+    } else if (typeof value === 'number' && value === 1) {
+      result = true
+    }
+  }
+  return result
+}
+
+export const convertToInt = (value: unknown): number => {
+  if (value == null) {
+    return 0
+  }
+  if (Number.isSafeInteger(value)) {
+    return value as number
+  }
+  if (typeof value === 'number') {
+    return Math.trunc(value)
+  }
+  let changedValue: number = value as number
+  if (typeof value === 'string') {
+    changedValue = Number.parseInt(value)
+  }
+  if (Number.isNaN(changedValue)) {
+    // eslint-disable-next-line @typescript-eslint/no-base-to-string
+    throw new Error(`Cannot convert to integer: '${value.toString()}'`)
+  }
+  return changedValue
+}
diff --git a/ui/common/src/utils/websocket.ts b/ui/common/src/utils/websocket.ts
new file mode 100644 (file)
index 0000000..302f28e
--- /dev/null
@@ -0,0 +1,16 @@
+import { WebSocketReadyState } from '../client/types.js'
+
+export const getWebSocketStateName = (state: number | undefined): string | undefined => {
+  switch (state) {
+    case WebSocketReadyState.CLOSED:
+      return 'Closed'
+    case WebSocketReadyState.CLOSING:
+      return 'Closing'
+    case WebSocketReadyState.CONNECTING:
+      return 'Connecting'
+    case WebSocketReadyState.OPEN:
+      return 'Open'
+    default:
+      return undefined
+  }
+}
index 159a4006ba4874854a91e9b0d140a9ff33324d4a..935a02107fda162e8019495de0115ba08cd06f77 100644 (file)
 </template>
 
 <script setup lang="ts">
-import { randomUUID, type UUIDv4 } from 'ui-common'
+import { convertToBoolean, randomUUID, type UUIDv4 } from 'ui-common'
 import { ref, watch } from 'vue'
 import { useRouter } from 'vue-router'
 
 import Button from '@/components/buttons/Button.vue'
 import {
-  convertToBoolean,
   resetToggleButtonState,
   ROUTE_NAMES,
   useExecuteAction,
index 275408020887cb0c3c4c7615a1169d48d8a285b3..f115303b167e12c51abedd58361c1c61519e9dd1 100644 (file)
 </template>
 
 <script setup lang="ts">
-import { type OCPPVersion } from 'ui-common'
+import { convertToInt, type OCPPVersion } from 'ui-common'
 import { computed, ref } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import { useToast } from 'vue-toast-notification'
 
 import Button from '@/components/buttons/Button.vue'
-import { convertToInt, resetToggleButtonState, ROUTE_NAMES, useUIClient } from '@/composables'
+import { resetToggleButtonState, ROUTE_NAMES, useUIClient } from '@/composables'
 
 const props = defineProps<{
   chargingStationId: string
index 72f62b0a3a53040aa4f19982b4a065096b4f3eb7..ef9f30ef9c904314602aa944b023f0810d25b83d 100644 (file)
@@ -10,7 +10,7 @@
       {{ getSupervisionUrl() }}
     </td>
     <td>
-      {{ getWebSocketStateName(chargingStation.wsState) }}
+      {{ getWebSocketStateName(chargingStation.wsState) ?? EMPTY_VALUE_PLACEHOLDER }}
     </td>
     <td>
       {{ chargingStation.bootNotificationResponse?.status ?? EMPTY_VALUE_PLACEHOLDER }}
 </template>
 
 <script setup lang="ts">
-import type { ChargingStationData, ConnectorEntry, Status } from 'ui-common'
-
+import { type ChargingStationData, type ConnectorEntry, getWebSocketStateName, type Status } from 'ui-common'
 import { computed } from 'vue'
 import { useToast } from 'vue-toast-notification'
 
@@ -128,7 +127,6 @@ import CSConnector from '@/components/charging-stations/CSConnector.vue'
 import {
   deleteLocalStorageByKeyPattern,
   EMPTY_VALUE_PLACEHOLDER,
-  getWebSocketStateName,
   ROUTE_NAMES,
   useExecuteAction,
   useUIClient,
index 727a86738dc2310900261f84ae71c2c213c8bde8..405d517fa48e695ad2f6fbaf677ce24060dbcb1a 100644 (file)
@@ -1,14 +1,10 @@
-import type { ChargingStationData, ConfigurationData } from 'ui-common'
+import type { ChargingStationData, ConfigurationData, ResponsePayload } from 'ui-common'
 import type { InjectionKey, Ref } from 'vue'
 
-import { inject } from 'vue'
+import { inject, ref as vueRef } from 'vue'
 import { useToast } from 'vue-toast-notification'
 
-import {
-  EMPTY_VALUE_PLACEHOLDER,
-  SHARED_TOGGLE_BUTTON_KEY_PREFIX,
-  TOGGLE_BUTTON_KEY_PREFIX,
-} from './Constants'
+import { SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX } from './Constants'
 import { UIClient } from './UIClient'
 
 export const configurationKey: InjectionKey<Ref<ConfigurationData>> = Symbol('configuration')
@@ -17,43 +13,6 @@ export const chargingStationsKey: InjectionKey<Ref<ChargingStationData[]>> =
 export const templatesKey: InjectionKey<Ref<string[]>> = Symbol('templates')
 export const uiClientKey: InjectionKey<UIClient> = Symbol('uiClient')
 
-export const convertToBoolean = (value: unknown): boolean => {
-  let result = false
-  if (value != null) {
-    // Check the type
-    if (typeof value === 'boolean') {
-      return value
-    } else if (typeof value === 'string') {
-      const normalized = value.trim().toLowerCase()
-      result = normalized === 'true' || normalized === '1'
-    } else if (typeof value === 'number' && value === 1) {
-      result = true
-    }
-  }
-  return result
-}
-
-export const convertToInt = (value: unknown): number => {
-  if (value == null) {
-    return 0
-  }
-  if (Number.isSafeInteger(value)) {
-    return value as number
-  }
-  if (typeof value === 'number') {
-    return Math.trunc(value)
-  }
-  let changedValue: number = value as number
-  if (typeof value === 'string') {
-    changedValue = Number.parseInt(value)
-  }
-  if (Number.isNaN(changedValue)) {
-    // eslint-disable-next-line @typescript-eslint/no-base-to-string
-    throw new Error(`Cannot convert to integer: '${value.toString()}'`)
-  }
-  return changedValue
-}
-
 export const getFromLocalStorage = <T>(key: string, defaultValue: T): T => {
   const item = localStorage.getItem(key)
   return item != null ? (JSON.parse(item) as T) : defaultValue
@@ -88,26 +47,6 @@ export const deleteLocalStorageByKeyPattern = (pattern: string): void => {
   }
 }
 
-/**
- * Returns a human-readable name for a WebSocket ready state.
- * @param state - The WebSocket readyState value
- * @returns The state name or EMPTY_VALUE_PLACEHOLDER for unknown/undefined states
- */
-export const getWebSocketStateName = (state: number | undefined): string => {
-  switch (state) {
-    case WebSocket.CLOSED:
-      return 'Closed'
-    case WebSocket.CLOSING:
-      return 'Closing'
-    case WebSocket.CONNECTING:
-      return 'Connecting'
-    case WebSocket.OPEN:
-      return 'Open'
-    default:
-      return EMPTY_VALUE_PLACEHOLDER
-  }
-}
-
 /**
  * Resets the state of a toggle button by removing its entry from localStorage.
  * @param id - The identifier of the toggle button
@@ -170,3 +109,32 @@ export const useExecuteAction = (emit?: (event: 'need-refresh') => void) => {
       })
   }
 }
+
+export const useFetchData = (
+  clientFn: () => Promise<ResponsePayload>,
+  onSuccess: (response: ResponsePayload) => void,
+  errorMsg: string,
+  onError?: () => void
+): { fetch: () => void; fetching: Ref<boolean> } => {
+  const fetching = vueRef(false)
+  const $toast = useToast()
+  const fetch = (): void => {
+    if (!fetching.value) {
+      fetching.value = true
+      clientFn()
+        .then((response: ResponsePayload) => {
+          onSuccess(response)
+          return undefined
+        })
+        .finally(() => {
+          fetching.value = false
+        })
+        .catch((error: unknown) => {
+          onError?.()
+          $toast.error(errorMsg)
+          console.error(`${errorMsg}:`, error)
+        })
+    }
+  }
+  return { fetch, fetching }
+}
index c63fc2df667115b1892a3884a94cf2fdc7835fb6..3fb262deef3430506f60ee4af4d5b4dbbcf69601 100644 (file)
@@ -9,13 +9,10 @@ export { UIClient } from './UIClient'
 export {
   chargingStationsKey,
   configurationKey,
-  convertToBoolean,
-  convertToInt,
   deleteFromLocalStorage,
   deleteLocalStorageByKeyPattern,
   getFromLocalStorage,
   getLocalStorage,
-  getWebSocketStateName,
   resetToggleButtonState,
   setToLocalStorage,
   templatesKey,
@@ -23,6 +20,7 @@ export {
   useChargingStations,
   useConfiguration,
   useExecuteAction,
+  useFetchData,
   useTemplates,
   useUIClient,
 } from './Utils'
index 499abace879f60f9e17e567b506c9c2357e71560..c7c8dbc2067297e1278677f78d0ee9ec1e932cc4 100644 (file)
 import {
   type ChargingStationData,
   randomUUID,
-  type ResponsePayload,
   type SimulatorState,
   type UIServerConfigurationSection,
   type UUIDv4,
@@ -128,6 +127,7 @@ import {
   UI_SERVER_CONFIGURATION_INDEX_KEY,
   useChargingStations,
   useConfiguration,
+  useFetchData,
   useTemplates,
   useUIClient,
 } from '@/composables'
@@ -142,16 +142,10 @@ const simulatorLabel = (action: string): string =>
   }`
 
 const state = ref<{
-  gettingChargingStations: boolean
-  gettingSimulatorState: boolean
-  gettingTemplates: boolean
   renderAddChargingStations: UUIDv4
   renderChargingStations: UUIDv4
   uiServerIndex: number
 }>({
-  gettingChargingStations: false,
-  gettingSimulatorState: false,
-  gettingTemplates: false,
   renderAddChargingStations: randomUUID(),
   renderChargingStations: randomUUID(),
   uiServerIndex: getFromLocalStorage<number>(UI_SERVER_CONFIGURATION_INDEX_KEY, 0),
@@ -186,64 +180,31 @@ const $uiClient = useUIClient()
 
 const $toast = useToast()
 
-const getSimulatorState = (): void => {
-  if (state.value.gettingSimulatorState === false) {
-    state.value.gettingSimulatorState = true
-    $uiClient
-      .simulatorState()
-      .then((response: ResponsePayload) => {
-        simulatorState.value = response.state as unknown as SimulatorState
-        return undefined
-      })
-      .finally(() => {
-        state.value.gettingSimulatorState = false
-      })
-      .catch((error: Error) => {
-        $toast.error('Error at fetching simulator state')
-        console.error('Error at fetching simulator state:', error)
-      })
-  }
-}
+const { fetch: getSimulatorState } = useFetchData(
+  () => $uiClient.simulatorState(),
+  (response) => {
+    simulatorState.value = response.state as unknown as SimulatorState
+  },
+  'Error at fetching simulator state'
+)
 
-const getTemplates = (): void => {
-  if (state.value.gettingTemplates === false) {
-    state.value.gettingTemplates = true
-    $uiClient
-      .listTemplates()
-      .then((response: ResponsePayload) => {
-        $templates.value = response.templates as string[]
-        return undefined
-      })
-      .finally(() => {
-        state.value.gettingTemplates = false
-      })
-      .catch((error: Error) => {
-        clearTemplates()
-        $toast.error('Error at fetching charging station templates')
-        console.error('Error at fetching charging station templates:', error)
-      })
-  }
-}
+const { fetch: getTemplates } = useFetchData(
+  () => $uiClient.listTemplates(),
+  (response) => {
+    $templates.value = response.templates as string[]
+  },
+  'Error at fetching charging station templates',
+  clearTemplates
+)
 
-const getChargingStations = (): void => {
-  if (state.value.gettingChargingStations === false) {
-    state.value.gettingChargingStations = true
-    $uiClient
-      .listChargingStations()
-      .then((response: ResponsePayload) => {
-        $chargingStations.value = response.chargingStations as ChargingStationData[]
-        return undefined
-      })
-      .finally(() => {
-        state.value.gettingChargingStations = false
-      })
-      .catch((error: Error) => {
-        clearChargingStations()
-        $toast.error('Error at fetching charging stations')
-        console.error('Error at fetching charging stations:', error)
-      })
-  }
-}
+const { fetch: getChargingStations } = useFetchData(
+  () => $uiClient.listChargingStations(),
+  (response) => {
+    $chargingStations.value = response.chargingStations as ChargingStationData[]
+  },
+  'Error at fetching charging stations',
+  clearChargingStations
+)
 
 const getData = (): void => {
   getSimulatorState()
index ab8cd6886b798aa196b305b35d00ff7336fd0356..743fbb884c91651327b4915f9eff532da723b25a 100644 (file)
@@ -3,12 +3,10 @@
  * @description Unit tests for type conversion, localStorage, UUID, and toggle state utilities.
  */
 import { flushPromises } from '@vue/test-utils'
-import { randomUUID, validateUUID } from 'ui-common'
+import { convertToBoolean, convertToInt, randomUUID, validateUUID } from 'ui-common'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 
 import {
-  convertToBoolean,
-  convertToInt,
   deleteFromLocalStorage,
   getFromLocalStorage,
   getLocalStorage,