From 50c6a1e098df87bac70cb8c6f7262ca8e8a0ca3e Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 30 Apr 2026 09:35:59 +0200 Subject: [PATCH] refactor(ui-web): reorganize composables into core/ infrastructure and shared/composables/ --- ui/web/src/composables/Utils.ts | 148 ------------------ ui/web/src/{composables => core}/Constants.ts | 0 ui/web/src/{composables => core}/UIClient.ts | 0 ui/web/src/{composables => core}/index.ts | 19 +-- ui/web/src/core/providers.ts | 39 +++++ ui/web/src/core/storage.ts | 75 +++++++++ ui/web/src/main.ts | 2 +- ui/web/src/router/index.ts | 2 +- .../src/shared/components/SkinLoadError.vue | 2 +- ui/web/src/shared/composables/index.ts | 1 + .../shared/composables/useAddStationsForm.ts | 2 +- .../shared/composables/useConnectorActions.ts | 2 +- ui/web/src/shared/composables/useFetchData.ts | 38 +++++ .../src/shared/composables/useLayoutData.ts | 10 +- .../src/shared/composables/useSetUrlForm.ts | 2 +- .../shared/composables/useSimulatorControl.ts | 2 +- ui/web/src/shared/composables/useSkin.ts | 2 +- .../src/shared/composables/useStartTxForm.ts | 2 +- .../shared/composables/useStationActions.ts | 2 +- ui/web/src/shared/composables/useTheme.ts | 2 +- .../src/shared/utils/formatSupervisionUrl.ts | 2 +- ui/web/src/skins/classic/ClassicLayout.vue | 2 +- .../actions/AddChargingStations.vue | 2 +- .../components/actions/SetSupervisionUrl.vue | 2 +- .../components/actions/StartTransaction.vue | 2 +- .../components/buttons/ToggleButton.vue | 2 +- .../charging-stations/CSConnector.vue | 2 +- .../components/charging-stations/CSData.vue | 2 +- ui/web/src/skins/modern/ModernLayout.vue | 6 +- .../skins/modern/components/StationCard.vue | 2 +- .../components/dialogs/AuthorizeDialog.vue | 2 +- .../dialogs/SetSupervisionUrlDialog.vue | 2 +- ui/web/tests/unit/UIClient.test.ts | 2 +- ui/web/tests/unit/Utils.test.ts | 4 +- ui/web/tests/unit/router.test.ts | 2 +- .../composables/useAddStationsForm.test.ts | 2 +- .../composables/useConnectorActions.test.ts | 2 +- .../shared/composables/useLayoutData.test.ts | 4 +- .../shared/composables/useSetUrlForm.test.ts | 2 +- .../composables/useSimulatorControl.test.ts | 4 +- .../shared/composables/useStartTxForm.test.ts | 2 +- .../composables/useStationActions.test.ts | 2 +- .../tests/unit/skins/classic/Actions.test.ts | 2 +- .../unit/skins/classic/CSConnector.test.ts | 2 +- .../tests/unit/skins/classic/CSData.test.ts | 2 +- .../tests/unit/skins/classic/CSTable.test.ts | 2 +- .../skins/classic/ClassicComponents.test.ts | 2 +- .../unit/skins/classic/ClassicLayout.test.ts | 2 +- .../unit/skins/modern/ConnectorRow.test.ts | 2 +- .../tests/unit/skins/modern/Dialogs.test.ts | 2 +- .../unit/skins/modern/ModernLayout.test.ts | 4 +- .../unit/skins/modern/StationCard.test.ts | 2 +- 52 files changed, 213 insertions(+), 215 deletions(-) delete mode 100644 ui/web/src/composables/Utils.ts rename ui/web/src/{composables => core}/Constants.ts (100%) rename ui/web/src/{composables => core}/UIClient.ts (100%) rename ui/web/src/{composables => core}/index.ts (90%) create mode 100644 ui/web/src/core/providers.ts create mode 100644 ui/web/src/core/storage.ts create mode 100644 ui/web/src/shared/composables/useFetchData.ts diff --git a/ui/web/src/composables/Utils.ts b/ui/web/src/composables/Utils.ts deleted file mode 100644 index 1917a855..00000000 --- a/ui/web/src/composables/Utils.ts +++ /dev/null @@ -1,148 +0,0 @@ -import type { ChargingStationData, ConfigurationData, ResponsePayload } from 'ui-common' -import type { InjectionKey, Ref } from 'vue' - -import { inject, ref as vueRef } from 'vue' -import { useToast } from 'vue-toast-notification' - -import { SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX } from './Constants.js' -import { UIClient } from './UIClient.js' - -export const configurationKey: InjectionKey> = Symbol('configuration') -export const chargingStationsKey: InjectionKey> = - Symbol('chargingStations') -export const templatesKey: InjectionKey> = Symbol('templates') -export const uiClientKey: InjectionKey = Symbol('uiClient') - -export const getFromLocalStorage = (key: string, defaultValue: T): T => { - try { - const item = localStorage.getItem(key) - return item != null ? (JSON.parse(item) as T) : defaultValue - } catch { - if (import.meta.env.DEV) { - console.debug(`[localStorage] Failed to read key '${key}', using default`) - } - return defaultValue - } -} - -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -export const setToLocalStorage = (key: string, value: T): void => { - try { - localStorage.setItem(key, JSON.stringify(value)) - } catch { - // localStorage.setItem() can throw: - // - QuotaExceededError when the origin's storage quota is genuinely exceeded - // - SecurityError when storage is blocked by user settings or browser policies - // (e.g., "Block All Cookies" in Safari, third-party iframe in Chrome, file: URLs) - if (import.meta.env.DEV) { - console.debug(`[localStorage] Failed to write key '${key}'`) - } - } -} - -export const deleteFromLocalStorage = (key: string): void => { - try { - localStorage.removeItem(key) - } catch { - if (import.meta.env.DEV) { - console.debug(`[localStorage] Failed to delete key '${key}'`) - } - } -} - -export const getLocalStorage = (): Storage => { - try { - return localStorage - } catch { - throw new Error('localStorage is not available') - } -} - -/** - * Deletes all localStorage entries whose key includes the given pattern. - * @param pattern - Substring to match against localStorage keys - */ -export const deleteLocalStorageByKeyPattern = (pattern: string): void => { - try { - const keysToDelete = Object.keys(localStorage).filter(key => key.includes(pattern)) - for (const key of keysToDelete) { - deleteFromLocalStorage(key) - } - } catch { - if (import.meta.env.DEV) { - console.debug(`[localStorage] Failed to delete keys matching '${pattern}'`) - } - } -} - -/** - * Resets the state of a toggle button by removing its entry from localStorage. - * @param id - The identifier of the toggle button - * @param shared - Whether the toggle button is shared - */ -export const resetToggleButtonState = (id: string, shared = false): void => { - const key = shared - ? `${SHARED_TOGGLE_BUTTON_KEY_PREFIX}${id}` - : `${TOGGLE_BUTTON_KEY_PREFIX}${id}` - deleteFromLocalStorage(key) -} - -export const useUIClient = (): UIClient => { - const injected = inject(uiClientKey, undefined) - if (injected != null) return injected - if (import.meta.env.DEV) { - console.debug('[useUIClient] Accessed outside provide scope — using singleton fallback') - } - return UIClient.getInstance() -} - -export const useConfiguration = (): Ref => { - const injected = inject(configurationKey, undefined) - if (injected != null) return injected - throw new Error('configuration not provided') -} - -export const useChargingStations = (): Ref => { - const injected = inject(chargingStationsKey, undefined) - if (injected != null) return injected - throw new Error('chargingStations not provided') -} - -export const useTemplates = (): Ref => { - const injected = inject(templatesKey, undefined) - if (injected != null) return injected - throw new Error('templates not provided') -} - -export const useFetchData = ( - clientFn: () => Promise, - onSuccess: (response: ResponsePayload) => void, - errorMsg: string, - onError?: () => void -): { fetch: () => void; fetching: Ref } => { - 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) => { - try { - onError?.() - } catch (callbackError: unknown) { - console.error('Error in onError callback:', callbackError) - } - $toast.error(errorMsg) - console.error(`${errorMsg}:`, error) - }) - } - } - return { fetch, fetching } -} diff --git a/ui/web/src/composables/Constants.ts b/ui/web/src/core/Constants.ts similarity index 100% rename from ui/web/src/composables/Constants.ts rename to ui/web/src/core/Constants.ts diff --git a/ui/web/src/composables/UIClient.ts b/ui/web/src/core/UIClient.ts similarity index 100% rename from ui/web/src/composables/UIClient.ts rename to ui/web/src/core/UIClient.ts diff --git a/ui/web/src/composables/index.ts b/ui/web/src/core/index.ts similarity index 90% rename from ui/web/src/composables/index.ts rename to ui/web/src/core/index.ts index 7086a0a9..1b6e7b98 100644 --- a/ui/web/src/composables/index.ts +++ b/ui/web/src/core/index.ts @@ -6,21 +6,22 @@ export { TOGGLE_BUTTON_KEY_PREFIX, UI_SERVER_CONFIGURATION_INDEX_KEY, } from './Constants.js' -export { UIClient } from './UIClient.js' export { chargingStationsKey, configurationKey, - deleteFromLocalStorage, - deleteLocalStorageByKeyPattern, - getFromLocalStorage, - getLocalStorage, - resetToggleButtonState, - setToLocalStorage, templatesKey, uiClientKey, useChargingStations, useConfiguration, - useFetchData, useTemplates, useUIClient, -} from './Utils.js' +} from './providers.js' +export { + deleteFromLocalStorage, + deleteLocalStorageByKeyPattern, + getFromLocalStorage, + getLocalStorage, + resetToggleButtonState, + setToLocalStorage, +} from './storage.js' +export { UIClient } from './UIClient.js' diff --git a/ui/web/src/core/providers.ts b/ui/web/src/core/providers.ts new file mode 100644 index 00000000..b28c53ba --- /dev/null +++ b/ui/web/src/core/providers.ts @@ -0,0 +1,39 @@ +import type { ChargingStationData, ConfigurationData } from 'ui-common' +import type { InjectionKey, Ref } from 'vue' + +import { inject } from 'vue' + +import { UIClient } from './UIClient.js' + +export const configurationKey: InjectionKey> = Symbol('configuration') +export const chargingStationsKey: InjectionKey> = + Symbol('chargingStations') +export const templatesKey: InjectionKey> = Symbol('templates') +export const uiClientKey: InjectionKey = Symbol('uiClient') + +export const useUIClient = (): UIClient => { + const injected = inject(uiClientKey, undefined) + if (injected != null) return injected + if (import.meta.env.DEV) { + console.debug('[useUIClient] Accessed outside provide scope — using singleton fallback') + } + return UIClient.getInstance() +} + +export const useConfiguration = (): Ref => { + const injected = inject(configurationKey, undefined) + if (injected != null) return injected + throw new Error('configuration not provided') +} + +export const useChargingStations = (): Ref => { + const injected = inject(chargingStationsKey, undefined) + if (injected != null) return injected + throw new Error('chargingStations not provided') +} + +export const useTemplates = (): Ref => { + const injected = inject(templatesKey, undefined) + if (injected != null) return injected + throw new Error('templates not provided') +} diff --git a/ui/web/src/core/storage.ts b/ui/web/src/core/storage.ts new file mode 100644 index 00000000..bd4893a0 --- /dev/null +++ b/ui/web/src/core/storage.ts @@ -0,0 +1,75 @@ +import { SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX } from './Constants.js' + +export const getFromLocalStorage = (key: string, defaultValue: T): T => { + try { + const item = localStorage.getItem(key) + return item != null ? (JSON.parse(item) as T) : defaultValue + } catch { + if (import.meta.env.DEV) { + console.debug(`[localStorage] Failed to read key '${key}', using default`) + } + return defaultValue + } +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export const setToLocalStorage = (key: string, value: T): void => { + try { + localStorage.setItem(key, JSON.stringify(value)) + } catch { + // localStorage.setItem() can throw: + // - QuotaExceededError when the origin's storage quota is genuinely exceeded + // - SecurityError when storage is blocked by user settings or browser policies + // (e.g., "Block All Cookies" in Safari, third-party iframe in Chrome, file: URLs) + if (import.meta.env.DEV) { + console.debug(`[localStorage] Failed to write key '${key}'`) + } + } +} + +export const deleteFromLocalStorage = (key: string): void => { + try { + localStorage.removeItem(key) + } catch { + if (import.meta.env.DEV) { + console.debug(`[localStorage] Failed to delete key '${key}'`) + } + } +} + +export const getLocalStorage = (): Storage => { + try { + return localStorage + } catch { + throw new Error('localStorage is not available') + } +} + +/** + * Deletes all localStorage entries whose key includes the given pattern. + * @param pattern - Substring to match against localStorage keys + */ +export const deleteLocalStorageByKeyPattern = (pattern: string): void => { + try { + const keysToDelete = Object.keys(localStorage).filter(key => key.includes(pattern)) + for (const key of keysToDelete) { + deleteFromLocalStorage(key) + } + } catch { + if (import.meta.env.DEV) { + console.debug(`[localStorage] Failed to delete keys matching '${pattern}'`) + } + } +} + +/** + * Resets the state of a toggle button by removing its entry from localStorage. + * @param id - The identifier of the toggle button + * @param shared - Whether the toggle button is shared + */ +export const resetToggleButtonState = (id: string, shared = false): void => { + const key = shared + ? `${SHARED_TOGGLE_BUTTON_KEY_PREFIX}${id}` + : `${TOGGLE_BUTTON_KEY_PREFIX}${id}` + deleteFromLocalStorage(key) +} diff --git a/ui/web/src/main.ts b/ui/web/src/main.ts index a574a776..48c8db14 100644 --- a/ui/web/src/main.ts +++ b/ui/web/src/main.ts @@ -18,7 +18,7 @@ import { UI_SERVER_CONFIGURATION_INDEX_KEY, UIClient, uiClientKey, -} from '@/composables/index.js' +} from '@/core/index.js' import { router } from '@/router' import { SKIN_STORAGE_KEY, useSkin } from '@/shared/composables/useSkin.js' import { DEFAULT_THEME, THEME_STORAGE_KEY, useTheme } from '@/shared/composables/useTheme.js' diff --git a/ui/web/src/router/index.ts b/ui/web/src/router/index.ts index 13b35cc6..b1685a20 100644 --- a/ui/web/src/router/index.ts +++ b/ui/web/src/router/index.ts @@ -3,7 +3,7 @@ import { h } from 'vue' import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' import { useToast } from 'vue-toast-notification' -import { ROUTE_NAMES } from '@/composables/index.js' +import { ROUTE_NAMES } from '@/core/index.js' import { useSkin } from '@/shared/composables/useSkin.js' import { DEFAULT_SKIN } from '@/skins/registry.js' diff --git a/ui/web/src/shared/components/SkinLoadError.vue b/ui/web/src/shared/components/SkinLoadError.vue index 0c995522..60dfeea5 100644 --- a/ui/web/src/shared/components/SkinLoadError.vue +++ b/ui/web/src/shared/components/SkinLoadError.vue @@ -11,7 +11,7 @@