From 6b05cc62c3dd9558876a8fc3d407b0f209e73bcd Mon Sep 17 00:00:00 2001 From: busya Date: Thu, 18 Jun 2026 10:46:17 +0300 Subject: [PATCH] refactor(MaintenanceEventsTable): accessibility, types, edge cases, i18n - Add aria-expanded, aria-label on expand button and sub-row - Add TypeScript interfaces (MaintenanceEvent, MaintenanceDashboard) - Add @PRE/@POST contracts to all functions - Add @INVARIANT for expandedEventIds subset guarantee - Add @UX_TRANSITION for state machine completeness - Cleanup expandedEventIds on remove (prevents stale expanded rows) - Null-safe dashboards access (event.dashboards ?? []) - Translate ConfirmDialog titles via i18n keys - Extract truncateId/joinTables helpers - Add MAX_ID_DISPLAY_LENGTH constant --- .../components/MaintenanceEventsTable.svelte | 243 +++++++++++------- .../src/lib/i18n/locales/en/maintenance.json | 7 +- .../src/lib/i18n/locales/ru/maintenance.json | 7 +- 3 files changed, 165 insertions(+), 92 deletions(-) diff --git a/frontend/src/lib/components/MaintenanceEventsTable.svelte b/frontend/src/lib/components/MaintenanceEventsTable.svelte index 25214236..ca5a6e6d 100644 --- a/frontend/src/lib/components/MaintenanceEventsTable.svelte +++ b/frontend/src/lib/components/MaintenanceEventsTable.svelte @@ -4,6 +4,8 @@ + + @@ -11,6 +13,10 @@ + + + + @@ -20,16 +26,45 @@ import { EmptyState, ConfirmDialog } from "$lib/ui"; import { t } from "$lib/i18n/index.svelte.js"; - let removingEventId = $state(null); + // ── Types ───────────────────────────────────────────────────── + interface MaintenanceDashboard { + id: number; + title: string; + } + + interface MaintenanceEvent { + id: string; + tables?: string[]; + affected_count?: number; + dashboards?: MaintenanceDashboard[]; + start_time?: string; + end_time?: string; + status?: string; + [k: string]: unknown; + } + + // ── State ───────────────────────────────────────────────────── + let removingEventId = $state(null); let isRemovingAll = $state(false); let showRemoveConfirm = $state(false); let showRemoveAllConfirm = $state(false); - let removeTarget = $state(null); + let removeTarget = $state(null); // ── Expandable rows ───────────────────────────────────────── let expandedEventIds = $state>(new Set()); - function toggleExpanded(eventId: string) { + // ── Constants ──────────────────────────────────────────────── + const MAX_ID_DISPLAY_LENGTH = 16; + + let isLoading = $derived(maintenanceStore.isLoading); + let activeEvents = $derived(maintenanceStore.activeEvents as MaintenanceEvent[]); + let completedEvents = $derived(maintenanceStore.completedEvents as MaintenanceEvent[]); + + // #region toggleExpanded [C:2] [TYPE Function] + // @BRIEF Toggle expanded state for an event's dashboard list. + // @PRE eventId is a non-empty string. + // @POST expandedEventIds contains or does not contain eventId. + function toggleExpanded(eventId: string): void { const next = new Set(expandedEventIds); if (next.has(eventId)) { next.delete(eventId); @@ -38,17 +73,13 @@ } expandedEventIds = next; } + // #endregion toggleExpanded - let isLoading = $derived(maintenanceStore.isLoading); - let activeEvents = $derived(maintenanceStore.activeEvents); - let completedEvents = $derived(maintenanceStore.completedEvents); - - /** - * Get CSS class for status badge. - * @param {string} status - * @returns {string} - */ - function statusBadgeClass(status) { + // #region statusBadgeClass [C:2] [TYPE Function] + // @BRIEF Map event status to Tailwind badge classes. + // @PRE status is a string or null/undefined. + // @POST Returns a valid Tailwind class string. + function statusBadgeClass(status: string | undefined | null): string { switch ((status || "").toLowerCase()) { case "active": case "applying": @@ -65,59 +96,13 @@ return "bg-surface-muted text-text"; } } + // #endregion statusBadgeClass - /** - * Handle removing a single event — opens confirmation dialog. - * @param {object} event - */ - function promptRemove(event) { - removeTarget = event; - showRemoveConfirm = true; - } - - /** - * Handle confirmed removal of a single event. - */ - async function onConfirmRemove() { - const event = removeTarget; - removeTarget = null; - removingEventId = event.id; - try { - await maintenanceStore.endEvent(event.id); - } catch { - // Toast is handled by store - } finally { - removingEventId = null; - } - } - - /** - * Handle removing all events — opens confirmation dialog. - */ - function promptRemoveAll() { - showRemoveAllConfirm = true; - } - - /** - * Handle confirmed removal of all events. - */ - async function onConfirmRemoveAll() { - isRemovingAll = true; - try { - await maintenanceStore.endAllEvents(); - } catch { - // Toast handled by store - } finally { - isRemovingAll = false; - } - } - - /** - * Format a timestamp string for display. - * @param {string} ts - * @returns {string} - */ - function formatTime(ts) { + // #region formatTime [C:2] [TYPE Function] + // @BRIEF Format an ISO timestamp string for display. + // @PRE ts is an ISO-8601 string or null/undefined. + // @POST Returns localized time string or "-" for missing input. + function formatTime(ts: string | undefined | null): string { if (!ts) return "-"; try { const d = new Date(ts); @@ -127,6 +112,76 @@ return ts; } } + // #endregion formatTime + + // #region truncateId [C:1] [TYPE Function] + // @BRIEF Truncate long ID for display with ellipsis. + function truncateId(id: string | undefined): string { + if (!id) return "-"; + return id.length > MAX_ID_DISPLAY_LENGTH ? id.slice(0, MAX_ID_DISPLAY_LENGTH) + "…" : id; + } + // #endregion truncateId + + // #region promptRemove [C:2] [TYPE Function] + // @BRIEF Open confirmation dialog for single event removal. + function promptRemove(event: MaintenanceEvent): void { + removeTarget = event; + showRemoveConfirm = true; + } + // #endregion promptRemove + + // #region onConfirmRemove [C:2] [TYPE Function] + // @BRIEF Handle confirmed removal of a single event. Cleans up expanded state. + // @POST expandedEventIds no longer contains the removed event ID. + async function onConfirmRemove(): Promise { + const event = removeTarget; + if (!event) return; + removeTarget = null; + + // @INVARIANT: cleanup expanded state before removal + expandedEventIds.delete(event.id); + expandedEventIds = new Set(expandedEventIds); + + removingEventId = event.id; + try { + await maintenanceStore.endEvent(event.id); + } catch { + // Toast is handled by store + } finally { + removingEventId = null; + } + } + // #endregion onConfirmRemove + + // #region promptRemoveAll [C:1] [TYPE Function] + // @BRIEF Open confirmation dialog for bulk removal. + function promptRemoveAll(): void { + showRemoveAllConfirm = true; + } + // #endregion promptRemoveAll + + // #region onConfirmRemoveAll [C:2] [TYPE Function] + // @BRIEF Handle confirmed removal of all events. Clears all expanded state. + // @POST expandedEventIds is empty after execution. + async function onConfirmRemoveAll(): Promise { + isRemovingAll = true; + expandedEventIds = new Set(); + try { + await maintenanceStore.endAllEvents(); + } catch { + // Toast handled by store + } finally { + isRemovingAll = false; + } + } + // #endregion onConfirmRemoveAll + + // #region joinTables [C:1] [TYPE Function] + // @BRIEF Join table names with comma, return "-" if empty. + function joinTables(tables: string[] | undefined): string { + return (tables || []).join(", ") || "-"; + } + // #endregion joinTables
@@ -153,7 +208,7 @@ {#if isLoading && activeEvents.length === 0}
- +
@@ -186,7 +241,7 @@ {:else}
-
ID
+
{#each activeEvents as event (event.id)} - - + - - {#if expandedEventIds.has(event.id) && event.dashboards && event.dashboards.length > 0} - + {#if isExpanded && hasDashboards} +
@@ -214,23 +269,31 @@
- {event.id?.length > 16 ? event.id.slice(0, 16) + "…" : event.id} + {@const isExpanded = expandedEventIds.has(event.id)} + {@const hasDashboards = (event.dashboards?.length ?? 0) > 0} +
+ {truncateId(event.id)} - {(event.tables || []).join(", ") || "-"} + + {joinTables(event.tables)} - {#if event.dashboards && event.dashboards.length > 0} + {#if hasDashboards}

{$t.maintenance?.affected_dashboards_list || "Affected dashboards"}:

-
    - {#each event.dashboards as dash (dash.id)} +
      + {#each event.dashboards ?? [] as dash (dash.id)}
    • - + {dash.title} #{dash.id}
    • @@ -299,7 +362,7 @@
      - +
      @@ -313,11 +376,11 @@ {#each completedEvents as event (event.id)} - -
      ID
      - {event.id?.length > 16 ? event.id.slice(0, 16) + "…" : event.id} + + {truncateId(event.id)} - {(event.tables || []).join(", ") || "-"} + + {joinTables(event.tables)} {event.affected_count ?? "-"} @@ -345,22 +408,22 @@ {}} /> {}} /> diff --git a/frontend/src/lib/i18n/locales/en/maintenance.json b/frontend/src/lib/i18n/locales/en/maintenance.json index 7cebe352..ee55fc8d 100644 --- a/frontend/src/lib/i18n/locales/en/maintenance.json +++ b/frontend/src/lib/i18n/locales/en/maintenance.json @@ -92,5 +92,10 @@ "loading_dashboards": "Loading dashboards...", "no_dashboards_found": "No dashboards found for this table.", "affected_dashboards_list": "Affected dashboards", - "preview_error": "Failed to preview dashboards" + "preview_error": "Failed to preview dashboards", + + "remove_event_title": "Remove event?", + "remove_all_title": "Remove all events?", + "expand_dashboards": "Show {count} dashboards", + "collapse_dashboards": "Hide dashboards" } diff --git a/frontend/src/lib/i18n/locales/ru/maintenance.json b/frontend/src/lib/i18n/locales/ru/maintenance.json index 5d7bb5bb..24db0825 100644 --- a/frontend/src/lib/i18n/locales/ru/maintenance.json +++ b/frontend/src/lib/i18n/locales/ru/maintenance.json @@ -92,5 +92,10 @@ "loading_dashboards": "Загрузка дашбордов...", "no_dashboards_found": "Дашборды для этой таблицы не найдены.", "affected_dashboards_list": "Затронутые дашборды", - "preview_error": "Не удалось загрузить предпросмотр дашбордов" + "preview_error": "Не удалось загрузить предпросмотр дашбордов", + + "remove_event_title": "Удалить событие?", + "remove_all_title": "Удалить все события?", + "expand_dashboards": "Показать {count} дашбордов", + "collapse_dashboards": "Скрыть дашборды" }