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:
@@ -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={() => {}}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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": "Скрыть дашборды"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user