feat(ui): allow incidents to be deleted when closed (#1156)

* feat(ui): allow incidents to be deleted
* fix(incidents): fix broken Incident list view tests
This commit is contained in:
Daniel Aschwanden
2025-11-24 11:44:18 +01:00
committed by GitHub
parent fcdee40074
commit e6cbb9287f
10 changed files with 427 additions and 285 deletions
@@ -62,6 +62,7 @@ update_permissions:
permission:
columns:
- closed_at
- deleted_at
- location_id
- name
filter: {}
@@ -28,6 +28,7 @@ select_permissions:
- created_at
- deleted_at
- id
- incident_id
- name
- updated_at
filter: {}
@@ -38,6 +39,7 @@ select_permissions:
- created_at
- deleted_at
- id
- incident_id
- name
- updated_at
filter: {}
@@ -46,6 +48,7 @@ update_permissions:
permission:
columns:
- closed_at
- deleted_at
- name
filter: {}
check: null
+2 -1
View File
@@ -12,6 +12,7 @@
"createNewRequest": "Neuen Antrag erstellen",
"createNewTask": "Pendenz erfassen",
"createdAt": "Eröffnet",
"delete": "löschen",
"divisions": "Fachbereiche",
"edit": "bearbeiten",
"editIncident": "Ereignis bearbeiten",
@@ -270,4 +271,4 @@
"styleController": {
"maps": "Karten"
}
}
}
+2 -1
View File
@@ -13,6 +13,7 @@
"createNewTask": "create new task",
"createdAt": "opened",
"divisions": "divisions",
"delete": "delete",
"edit": "edit",
"editIncident": "edit incident",
"editor": "editor",
@@ -270,4 +271,4 @@
"styleController": {
"maps": "Maps"
}
}
}
+2 -1
View File
@@ -13,6 +13,7 @@
"createNewTask": "créer une nouvelle tâche",
"createdAt": "ouvert",
"divisions": "domaine",
"delete": "supprimer",
"edit": "éditer",
"editIncident": "modifier l'incident",
"editor": "éditeur",
@@ -269,4 +270,4 @@
},
"write": "écrivez",
"updateNotification": "Une nouvelle version de l'application est disponible. Veuillez actualiser la page en cliquant sur le bouton Refresh."
}
}
+2 -1
View File
@@ -13,6 +13,7 @@
"createNewTask": "crea una nuova attività",
"createdAt": "aperto",
"divisions": "dipartimenti",
"delete": "eliminare",
"edit": "modificare",
"editIncident": "modifica incidente",
"editor": "editore",
@@ -269,4 +270,4 @@
},
"write": "scrivere",
"updateNotification": "È disponibile una nuova versione dell'applicazione. Si prega di aggiornare la pagina con il pulsante di aggiornamento."
}
}
+11
View File
@@ -81,3 +81,14 @@ export type CloseIncidentMutationVariables = {
incidentId?: string;
closedAt?: Date | null;
};
export type DeleteIncidentMutation = {
updateIncidents: {
affectedRows: number;
returning: { id: string; deletedAt: Date | null }[];
} | null;
};
export type DeleteIncidentMutationVariables = {
incidentId?: string;
deletedAt?: Date | null;
};
+160 -122
View File
@@ -1,151 +1,189 @@
import { render, screen, fireEvent } from "@testing-library/react";
import { IncidentCard, IncidentCards } from "./List";
import { fireEvent, render, screen } from "@testing-library/react";
import { vi } from "vitest";
import type { Incident } from "../../types";
import { IncidentCard, IncidentCards } from "./List";
// Mock useNavigate
const mockNavigate = vi.fn();
vi.mock("react-router", () => ({
useNavigate: () => mockNavigate,
useNavigate: () => mockNavigate,
}));
// Mock useTranslation
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({ t: (key: string) => key }),
}));
// Mock dayjs
vi.mock("dayjs", () => {
const actual = vi.importActual("dayjs");
return {
__esModule: true,
default: (_date: string | Date) => ({ format: () => "formatted-date" }),
...actual,
};
const actual = vi.importActual("dayjs");
return {
__esModule: true,
default: (_date: string | Date) => ({ format: () => "formatted-date" }),
...actual,
};
});
describe("IncidentCard", () => {
const baseIncident: Incident = {
id: "1",
name: "Test Incident",
location: { name: "Test Location", id: "loc1", coordinates: "0,0" },
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: new Date(0),
closedAt: new Date(0),
divisions: [],
journals: [],
layers: [],
};
const mockCloseIncident = vi.fn();
const baseIncident: Incident = {
id: "1",
name: "Test Incident",
location: { name: "Test Location", id: "loc1", coordinates: "0,0" },
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
closedAt: null,
divisions: [],
journals: [],
layers: [],
};
const mockCloseIncident = vi.fn();
const mockDeleteIncident = vi.fn();
it("renders incident name and location", () => {
render(
<IncidentCard
incident={baseIncident}
closeIncident={mockCloseIncident}
/>,
);
expect(screen.getByText("Test Incident")).toBeInTheDocument();
expect(screen.getByText("Test Location")).toBeInTheDocument();
});
it("renders incident name and location", () => {
render(
<IncidentCard
incident={baseIncident}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
expect(screen.getByText("Test Incident")).toBeInTheDocument();
expect(screen.getByText("Test Location")).toBeInTheDocument();
});
it("renders close button when incident is open", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: null }}
closeIncident={mockCloseIncident}
/>,
);
expect(screen.getByTestId("close-button")).toBeInTheDocument();
expect(screen.queryByTestId("open-button")).not.toBeInTheDocument();
});
it("renders close button when incident is open", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: null }}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
expect(screen.getByTestId("close-button")).toBeInTheDocument();
expect(screen.queryByTestId("open-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).not.toBeInTheDocument();
});
it("renders open button when incident is closed", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: new Date() }}
closeIncident={mockCloseIncident}
/>,
);
expect(screen.getByTestId("open-button")).toBeInTheDocument();
expect(screen.queryByTestId("close-button")).not.toBeInTheDocument();
});
it("renders open button when incident is closed", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: new Date() }}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
expect(screen.getByTestId("open-button")).toBeInTheDocument();
expect(screen.queryByTestId("close-button")).not.toBeInTheDocument();
});
it("renders delete button when incident is closed", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: new Date() }}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
expect(screen.getByTestId("open-button")).toBeInTheDocument();
expect(screen.queryByTestId("delete-button")).toBeInTheDocument();
});
it("calls navigate when enter button is clicked", () => {
render(
<IncidentCard
incident={baseIncident}
closeIncident={mockCloseIncident}
/>,
);
fireEvent.click(screen.getByTestId("enter-button"));
expect(mockNavigate).toHaveBeenCalled();
});
it("calls navigate when enter button is clicked", () => {
render(
<IncidentCard
incident={baseIncident}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
fireEvent.click(screen.getByTestId("enter-button"));
expect(mockNavigate).toHaveBeenCalled();
});
it("calls navigate when edit button is clicked", () => {
render(
<IncidentCard
incident={baseIncident}
closeIncident={mockCloseIncident}
/>,
);
fireEvent.click(screen.getByTestId("edit-button"));
expect(mockNavigate).toHaveBeenCalled();
});
it("calls navigate when edit button is clicked", () => {
render(
<IncidentCard
incident={baseIncident}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
fireEvent.click(screen.getByTestId("edit-button"));
expect(mockNavigate).toHaveBeenCalled();
});
it("calls closeIncident when close button is clicked", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: null }}
closeIncident={mockCloseIncident}
/>,
);
fireEvent.click(screen.getByTestId("close-button"));
expect(mockCloseIncident).toHaveBeenCalled();
});
it("calls closeIncident when close button is clicked", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: null }}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
fireEvent.click(screen.getByTestId("close-button"));
expect(mockCloseIncident).toHaveBeenCalled();
});
it("calls closeIncident when open button is clicked", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: new Date() }}
closeIncident={mockCloseIncident}
/>,
);
const openButton = screen.getByTestId("open-button");
fireEvent.click(openButton);
expect(mockCloseIncident).toHaveBeenCalled();
});
it("calls closeIncident when open button is clicked", () => {
render(
<IncidentCard
incident={{ ...baseIncident, closedAt: new Date() }}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
const openButton = screen.getByTestId("open-button");
fireEvent.click(openButton);
expect(mockCloseIncident).toHaveBeenCalled();
});
});
describe("IncidentCards", () => {
const baseIncident: Incident = {
id: "1",
name: "Test Incident",
location: { name: "Test Location", id: "loc1", coordinates: "0,0" },
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: new Date(0),
closedAt: new Date(0),
divisions: [],
journals: [],
layers: [],
};
const mockCloseIncident = vi.fn();
const baseIncident: Incident = {
id: "1",
name: "Test Incident",
location: { name: "Test Location", id: "loc1", coordinates: "0,0" },
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
closedAt: null,
divisions: [],
journals: [],
layers: [],
};
const mockCloseIncident = vi.fn();
const mockDeleteIncident = vi.fn();
it("renders multiple IncidentCard components", () => {
render(
<IncidentCards
incidents={[
baseIncident,
{ ...baseIncident, id: "2", name: "Another Incident" },
]}
closeIncident={mockCloseIncident}
/>,
);
expect(screen.getByText("Test Incident")).toBeInTheDocument();
expect(screen.getByText("Another Incident")).toBeInTheDocument();
// Assert the number of IncidentCard children by counting a unique test id
expect(screen.getAllByTestId("edit-button")).toHaveLength(2);
});
it("renders multiple IncidentCard components", () => {
render(
<IncidentCards
incidents={[
baseIncident,
{ ...baseIncident, id: "2", name: "Another Incident" },
]}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
expect(screen.getByText("Test Incident")).toBeInTheDocument();
expect(screen.getByText("Another Incident")).toBeInTheDocument();
// Assert the number of IncidentCard children by counting a unique test id
expect(screen.getAllByTestId("edit-button")).toHaveLength(2);
});
it("renders only non-deleted IncidentCard components", () => {
render(
<IncidentCards
incidents={[
baseIncident,
{ ...baseIncident, id: "2", name: "Another Incident", deletedAt: new Date() },
]}
closeIncident={mockCloseIncident}
deleteIncident={mockDeleteIncident}
/>,
);
expect(screen.getByText("Test Incident")).toBeInTheDocument();
// Assert the number of IncidentCard children by counting a unique test id
expect(screen.getAllByTestId("edit-button")).toHaveLength(1);
});
});
+203 -158
View File
@@ -1,12 +1,13 @@
import { useMutation, useQuery } from "@apollo/client/react";
import {
faArrowRightFromBracket,
faEdit,
faEye,
faEyeLowVision,
faFolderClosed,
faFolderOpen,
faPlusCircle,
faArrowRightFromBracket,
faEdit,
faEye,
faEyeLowVision,
faFolderClosed,
faFolderOpen,
faPlusCircle,
faTrash,
} from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import classNames from "classnames";
@@ -17,187 +18,231 @@ import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router";
import type {
CloseIncidentMutation,
CloseIncidentMutationVariables,
Incident,
DeleteIncidentMutation,
DeleteIncidentMutationVariables,
} from "types/incident";
import type {
CloseIncidentMutation,
CloseIncidentMutationVariables,
Incident,
} from "../../types";
import { CloseIncident, GetIncidentDetails, GetIncidents } from "./graphql";
import {
CloseIncident,
DeleteIncident,
GetIncidentDetails,
GetIncidents,
} from "./graphql";
function List() {
const [filterClosed, setFilterClosed] = useState(true);
const navigate = useNavigate();
const { t } = useTranslation();
const [filterClosed, setFilterClosed] = useState(true);
const navigate = useNavigate();
const { t } = useTranslation();
const { loading, error, data } = useQuery(GetIncidents, {
pollInterval: 10000,
});
const [closeIncident] = useMutation(CloseIncident, {
refetchQueries: [{ query: GetIncidents }, { query: GetIncidentDetails }],
});
const { loading, error, data } = useQuery(GetIncidents, {
pollInterval: 10000,
});
const [closeIncident] = useMutation(CloseIncident, {
refetchQueries: [{ query: GetIncidents }, { query: GetIncidentDetails }],
});
if (error)
return <div className="notification is-danger">{error.message}</div>;
if (loading && !data) return <Spinner />;
const [deleteIncident] = useMutation(DeleteIncident, {
refetchQueries: [{ query: GetIncidents }, { query: GetIncidentDetails }],
});
return (
<div>
<h3 className="title is-size-3 is-capitalized">{t("incidents")}</h3>
<div className="buttons">
<button
type="button"
className="button is-success is-small is-responsive is-rounded is-light is-capitalized"
onClick={() => navigate("../new")}
>
<span className="icon is-small">
<FontAwesomeIcon icon={faPlusCircle} />
</span>
<span>{t("create")}</span>
</button>
<button
type="button"
className="button is-primary is-small is-responsive is-rounded is-light"
onClick={() => setFilterClosed(!filterClosed)}
>
<span className="icon is-small">
<FontAwesomeIcon icon={filterClosed ? faEye : faEyeLowVision} />
</span>
<span>{filterClosed ? t("showClosed") : t("hideClosed")}</span>
</button>
</div>
<IncidentCards
incidents={
data?.incidents.filter(
(incident) => !filterClosed || incident.closedAt === null,
) || []
}
closeIncident={closeIncident}
/>
</div>
);
if (error)
return <div className="notification is-danger">{error.message}</div>;
if (loading && !data) return <Spinner />;
return (
<div>
<h3 className="title is-size-3 is-capitalized">{t("incidents")}</h3>
<div className="buttons">
<button
type="button"
className="button is-success is-small is-responsive is-rounded is-light is-capitalized"
onClick={() => navigate("../new")}
>
<span className="icon is-small">
<FontAwesomeIcon icon={faPlusCircle} />
</span>
<span>{t("create")}</span>
</button>
<button
type="button"
className="button is-primary is-small is-responsive is-rounded is-light"
onClick={() => setFilterClosed(!filterClosed)}
>
<span className="icon is-small">
<FontAwesomeIcon icon={filterClosed ? faEye : faEyeLowVision} />
</span>
<span>{filterClosed ? t("showClosed") : t("hideClosed")}</span>
</button>
</div>
<IncidentCards
incidents={
data?.incidents.filter(
(incident) => !filterClosed || incident.closedAt === null,
) || []
}
closeIncident={closeIncident}
deleteIncident={deleteIncident}
/>
</div>
);
}
export function IncidentCards(props: {
incidents: Incident[];
closeIncident: useMutation.MutationFunction<
CloseIncidentMutation,
CloseIncidentMutationVariables
>;
incidents: Incident[];
closeIncident: useMutation.MutationFunction<
CloseIncidentMutation,
CloseIncidentMutationVariables
>;
deleteIncident: useMutation.MutationFunction<
DeleteIncidentMutation,
DeleteIncidentMutationVariables
>;
}) {
const { incidents, closeIncident } = props;
const { incidents, closeIncident, deleteIncident } = props;
return (
<div className="container-flex">
{incidents.map((incident) => (
<IncidentCard
key={incident.id}
incident={incident}
closeIncident={closeIncident}
/>
))}
</div>
);
return (
<div className="container-flex">
{incidents.filter((incident) => !incident.deletedAt).map((incident) => (
<IncidentCard
key={incident.id}
incident={incident}
closeIncident={closeIncident}
deleteIncident={deleteIncident}
/>
))}
</div>
);
}
export function IncidentCard(props: {
incident: Incident;
closeIncident: useMutation.MutationFunction<
CloseIncidentMutation,
CloseIncidentMutationVariables
>;
incident: Incident;
closeIncident: useMutation.MutationFunction<
CloseIncidentMutation,
CloseIncidentMutationVariables
>;
deleteIncident: useMutation.MutationFunction<
DeleteIncidentMutation,
DeleteIncidentMutationVariables
>;
}) {
const { incident, closeIncident } = props;
const navigate = useNavigate();
const { incident, closeIncident, deleteIncident } = props;
const navigate = useNavigate();
const cardClass = classNames({
card: true,
"mb-3": true,
"has-background-primary-light": incident.closedAt,
});
return (
<div className={cardClass}>
<div className="card-content">
<div className="content has-text-small">
<h4 className="title is-5">{incident.name}</h4>
<div className="columns">
<div className="column is-one-third">
<strong>{t("location")}: </strong>
{incident.location.name}
</div>
<div className="column is-one-third">
<strong>{t("createdAt")}: </strong>
{dayjs(incident.createdAt).format("LLL")}
</div>
{incident.closedAt && (
<div className="column">
<strong>{t("closedAt")}: </strong>
{dayjs(incident.closedAt).format("LLL")}
</div>
)}
</div>
</div>
</div>
<footer className="card-footer">
<button
type="button"
data-testid="enter-button"
className="card-footer-item is-ahref is-capitalized"
onClick={() => navigate(`../${props.incident.id}/journal/view`)}
>
<span className="icon">
<FontAwesomeIcon icon={faArrowRightFromBracket} />
</span>
<span>{t("enter")}</span>
</button>
<button
type="button"
data-testid="edit-button"
className="card-footer-item is-ahref is-capitalized"
onClick={() => navigate(`../${incident.id}/edit`)}
>
<span className="icon">
<FontAwesomeIcon icon={faEdit} />
</span>
<span>{t("edit")}</span>
</button>
{incident.closedAt === null ? (
const cardClass = classNames({
card: true,
"mb-3": true,
"has-background-primary-light": incident.closedAt,
});
return (
<div className={cardClass}>
<div className="card-content">
<div className="content has-text-small">
<h4 className="title is-5">{incident.name}</h4>
<div className="columns">
<div className="column is-one-third">
<strong>{t("location")}: </strong>
{incident.location.name}
</div>
<div className="column is-one-third">
<strong>{t("createdAt")}: </strong>
{dayjs(incident.createdAt).format("LLL")}
</div>
{incident.closedAt && (
<div className="column">
<strong>{t("closedAt")}: </strong>
{dayjs(incident.closedAt).format("LLL")}
</div>
)}
</div>
</div>
</div>
<footer className="card-footer">
<button
type="button"
data-testid="enter-button"
className="card-footer-item is-ahref is-capitalized"
onClick={() => navigate(`../${props.incident.id}/journal/view`)}
>
<span className="icon">
<FontAwesomeIcon icon={faArrowRightFromBracket} />
</span>
<span>{t("enter")}</span>
</button>
{incident.closedAt === null ? (
<button
type="button"
data-testid="close-button"
className="card-footer-item is-ahref is-capitalized is-danger"
onClick={() => {
closeIncident({
variables: {
incidentId: incident.id,
closedAt: new Date(),
},
});
}}
data-testid="edit-button"
className="card-footer-item is-ahref is-capitalized"
onClick={() => navigate(`../${incident.id}/edit`)}
>
<span className="icon">
<FontAwesomeIcon icon={faFolderClosed} />
<FontAwesomeIcon icon={faEdit} />
</span>
<span>{t("close")}</span>
<span>{t("edit")}</span>
</button>
) : (
<button
type="button"
className="card-footer-item is-ahref is-capitalized is-success"
data-testid="open-button"
onClick={() => {
closeIncident({
variables: { incidentId: incident.id, closedAt: null },
});
}}
data-testid="delete-button"
className="card-footer-item is-ahref is-capitalized"
onClick={() => {
deleteIncident({
variables: {
incidentId: incident.id,
deletedAt: new Date(),
},
});
}}
>
<span className="icon">
<FontAwesomeIcon icon={faFolderOpen} />
<FontAwesomeIcon icon={faTrash} />
</span>
<span>{t("open")}</span>
<span>{t("delete")}</span>
</button>
)}
</footer>
</div>
);
{incident.closedAt === null ? (
<button
type="button"
data-testid="close-button"
className="card-footer-item is-ahref is-capitalized is-danger"
onClick={() => {
closeIncident({
variables: {
incidentId: incident.id,
closedAt: new Date(),
},
});
}}
>
<span className="icon">
<FontAwesomeIcon icon={faFolderClosed} />
</span>
<span>{t("close")}</span>
</button>
) : (
<button
type="button"
className="card-footer-item is-ahref is-capitalized is-success"
data-testid="open-button"
onClick={() => {
closeIncident({
variables: { incidentId: incident.id, closedAt: null },
});
}}
>
<span className="icon">
<FontAwesomeIcon icon={faFolderOpen} />
</span>
<span>{t("open")}</span>
</button>
)}
</footer>
</div>
);
}
export default List;
+41 -1
View File
@@ -2,6 +2,8 @@ import { gql, type TypedDocumentNode } from "@apollo/client";
import type {
CloseIncidentMutation,
CloseIncidentMutationVariables,
DeleteIncidentMutation,
DeleteIncidentMutationVariables,
IncidentDetailsData,
IncidentDetailsVars,
IncidentListData,
@@ -16,7 +18,7 @@ const GET_INCIDENTS: TypedDocumentNode<
Record<string, never>
> = gql`
query FetchIncidents {
incidents(orderBy: { createdAt: DESC }) {
incidents(orderBy: {createdAt: DESC}, where: {deletedAt: {_isNull: true}}) {
id
name
createdAt
@@ -137,11 +139,48 @@ const CLOSE_INCIDENT: TypedDocumentNode<
CloseIncidentMutationVariables
> = gql`
mutation CloseIncident($incidentId: uuid, $closedAt: timestamptz) {
updateJournals(where: { incidentId: { _eq: $incidentId }, closedAt: { _isNull: true } }, _set: { closedAt: $closedAt }) {
affectedRows
returning {
id
closedAt
}
}
updateIncidents(where: { id: { _eq: $incidentId } }, _set: { closedAt: $closedAt }) {
affectedRows
returning {
id
closedAt
journals {
id
closedAt
}
}
}
}
`;
const DELETE_INCIDENT: TypedDocumentNode<
DeleteIncidentMutation,
DeleteIncidentMutationVariables
> = gql`
mutation DeleteIncident($incidentId: uuid, $deletedAt: timestamptz) {
updateJournals(where: { incidentId: { _eq: $incidentId }, deletedAt: { _isNull: true } }, _set: { deletedAt: $deletedAt }) {
affectedRows
returning {
id
deletedAt
}
}
updateIncidents(where: { id: { _eq: $incidentId }, deletedAt: {_isNull: true}, closedAt: {_isNull: false} }, _set: { deletedAt: $deletedAt }) {
affectedRows
returning {
id
deletedAt
journals {
id
deletedAt
}
}
}
}
@@ -153,4 +192,5 @@ export {
GET_INCIDENTS as GetIncidents,
INSERT_INCIDENT as InsertIncident,
UPDATE_INCIDENT as UpdateIncident,
DELETE_INCIDENT as DeleteIncident,
};