]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(ui): second-pass factorization audit implementation
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 01:33:27 +0000 (03:33 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 01:33:27 +0000 (03:33 +0200)
- Extend useExecuteAction with onSuccess callback support (backward-compatible)
- Migrate startSimulator/stopSimulator/deleteChargingStation to useExecuteAction
- Remove unused useToast imports from ChargingStationsView and CSData
- Extract .supervision-url CSS to shared.css as .input-url
- Add pickPresent helper for identity-key optional spreading in CLI
- Migrate atg.ts optional connectorIds and station.ts delete to helpers
- Add 12 payload helper tests (buildHashIdsPayload/pickDefined/pickPresent)
- Refactor main.ts config fetch from nested promises to async/await
- Remove 2 eslint-disable promise/no-nesting comments

ui/cli/src/commands/atg.ts
ui/cli/src/commands/payload.ts
ui/cli/src/commands/station.ts
ui/cli/tests/payload.test.ts [new file with mode: 0644]
ui/web/src/assets/shared.css
ui/web/src/components/actions/AddChargingStations.vue
ui/web/src/components/actions/SetSupervisionUrl.vue
ui/web/src/components/charging-stations/CSData.vue
ui/web/src/composables/Utils.ts
ui/web/src/main.ts
ui/web/src/views/ChargingStationsView.vue

index 0bac889f8251e20df6c0cc393974a11fd9b55aec..8afabef7ee24fdc0937235d7d7904105946419f3 100644 (file)
@@ -2,7 +2,7 @@ import { Command } from 'commander'
 import { ProcedureName, type RequestPayload } from 'ui-common'
 
 import { runAction } from './action.js'
-import { buildHashIdsPayload } from './payload.js'
+import { buildHashIdsPayload, pickPresent } from './payload.js'
 
 const parseCommaSeparatedInts = (value: string): number[] => {
   const parsed = value.split(',').map(s => Number.parseInt(s.trim(), 10))
@@ -21,7 +21,7 @@ export const createAtgCommands = (program: Command): Command => {
     .option('--connector-ids <ids>', 'comma-separated connector IDs', parseCommaSeparatedInts)
     .action(async (hashIds: string[], options: { connectorIds?: number[] }) => {
       const payload: RequestPayload = {
-        ...(options.connectorIds != null && { connectorIds: options.connectorIds }),
+        ...(pickPresent(options as Record<string, unknown>, ['connectorIds']) as RequestPayload),
         ...buildHashIdsPayload(hashIds),
       }
       await runAction(program, ProcedureName.START_AUTOMATIC_TRANSACTION_GENERATOR, payload)
@@ -33,7 +33,7 @@ export const createAtgCommands = (program: Command): Command => {
     .option('--connector-ids <ids>', 'comma-separated connector IDs', parseCommaSeparatedInts)
     .action(async (hashIds: string[], options: { connectorIds?: number[] }) => {
       const payload: RequestPayload = {
-        ...(options.connectorIds != null && { connectorIds: options.connectorIds }),
+        ...(pickPresent(options as Record<string, unknown>, ['connectorIds']) as RequestPayload),
         ...buildHashIdsPayload(hashIds),
       }
       await runAction(program, ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR, payload)
index da28585d2da91b99a866966540a07a1c705e7264..d99b46e391944774b9116edbfe04a6b1cd6a2751 100644 (file)
@@ -15,3 +15,9 @@ export const pickDefined = (
   }
   return result
 }
+
+export const pickPresent = (
+  source: Record<string, unknown>,
+  keys: string[]
+): Record<string, unknown> =>
+  Object.fromEntries(keys.filter(k => source[k] != null).map(k => [k, source[k]]))
index 8e082b8a1582b0c5f26d8ab24deae18b4fe41e78..933290f9ed9841e5881d7646d0f4ecb17fe6ad6a 100644 (file)
@@ -68,7 +68,9 @@ export const createStationCommands = (program: Command): Command => {
     .option('--delete-config', 'delete station configuration files')
     .action(async (hashIds: string[], options: { deleteConfig?: true }) => {
       const payload: RequestPayload = {
-        ...(options.deleteConfig != null && { deleteConfiguration: options.deleteConfig }),
+        ...(pickDefined(options as Record<string, unknown>, {
+          deleteConfig: 'deleteConfiguration',
+        }) as RequestPayload),
         ...buildHashIdsPayload(hashIds),
       }
       await runAction(program, ProcedureName.DELETE_CHARGING_STATIONS, payload)
diff --git a/ui/cli/tests/payload.test.ts b/ui/cli/tests/payload.test.ts
new file mode 100644 (file)
index 0000000..e4d407f
--- /dev/null
@@ -0,0 +1,74 @@
+/** @file Unit tests for payload helper functions */
+
+import assert from 'node:assert'
+import { describe, it } from 'node:test'
+
+import { buildHashIdsPayload, pickDefined, pickPresent } from '../src/commands/payload.js'
+
+await describe('payload helpers', async () => {
+  await describe('buildHashIdsPayload', async () => {
+    await it('should return object with hashIds when array is non-empty', () => {
+      assert.deepStrictEqual(buildHashIdsPayload(['a', 'b']), { hashIds: ['a', 'b'] })
+    })
+
+    await it('should return empty object when array is empty', () => {
+      assert.deepStrictEqual(buildHashIdsPayload([]), {})
+    })
+
+    await it('should return object with single hashId', () => {
+      assert.deepStrictEqual(buildHashIdsPayload(['abc']), { hashIds: ['abc'] })
+    })
+  })
+
+  await describe('pickDefined', async () => {
+    await it('should pick and rename defined keys', () => {
+      const result = pickDefined(
+        { a: 1, b: 'hello', c: undefined },
+        { a: 'alpha', b: 'beta', c: 'gamma' }
+      )
+      assert.deepStrictEqual(result, { alpha: 1, beta: 'hello' })
+    })
+
+    await it('should skip null values', () => {
+      const result = pickDefined({ a: null, b: 2 }, { a: 'x', b: 'y' })
+      assert.deepStrictEqual(result, { y: 2 })
+    })
+
+    await it('should return empty object when no keys match', () => {
+      const result = pickDefined({ a: undefined }, { a: 'x' })
+      assert.deepStrictEqual(result, {})
+    })
+
+    await it('should return empty object for empty keyMap', () => {
+      const result = pickDefined({ a: 1 }, {})
+      assert.deepStrictEqual(result, {})
+    })
+  })
+
+  await describe('pickPresent', async () => {
+    await it('should pick keys that are present', () => {
+      const result = pickPresent({ a: 1, b: 'hello', c: undefined }, ['a', 'b', 'c'])
+      assert.deepStrictEqual(result, { a: 1, b: 'hello' })
+    })
+
+    await it('should skip null values', () => {
+      const result = pickPresent({ a: null, b: 2 }, ['a', 'b'])
+      assert.deepStrictEqual(result, { b: 2 })
+    })
+
+    await it('should return empty object when no keys match', () => {
+      const result = pickPresent({ a: undefined }, ['a'])
+      assert.deepStrictEqual(result, {})
+    })
+
+    await it('should return empty object for empty keys array', () => {
+      const result = pickPresent({ a: 1 }, [])
+      assert.deepStrictEqual(result, {})
+    })
+
+    await it('should handle keys not present in source', () => {
+      const result = pickPresent({ a: 1 }, ['a', 'b'])
+      assert.deepStrictEqual(result, { a: 1 })
+    })
+  })
+})
index a6bfdb8d883c56d07bc1e7574a0c1f4de3dd3852..320c8c300154013f29b580dd3f60a4e829bba35b 100644 (file)
   background-color: var(--color-bg-caption);
   padding: var(--spacing-lg);
 }
+
+/* ── Form inputs ───────────────────────────────────────────────────── */
+.input-url {
+  width: 100%;
+  max-width: 40rem;
+  text-align: left;
+}
index 935a02107fda162e8019495de0115ba08cd06f77..b133ac98c7fc780d97c844172dde3a62dac012cb 100644 (file)
@@ -38,7 +38,7 @@
       <input
         id="supervision-url"
         v-model.trim="state.supervisionUrl"
-        class="supervision-url"
+        class="input-url"
         name="supervision-url"
         placeholder="wss://"
         type="url"
@@ -160,12 +160,6 @@ const addChargingStations = (): void => {
   text-align: center;
 }
 
-.supervision-url {
-  width: 100%;
-  max-width: 40rem;
-  text-align: left;
-}
-
 .template-options {
   list-style: circle inside;
   text-align: left;
index e64346574a3d7d56ac122f9273279272be9c72a9..bcde5ff1ed5d171fcbb181df633db8347d187e35 100644 (file)
@@ -7,7 +7,7 @@
   <input
     id="supervision-url"
     v-model.trim="state.supervisionUrl"
-    class="supervision-url"
+    class="input-url"
     name="supervision-url"
     placeholder="wss://"
     type="url"
@@ -53,11 +53,3 @@ const setSupervisionUrl = (): void => {
   )
 }
 </script>
-
-<style scoped>
-.supervision-url {
-  width: 100%;
-  max-width: 40rem;
-  text-align: left;
-}
-</style>
index 1aa169d8eeba0e398273b1af4f323817488e1cfc..f73f822caad73f85d278bbe24d6a65c06f7a08c2 100644 (file)
@@ -124,7 +124,6 @@ import {
   WebSocketReadyState,
 } from 'ui-common'
 import { computed } from 'vue'
-import { useToast } from 'vue-toast-notification'
 
 import Button from '@/components/buttons/Button.vue'
 import StateButton from '@/components/buttons/StateButton.vue'
@@ -183,8 +182,6 @@ const getSupervisionUrl = (): string => {
 
 const $uiClient = useUIClient()
 
-const $toast = useToast()
-
 const executeAction = useExecuteAction($emit)
 
 const startChargingStation = (): void => {
@@ -216,17 +213,16 @@ const closeConnection = (): void => {
   )
 }
 const deleteChargingStation = (): void => {
-  $uiClient
-    .deleteChargingStation(props.chargingStation.stationInfo.hashId)
-    .then(() => {
-      deleteLocalStorageByKeyPattern(props.chargingStation.stationInfo.hashId)
-      $emit('need-refresh')
-      return $toast.success('Charging station successfully deleted')
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at deleting charging station')
-      console.error('Error at deleting charging station:', error)
-    })
+  executeAction(
+    $uiClient.deleteChargingStation(props.chargingStation.stationInfo.hashId),
+    'Charging station successfully deleted',
+    'Error at deleting charging station',
+    {
+      onSuccess: () => {
+        deleteLocalStorageByKeyPattern(props.chargingStation.stationInfo.hashId)
+      },
+    }
+  )
 }
 </script>
 
index 9e001dbc69395786f704fa3fa62e6d748fc75fc2..5faae924fa880b6d7fba0db1d494295fc29faada 100644 (file)
@@ -83,16 +83,30 @@ export const useTemplates = (): Ref<string[]> => {
   throw new Error('templates not provided')
 }
 
+export interface ExecuteActionCallbacks {
+  onFinally?: () => void
+  onSuccess?: () => void
+}
+
 export const useExecuteAction = (emit?: (event: 'need-refresh') => void) => {
   const $toast = useToast()
   return (
     action: Promise<unknown>,
     successMsg: string,
     errorMsg: string,
-    onFinally?: () => void
+    callbacks?: (() => void) | ExecuteActionCallbacks
   ): void => {
+    const { onFinally, onSuccess } =
+      typeof callbacks === 'function'
+        ? { onFinally: callbacks, onSuccess: undefined }
+        : (callbacks ?? {})
     action
       .then(() => {
+        try {
+          onSuccess?.()
+        } catch (error: unknown) {
+          console.error('Error in onSuccess callback:', error)
+        }
         emit?.('need-refresh')
         return $toast.success(successMsg)
       })
index efa8f3eb6fd9e105cd29291eb0e954eedb9a71d6..351a2778333fde08994215e767065393003fbee9 100644 (file)
@@ -70,28 +70,21 @@ const initializeApp = async (app: AppType, config: ConfigurationData): Promise<v
   app.use(router).use(ToastPlugin).mount('#app')
 }
 
-fetch('/config.json')
-  .then(response => {
-    if (!response.ok) {
+try {
+  const response = await fetch('/config.json')
+  if (!response.ok) {
+    // TODO: add code for UI notifications or other error handling logic
+    console.error('Failed to fetch app configuration')
+  } else {
+    try {
+      const config = (await response.json()) as ConfigurationData
+      await initializeApp(app, config)
+    } catch (error: unknown) {
       // TODO: add code for UI notifications or other error handling logic
-      console.error('Failed to fetch app configuration')
-      return undefined
+      console.error('Error at deserializing JSON app configuration:', error)
     }
-    response
-      .json()
-      // eslint-disable-next-line promise/no-nesting
-      .then(async config => {
-        await initializeApp(app, config as ConfigurationData)
-        return undefined
-      })
-      // eslint-disable-next-line promise/no-nesting
-      .catch((error: unknown) => {
-        // TODO: add code for UI notifications or other error handling logic
-        console.error('Error at deserializing JSON app configuration:', error)
-      })
-    return undefined
-  })
-  .catch((error: unknown) => {
-    // TODO: add code for UI notifications or other error handling logic
-    console.error('Error at fetching app configuration:', error)
-  })
+  }
+} catch (error: unknown) {
+  // TODO: add code for UI notifications or other error handling logic
+  console.error('Error at fetching app configuration:', error)
+}
index 8048de4637b1e13d37ef0c202f65f61b9178bb88..50aa28e427b70e76e2e960b5b2ffc42585754bf9 100644 (file)
@@ -112,7 +112,6 @@ import {
   type UUIDv4,
 } from 'ui-common'
 import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
-import { useToast } from 'vue-toast-notification'
 
 import StateButton from '@/components/buttons/StateButton.vue'
 import ToggleButton from '@/components/buttons/ToggleButton.vue'
@@ -127,6 +126,7 @@ import {
   UI_SERVER_CONFIGURATION_INDEX_KEY,
   useChargingStations,
   useConfiguration,
+  useExecuteAction,
   useFetchData,
   useTemplates,
   useUIClient,
@@ -178,7 +178,7 @@ const clearChargingStations = (): void => {
 
 const $uiClient = useUIClient()
 
-const $toast = useToast()
+const executeAction = useExecuteAction()
 
 const { fetch: getSimulatorState } = useFetchData(
   () => $uiClient.simulatorState(),
@@ -249,33 +249,20 @@ const uiServerConfigurations: {
 )
 
 const startSimulator = (): void => {
-  $uiClient
-    .startSimulator()
-    .then(() => {
-      return $toast.success('Simulator successfully started')
-    })
-    .finally(() => {
-      getSimulatorState()
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at starting simulator')
-      console.error('Error at starting simulator:', error)
-    })
+  executeAction(
+    $uiClient.startSimulator(),
+    'Simulator successfully started',
+    'Error at starting simulator',
+    { onFinally: getSimulatorState }
+  )
 }
 const stopSimulator = (): void => {
-  $uiClient
-    .stopSimulator()
-    .then(() => {
-      clearChargingStations()
-      return $toast.success('Simulator successfully stopped')
-    })
-    .finally(() => {
-      getSimulatorState()
-    })
-    .catch((error: Error) => {
-      $toast.error('Error at stopping simulator')
-      console.error('Error at stopping simulator:', error)
-    })
+  executeAction(
+    $uiClient.stopSimulator(),
+    'Simulator successfully stopped',
+    'Error at stopping simulator',
+    { onFinally: getSimulatorState, onSuccess: clearChargingStations }
+  )
 }
 </script>