Skip to content
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
103 changes: 103 additions & 0 deletions apps/web/app/incidents/IncidentFilters.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import React from 'react';
import {
Incident,
IncidentPriority,
IncidentStatus,
INCIDENT_PRIORITIES,
INCIDENT_STATUSES,
PRIORITY_LABELS,
} from './types';

export type StatusFilter = IncidentStatus | 'all';
export type PriorityFilter = IncidentPriority | 'all';

interface IncidentFiltersProps {
incidents: Incident[];
status: StatusFilter;
priority: PriorityFilter;
onStatusChange: (status: StatusFilter) => void;
onPriorityChange: (priority: PriorityFilter) => void;
}

/**
* Status and priority filters.
*
* Counts are computed from the unfiltered incident set so that each control
* shows how many incidents it would reveal, rather than how many are currently
* on screen. A filter that would show nothing is disabled instead of hidden, so
* the set of controls stays stable as the data changes.
*/
export const IncidentFilters: React.FC<IncidentFiltersProps> = ({
incidents,
status,
priority,
onStatusChange,
onPriorityChange,
}) => {
const countByStatus = (value: IncidentStatus) =>
incidents.filter(incident => incident.status === value).length;

const countByPriority = (value: IncidentPriority) =>
incidents.filter(incident => incident.priority === value).length;

return (
<section className="inc-filters" aria-label="Incident filters">
<div className="inc-filter-group" role="group" aria-label="Filter by status">
<span className="inc-filter-legend">Status</span>
<button
type="button"
className={`inc-chip ${status === 'all' ? 'inc-chip--active' : ''}`}
aria-pressed={status === 'all'}
onClick={() => onStatusChange('all')}
>
All <span className="inc-chip-count">{incidents.length}</span>
</button>
{INCIDENT_STATUSES.map(value => {
const count = countByStatus(value);
return (
<button
key={value}
type="button"
className={`inc-chip inc-chip--${value} ${status === value ? 'inc-chip--active' : ''}`}
aria-pressed={status === value}
disabled={count === 0}
onClick={() => onStatusChange(value)}
>
{value} <span className="inc-chip-count">{count}</span>
</button>
);
})}
</div>

<div className="inc-filter-group" role="group" aria-label="Filter by priority">
<span className="inc-filter-legend">Priority</span>
<button
type="button"
className={`inc-chip ${priority === 'all' ? 'inc-chip--active' : ''}`}
aria-pressed={priority === 'all'}
onClick={() => onPriorityChange('all')}
>
All <span className="inc-chip-count">{incidents.length}</span>
</button>
{INCIDENT_PRIORITIES.map(value => {
const count = countByPriority(value);
return (
<button
key={value}
type="button"
className={`inc-chip inc-chip--${value} ${priority === value ? 'inc-chip--active' : ''}`}
aria-pressed={priority === value}
disabled={count === 0}
onClick={() => onPriorityChange(value)}
title={PRIORITY_LABELS[value]}
>
{value.toUpperCase()} <span className="inc-chip-count">{count}</span>
</button>
);
})}
</div>
</section>
);
};

export default IncidentFilters;
100 changes: 100 additions & 0 deletions apps/web/app/incidents/IncidentList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from 'react';
import { Incident, PRIORITY_LABELS, isTerminal } from './types';

interface IncidentListProps {
incidents: Incident[];
}

const formatTimestamp = (iso: string): string => {
const date = new Date(iso);
return Number.isNaN(date.getTime()) ? iso : date.toISOString().replace('T', ' ').slice(0, 16);
};

/**
* The incident listing.
*
* Each row leads with a priority indicator: a colour-coded dot plus its literal
* priority text. Colour alone would leave the most important signal on the page
* unreadable to anyone who cannot distinguish the hues, so the label is always
* rendered and the dot carries no information of its own.
*/
export const IncidentList: React.FC<IncidentListProps> = ({ incidents }) => {
if (incidents.length === 0) {
return (
<div className="inc-empty" role="status">
<p className="inc-empty-title">No incidents match these filters</p>
<p className="inc-empty-hint">Clear a filter to widen the search.</p>
</div>
);
}

return (
<ul className="inc-list" aria-label="Incidents">
{incidents.map(incident => (
<li
key={incident.id}
className={`inc-row ${isTerminal(incident.status) ? 'inc-row--terminal' : ''}`}
>
<div className="inc-row-priority">
<span
className={`inc-priority-dot inc-priority-dot--${incident.priority}`}
aria-hidden="true"
/>
<span className="inc-priority-label">{PRIORITY_LABELS[incident.priority]}</span>
</div>

<div className="inc-row-main">
<div className="inc-row-heading">
<h3 className="inc-row-title">{incident.title}</h3>
<span className={`inc-status inc-status--${incident.status}`}>{incident.status}</span>
<span className={`inc-severity inc-severity--${incident.severity}`}>
{incident.severity}
</span>
</div>

<p className="inc-row-description">{incident.description}</p>

<dl className="inc-row-meta">
<div className="inc-meta-item">
<dt>ID</dt>
<dd>{incident.id}</dd>
</div>
{incident.category && (
<div className="inc-meta-item">
<dt>Category</dt>
<dd>{incident.category}</dd>
</div>
)}
<div className="inc-meta-item">
<dt>Owner</dt>
<dd>{incident.assignedTo ?? 'Unassigned'}</dd>
</div>
{incident.detectionSource && (
<div className="inc-meta-item">
<dt>Source</dt>
<dd>{incident.detectionSource}</dd>
</div>
)}
<div className="inc-meta-item">
<dt>Updated</dt>
<dd>{formatTimestamp(incident.updatedAt)}</dd>
</div>
</dl>

{incident.tags.length > 0 && (
<ul className="inc-tags" aria-label={`Tags for ${incident.title}`}>
{incident.tags.map(tag => (
<li key={tag} className="inc-tag">
{tag}
</li>
))}
</ul>
)}
</div>
</li>
))}
</ul>
);
};

export default IncidentList;
Loading
Loading