From: Jérôme Benoit Date: Fri, 17 Apr 2026 01:33:27 +0000 (+0200) Subject: refactor(ui): second-pass factorization audit implementation X-Git-Tag: cli@v4.5.0~41 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=5f58e5604c968bad180ba01dd110521cdb9c6c86;p=e-mobility-charging-stations-simulator.git refactor(ui): second-pass factorization audit implementation - 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 --- diff --git a/ui/cli/src/commands/atg.ts b/ui/cli/src/commands/atg.ts index 0bac889f..8afabef7 100644 --- a/ui/cli/src/commands/atg.ts +++ b/ui/cli/src/commands/atg.ts @@ -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 ', '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, ['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 ', '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, ['connectorIds']) as RequestPayload), ...buildHashIdsPayload(hashIds), } await runAction(program, ProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR, payload) diff --git a/ui/cli/src/commands/payload.ts b/ui/cli/src/commands/payload.ts index da28585d..d99b46e3 100644 --- a/ui/cli/src/commands/payload.ts +++ b/ui/cli/src/commands/payload.ts @@ -15,3 +15,9 @@ export const pickDefined = ( } return result } + +export const pickPresent = ( + source: Record, + keys: string[] +): Record => + Object.fromEntries(keys.filter(k => source[k] != null).map(k => [k, source[k]])) diff --git a/ui/cli/src/commands/station.ts b/ui/cli/src/commands/station.ts index 8e082b8a..933290f9 100644 --- a/ui/cli/src/commands/station.ts +++ b/ui/cli/src/commands/station.ts @@ -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, { + 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 index 00000000..e4d407fe --- /dev/null +++ b/ui/cli/tests/payload.test.ts @@ -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 }) + }) + }) +}) diff --git a/ui/web/src/assets/shared.css b/ui/web/src/assets/shared.css index a6bfdb8d..320c8c30 100644 --- a/ui/web/src/assets/shared.css +++ b/ui/web/src/assets/shared.css @@ -51,3 +51,10 @@ background-color: var(--color-bg-caption); padding: var(--spacing-lg); } + +/* ── Form inputs ───────────────────────────────────────────────────── */ +.input-url { + width: 100%; + max-width: 40rem; + text-align: left; +} diff --git a/ui/web/src/components/actions/AddChargingStations.vue b/ui/web/src/components/actions/AddChargingStations.vue index 935a0210..b133ac98 100644 --- a/ui/web/src/components/actions/AddChargingStations.vue +++ b/ui/web/src/components/actions/AddChargingStations.vue @@ -38,7 +38,7 @@ { text-align: center; } -.supervision-url { - width: 100%; - max-width: 40rem; - text-align: left; -} - .template-options { list-style: circle inside; text-align: left; diff --git a/ui/web/src/components/actions/SetSupervisionUrl.vue b/ui/web/src/components/actions/SetSupervisionUrl.vue index e6434657..bcde5ff1 100644 --- a/ui/web/src/components/actions/SetSupervisionUrl.vue +++ b/ui/web/src/components/actions/SetSupervisionUrl.vue @@ -7,7 +7,7 @@ { ) } - - diff --git a/ui/web/src/components/charging-stations/CSData.vue b/ui/web/src/components/charging-stations/CSData.vue index 1aa169d8..f73f822c 100644 --- a/ui/web/src/components/charging-stations/CSData.vue +++ b/ui/web/src/components/charging-stations/CSData.vue @@ -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) + }, + } + ) } diff --git a/ui/web/src/composables/Utils.ts b/ui/web/src/composables/Utils.ts index 9e001dbc..5faae924 100644 --- a/ui/web/src/composables/Utils.ts +++ b/ui/web/src/composables/Utils.ts @@ -83,16 +83,30 @@ export const useTemplates = (): Ref => { 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, 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) }) diff --git a/ui/web/src/main.ts b/ui/web/src/main.ts index efa8f3eb..351a2778 100644 --- a/ui/web/src/main.ts +++ b/ui/web/src/main.ts @@ -70,28 +70,21 @@ const initializeApp = async (app: AppType, config: ConfigurationData): Promise { - 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) +} diff --git a/ui/web/src/views/ChargingStationsView.vue b/ui/web/src/views/ChargingStationsView.vue index 8048de46..50aa28e4 100644 --- a/ui/web/src/views/ChargingStationsView.vue +++ b/ui/web/src/views/ChargingStationsView.vue @@ -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 } + ) }