Skip to content
This repository was archived by the owner on May 3, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions TASK.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

34 changes: 34 additions & 0 deletions examples/EmergencyManagement/webui/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EAMStatus, number> = {
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 (
<span
className={`sortable-header__icon${isActive ? ' sortable-header__icon--active' : ''}`}
aria-hidden="true"
>
{icon}
</span>
);
}

export interface MessagesTableProps {
messages: EmergencyActionMessage[];
isLoading: boolean;
Expand All @@ -17,6 +146,22 @@ export function MessagesTable({
onDelete,
onCreateNew,
}: MessagesTableProps): JSX.Element {
const [sortState, setSortState] = useState<SortState>({ 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 (
<div className="table-card">
<header className="table-card__header">
Expand All @@ -33,22 +178,107 @@ export function MessagesTable({
<table>
<thead>
<tr>
<th>Callsign</th>
<th>Group</th>
<th>Security</th>
<th>Capability</th>
<th>Preparedness</th>
<th>Medical</th>
<th>Mobility</th>
<th>Comms</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'callsign')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('callsign')}
aria-label={getSortButtonLabel(sortState, 'callsign', 'Callsign')}
title="Sort by Callsign"
>
<span>Callsign</span>
{renderSortIndicator(sortState, 'callsign')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'groupName')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('groupName')}
aria-label={getSortButtonLabel(sortState, 'groupName', 'Group')}
title="Sort by Group"
>
<span>Group</span>
{renderSortIndicator(sortState, 'groupName')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'securityStatus')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('securityStatus')}
aria-label={getSortButtonLabel(sortState, 'securityStatus', 'Security')}
title="Sort by Security"
>
<span>Security</span>
{renderSortIndicator(sortState, 'securityStatus')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'securityCapability')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('securityCapability')}
aria-label={getSortButtonLabel(sortState, 'securityCapability', 'Capability')}
title="Sort by Capability"
>
<span>Capability</span>
{renderSortIndicator(sortState, 'securityCapability')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'preparednessStatus')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('preparednessStatus')}
aria-label={getSortButtonLabel(sortState, 'preparednessStatus', 'Preparedness')}
title="Sort by Preparedness"
>
<span>Preparedness</span>
{renderSortIndicator(sortState, 'preparednessStatus')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'medicalStatus')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('medicalStatus')}
aria-label={getSortButtonLabel(sortState, 'medicalStatus', 'Medical')}
title="Sort by Medical"
>
<span>Medical</span>
{renderSortIndicator(sortState, 'medicalStatus')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'mobilityStatus')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('mobilityStatus')}
aria-label={getSortButtonLabel(sortState, 'mobilityStatus', 'Mobility')}
title="Sort by Mobility"
>
<span>Mobility</span>
{renderSortIndicator(sortState, 'mobilityStatus')}
</button>
</th>
<th scope="col" aria-sort={getAriaSort(sortState, 'commsStatus')}>
<button
type="button"
className="sortable-header__button"
onClick={() => handleSort('commsStatus')}
aria-label={getSortButtonLabel(sortState, 'commsStatus', 'Comms')}
title="Sort by Comms"
>
<span>Comms</span>
{renderSortIndicator(sortState, 'commsStatus')}
</button>
</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{messages
.slice()
.sort((a, b) => a.callsign.localeCompare(b.callsign))
.map((message) => (
{sortedMessages.map((message) => (
<tr key={message.callsign}>
<td>
<div className="callsign-cell">
Expand Down
Loading
Loading