From 72aba1edf1957107024a043cbbd122fc0a4ee552 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Thu, 30 Apr 2026 00:25:44 +0200 Subject: [PATCH] feat(ui-web): implement runtime skin system with classic and modern skins (#1815) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(ui-web): add opt-in v2 UI with Material-inspired design Parallel Vue 3 UI at /v2 that mirrors the existing feature set with a flat Material-inspired palette, card-based station layout, modal dialogs (Teleport + focus trap), grouped action buttons with clear hierarchy, and a theme toggle (auto/light/dark, persisted per-user). The v2 tree is fully self-contained under src/v2/; only three files outside that folder change: - router/index.ts: five /v2/* routes mirroring the existing ones - App.vue: v1/v2 toggle link + top-level named for v2 dialogs - README.md: note on /v2 opt-in and merge path Co-Authored-By: Claude Opus 4.7 (1M context) * test(ui-web): add unit tests for the v2 UI subtree Covers every v2 file (ActionButton, StatePill, Modal, ConfirmDialog, SimulatorBar, StationCard, ConnectorRow, V2NotFoundView, the four dialogs, v2Errors, and V2ChargingStationsView) under a dedicated tests/unit/v2/ folder so it can be dropped as a unit when v1 is removed. Takes the global coverage back above the existing 91/89/83/91 thresholds (lines 93.2%, branches 92.2%, functions 86.1%, statements 92.3%) after the v2 subtree added 643 previously-untested lines. Mocks the Teleport-based Modal with an inline stub in dialog tests so wrapper.find() reaches the form inputs. * feat(ui-web): add skin+theme schema, token contract, and skin registry - feat(ui-common): add skin and theme fields to configuration schema - ConfigurationData: add skin?: string alongside existing theme?: string - configurationSchema: add skin and theme as optional string fields - Fixes pre-existing gap where theme was typed but not Zod-validated - feat(ui-web): extend theme CSS files with unified token contract - All 3 theme files: add [data-theme=] selector for runtime switching - Add 9 new semantic tokens: bg-raised, bg-sunken, state-ok/warn/err/idle, spacing-xl, font-size-base/xs; color-scheme: dark|light - All existing token names and values unchanged (backward compatible) - feat(ui-web): add TOKEN_CONTRACT TypeScript interface in shared/tokens - 33-entry as const map of semantic name to CSS custom property name - Exports TokenName and CssCustomProperty derived types - feat(ui-web): add skin registry with classic and modern skin definitions - SkinDefinition interface with lazy CSS loader for code splitting - DEFAULT_SKIN = 'classic' - CSS module shim added to shims-vue.d.ts for TypeScript resolution * feat(ui-web): add useSkin and useTheme composables - useSkin: module-level singleton for runtime skin switching - activeSkinId ref initialized from localStorage (fallback: 'classic') - switchSkin() validates, lazy-loads CSS, persists preference - Eager load of initial skin styles at module init - useTheme: module-level singleton for runtime theme switching - activeTheme ref initialized from localStorage (fallback: 'tokyo-night-storm') - setTheme() sets data-theme attribute + color-scheme on document root - ThemeName union type exported for component type safety - Themes: catppuccin-latte | sap-horizon | tokyo-night-storm (alphabetical) - eslint.config.js: add 'catppuccin' to cspell words list * feat(ui-web): add shared composables and classic skin structure - feat(ui-web): add shared headless composables - useStationStatus: pure OCPP status → 'ok'|'warn'|'err'|'idle' mapping - useAddStationsForm: all 12 form fields + submit/reset for adding stations - useSetUrlForm: supervision URL form state + validated submit - useStartTxForm: start transaction with optional authorize flow - feat(ui-web): create skins/classic/ directory structure - ClassicLayout.vue: table-based root layout (adapted from ChargingStationsView) - classic.css: structural tokens for classic skin - Full component tree: Container, Button, StateButton, ToggleButton, CSTable, CSData, CSConnector, and 3 action components - Internal imports use relative paths; @/composables remain shared - fix(eslint): add skins/classic/ paths to multi-word component name exceptions * feat(ui-web): create skins/modern/ structure with migrated CSS tokens - feat(ui-web): modern skin directory structure - ModernLayout.vue: card-based root layout (from V2ChargingStationsView.vue) - CSS import updated to './modern.css' - Component imports updated to './components/...' - Theme cycle logic removed (replaced by unified useTheme() in T18) - 7 base components + 4 dialog components copied from v2 - composables/constants.ts: skin-local route/key constants - composables/errors.ts: error handling utilities - feat(ui-web): migrate modern.css CSS tokens to unified contract - 0 --v2-primary refs remain (replaced by --color-primary) - 0 --v2-state-* refs remain (replaced by --color-state-*) - 0 [data-v2-theme] blocks remain (removed — theme via unified system) - 242 var(--v2-*) references migrated to --skin-* or --color-* tokens - Structural tokens renamed: --v2-space/radius/elev/font/* → --skin-space/radius/shadow/font/* - File reduced from 1311 to 1218 lines (93 lines of redundant light overrides removed) * feat(ui-web): integrate skin system into App.vue, main.ts, and router - feat(ui-web/App.vue): replace dual-view shell with skin switching shell - defineAsyncComponent layoutMap for ClassicLayout + ModernLayout - :key={activeSkinId} forces full remount on skin switch - Fade transition (opacity 0.2s) between skins - App.vue reduced to 46 lines (pure orchestration, no skin logic) - feat(ui-web/main.ts): replace dynamic theme loading with static imports - All 3 theme CSS files loaded eagerly at boot - Theme applied via useTheme().setTheme() composable - Skin preference initialized from config.skin → useSkin().switchSkin() - Config.skin and config.theme both respected with localStorage override - feat(ui-web/router): remove v2 routes, keep v1 skin-agnostic routes - Remove all /v2/* routes and V2 component imports - Keep named 'action' view routes for classic skin sidebar panels - Action panel components loaded lazily via async imports - No skin-specific paths in router (both skins share same URLs) - fix(ui-web/ClassicLayout.vue): add router-view name='action' for sidebar * refactor(ui-web): integrate shared composables into both skins and add skin/theme switchers - refactor(ui-web/classic): action components use shared headless composables - AddChargingStations: uses useAddStationsForm() for all form state + submit - SetSupervisionUrl: uses useSetUrlForm() for form state + validated submit - StartTransaction: uses useStartTxForm() for form state + async submit - feat(ui-web/classic): add skin/theme selectors to ClassicLayout top bar - useSkin() provides activeSkinId, skins, switchSkin - useTheme() provides activeTheme, availableThemes, setTheme - Two - - - -

Number of stations:

- -

Template options overrides:

-
    -
  • - Base name: - - Fixed name: - -
  • -
  • - Supervision url: - -
  • -
  • - Supervision credentials: - - -
  • -
  • - Auto start: - -
  • -
  • - Persistent configuration: - -
  • -
  • - OCPP strict compliance: - -
  • -
  • - Performance statistics: - -
  • -
-
- - - - - - diff --git a/ui/web/src/components/actions/SetSupervisionUrl.vue b/ui/web/src/components/actions/SetSupervisionUrl.vue deleted file mode 100644 index 9faedaa3..00000000 --- a/ui/web/src/components/actions/SetSupervisionUrl.vue +++ /dev/null @@ -1,103 +0,0 @@ - - - - - diff --git a/ui/web/src/components/actions/StartTransaction.vue b/ui/web/src/components/actions/StartTransaction.vue deleted file mode 100644 index f115303b..00000000 --- a/ui/web/src/components/actions/StartTransaction.vue +++ /dev/null @@ -1,115 +0,0 @@ - - - - - diff --git a/ui/web/src/composables/Constants.ts b/ui/web/src/composables/Constants.ts index 83143d64..f904db63 100644 --- a/ui/web/src/composables/Constants.ts +++ b/ui/web/src/composables/Constants.ts @@ -11,5 +11,8 @@ export const ROUTE_NAMES = { } as const export const SHARED_TOGGLE_BUTTON_KEY_PREFIX = 'shared-toggle-button-' +// Per-station keys (dynamic) — no `ecs-ui-` namespace unlike global skin/theme keys. export const TOGGLE_BUTTON_KEY_PREFIX = 'toggle-button-' -export const UI_SERVER_CONFIGURATION_INDEX_KEY = 'uiServerConfigurationIndex' +export const UI_SERVER_CONFIGURATION_INDEX_KEY = 'ecs-ui-server-index' +// Legacy key — used only for one-time migration read at boot. +export const LEGACY_UI_SERVER_CONFIG_KEY = 'uiServerConfigurationIndex' diff --git a/ui/web/src/composables/Utils.ts b/ui/web/src/composables/Utils.ts index 623b209d..1917a855 100644 --- a/ui/web/src/composables/Utils.ts +++ b/ui/web/src/composables/Utils.ts @@ -4,8 +4,8 @@ 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' -import { UIClient } from './UIClient' +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> = @@ -14,21 +14,48 @@ export const templatesKey: InjectionKey> = Symbol('templates') export const uiClientKey: InjectionKey = Symbol('uiClient') export const getFromLocalStorage = (key: string, defaultValue: T): T => { - const item = localStorage.getItem(key) - return item != null ? (JSON.parse(item) as T) : defaultValue + 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 => { - localStorage.setItem(key, JSON.stringify(value)) + 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 => { - localStorage.removeItem(key) + try { + localStorage.removeItem(key) + } catch { + if (import.meta.env.DEV) { + console.debug(`[localStorage] Failed to delete key '${key}'`) + } + } } export const getLocalStorage = (): Storage => { - return localStorage + try { + return localStorage + } catch { + throw new Error('localStorage is not available') + } } /** @@ -36,9 +63,15 @@ export const getLocalStorage = (): Storage => { * @param pattern - Substring to match against localStorage keys */ export const deleteLocalStorageByKeyPattern = (pattern: string): void => { - const keysToDelete = Object.keys(localStorage).filter(key => key.includes(pattern)) - for (const key of keysToDelete) { - deleteFromLocalStorage(key) + 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}'`) + } } } @@ -57,6 +90,9 @@ export const resetToggleButtonState = (id: string, shared = false): void => { 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() } @@ -78,44 +114,6 @@ 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, - callbacks?: ExecuteActionCallbacks - ): void => { - const { onFinally, onSuccess } = callbacks ?? {} - action - .then(() => { - try { - onSuccess?.() - } catch (error: unknown) { - console.error('Error in onSuccess callback:', error) - } - emit?.('need-refresh') - return $toast.success(successMsg) - }) - .finally(() => { - try { - onFinally?.() - } catch (error: unknown) { - console.error('Error in onFinally callback:', error) - } - }) - .catch((error: unknown) => { - $toast.error(errorMsg) - console.error(`${errorMsg}:`, error) - }) - } -} - export const useFetchData = ( clientFn: () => Promise, onSuccess: (response: ResponsePayload) => void, diff --git a/ui/web/src/composables/index.ts b/ui/web/src/composables/index.ts index 3fb262de..7086a0a9 100644 --- a/ui/web/src/composables/index.ts +++ b/ui/web/src/composables/index.ts @@ -1,11 +1,12 @@ export { EMPTY_VALUE_PLACEHOLDER, + LEGACY_UI_SERVER_CONFIG_KEY, ROUTE_NAMES, SHARED_TOGGLE_BUTTON_KEY_PREFIX, TOGGLE_BUTTON_KEY_PREFIX, UI_SERVER_CONFIGURATION_INDEX_KEY, -} from './Constants' -export { UIClient } from './UIClient' +} from './Constants.js' +export { UIClient } from './UIClient.js' export { chargingStationsKey, configurationKey, @@ -19,8 +20,7 @@ export { uiClientKey, useChargingStations, useConfiguration, - useExecuteAction, useFetchData, useTemplates, useUIClient, -} from './Utils' +} from './Utils.js' diff --git a/ui/web/src/main.ts b/ui/web/src/main.ts index 48eda6b8..3b303dd7 100644 --- a/ui/web/src/main.ts +++ b/ui/web/src/main.ts @@ -4,50 +4,72 @@ import type { UIServerConfigurationSection, } from 'ui-common' -import { type App as AppType, type Component, createApp, ref } from 'vue' +import { configurationSchema } from 'ui-common' +import { type App as AppType, type Component, createApp, shallowRef } from 'vue' import App from '@/App.vue' import { chargingStationsKey, configurationKey, getFromLocalStorage, + LEGACY_UI_SERVER_CONFIG_KEY, setToLocalStorage, templatesKey, UI_SERVER_CONFIGURATION_INDEX_KEY, UIClient, uiClientKey, -} from '@/composables' +} from '@/composables/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' +import { DEFAULT_SKIN } from '@/skins/registry.js' import 'vue-toast-notification/dist/theme-bootstrap.css' import './assets/shared.css' - -const DEFAULT_THEME = 'tokyo-night-storm' - -const loadTheme = async (theme: string): Promise => { - try { - await import(`./assets/themes/${theme}.css`) - } catch { - console.error(`Theme '${theme}' not found, falling back to '${DEFAULT_THEME}'`) - await import(`./assets/themes/${DEFAULT_THEME}.css`) - } -} +import './assets/themes/base.css' +import './assets/themes/catppuccin-latte.css' +import './assets/themes/sap-horizon.css' +import './assets/themes/tokyo-night-storm.css' const initializeApp = async (app: AppType, config: ConfigurationData): Promise => { - await loadTheme(config.theme ?? DEFAULT_THEME) app.config.errorHandler = (error, instance, info) => { console.error('Error:', error) console.info('Vue instance:', instance) console.info('Error info:', info) // TODO: add code for UI notifications or other error handling logic } + + const { switchTheme } = useTheme() + const storedTheme = getFromLocalStorage(THEME_STORAGE_KEY, config.theme ?? DEFAULT_THEME) + switchTheme(storedTheme) + + const { switchSkin } = useSkin() + if (getFromLocalStorage(SKIN_STORAGE_KEY, '') === '' && config.skin != null) { + setToLocalStorage(SKIN_STORAGE_KEY, config.skin) + } + const initialSkin = getFromLocalStorage(SKIN_STORAGE_KEY, config.skin ?? 'classic') + const switched = await switchSkin(initialSkin) + if (!switched && initialSkin !== DEFAULT_SKIN) { + console.warn(`[useSkin] Failed to load skin '${initialSkin}', falling back to default`) + await switchSkin(DEFAULT_SKIN) + } + if (!Array.isArray(config.uiServer)) { config.uiServer = [config.uiServer] } - const configuration = ref(config) - const templates = ref([]) - const chargingStations = ref([]) + const configuration = shallowRef(config) + const templates = shallowRef([]) + const chargingStations = shallowRef([]) + try { + const legacyIndex = localStorage.getItem(LEGACY_UI_SERVER_CONFIG_KEY) + if (legacyIndex != null && localStorage.getItem(UI_SERVER_CONFIGURATION_INDEX_KEY) == null) { + localStorage.setItem(UI_SERVER_CONFIGURATION_INDEX_KEY, legacyIndex) + localStorage.removeItem(LEGACY_UI_SERVER_CONFIG_KEY) + } + } catch { + // localStorage access can throw in restricted environments + } if ( getFromLocalStorage(UI_SERVER_CONFIGURATION_INDEX_KEY, undefined) == null || getFromLocalStorage(UI_SERVER_CONFIGURATION_INDEX_KEY, 0) > @@ -74,6 +96,10 @@ const bootstrap = async (): Promise => { response = await fetch('/config.json') } catch (error: unknown) { console.error('Error at fetching app configuration:', error) + const errorPre = document.createElement('pre') + errorPre.className = 'config-error' + errorPre.textContent = 'Failed to load configuration. Check that config.json is accessible.' + document.body.replaceChildren(errorPre) return } if (!response.ok) { @@ -82,7 +108,17 @@ const bootstrap = async (): Promise => { } let config: ConfigurationData try { - config = (await response.json()) as ConfigurationData + const rawConfig: unknown = await response.json() + const parseResult = configurationSchema.safeParse(rawConfig) + if (!parseResult.success) { + const msgs = parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`).join('\n') + const errorPre = document.createElement('pre') + errorPre.className = 'config-error' + errorPre.textContent = `Configuration error in config.json:\n${msgs}` + document.body.replaceChildren(errorPre) + return + } + config = parseResult.data } catch (error: unknown) { console.error('Error at deserializing JSON app configuration:', error) return diff --git a/ui/web/src/router/index.ts b/ui/web/src/router/index.ts index adf84665..13b35cc6 100644 --- a/ui/web/src/router/index.ts +++ b/ui/web/src/router/index.ts @@ -1,55 +1,106 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -import { createRouter, createWebHistory } from 'vue-router' +import { type SKIN_IDS } from 'ui-common' +import { h } from 'vue' +import { createRouter, createWebHistory, type RouteLocationNormalized } from 'vue-router' +import { useToast } from 'vue-toast-notification' -import AddChargingStations from '@/components/actions/AddChargingStations.vue' -import SetSupervisionUrl from '@/components/actions/SetSupervisionUrl.vue' -import StartTransaction from '@/components/actions/StartTransaction.vue' -import { ROUTE_NAMES } from '@/composables' -import ChargingStationsView from '@/views/ChargingStationsView.vue' -import NotFoundView from '@/views/NotFoundView.vue' +import { ROUTE_NAMES } from '@/composables/index.js' +import { useSkin } from '@/shared/composables/useSkin.js' +import { DEFAULT_SKIN } from '@/skins/registry.js' + +declare module 'vue-router' { + interface RouteMeta { + skinOnly?: (typeof SKIN_IDS)[number] + } +} + +/** Placeholder component for routes where the skin layout handles all rendering. */ +const PassthroughRoute = { render: () => null } as const + +/** + * Routes serve the classic skin's action panel (sidebar forms via named `action` view). + * The modern skin uses modal dialogs instead of router navigation. + * The home route (`/`) renders null because layout components handle content directly. + * Routes with `meta.skinOnly` are guarded and redirect to `/` for other skins. + * + * NOTE: Classic action routes directly import from `@/skins/classic/components/actions/`. + * This coupling is intentional — only the classic skin uses router-based navigation panels. + * If a third skin needs router-based panels, consider a dynamic route registration pattern. + */ + +/** + * Restricts skin-specific routes, redirecting to home with a toast if the skin doesn't match. + * @param to - The target route location to evaluate + * @returns A redirect object to the home route, or undefined to allow navigation + */ +function skinGuard (to: RouteLocationNormalized) { + if (to.meta.skinOnly != null) { + const { activeSkinId } = useSkin() + if (to.meta.skinOnly !== activeSkinId.value) { + // Safe outside setup: useToast() is a stateless factory, no injection context required. + const $toast = useToast() + $toast.info('This page is not available in the current skin.') + return { name: ROUTE_NAMES.CHARGING_STATIONS } + } + } +} export const router = createRouter({ history: createWebHistory(), routes: [ { - components: { - default: ChargingStationsView, - }, + component: PassthroughRoute, name: ROUTE_NAMES.CHARGING_STATIONS, path: '/', }, { + beforeEnter: skinGuard, components: { - action: AddChargingStations, - default: ChargingStationsView, + action: () => import('@/skins/classic/components/actions/AddChargingStations.vue'), }, + meta: { skinOnly: DEFAULT_SKIN }, name: ROUTE_NAMES.ADD_CHARGING_STATIONS, path: '/add-charging-stations', }, { + beforeEnter: skinGuard, components: { - action: SetSupervisionUrl, - default: ChargingStationsView, + action: () => import('@/skins/classic/components/actions/SetSupervisionUrl.vue'), }, + meta: { skinOnly: DEFAULT_SKIN }, name: ROUTE_NAMES.SET_SUPERVISION_URL, path: '/set-supervision-url/:hashId/:chargingStationId', - props: { action: true, default: false }, + props: { action: true }, }, { + beforeEnter: skinGuard, components: { - action: StartTransaction, - default: ChargingStationsView, + action: () => import('@/skins/classic/components/actions/StartTransaction.vue'), }, + meta: { skinOnly: DEFAULT_SKIN }, name: ROUTE_NAMES.START_TRANSACTION, path: '/start-transaction/:hashId/:chargingStationId/:connectorId', - props: { action: true, default: false }, + props: { action: true }, }, { - components: { - default: NotFoundView, + component: { + render: () => + h( + 'p', + { + style: + 'padding: var(--spacing-md, 1rem); text-align: center; color: var(--color-text, inherit)', + }, + '404 — Page not found' + ), }, name: ROUTE_NAMES.NOT_FOUND, path: '/:pathMatch(.*)*', }, ], }) + +router.beforeEach(to => { + if (to.name === ROUTE_NAMES.NOT_FOUND) { + return { name: ROUTE_NAMES.CHARGING_STATIONS } + } +}) diff --git a/ui/web/src/shared/components/SkinLoadError.vue b/ui/web/src/shared/components/SkinLoadError.vue new file mode 100644 index 00000000..0c995522 --- /dev/null +++ b/ui/web/src/shared/components/SkinLoadError.vue @@ -0,0 +1,75 @@ + + + + + diff --git a/ui/web/src/shared/components/SkinLoading.vue b/ui/web/src/shared/components/SkinLoading.vue new file mode 100644 index 00000000..bbfb70ed --- /dev/null +++ b/ui/web/src/shared/components/SkinLoading.vue @@ -0,0 +1,39 @@ + + + + + diff --git a/ui/web/src/shared/composables/index.ts b/ui/web/src/shared/composables/index.ts new file mode 100644 index 00000000..74132e0c --- /dev/null +++ b/ui/web/src/shared/composables/index.ts @@ -0,0 +1,12 @@ +export { useAddStationsForm } from './useAddStationsForm.js' +export { useAsyncAction } from './useAsyncAction.js' +export { useConnectorActions } from './useConnectorActions.js' +export { useLayoutData } from './useLayoutData.js' +export { useSetUrlForm } from './useSetUrlForm.js' +export { useSimulatorControl } from './useSimulatorControl.js' +export { SKIN_STORAGE_KEY, useSkin } from './useSkin.js' +export type { SkinName } from './useSkin.js' +export { useStartTxForm } from './useStartTxForm.js' +export { useStationActions } from './useStationActions.js' +export { AVAILABLE_THEMES, DEFAULT_THEME, THEME_STORAGE_KEY, useTheme } from './useTheme.js' +export type { ThemeName } from './useTheme.js' diff --git a/ui/web/src/shared/composables/useAddStationsForm.ts b/ui/web/src/shared/composables/useAddStationsForm.ts new file mode 100644 index 00000000..bc1a0035 --- /dev/null +++ b/ui/web/src/shared/composables/useAddStationsForm.ts @@ -0,0 +1,129 @@ +import { randomUUID, type UUIDv4 } from 'ui-common' +import { type DeepReadonly, readonly, ref, type Ref, watch } from 'vue' +import { useToast } from 'vue-toast-notification' + +import { useTemplates, useUIClient } from '@/composables/Utils.js' + +export interface AddStationsFormState { + autoStart: boolean + baseName: string + enableStatistics: boolean + fixedName: boolean + numberOfStations: number + ocppStrictCompliance: boolean + persistentConfiguration: boolean + renderTemplates: UUIDv4 + supervisionPassword: string + supervisionUrl: string + supervisionUser: string + template: string +} + +/** + * Returns form state and submission logic for adding charging stations. + * @param options - Optional callbacks + * @param options.onFinally - Called after the action completes (success or failure), before form reset + * @returns Form state, templates, submit, and reset functions + */ +export function useAddStationsForm (options?: { onFinally?: () => void }): { + formState: Ref + pending: Readonly> + resetForm: () => void + submitForm: () => Promise + templates: DeepReadonly> +} { + const $uiClient = useUIClient() + const $templates = useTemplates() + const $toast = useToast() + + const formState = ref(makeInitialState()) + const pending = ref(false) + + watch($templates, () => { + formState.value.renderTemplates = randomUUID() + }) + + /** Resets form state to initial defaults. */ + function resetForm (): void { + formState.value = makeInitialState() + } + + /** + * Submits the form to add charging stations via the UI client. + * @returns Whether the submission was successful + */ + async function submitForm (): Promise { + if (formState.value.template.length === 0) { + $toast.error('Please select a template') + return false + } + if (pending.value) return false + pending.value = true + try { + await $uiClient.addChargingStations( + formState.value.template, + formState.value.numberOfStations, + { + autoStart: formState.value.autoStart, + baseName: nonEmpty(formState.value.baseName), + enableStatistics: formState.value.enableStatistics, + fixedName: formState.value.baseName.length > 0 ? formState.value.fixedName : undefined, + ocppStrictCompliance: formState.value.ocppStrictCompliance, + persistentConfiguration: formState.value.persistentConfiguration, + supervisionPassword: nonEmpty(formState.value.supervisionPassword), + supervisionUrls: nonEmpty(formState.value.supervisionUrl), + supervisionUser: nonEmpty(formState.value.supervisionUser), + } + ) + $toast.success('Charging stations successfully added') + return true + } catch (error: unknown) { + $toast.error('Error at adding charging stations') + console.error('Error at adding charging stations:', error) + return false + } finally { + pending.value = false + options?.onFinally?.() + resetForm() + } + } + + return { + formState, + pending: readonly(pending), + resetForm, + submitForm, + templates: readonly($templates), + } +} + +/** + * Returns a fresh copy of the default form state. + * Using a factory avoids sharing mutable state between initialization and reset. + * @returns A new {@link AddStationsFormState} with all fields set to their defaults. + */ +function makeInitialState (): AddStationsFormState { + return { + autoStart: false, + baseName: '', + enableStatistics: false, + fixedName: false, + numberOfStations: 1, + ocppStrictCompliance: true, + persistentConfiguration: true, + renderTemplates: randomUUID(), + supervisionPassword: '', + supervisionUrl: '', + supervisionUser: '', + template: '', + } +} + +/** + * Returns `value` when it is non-empty, otherwise `undefined`. + * @param value - The string to test. + * @returns The original string, or `undefined` if it is empty. + */ +function nonEmpty (value: string): string | undefined { + return value.length > 0 ? value : undefined +} diff --git a/ui/web/src/shared/composables/useAsyncAction.ts b/ui/web/src/shared/composables/useAsyncAction.ts new file mode 100644 index 00000000..1b40bb1b --- /dev/null +++ b/ui/web/src/shared/composables/useAsyncAction.ts @@ -0,0 +1,96 @@ +/** + * @file useAsyncAction.ts + * @description Shared async action executor with pending-key guard and toast notifications. + */ +import { getCurrentScope, onScopeDispose, reactive, readonly } from 'vue' +import { useToast } from 'vue-toast-notification' + +/** + * Creates a reactive pending-state map and a run() helper for async actions with toast notifications. + * + * Encapsulates the pending-key guard, toast feedback, and error logging pattern + * shared by modern skin components. + * @param initialPending - Object defining the pending keys (e.g. `{ connection: false, startStop: false }`) + * @param onRefresh - Called after each successful action (e.g. `() => emit('need-refresh')`) + * @returns `{ pending, run }` — reactive pending map and action executor + */ +export function useAsyncAction> ( + initialPending: T, + onRefresh?: () => void +): { + pending: Readonly + run: ( + key: keyof T, + options: { + action: () => Promise + errorMsg: string + onSuccess?: () => void + successMsg: string + } + ) => void + } { + const $toast = useToast() + /** + * Reactive pending-state map. Access properties directly (e.g. `pending.connection`) + * — do NOT destructure individual keys, as `reactive()` proxies lose reactivity on destructure. + */ + const pending = reactive({ ...initialPending }) as T + + let disposed = false + if (getCurrentScope() != null) { + onScopeDispose(() => { + disposed = true + }) + } + + /** + * Executes an async action with pending-key guard, toast feedback, and error logging. + * @param key - The pending key to guard and track + * @param options - Action configuration with action, messages, and optional success callback + * @param options.action - The async operation to execute + * @param options.errorMsg - Toast message and console prefix on failure + * @param options.onSuccess - Optional callback invoked after success (before onRefresh) + * @param options.successMsg - Toast message on success + */ + function run ( + key: keyof T, + options: { + action: () => Promise + errorMsg: string + onSuccess?: () => void + successMsg: string + } + ): void { + const { action, errorMsg, onSuccess, successMsg } = options + if (pending[key]) return + pending[key] = true as T[keyof T] + // eslint-disable-next-line no-void + void (async () => { + try { + await action() + if (disposed) return + try { + onSuccess?.() + } catch (error: unknown) { + console.error('Error in onSuccess callback:', error) + } + $toast.success(successMsg) + try { + onRefresh?.() + } catch (error: unknown) { + console.error('Error in onRefresh callback:', error) + } + } catch (error: unknown) { + if (disposed) return + console.error(`${errorMsg}:`, error) + $toast.error(errorMsg) + } finally { + if (!disposed) { + pending[key] = false as T[keyof T] + } + } + })() + } + + return { pending: readonly(pending) as Readonly, run } +} diff --git a/ui/web/src/shared/composables/useConnectorActions.ts b/ui/web/src/shared/composables/useConnectorActions.ts new file mode 100644 index 00000000..01b719c7 --- /dev/null +++ b/ui/web/src/shared/composables/useConnectorActions.ts @@ -0,0 +1,104 @@ +/** + * @file useConnectorActions.ts + * @description Headless composable for connector-level actions (stop transaction, lock/unlock, ATG toggle). + */ +import type { OCPPVersion } from 'ui-common' + +import { computed, type MaybeRefOrGetter, readonly, toValue } from 'vue' +import { useToast } from 'vue-toast-notification' + +import { useUIClient } from '@/composables/Utils.js' +import { useAsyncAction } from '@/shared/composables/useAsyncAction.js' + +interface ConnectorActionsDeps { + connectorId: MaybeRefOrGetter + hashId: MaybeRefOrGetter + onRefresh?: () => void +} + +/** + * Provides connector-level action handlers with pending state management. + * @param deps - Connector identity and optional refresh callback + * @returns Action functions and reactive pending state + */ +export function useConnectorActions (deps: ConnectorActionsDeps): { + lockConnector: () => void + pending: Readonly<{ atg: boolean; lock: boolean; stopTx: boolean }> + startATG: () => void + stopATG: () => void + stopTransaction: ( + transactionId: null | number | string | undefined, + ocppVersion?: OCPPVersion + ) => void + unlockConnector: () => void +} { + const $uiClient = useUIClient() + const $toast = useToast() + const { pending, run } = useAsyncAction( + { atg: false, lock: false, stopTx: false }, + deps.onRefresh + ) + + const hashId = computed(() => toValue(deps.hashId)) + const connectorId = computed(() => toValue(deps.connectorId)) + + const stopTransaction = ( + transactionId: null | number | string | undefined, + ocppVersion?: OCPPVersion + ): void => { + if (transactionId == null) { + $toast.error('No transaction to stop') + return + } + run('stopTx', { + action: () => + $uiClient.stopTransaction(hashId.value, { + ocppVersion, + transactionId, + }), + errorMsg: 'Error stopping transaction', + successMsg: 'Transaction stopped', + }) + } + + const lockConnector = (): void => { + run('lock', { + action: () => $uiClient.lockConnector(hashId.value, connectorId.value), + errorMsg: 'Error locking connector', + successMsg: 'Connector locked', + }) + } + + const unlockConnector = (): void => { + run('lock', { + action: () => $uiClient.unlockConnector(hashId.value, connectorId.value), + errorMsg: 'Error unlocking connector', + successMsg: 'Connector unlocked', + }) + } + + const startATG = (): void => { + run('atg', { + action: () => $uiClient.startAutomaticTransactionGenerator(hashId.value, connectorId.value), + errorMsg: 'Error starting ATG', + successMsg: 'ATG started', + }) + } + + const stopATG = (): void => { + run('atg', { + action: () => $uiClient.stopAutomaticTransactionGenerator(hashId.value, connectorId.value), + errorMsg: 'Error stopping ATG', + successMsg: 'ATG stopped', + }) + } + + return { + lockConnector, + pending: readonly(pending), + startATG, + stopATG, + stopTransaction, + unlockConnector, + } +} diff --git a/ui/web/src/shared/composables/useLayoutData.ts b/ui/web/src/shared/composables/useLayoutData.ts new file mode 100644 index 00000000..2a00d966 --- /dev/null +++ b/ui/web/src/shared/composables/useLayoutData.ts @@ -0,0 +1,147 @@ +import type { ChargingStationData, SimulatorState, UIServerConfigurationSection } from 'ui-common' + +import { + computed, + type ComputedRef, + onMounted, + onUnmounted, + readonly, + type Ref, + shallowRef, +} from 'vue' + +import { + useChargingStations, + useConfiguration, + useFetchData, + useTemplates, + useUIClient, +} from '@/composables/index.js' + +export interface LayoutData { + /** Fetches only the charging stations list. */ + getChargingStations: () => void + /** Fetches simulator state, templates, and charging stations. */ + getData: () => void + /** Fetches only the simulator state. */ + getSimulatorState: () => void + /** Whether any data fetch is currently in progress. */ + loading: ComputedRef + /** Registers WS event listeners for open/error/close. */ + registerWSEventListeners: () => void + /** Whether the simulator has been started. */ + simulatorStarted: ComputedRef + /** The current simulator state object. */ + simulatorState: Readonly> + /** Mapped array of UI server configurations with their indices. */ + uiServerConfigurations: ComputedRef< + { configuration: UIServerConfigurationSection; index: number }[] + > + /** Unregisters WS event listeners previously registered. */ + unregisterWSEventListeners: () => void +} + +/** + * Extracts the common data-fetching and WebSocket lifecycle logic shared by layout components. + * + * Registers `onMounted` / `onUnmounted` hooks internally so consumers do not need to. + * @returns Layout data state and control functions + */ +export function useLayoutData (): LayoutData { + const $uiClient = useUIClient() + const $configuration = useConfiguration() + const $templates = useTemplates() + const $chargingStations = useChargingStations() + + const simulatorState = shallowRef(undefined) + const simulatorStarted = computed((): boolean | undefined => simulatorState.value?.started) + + const clearTemplates = (): void => { + $templates.value = [] + } + + const clearChargingStations = (): void => { + $chargingStations.value = [] + } + + const { fetch: getSimulatorState, fetching: fetchingSimulatorState } = useFetchData( + () => $uiClient.simulatorState(), + response => { + simulatorState.value = response.state as unknown as SimulatorState + }, + 'Error at fetching simulator state' + ) + + const { fetch: getTemplates, fetching: fetchingTemplates } = useFetchData( + () => $uiClient.listTemplates(), + response => { + $templates.value = response.templates as string[] + }, + 'Error at fetching charging station templates', + clearTemplates + ) + + const { fetch: getChargingStations, fetching: fetchingChargingStations } = useFetchData( + () => $uiClient.listChargingStations(), + response => { + $chargingStations.value = response.chargingStations as ChargingStationData[] + }, + 'Error at fetching charging stations', + clearChargingStations + ) + + const loading = computed( + () => fetchingSimulatorState.value || fetchingTemplates.value || fetchingChargingStations.value + ) + + const uiServerConfigurations = computed(() => + ($configuration.value.uiServer as UIServerConfigurationSection[]).map( + (configuration, index) => ({ configuration, index }) + ) + ) + + const getData = (): void => { + getSimulatorState() + getTemplates() + getChargingStations() + } + + const registerWSEventListeners = (): void => { + $uiClient.registerWSEventListener('open', getData) + $uiClient.registerWSEventListener('error', clearChargingStations) + $uiClient.registerWSEventListener('close', clearChargingStations) + } + + const unregisterWSEventListeners = (): void => { + $uiClient.unregisterWSEventListener('open', getData) + $uiClient.unregisterWSEventListener('error', clearChargingStations) + $uiClient.unregisterWSEventListener('close', clearChargingStations) + } + + let unsubscribeRefresh: (() => void) | undefined + + onMounted(() => { + registerWSEventListeners() + unsubscribeRefresh = $uiClient.onRefresh(() => { + getChargingStations() + }) + }) + + onUnmounted(() => { + unregisterWSEventListeners() + unsubscribeRefresh?.() + }) + + return { + getChargingStations, + getData, + getSimulatorState, + loading, + // Exposed for edge cases (e.g. hot-reload); normally called via onMounted/onUnmounted. + registerWSEventListeners, + simulatorStarted, + simulatorState: readonly(simulatorState) as Readonly>, + uiServerConfigurations, + unregisterWSEventListeners, + } +} diff --git a/ui/web/src/shared/composables/useSetUrlForm.ts b/ui/web/src/shared/composables/useSetUrlForm.ts new file mode 100644 index 00000000..37d9fa22 --- /dev/null +++ b/ui/web/src/shared/composables/useSetUrlForm.ts @@ -0,0 +1,88 @@ +import { readonly, ref, type Ref } from 'vue' +import { useToast } from 'vue-toast-notification' + +import { useUIClient } from '@/composables/Utils.js' + +export interface SetUrlFormState { + supervisionPassword: string + supervisionUrl: string + supervisionUser: string +} + +/** + * Returns form state and submission logic for setting the supervision URL. + * @param hashId - The charging station hash identifier + * @param chargingStationId - The charging station display identifier + * @returns Form state and submit/reset functions + */ +export function useSetUrlForm ( + hashId: string, + chargingStationId: string +): { + chargingStationId: string + formState: Ref + pending: Readonly> + resetForm: () => void + submitForm: () => Promise + } { + const $uiClient = useUIClient() + const $toast = useToast() + + const formState = ref(makeInitialState()) + const pending = ref(false) + + /** Resets form state to initial defaults. */ + function resetForm (): void { + formState.value = makeInitialState() + } + + /** + * Validates and submits the supervision URL update. + * @returns Whether the submission was successful + */ + async function submitForm (): Promise { + if (pending.value) return false + if (formState.value.supervisionUrl.length === 0) { + $toast.error('Supervision url is required') + return false + } + pending.value = true + try { + await $uiClient.setSupervisionUrl( + hashId, + formState.value.supervisionUrl, + formState.value.supervisionUser, + formState.value.supervisionPassword + ) + $toast.success('Supervision url successfully set') + return true + } catch (error: unknown) { + $toast.error('Error at setting supervision url') + console.error('Error at setting supervision url:', error) + return false + } finally { + pending.value = false + } + } + + return { + chargingStationId, + formState, + pending: readonly(pending), + resetForm, + submitForm, + } +} + +/** + * Returns a fresh copy of the default form state. + * Using a factory avoids sharing mutable state between initialization and reset. + * @returns A new {@link SetUrlFormState} with all fields set to their defaults. + */ +function makeInitialState (): SetUrlFormState { + return { + supervisionPassword: '', + supervisionUrl: '', + supervisionUser: '', + } +} diff --git a/ui/web/src/shared/composables/useSimulatorControl.ts b/ui/web/src/shared/composables/useSimulatorControl.ts new file mode 100644 index 00000000..3ba75998 --- /dev/null +++ b/ui/web/src/shared/composables/useSimulatorControl.ts @@ -0,0 +1,170 @@ +import type { UIServerConfigurationSection } from 'ui-common' +import type { ComputedRef, Ref } from 'vue' + +import { computed, onScopeDispose, readonly, ref } from 'vue' + +import { + getFromLocalStorage, + setToLocalStorage, + UI_SERVER_CONFIGURATION_INDEX_KEY, + useChargingStations, + useConfiguration, + useUIClient, +} from '@/composables/index.js' +import { useAsyncAction } from '@/shared/composables/useAsyncAction.js' +import { type LayoutData } from '@/shared/composables/useLayoutData.js' + +export interface SimulatorControlActions { + /** Switches the active UI server, with error rollback on connection failure. */ + handleUIServerChange: (newIndex: number) => void + /** Whether a server switch operation is in progress. */ + serverSwitchPending: Readonly> + /** Whether a simulator start/stop operation is in progress. */ + simulatorPending: ComputedRef + /** Starts the simulator and refreshes state on completion. */ + startSimulator: () => void + /** Stops the simulator and clears charging stations on success. */ + stopSimulator: () => void +} + +export interface SimulatorControlOptions { + /** Called after a successful server switch (e.g. to clear UI state). */ + onServerSwitched?: () => void + /** Called after the simulator stops successfully (e.g. to reset toggle buttons). */ + onSimulatorStopped?: () => void +} + +/** + * Shared composable encapsulating simulator start/stop and UI server switching logic. + * + * Provides consistent error handling and rollback behavior across layout skins. + * @param layoutData - Layout data providing getSimulatorState, registerWSEventListeners, and unregisterWSEventListeners + * @param options - Optional callbacks for skin-specific side effects + * @returns Simulator control actions and pending state refs + */ +export function useSimulatorControl ( + layoutData: Partial> & + Pick, + options?: SimulatorControlOptions +): SimulatorControlActions { + const $uiClient = useUIClient() + const $configuration = useConfiguration() + const $chargingStations = useChargingStations() + + const { getSimulatorState, registerWSEventListeners } = layoutData + const unregisterWSEventListeners = + layoutData.unregisterWSEventListeners ?? + ((): void => { + /* no-op */ + }) + + const { pending: simulatorPendingState, run: runSimulatorAction } = useAsyncAction( + { simulator: false }, + getSimulatorState + ) + const serverSwitchPending = ref(false) + let activeTimeoutId: ReturnType | undefined + let pendingOpenHandler: (() => void) | undefined + let pendingErrorHandler: (() => void) | undefined + + const startSimulator = (): void => { + runSimulatorAction('simulator', { + action: () => $uiClient.startSimulator(), + errorMsg: 'Error at starting simulator', + successMsg: 'Simulator successfully started', + }) + } + + const stopSimulator = (): void => { + runSimulatorAction('simulator', { + action: async () => { + await $uiClient.stopSimulator() + $chargingStations.value = [] + options?.onSimulatorStopped?.() + }, + errorMsg: 'Error at stopping simulator', + successMsg: 'Simulator successfully stopped', + }) + } + + const SERVER_SWITCH_TIMEOUT_MS = 15_000 + + const handleUIServerChange = (newIndex: number): void => { + const currentIndex = getFromLocalStorage(UI_SERVER_CONFIGURATION_INDEX_KEY, 0) + if (newIndex === currentIndex || serverSwitchPending.value) return + + const servers = $configuration.value.uiServer as UIServerConfigurationSection[] + if (newIndex < 0 || newIndex >= servers.length) return + + serverSwitchPending.value = true + + $uiClient.setConfiguration(servers[newIndex]) + unregisterWSEventListeners() + registerWSEventListeners() + + let settled = false + + const openHandler = (): void => { + if (settled) return + settled = true + clearTimeout(activeTimeoutId) + $uiClient.unregisterWSEventListener('error', errorHandler) + pendingOpenHandler = undefined + pendingErrorHandler = undefined + setToLocalStorage(UI_SERVER_CONFIGURATION_INDEX_KEY, newIndex) + serverSwitchPending.value = false + options?.onServerSwitched?.() + } + + const errorHandler = (): void => { + if (settled) return + settled = true + clearTimeout(activeTimeoutId) + $uiClient.unregisterWSEventListener('open', openHandler) + pendingOpenHandler = undefined + pendingErrorHandler = undefined + serverSwitchPending.value = false + const previousIndex = getFromLocalStorage(UI_SERVER_CONFIGURATION_INDEX_KEY, 0) + const rollbackServers = $configuration.value.uiServer as UIServerConfigurationSection[] + if (previousIndex >= 0 && previousIndex < rollbackServers.length) { + $uiClient.setConfiguration(rollbackServers[previousIndex]) + } + unregisterWSEventListeners() + registerWSEventListeners() + } + + $uiClient.registerWSEventListener('open', openHandler, { once: true }) + $uiClient.registerWSEventListener('error', errorHandler, { once: true }) + pendingOpenHandler = openHandler + pendingErrorHandler = errorHandler + + activeTimeoutId = setTimeout(() => { + if (!settled) { + errorHandler() + } + }, SERVER_SWITCH_TIMEOUT_MS) + } + + onScopeDispose(() => { + if (activeTimeoutId != null) { + clearTimeout(activeTimeoutId) + activeTimeoutId = undefined + } + if (pendingOpenHandler != null) { + $uiClient.unregisterWSEventListener('open', pendingOpenHandler) + pendingOpenHandler = undefined + } + if (pendingErrorHandler != null) { + $uiClient.unregisterWSEventListener('error', pendingErrorHandler) + pendingErrorHandler = undefined + } + }) + + return { + handleUIServerChange, + serverSwitchPending: readonly(serverSwitchPending), + simulatorPending: computed(() => simulatorPendingState.simulator), + startSimulator, + stopSimulator, + } +} diff --git a/ui/web/src/shared/composables/useSkin.ts b/ui/web/src/shared/composables/useSkin.ts new file mode 100644 index 00000000..d8b20609 --- /dev/null +++ b/ui/web/src/shared/composables/useSkin.ts @@ -0,0 +1,145 @@ +import { type SKIN_IDS } from 'ui-common' +import { readonly, ref, type Ref } from 'vue' + +import { getFromLocalStorage, setToLocalStorage } from '@/composables/Utils.js' +import { validateTokenContract } from '@/shared/tokens/contract.js' +// Intentional: registry.ts is pure metadata (ids, labels, loaders) — no behavioral coupling. +import { DEFAULT_SKIN, type SkinDefinition, skins } from '@/skins/registry.js' + +export const SKIN_STORAGE_KEY = 'ecs-ui-skin' + +export type SkinName = (typeof SKIN_IDS)[number] + +/** + * Checks whether a string is a valid registered skin id. + * @param skinId - The skin identifier to validate + * @returns Whether the id is a registered skin identifier + */ +function isValidSkin (skinId: string): skinId is SkinName { + return skins.some(s => s.id === skinId) +} + +/** + * Singleton state — shared across all useSkin() consumers (global skin config). + * Uses `isSwitching` (not `pending`) because this tracks a UI-visible CSS transition, + * not merely a pending network request. + */ +const activeSkinId: Ref = ref( + (() => { + const stored = getFromLocalStorage(SKIN_STORAGE_KEY, DEFAULT_SKIN) + return isValidSkin(stored) ? stored : DEFAULT_SKIN + })() +) +// JS/testing hook — no CSS uses [data-skin]; skin isolation is via component class scoping. +if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-skin', activeSkinId.value) +} +const loadedSkins = new Set() +let switchPromise: null | Promise = null +const lastError: Ref = ref(null) + +/** Whether a skin switch is currently in progress (CSS transition-dependent). */ +const isSwitching: Ref = ref(false) + +/** + * Returns the active skin id, available skins, and a function to switch skins at runtime. + * @returns Skin state and switcher + */ +export function useSkin (): { + activeSkinId: Readonly> + availableSkins: readonly SkinDefinition[] + isSwitching: Readonly> + lastError: Readonly> + switchSkin: (id: string) => Promise +} { + /** + * Switches the active skin and lazy-loads its CSS if needed. + * Uses Promise coalescing to prevent concurrent skin switches. + * @param skinId - The skin identifier to switch to + * @returns `true` if the skin was successfully switched, `false` otherwise + */ + async function switchSkin (skinId: string): Promise { + if (switchPromise != null) { + await switchPromise + if (activeSkinId.value === skinId) return true + } + switchPromise = performSkinSwitch(skinId).finally(() => { + switchPromise = null + }) + return switchPromise + } + + return { + activeSkinId: readonly(activeSkinId), + availableSkins: skins, + isSwitching: readonly(isSwitching), + lastError: readonly(lastError), + switchSkin, + } +} + +/** + * Loads the CSS file for a skin if not already loaded. + * @param skinId - The skin identifier to load styles for + */ +async function loadSkinStyles (skinId: string): Promise { + if (loadedSkins.has(skinId)) { + return + } + const skin = skins.find(s => s.id === skinId) + if (skin == null) { + return + } + await skin.loadStyles() + loadedSkins.add(skinId) + validateTokenContract('useSkin', skinId) +} + +/** + * Performs the actual skin switch logic. + * @param skinId - The skin identifier to switch to + * @returns `true` if the skin was successfully switched, `false` otherwise + */ +async function performSkinSwitch (skinId: string): Promise { + const skin = skins.find(s => s.id === skinId) + if (skin == null) { + return false + } + isSwitching.value = true + if (skinId === activeSkinId.value) { + try { + await loadSkinStyles(skinId) + try { + sessionStorage.removeItem('skin-error-reload-count') + } catch { + /* sessionStorage unavailable */ + } + return true + } finally { + isSwitching.value = false + } + } + try { + await loadSkinStyles(skinId) + lastError.value = null + activeSkinId.value = skinId as SkinName + if (typeof document !== 'undefined') { + document.documentElement.setAttribute('data-skin', skinId) + document.body.style.overflow = '' + } + setToLocalStorage(SKIN_STORAGE_KEY, skinId) + try { + sessionStorage.removeItem('skin-error-reload-count') + } catch { + /* sessionStorage unavailable */ + } + return true + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.warn(`[useSkin] Failed to load CSS for skin '${skinId}':`, message) + lastError.value = message + return false + } finally { + isSwitching.value = false + } +} diff --git a/ui/web/src/shared/composables/useStartTxForm.ts b/ui/web/src/shared/composables/useStartTxForm.ts new file mode 100644 index 00000000..0ba82579 --- /dev/null +++ b/ui/web/src/shared/composables/useStartTxForm.ts @@ -0,0 +1,107 @@ +import { convertToInt, type OCPPVersion } from 'ui-common' +import { readonly, ref, type Ref } from 'vue' +import { useToast } from 'vue-toast-notification' + +import { useUIClient } from '@/composables/Utils.js' + +export interface StartTxFormConfig { + connectorId: string + evseId?: number + hashId: string + ocppVersion?: OCPPVersion + options?: { + onCleanup?: () => void + onError?: (error: unknown, step?: 'authorize' | 'startTransaction') => void + } +} + +export interface StartTxFormState { + authorizeIdTag: boolean + idTag: string +} + +/** + * Returns form state and submission logic for starting a transaction. + * @param config - Configuration for the start transaction form + * @returns Form state and submit/reset functions + */ +export function useStartTxForm (config: StartTxFormConfig): { + formState: Ref + pending: Readonly> + resetForm: () => void + submitForm: () => Promise +} { + const { connectorId, evseId, hashId, ocppVersion, options } = config + const $uiClient = useUIClient() + const $toast = useToast() + + const formState = ref({ + authorizeIdTag: true, + idTag: '', + }) + + const pending = ref(false) + + /** Resets form state to initial defaults. */ + function resetForm (): void { + formState.value = { + authorizeIdTag: true, + idTag: '', + } + } + + /** + * Submits the start transaction request, optionally authorizing first. + * @returns `true` on success, `false` on error + */ + async function submitForm (): Promise { + if (pending.value) return false + pending.value = true + try { + const idTag = formState.value.idTag.length > 0 ? formState.value.idTag : undefined + + if (formState.value.authorizeIdTag) { + if (idTag == null) { + $toast.error('Please provide an RFID tag to authorize') + return false + } + try { + await $uiClient.authorize(hashId, idTag) + } catch (error) { + $toast.error('Error at authorizing RFID tag') + console.error('Error at authorizing RFID tag:', error) + options?.onError?.(error, 'authorize') + options?.onCleanup?.() + return false + } + } + + try { + await $uiClient.startTransaction(hashId, { + connectorId: convertToInt(connectorId), + evseId, + idTag, + ocppVersion, + }) + $toast.success('Transaction successfully started') + return true + } catch (error) { + $toast.error('Error at starting transaction') + console.error('Error at starting transaction:', error) + options?.onError?.(error, 'startTransaction') + return false + } finally { + options?.onCleanup?.() + } + } finally { + pending.value = false + } + } + + return { + formState, + pending: readonly(pending), + resetForm, + submitForm, + } +} diff --git a/ui/web/src/shared/composables/useStationActions.ts b/ui/web/src/shared/composables/useStationActions.ts new file mode 100644 index 00000000..ea0c152d --- /dev/null +++ b/ui/web/src/shared/composables/useStationActions.ts @@ -0,0 +1,79 @@ +/** + * @file useStationActions.ts + * @description Headless composable for station-level actions (start/stop, connect/disconnect, delete). + */ +import { readonly } from 'vue' + +import { useUIClient } from '@/composables/Utils.js' +import { useAsyncAction } from '@/shared/composables/useAsyncAction.js' + +/** + * Provides station-level action handlers with pending state management. + * @param options - Optional configuration + * @param options.onRefresh - Callback invoked after successful actions + * @returns Action functions and reactive pending state + */ +export function useStationActions (options?: { onRefresh?: () => void }): { + closeConnection: (hashId: string) => void + deleteStation: (hashId: string, onSuccess?: () => void) => void + openConnection: (hashId: string) => void + pending: Readonly<{ connection: boolean; delete: boolean; startStop: boolean }> + startStation: (hashId: string) => void + stopStation: (hashId: string) => void +} { + const $uiClient = useUIClient() + const { pending, run } = useAsyncAction( + { connection: false, delete: false, startStop: false }, + options?.onRefresh + ) + + const startStation = (hashId: string): void => { + run('startStop', { + action: () => $uiClient.startChargingStation(hashId), + errorMsg: 'Error starting charging station', + successMsg: 'Charging station started', + }) + } + + const stopStation = (hashId: string): void => { + run('startStop', { + action: () => $uiClient.stopChargingStation(hashId), + errorMsg: 'Error stopping charging station', + successMsg: 'Charging station stopped', + }) + } + + const openConnection = (hashId: string): void => { + run('connection', { + action: () => $uiClient.openConnection(hashId), + errorMsg: 'Error opening connection', + successMsg: 'Connection opened', + }) + } + + const closeConnection = (hashId: string): void => { + run('connection', { + action: () => $uiClient.closeConnection(hashId), + errorMsg: 'Error closing connection', + successMsg: 'Connection closed', + }) + } + + const deleteStation = (hashId: string, onSuccess?: () => void): void => { + run('delete', { + action: () => $uiClient.deleteChargingStation(hashId), + errorMsg: 'Error deleting charging station', + onSuccess, + successMsg: 'Charging station deleted', + }) + } + + return { + closeConnection, + deleteStation, + openConnection, + pending: readonly(pending), + startStation, + stopStation, + } +} diff --git a/ui/web/src/shared/composables/useTheme.ts b/ui/web/src/shared/composables/useTheme.ts new file mode 100644 index 00000000..57e5faf9 --- /dev/null +++ b/ui/web/src/shared/composables/useTheme.ts @@ -0,0 +1,82 @@ +import { THEME_IDS } from 'ui-common' +import { readonly, ref, type Ref } from 'vue' + +import { getFromLocalStorage, setToLocalStorage } from '@/composables/Utils.js' +import { validateTokenContract } from '@/shared/tokens/contract.js' + +export const AVAILABLE_THEMES = THEME_IDS +export const DEFAULT_THEME: ThemeName = 'tokyo-night-storm' +export const THEME_STORAGE_KEY = 'ecs-ui-theme' + +export type ThemeName = (typeof THEME_IDS)[number] + +/** + * Checks whether a string is a valid theme name. + * @param name - The theme name to validate + * @returns Whether the name is a valid theme name + */ +function isValidTheme (name: string): name is ThemeName { + return (AVAILABLE_THEMES as readonly string[]).includes(name) +} + +const activeThemeId: Ref = ref( + (() => { + const stored = getFromLocalStorage(THEME_STORAGE_KEY, DEFAULT_THEME) + return isValidTheme(stored) ? stored : DEFAULT_THEME + })() +) + +const lastError: Ref = ref(null) + +/** + * Applies a theme by setting the data-theme attribute on the document root. + * Disables CSS transitions during the swap to prevent color flash (VueUse pattern). + * The color-scheme is handled by CSS [data-theme] declarations. + * @param themeName - The theme name to apply + */ +function applyTheme (themeName: ThemeName): void { + if (typeof document === 'undefined') return + // Disable CSS transitions during theme swap to prevent color flash (VueUse pattern). + document.documentElement.classList.add('theme-switching') + document.documentElement.setAttribute('data-theme', themeName) + // Force reflow so browsers apply the transition-disable before restoring transitions. + // eslint-disable-next-line no-void + void document.documentElement.offsetHeight + document.documentElement.classList.remove('theme-switching') +} + +applyTheme(activeThemeId.value) + +/** + * Returns the active theme, available themes, and a function to switch themes at runtime. + * @returns Theme state and switcher + */ +export function useTheme (): { + activeThemeId: Readonly> + availableThemes: typeof AVAILABLE_THEMES + lastError: Readonly> + switchTheme: (name: string) => void +} { + /** + * Switches the active theme, updates the DOM, and persists the preference. + * @param name - The theme name to activate + */ + function switchTheme (name: string): void { + if (!isValidTheme(name)) { + lastError.value = `Invalid theme: '${name}'` + return + } + lastError.value = null + applyTheme(name) + activeThemeId.value = name + setToLocalStorage(THEME_STORAGE_KEY, name) + validateTokenContract('useTheme', name) + } + + return { + activeThemeId: readonly(activeThemeId), + availableThemes: AVAILABLE_THEMES, + lastError: readonly(lastError), + switchTheme, + } +} diff --git a/ui/web/src/shared/tokens/contract.ts b/ui/web/src/shared/tokens/contract.ts new file mode 100644 index 00000000..822e4561 --- /dev/null +++ b/ui/web/src/shared/tokens/contract.ts @@ -0,0 +1,66 @@ +/** + * CSS token contract. + * + * Typography and spacing tokens are provided by `base.css` (shared across all themes). + * Color tokens (`color-*`) and `color-scheme` must be defined per theme file. + * When adding a new theme, ensure all `color-*` tokens below are defined in your theme CSS. + * + * Every theme file MUST define a value for each token (as `--{token-name}`). + */ +export const TOKEN_CONTRACT = [ + 'color-accent', + 'color-bg', + 'color-bg-active', + 'color-bg-button', + 'color-bg-button-hover', + 'color-bg-caption', + 'color-bg-header', + 'color-bg-hover', + 'color-bg-input', + 'color-bg-raised', + 'color-bg-sunken', + 'color-bg-surface', + 'color-border', + 'color-border-row', + 'color-primary', + 'color-shadow-inset', + 'color-state-err', + 'color-state-idle', + 'color-state-ok', + 'color-state-warn', + 'color-text', + 'color-text-muted', + 'color-text-on-button', + 'color-text-strong', + 'font-family', + 'font-size-base', + 'font-size-sm', + 'font-size-xs', + 'spacing-lg', + 'spacing-md', + 'spacing-sm', + 'spacing-xl', + 'spacing-xs', +] as const + +export type CssCustomProperty = `--${TokenName}` +export type TokenName = (typeof TOKEN_CONTRACT)[number] + +/** + * Dev-mode runtime check that all required CSS custom properties are defined. + * Uses requestAnimationFrame to ensure styles are applied before checking. + * @param source - The composable/module name for the warning prefix (e.g. 'useSkin', 'useTheme') + * @param contextId - The skin/theme id that was just applied + */ +export function validateTokenContract (source: string, contextId: string): void { + if (!import.meta.env.DEV || typeof document === 'undefined') return + requestAnimationFrame(() => { + const style = getComputedStyle(document.documentElement) + for (const token of TOKEN_CONTRACT) { + const prop: CssCustomProperty = `--${token}` + if (!style.getPropertyValue(prop).trim()) { + console.warn(`[${source}] Missing CSS token '${prop}' after applying '${contextId}'`) + } + } + }) +} diff --git a/ui/web/src/shared/types.ts b/ui/web/src/shared/types.ts new file mode 100644 index 00000000..4eafd390 --- /dev/null +++ b/ui/web/src/shared/types.ts @@ -0,0 +1,5 @@ +/** Common station identification fields used across skin components. */ +export interface StationIdentifier { + chargingStationId: string + hashId: string +} diff --git a/ui/web/src/shared/utils/dom.ts b/ui/web/src/shared/utils/dom.ts new file mode 100644 index 00000000..e89ca1b8 --- /dev/null +++ b/ui/web/src/shared/utils/dom.ts @@ -0,0 +1,8 @@ +/** + * Extracts the value from a `` element + * @returns The selected option's value + */ +export function getSelectValue (event: Event): string { + return (event.target as HTMLSelectElement).value +} diff --git a/ui/web/src/shared/utils/formatSupervisionUrl.ts b/ui/web/src/shared/utils/formatSupervisionUrl.ts new file mode 100644 index 00000000..da394a42 --- /dev/null +++ b/ui/web/src/shared/utils/formatSupervisionUrl.ts @@ -0,0 +1,32 @@ +import { EMPTY_VALUE_PLACEHOLDER } from '@/composables/Constants.js' + +export interface FormatSupervisionUrlOptions { + /** Insert zero-width-space after dots for word-break in table cells. */ + wordBreak?: boolean +} + +/** + * Formats a supervision URL for display. Strips the pathname when it is '/'. + * Returns `EMPTY_VALUE_PLACEHOLDER` for undefined/empty input. + * @param url - The raw supervision URL string + * @param options - Formatting options + * @returns A formatted display string + */ +export function formatSupervisionUrl ( + url: string | undefined, + options?: FormatSupervisionUrlOptions +): string { + const trimmed = url?.trim() + if (!trimmed) { + return EMPTY_VALUE_PLACEHOLDER + } + + try { + const parsed = new URL(trimmed) + const host = options?.wordBreak === true ? parsed.host.split('.').join('.\u200b') : parsed.host + const pathname = parsed.pathname === '/' ? '' : parsed.pathname + return `${parsed.protocol}//${host}${pathname}` + } catch { + return trimmed + } +} diff --git a/ui/web/src/shared/utils/index.ts b/ui/web/src/shared/utils/index.ts new file mode 100644 index 00000000..62db02f2 --- /dev/null +++ b/ui/web/src/shared/utils/index.ts @@ -0,0 +1,10 @@ +export { getSelectValue } from './dom.js' +export { formatSupervisionUrl, type FormatSupervisionUrlOptions } from './formatSupervisionUrl.js' +export type { StatusVariant } from './stationStatus.js' +export { + getATGStatus, + getConnectorEntries, + getConnectorStatusVariant, + getWebSocketStateVariant, +} from './stationStatus.js' +export { stripStationId } from './stripStationId.js' diff --git a/ui/web/src/shared/utils/stationStatus.ts b/ui/web/src/shared/utils/stationStatus.ts new file mode 100644 index 00000000..aa5014d6 --- /dev/null +++ b/ui/web/src/shared/utils/stationStatus.ts @@ -0,0 +1,107 @@ +/** + * @file stationStatus.ts + * @description Pure utility functions for OCPP connector/station status mapping. + * These are not Vue composables (no reactive state) — they are pure utility functions + * consumed exclusively by skin components via the shared layer. + */ +import type { ChargingStationData, ConnectorEntry, Status } from 'ui-common' + +/** + * Status variant type for UI display. + * Maps semantic OCPP states to visual indicator categories. + */ +export type StatusVariant = 'err' | 'idle' | 'ok' | 'warn' + +/** + * Gets the ATG status for a specific connector from the station's ATG statuses array. + * @param station - The charging station data + * @param connectorId - The connector identifier + * @returns The ATG status, or undefined if not found + */ +export function getATGStatus ( + station: ChargingStationData, + connectorId: number +): Status | undefined { + return station.automaticTransactionGenerator?.automaticTransactionGeneratorStatuses?.find( + entry => entry.connectorId === connectorId + )?.status +} + +/** + * Extracts a flat array of connector entries from a charging station, filtering out placeholder + * connectors (connectorId === 0) and placeholder EVSEs (evseId === 0). + * @param station - The charging station data + * @returns Flat array of connector entries with optional evseId + */ +export function getConnectorEntries (station: ChargingStationData): ConnectorEntry[] { + if (Array.isArray(station.evses) && station.evses.length > 0) { + const entries: ConnectorEntry[] = [] + for (const evse of station.evses) { + if (evse.evseId > 0) { + for (const c of evse.evseStatus.connectors) { + if (c.connectorId > 0) { + entries.push({ + connectorId: c.connectorId, + connectorStatus: c.connectorStatus, + evseId: evse.evseId, + }) + } + } + } + } + return entries + } + return (station.connectors ?? []) + .filter(c => c.connectorId > 0) + .map(c => ({ + connectorId: c.connectorId, + connectorStatus: c.connectorStatus, + })) +} + +/** + * Maps an OCPP connector status string to a display variant. + * @param status - The OCPP connector status value + * @returns The display variant for the status + */ +export function getConnectorStatusVariant (status?: string): StatusVariant { + // cspell:ignore suspendedev suspendedevse + switch (status?.toLowerCase()) { + case 'available': + return 'ok' + // Active use states: amber to distinguish from 'available' (green) + case 'charging': + case 'occupied': + return 'warn' + case 'faulted': + case 'unavailable': + return 'err' + case 'finishing': + case 'preparing': + case 'suspendedev': + case 'suspendedevse': + return 'warn' + default: + return 'idle' + } +} + +/** + * Maps a WebSocket ready state to a display variant. + * @param wsState - The WebSocket readyState value + * @returns The display variant for the WebSocket state + */ +export function getWebSocketStateVariant (wsState?: number): StatusVariant { + switch (wsState) { + case 0: // WebSocket.CONNECTING + return 'warn' + case 1: // WebSocket.OPEN + return 'ok' + case 2: // WebSocket.CLOSING + return 'warn' + case 3: // WebSocket.CLOSED + return 'err' + default: + return 'idle' + } +} diff --git a/ui/web/src/shared/utils/stripStationId.ts b/ui/web/src/shared/utils/stripStationId.ts new file mode 100644 index 00000000..eb3e7ec6 --- /dev/null +++ b/ui/web/src/shared/utils/stripStationId.ts @@ -0,0 +1,11 @@ +/** + * Removes a trailing `/` suffix from a supervision URL. + * @param url - The supervision URL potentially containing the station ID + * @param stationId - The station ID to strip + * @returns The URL without the trailing station ID suffix + */ +export function stripStationId (url: string, stationId: string): string { + if (stationId.length === 0) return url + const suffix = `/${stationId}` + return url.endsWith(suffix) ? url.slice(0, -suffix.length) : url +} diff --git a/ui/web/src/shims-vue.d.ts b/ui/web/src/shims-vue.d.ts index 10ff1ef1..cded8ef5 100644 --- a/ui/web/src/shims-vue.d.ts +++ b/ui/web/src/shims-vue.d.ts @@ -1,5 +1,10 @@ export type {} +declare module '*.css' { + const _default: unknown + export default _default +} + declare module 'vue' { export interface GlobalComponents { RouterLink: (typeof import('vue-router'))['RouterLink'] diff --git a/ui/web/src/skins/classic/ClassicLayout.vue b/ui/web/src/skins/classic/ClassicLayout.vue new file mode 100644 index 00000000..c57ab1cc --- /dev/null +++ b/ui/web/src/skins/classic/ClassicLayout.vue @@ -0,0 +1,259 @@ + + + + + diff --git a/ui/web/src/skins/classic/classic.css b/ui/web/src/skins/classic/classic.css new file mode 100644 index 00000000..c6c92be0 --- /dev/null +++ b/ui/web/src/skins/classic/classic.css @@ -0,0 +1,5 @@ +/* Classic skin structural tokens. + * + * The color palette is provided by the active theme file. + * Only structural/layout tokens specific to the classic table-based layout belong here. + */ diff --git a/ui/web/src/components/Container.vue b/ui/web/src/skins/classic/components/ClassicContainer.vue similarity index 100% rename from ui/web/src/components/Container.vue rename to ui/web/src/skins/classic/components/ClassicContainer.vue diff --git a/ui/web/src/skins/classic/components/actions/AddChargingStations.vue b/ui/web/src/skins/classic/components/actions/AddChargingStations.vue new file mode 100644 index 00000000..6699651e --- /dev/null +++ b/ui/web/src/skins/classic/components/actions/AddChargingStations.vue @@ -0,0 +1,164 @@ + + + + + diff --git a/ui/web/src/skins/classic/components/actions/SetSupervisionUrl.vue b/ui/web/src/skins/classic/components/actions/SetSupervisionUrl.vue new file mode 100644 index 00000000..3689712c --- /dev/null +++ b/ui/web/src/skins/classic/components/actions/SetSupervisionUrl.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/ui/web/src/skins/classic/components/actions/StartTransaction.vue b/ui/web/src/skins/classic/components/actions/StartTransaction.vue new file mode 100644 index 00000000..33e82d65 --- /dev/null +++ b/ui/web/src/skins/classic/components/actions/StartTransaction.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/ui/web/src/components/buttons/Button.vue b/ui/web/src/skins/classic/components/buttons/ClassicButton.vue similarity index 79% rename from ui/web/src/components/buttons/Button.vue rename to ui/web/src/skins/classic/components/buttons/ClassicButton.vue index 2b2f8c91..5122b872 100644 --- a/ui/web/src/components/buttons/Button.vue +++ b/ui/web/src/skins/classic/components/buttons/ClassicButton.vue @@ -1,6 +1,6 @@