diff --git a/TASK.md b/TASK.md index 939f297..444e154 100644 --- a/TASK.md +++ b/TASK.md @@ -82,3 +82,6 @@ - [x] Upgrade esbuild dependency to version 0.25.0 or later to address the development server request vulnerability. - [x] Simplify EmergencyManagement web UI tables with a drawer form triggered by a New button. (2025-09-25) +## 2025-09-26 +- [x] Add sortable tables to the EmergencyManagement web UI for messages and events. + diff --git a/examples/EmergencyManagement/webui/src/index.css b/examples/EmergencyManagement/webui/src/index.css index 00498f6..4ad9c69 100644 --- a/examples/EmergencyManagement/webui/src/index.css +++ b/examples/EmergencyManagement/webui/src/index.css @@ -489,6 +489,40 @@ a { color: rgba(226, 232, 240, 0.6); } +.sortable-header__button { + display: inline-flex; + align-items: center; + gap: 0.35rem; + background: none; + border: none; + color: inherit; + font: inherit; + letter-spacing: inherit; + text-transform: inherit; + cursor: pointer; + padding: 0; +} + +.sortable-header__button:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 0.5rem; +} + +.sortable-header__icon { + font-size: 0.75rem; + opacity: 0.35; + transition: transform 0.15s ease, opacity 0.15s ease; +} + +.sortable-header__icon--active { + opacity: 1; +} + +.sortable-header__button:hover .sortable-header__icon { + opacity: 0.7; +} + .table-card tbody tr:hover { background: rgba(56, 189, 248, 0.12); } diff --git a/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/MessagesTable.tsx b/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/MessagesTable.tsx index d26d5be..7b1f6f9 100644 --- a/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/MessagesTable.tsx +++ b/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/MessagesTable.tsx @@ -1,7 +1,136 @@ -import type { EmergencyActionMessage } from '../../lib/apiClient'; +import { useMemo, useState } from 'react'; + +import type { EAMStatus, EmergencyActionMessage } from '../../lib/apiClient'; import { StatusBadge } from './StatusBadge'; +type SortDirection = 'asc' | 'desc'; + +type MessageSortColumn = + | 'callsign' + | 'groupName' + | 'securityStatus' + | 'securityCapability' + | 'preparednessStatus' + | 'medicalStatus' + | 'mobilityStatus' + | 'commsStatus'; + +interface SortState { + column: MessageSortColumn; + direction: SortDirection; +} + +const STATUS_ORDER: Record = { + Green: 0, + Yellow: 1, + Red: 2, +}; + +function compareStatus( + a: EAMStatus | null | undefined, + b: EAMStatus | null | undefined, + direction: SortDirection, +): number { + if (!a && !b) { + return 0; + } + if (!a) { + return direction === 'asc' ? 1 : -1; + } + if (!b) { + return direction === 'asc' ? -1 : 1; + } + + const result = STATUS_ORDER[a] - STATUS_ORDER[b]; + return direction === 'asc' ? result : -result; +} + +function compareString( + a: string | null | undefined, + b: string | null | undefined, + direction: SortDirection, +): number { + const normalizedA = a?.trim().toLowerCase(); + const normalizedB = b?.trim().toLowerCase(); + + if (!normalizedA && !normalizedB) { + return 0; + } + if (!normalizedA) { + return direction === 'asc' ? 1 : -1; + } + if (!normalizedB) { + return direction === 'asc' ? -1 : 1; + } + + const result = normalizedA.localeCompare(normalizedB, undefined, { + sensitivity: 'base', + }); + return direction === 'asc' ? result : -result; +} + +function sortMessages( + messages: EmergencyActionMessage[], + sortState: SortState, +): EmergencyActionMessage[] { + const sorted = messages.slice(); + + sorted.sort((a, b) => { + switch (sortState.column) { + case 'callsign': + return compareString(a.callsign, b.callsign, sortState.direction); + case 'groupName': + return compareString(a.groupName, b.groupName, sortState.direction); + case 'securityStatus': + return compareStatus(a.securityStatus, b.securityStatus, sortState.direction); + case 'securityCapability': + return compareStatus(a.securityCapability, b.securityCapability, sortState.direction); + case 'preparednessStatus': + return compareStatus(a.preparednessStatus, b.preparednessStatus, sortState.direction); + case 'medicalStatus': + return compareStatus(a.medicalStatus, b.medicalStatus, sortState.direction); + case 'mobilityStatus': + return compareStatus(a.mobilityStatus, b.mobilityStatus, sortState.direction); + case 'commsStatus': + return compareStatus(a.commsStatus, b.commsStatus, sortState.direction); + default: + return 0; + } + }); + + return sorted; +} + +function getAriaSort(sortState: SortState, column: MessageSortColumn): 'ascending' | 'descending' | 'none' { + if (sortState.column !== column) { + return 'none'; + } + + return sortState.direction === 'asc' ? 'ascending' : 'descending'; +} + +function getSortButtonLabel(sortState: SortState, column: MessageSortColumn, label: string): string { + if (sortState.column === column) { + const directionLabel = sortState.direction === 'asc' ? 'ascending' : 'descending'; + return `Sort by ${label} (currently ${directionLabel})`; + } + return `Sort by ${label} (ascending)`; +} + +function renderSortIndicator(sortState: SortState, column: MessageSortColumn): JSX.Element { + const isActive = sortState.column === column; + const icon = !isActive ? '↕' : sortState.direction === 'asc' ? '↑' : '↓'; + return ( + + ); +} + export interface MessagesTableProps { messages: EmergencyActionMessage[]; isLoading: boolean; @@ -17,6 +146,22 @@ export function MessagesTable({ onDelete, onCreateNew, }: MessagesTableProps): JSX.Element { + const [sortState, setSortState] = useState({ column: 'callsign', direction: 'asc' }); + + const sortedMessages = useMemo(() => sortMessages(messages, sortState), [messages, sortState]); + + function handleSort(column: MessageSortColumn): void { + setSortState((previous) => { + if (previous.column === column) { + return { + column, + direction: previous.direction === 'asc' ? 'desc' : 'asc', + }; + } + return { column, direction: 'asc' }; + }); + } + return (
@@ -33,22 +178,107 @@ export function MessagesTable({ - - - - - - - - + + + + + + + + - {messages - .slice() - .sort((a, b) => a.callsign.localeCompare(b.callsign)) - .map((message) => ( + {sortedMessages.map((message) => (
CallsignGroupSecurityCapabilityPreparednessMedicalMobilityComms + + + + + + + + + + + + + + + + Actions
diff --git a/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/__tests__/MessagesTable.test.tsx b/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/__tests__/MessagesTable.test.tsx new file mode 100644 index 0000000..e46bb16 --- /dev/null +++ b/examples/EmergencyManagement/webui/src/pages/EmergencyActionMessages/__tests__/MessagesTable.test.tsx @@ -0,0 +1,72 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EmergencyActionMessage } from '../../../lib/apiClient'; +import { MessagesTable } from '../MessagesTable'; + +function getCallsignOrder(): string[] { + return screen + .getAllByRole('row') + .slice(1) + .map((row) => { + const callsignElement = within(row).getByText((_, element) => + element?.classList.contains('callsign') ?? false, + ); + return callsignElement.textContent?.trim() ?? ''; + }); +} + +describe('MessagesTable', () => { + it('sorts messages by callsign and toggles direction', async () => { + const messages: EmergencyActionMessage[] = [ + { callsign: 'Bravo', medicalStatus: 'Green' }, + { callsign: 'Alpha', medicalStatus: 'Yellow' }, + { callsign: 'Charlie', medicalStatus: 'Red' }, + ]; + + render( + , + ); + + expect(getCallsignOrder()).toEqual(['Alpha', 'Bravo', 'Charlie']); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Sort by Callsign/i })); + + expect(getCallsignOrder()).toEqual(['Charlie', 'Bravo', 'Alpha']); + }); + + it('sorts messages by status severity', async () => { + const messages: EmergencyActionMessage[] = [ + { callsign: 'Alpha', securityStatus: 'Red' }, + { callsign: 'Bravo', securityStatus: 'Green' }, + { callsign: 'Charlie', securityStatus: 'Yellow' }, + ]; + + render( + , + ); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Sort by Security/i })); + + expect(getCallsignOrder()).toEqual(['Bravo', 'Charlie', 'Alpha']); + + await user.click(screen.getByRole('button', { name: /Sort by Security/i })); + + expect(getCallsignOrder()).toEqual(['Alpha', 'Charlie', 'Bravo']); + }); +}); diff --git a/examples/EmergencyManagement/webui/src/pages/Events/EventsTable.tsx b/examples/EmergencyManagement/webui/src/pages/Events/EventsTable.tsx index 9268663..dc88786 100644 --- a/examples/EmergencyManagement/webui/src/pages/Events/EventsTable.tsx +++ b/examples/EmergencyManagement/webui/src/pages/Events/EventsTable.tsx @@ -1,3 +1,5 @@ +import { useMemo, useState } from 'react'; + import type { EAMStatus, EventRecord } from '../../lib/apiClient'; export interface EventsTableProps { @@ -8,6 +10,148 @@ export interface EventsTableProps { onCreateNew: () => void; } +type SortDirection = 'asc' | 'desc'; + +type EventsSortColumn = 'uid' | 'type' | 'detail' | 'start' | 'how'; + +interface SortState { + column: EventsSortColumn; + direction: SortDirection; +} + +function compareNumber( + a: number | string | null | undefined, + b: number | string | null | undefined, + direction: SortDirection, +): number { + const parsedA = typeof a === 'number' ? a : a ? Number(a) : Number.NaN; + const parsedB = typeof b === 'number' ? b : b ? Number(b) : Number.NaN; + + const isValidA = Number.isFinite(parsedA); + const isValidB = Number.isFinite(parsedB); + + if (!isValidA && !isValidB) { + return 0; + } + if (!isValidA) { + return direction === 'asc' ? 1 : -1; + } + if (!isValidB) { + return direction === 'asc' ? -1 : 1; + } + + const result = parsedA - parsedB; + return direction === 'asc' ? result : -result; +} + +function compareString( + a: string | null | undefined, + b: string | null | undefined, + direction: SortDirection, +): number { + const normalizedA = a?.trim().toLowerCase(); + const normalizedB = b?.trim().toLowerCase(); + + if (!normalizedA && !normalizedB) { + return 0; + } + if (!normalizedA) { + return direction === 'asc' ? 1 : -1; + } + if (!normalizedB) { + return direction === 'asc' ? -1 : 1; + } + + const result = normalizedA.localeCompare(normalizedB, undefined, { + sensitivity: 'base', + }); + return direction === 'asc' ? result : -result; +} + +function compareDate( + a: string | null | undefined, + b: string | null | undefined, + direction: SortDirection, +): number { + const parsedA = a ? Date.parse(a) : Number.NaN; + const parsedB = b ? Date.parse(b) : Number.NaN; + + const isValidA = Number.isFinite(parsedA); + const isValidB = Number.isFinite(parsedB); + + if (!isValidA && !isValidB) { + return 0; + } + if (!isValidA) { + return direction === 'asc' ? 1 : -1; + } + if (!isValidB) { + return direction === 'asc' ? -1 : 1; + } + + const result = parsedA - parsedB; + return direction === 'asc' ? result : -result; +} + +function getDetailSortValue(detail: EventRecord['detail']): string | undefined { + return detail?.emergencyActionMessage?.callsign ?? undefined; +} + +function sortEvents(events: EventRecord[], sortState: SortState): EventRecord[] { + const sorted = events.slice(); + + sorted.sort((a, b) => { + switch (sortState.column) { + case 'uid': + return compareNumber(a.uid, b.uid, sortState.direction); + case 'type': + return compareString(a.type, b.type, sortState.direction); + case 'detail': + return compareString( + getDetailSortValue(a.detail), + getDetailSortValue(b.detail), + sortState.direction, + ); + case 'start': + return compareDate(a.start, b.start, sortState.direction); + case 'how': + return compareString(a.how, b.how, sortState.direction); + default: + return 0; + } + }); + + return sorted; +} + +function getAriaSort(sortState: SortState, column: EventsSortColumn): 'ascending' | 'descending' | 'none' { + if (sortState.column !== column) { + return 'none'; + } + return sortState.direction === 'asc' ? 'ascending' : 'descending'; +} + +function getSortButtonLabel(sortState: SortState, column: EventsSortColumn, label: string): string { + if (sortState.column === column) { + const directionLabel = sortState.direction === 'asc' ? 'ascending' : 'descending'; + return `Sort by ${label} (currently ${directionLabel})`; + } + return `Sort by ${label} (ascending)`; +} + +function renderSortIndicator(sortState: SortState, column: EventsSortColumn): JSX.Element { + const isActive = sortState.column === column; + const icon = !isActive ? '↕' : sortState.direction === 'asc' ? '↑' : '↓'; + return ( + + ); +} + interface StatusEntry { label: string; value: EAMStatus | null | undefined; @@ -63,6 +207,22 @@ export function EventsTable({ onDelete, onCreateNew, }: EventsTableProps): JSX.Element { + const [sortState, setSortState] = useState({ column: 'uid', direction: 'asc' }); + + const sortedEvents = useMemo(() => sortEvents(events, sortState), [events, sortState]); + + function handleSort(column: EventsSortColumn): void { + setSortState((previous) => { + if (previous.column === column) { + return { + column, + direction: previous.direction === 'asc' ? 'desc' : 'asc', + }; + } + return { column, direction: 'asc' }; + }); + } + return (
@@ -79,16 +239,71 @@ export function EventsTable({ - - - - - + + + + + - {events.map((event) => ( + {sortedEvents.map((event) => ( diff --git a/examples/EmergencyManagement/webui/src/pages/Events/__tests__/EventsTable.test.tsx b/examples/EmergencyManagement/webui/src/pages/Events/__tests__/EventsTable.test.tsx new file mode 100644 index 0000000..ccd2950 --- /dev/null +++ b/examples/EmergencyManagement/webui/src/pages/Events/__tests__/EventsTable.test.tsx @@ -0,0 +1,70 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +import type { EventRecord } from '../../../lib/apiClient'; +import { EventsTable } from '../EventsTable'; + +function getUidOrder(): number[] { + return screen + .getAllByRole('row') + .slice(1) + .map((row) => { + const [uidCell] = within(row).getAllByRole('cell'); + return Number(uidCell.textContent?.trim() ?? '0'); + }); +} + +describe('EventsTable', () => { + it('sorts events by uid and toggles direction', async () => { + const events: EventRecord[] = [ + { uid: 5 }, + { uid: 2 }, + { uid: 9 }, + ]; + + render( + , + ); + + expect(getUidOrder()).toEqual([2, 5, 9]); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Sort by UID/i })); + + expect(getUidOrder()).toEqual([9, 5, 2]); + }); + + it('sorts events by start time', async () => { + const events: EventRecord[] = [ + { uid: 1, start: '2025-09-20T10:00:00Z' }, + { uid: 2, start: '2025-09-18T08:00:00Z' }, + { uid: 3, start: '2025-09-19T09:30:00Z' }, + ]; + + render( + , + ); + + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: /Sort by Start/i })); + + expect(getUidOrder()).toEqual([2, 3, 1]); + + await user.click(screen.getByRole('button', { name: /Sort by Start/i })); + + expect(getUidOrder()).toEqual([1, 3, 2]); + }); +});
UIDTypeDetailStartHow + + + + + + + + + + Actions
{event.uid} {event.type ?? '—'}