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
This commit is contained in:
2026-06-18 10:46:17 +03:00
parent 190b913ae4
commit 6b05cc62c3
3 changed files with 165 additions and 92 deletions

View File

@@ -4,6 +4,8 @@
<!-- @LAYER Component -->
<!-- @RELATION DEPENDS_ON -> [MaintenanceStore] -->
<!-- @RELATION DEPENDS_ON -> [MaintenanceApi] -->
<!-- @INVARIANT expandedEventIds is always a subset of active event IDs. -->
<!-- @INVARIANT Only one ConfirmDialog can be open at a time. -->
<!-- @UX_STATE idle -> Table rendered with events. -->
<!-- @UX_STATE removing -> One row shows spinner on its Remove button, other buttons disabled. -->
<!-- @UX_STATE removing_all -> All buttons disabled, "Remove All" shows spinner. -->
@@ -11,6 +13,10 @@
<!-- @UX_STATE loading -> Skeleton rows displayed. -->
<!-- @UX_STATE error -> Toast shown on removal failure. -->
<!-- @UX_STATE expanded -> Dashboard list visible in sub-row for clicked event. -->
<!-- @UX_TRANSITION idle -> expanded: user clicks dashboard count. -->
<!-- @UX_TRANSITION expanded -> idle: user clicks again or event removed. -->
<!-- @UX_TRANSITION idle -> removing: user confirms remove dialog. -->
<!-- @UX_TRANSITION removing -> idle: remove completes or fails. -->
<!-- @UX_FEEDBACK Toast success/error on removal. -->
<!-- @UX_RECOVERY Retry via individual or bulk remove. -->
<!-- @UX_REACTIVITY Props -> $props(), LocalState -> $state(removingEventId, isRemovingAll, expandedEventIds) -->
@@ -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<string | null>(null);
let isRemovingAll = $state(false);
let showRemoveConfirm = $state(false);
let showRemoveAllConfirm = $state(false);
let removeTarget = $state(null);
let removeTarget = $state<MaintenanceEvent | null>(null);
// ── Expandable rows ─────────────────────────────────────────
let expandedEventIds = $state<Set<string>>(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<void> {
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<void> {
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
</script>
<div class="space-y-6">
@@ -153,7 +208,7 @@
{#if isLoading && activeEvents.length === 0}
<!-- Skeleton -->
<div class="border border-border rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-border">
<table class="min-w-full divide-y divide-border" aria-label="Loading maintenance events">
<thead class="bg-surface-muted">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">ID</th>
@@ -186,7 +241,7 @@
{:else}
<!-- Events table -->
<div class="border border-border rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-border">
<table class="min-w-full divide-y divide-border" aria-label={$t.maintenance?.active_events || "Active Events"}>
<thead class="bg-surface-muted">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
@@ -214,23 +269,31 @@
</thead>
<tbody class="bg-surface-card divide-y divide-border">
{#each activeEvents as event (event.id)}
<tr class="hover:bg-surface-muted">
<td class="px-4 py-3 text-sm text-text font-mono text-xs" title={event.id}>
{event.id?.length > 16 ? event.id.slice(0, 16) + "…" : event.id}
{@const isExpanded = expandedEventIds.has(event.id)}
{@const hasDashboards = (event.dashboards?.length ?? 0) > 0}
<tr class="hover:bg-surface-muted" aria-expanded={isExpanded}>
<td class="px-4 py-3 text-sm font-mono text-text" title={event.id}>
{truncateId(event.id)}
</td>
<td class="px-4 py-3 text-sm text-text-muted max-w-[200px] truncate" title={(event.tables || []).join(", ")}>
{(event.tables || []).join(", ") || "-"}
<td class="px-4 py-3 text-sm text-text-muted max-w-[200px] truncate" title={joinTables(event.tables)}>
{joinTables(event.tables)}
</td>
<td class="px-4 py-3 text-sm">
{#if event.dashboards && event.dashboards.length > 0}
{#if hasDashboards}
<button
type="button"
onclick={() => toggleExpanded(event.id)}
aria-expanded={isExpanded}
aria-label={isExpanded
? ($t.maintenance?.collapse_dashboards || "Hide dashboards")
: ($t.maintenance?.expand_dashboards || "Show {count} dashboards").replace("{count}", String(event.affected_count ?? 0))
}
class="inline-flex items-center gap-1 text-text hover:text-primary transition-colors cursor-pointer"
>
<svg
class="w-3.5 h-3.5 transition-transform {expandedEventIds.has(event.id) ? 'rotate-90' : ''}"
class="w-3.5 h-3.5 transition-transform {isExpanded ? 'rotate-90' : ''}"
fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"
aria-hidden="true"
>
<path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7" />
</svg>
@@ -263,17 +326,17 @@
</Button>
</td>
</tr>
{#if expandedEventIds.has(event.id) && event.dashboards && event.dashboards.length > 0}
<tr>
{#if isExpanded && hasDashboards}
<tr role="row" aria-label="Affected dashboards list">
<td colspan="7" class="px-4 py-2 bg-surface-muted">
<div class="pl-6">
<p class="text-xs font-medium text-text-muted mb-1">
{$t.maintenance?.affected_dashboards_list || "Affected dashboards"}:
</p>
<ul class="space-y-0.5">
{#each event.dashboards as dash (dash.id)}
<ul class="space-y-0.5" role="list">
{#each event.dashboards ?? [] as dash (dash.id)}
<li class="text-xs text-text flex items-center gap-1.5">
<span class="inline-block w-1.5 h-1.5 rounded-full bg-warning-DEFAULT flex-shrink-0"></span>
<span class="inline-block w-1.5 h-1.5 rounded-full bg-warning-DEFAULT flex-shrink-0" aria-hidden="true"></span>
<span class="truncate">{dash.title}</span>
<span class="text-text-subtle flex-shrink-0">#{dash.id}</span>
</li>
@@ -299,7 +362,7 @@
<div class="px-4 pb-4">
<div class="border border-border rounded-lg overflow-hidden">
<table class="min-w-full divide-y divide-border">
<table class="min-w-full divide-y divide-border" aria-label={$t.maintenance?.completed_events || "Completed Events"}>
<thead class="bg-surface-muted">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">ID</th>
@@ -313,11 +376,11 @@
<tbody class="bg-surface-card divide-y divide-border">
{#each completedEvents as event (event.id)}
<tr class="hover:bg-surface-muted">
<td class="px-4 py-3 text-sm text-text font-mono text-xs" title={event.id}>
{event.id?.length > 16 ? event.id.slice(0, 16) + "…" : event.id}
<td class="px-4 py-3 text-sm font-mono text-text" title={event.id}>
{truncateId(event.id)}
</td>
<td class="px-4 py-3 text-sm text-text-muted max-w-[200px] truncate" title={(event.tables || []).join(", ")}>
{(event.tables || []).join(", ") || "-"}
<td class="px-4 py-3 text-sm text-text-muted max-w-[200px] truncate" title={joinTables(event.tables)}>
{joinTables(event.tables)}
</td>
<td class="px-4 py-3 text-sm text-text">
{event.affected_count ?? "-"}
@@ -345,22 +408,22 @@
<ConfirmDialog
bind:show={showRemoveConfirm}
title="Remove event?"
title={$t.maintenance?.remove_event_title || "Remove event?"}
message={$t.maintenance?.remove_event_confirm || "Remove banners for this event?"}
variant="destructive"
confirmLabel="Remove"
cancelLabel="Cancel"
confirmLabel={$t.maintenance?.remove_banners || "Remove"}
cancelLabel={$t.common?.cancel || "Cancel"}
onConfirm={onConfirmRemove}
onCancel={() => {}}
/>
<ConfirmDialog
bind:show={showRemoveAllConfirm}
title="Remove all events?"
title={$t.maintenance?.remove_all_title || "Remove all events?"}
message={$t.maintenance?.remove_all_confirm || "Remove all active maintenance banners?"}
variant="destructive"
confirmLabel="Remove All"
cancelLabel="Cancel"
confirmLabel={$t.maintenance?.remove_all || "Remove All"}
cancelLabel={$t.common?.cancel || "Cancel"}
onConfirm={onConfirmRemoveAll}
onCancel={() => {}}
/>

View File

@@ -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"
}

View File

@@ -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": "Скрыть дашборды"
}