Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Confirmation Dialog #653

Merged
merged 9 commits into from
Sep 15, 2022
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
75 changes: 39 additions & 36 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { AbsencePlanner } from "./pages/AbsencePlanner";
import { AuthProvider } from "./components/AuthProvider";
import { ProtectedRoute } from "./components/ProtectedRoute";
import { getISOWeek } from "date-fns";
import { ConfirmDialogProvider } from "./components/ConfirmDialogProvider";

// Route calls
// Order of routes is critical.
Expand All @@ -22,42 +23,44 @@ export const App = () => {
<React.StrictMode>
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/report/:year/:week"
element={
<ProtectedRoute>
<Report />
</ProtectedRoute>
}
/>
<Route
path="/*"
element={
<Navigate
replace
to={`/report/${currentYear}/${currentWeek}`}
/>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<Help />
</ProtectedRoute>
}
/>
<Route
path="/absence"
element={
<ProtectedRoute>
<AbsencePlanner />
</ProtectedRoute>
}
/>
</Routes>
<ConfirmDialogProvider>
<Routes>
<Route path="/login" element={<Login />} />
<Route
path="/report/:year/:week"
element={
<ProtectedRoute>
<Report />
</ProtectedRoute>
}
/>
<Route
path="/*"
element={
<Navigate
replace
to={`/report/${currentYear}/${currentWeek}`}
/>
}
/>
<Route
path="/help"
element={
<ProtectedRoute>
<Help />
</ProtectedRoute>
}
/>
<Route
path="/absence"
element={
<ProtectedRoute>
<AbsencePlanner />
</ProtectedRoute>
}
/>
</Routes>
</ConfirmDialogProvider>
</AuthProvider>
</BrowserRouter>
</React.StrictMode>
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/components/ConfirmDialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React, {
createContext,
useState,
useRef,
useCallback,
useContext,
} from "react";
import { ModalDialog } from "./ModalDialog";

const ConfirmDialog = createContext(null);

export const ConfirmDialogProvider = ({ children }) => {
const [state, setState] = useState<{}>({ isOpen: false });
const handlerFunction = useRef<(choice: boolean) => void>();

const confirm = useCallback(
(forwarded) => {
return new Promise((resolve) => {
setState({ ...forwarded, isOpen: true });
handlerFunction.current = (choice: boolean) => {
resolve(choice);
setState({ isOpen: false });
};
});
},
[setState]
);

return (
<ConfirmDialog.Provider value={confirm}>
{children}
<ModalDialog
{...state}
onCancel={() => handlerFunction.current(false)}
onConfirm={() => handlerFunction.current(true)}
/>
</ConfirmDialog.Provider>
);
};

export const useConfirm = () => {
return useContext(ConfirmDialog);
};
76 changes: 76 additions & 0 deletions frontend/src/components/ModalDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import React, { useEffect, useRef } from "react";
import { useEscaper } from "../utils";

/*
The modal component can be filled with a simple string using the content prop.
In that case, the component should be rendered without children.
To fill it with more complex JSX, it can be rendered with children.
If children are present, the content prop will be ignored.
*/

export const ModalDialog = ({
isOpen,
title,
content,
confirmButtonLabel,
onCancel,
onConfirm,
children,
}: {
isOpen: boolean;
title: string;
content?: string;
confirmButtonLabel: string;
onCancel: () => void;
onConfirm: () => void;
children: JSX.Element;
}) => {
// Autofocus on cancel button when opening dialog
const cancelButton = useRef(null);
useEffect(() => {
cancelButton.current.focus();
});

// Close the dialog when pressing escape
const modal = useRef(null);
useEscaper(modal, () => onCancel());

return (
<section
className="modal-overlay"
style={{ display: `${isOpen ? "block" : "none"}` }}
ref={modal}
>
<div
className="modal-wrapper"
role="alertdialog"
aria-modal={true}
aria-labelledby="modalTitle"
aria-describedby="modalContent"
>
<section className="modal-title-wrapper">
<h2 id="modalTitle">{title}</h2>
</section>
<section className="modal-content-wrapper">
{!children && <p id="modalContent">{content}</p>}
{children}
</section>
<section className="modal-buttons-wrapper">
<button
ref={cancelButton}
onClick={onCancel}
className="basic-button modal-cancel-button"
>
Cancel
</button>
<button
onClick={onConfirm}
className="basic-button modal-confirm-button"
>
{confirmButtonLabel}
</button>
</section>
</div>
</section>
);
};
70 changes: 70 additions & 0 deletions frontend/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -936,3 +936,73 @@ tr:last-of-type td {
margin-top: 2rem;
margin-bottom: 3rem;
}

/* ModalDialog styles */

.modal-overlay {
width: 100%;
height: 100vh;
position: absolute;
left: 0;
top: -0.2rem;
background-color: hsla(0, 0%, 0%, 0.5);
z-index: 1;
}

.modal-wrapper {
background-color: white;
color: hsl(185deg 92% 11%);
height: fit-content;
width: fit-content;
max-width: 90%;
margin: 0 auto;
position: absolute;
top: 25%;
left: 0;
right: 0;
border-radius: 4px;
}

.modal-title-wrapper {
background-color: hsl(70deg 55% 98%);
border-bottom: 1px solid hsl(76deg 55% 77%);
padding: 1.5rem 1.5rem 0.2rem;
border-radius: 4px 4px 0 0;
}

.modal-content-wrapper {
padding: 1rem 1.5rem;
}

.modal-content-wrapper p {
margin: 0;
}

.modal-buttons-wrapper {
display: flex;
justify-content: flex-end;
padding: 1rem;
border-radius: 0 0 4px 4px;
}

.modal-confirm-button {
background-color: hsl(185deg 92% 16%);
padding: 0.2rem 1rem;
margin: 0.25rem;
}

.modal-confirm-button:hover {
background-color: hsl(185deg 92% 11%);
}

.modal-cancel-button {
border: 2px solid hsl(187deg 29% 94%);
background-color: white;
color: hsl(185deg 92% 11%);
padding: 0.2rem 0.5rem;
margin: 0.25rem;
}

.modal-cancel-button:hover {
background-color: hsl(187deg 29% 94%);
}
41 changes: 27 additions & 14 deletions frontend/src/pages/AbsencePlanner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import { HeaderUser } from "../components/HeaderUser";
import { Chart } from "react-google-charts";
import trash from "../icons/trash.svg";
import pencil from "../icons/pencil.svg";
import { FetchedTimeEntry } from "../model";
import { useConfirm } from "../components/ConfirmDialogProvider";

export const AbsencePlanner = () => {
const [startDate, setStartDate] = useState<Date>(undefined);
Expand All @@ -47,6 +47,7 @@ export const AbsencePlanner = () => {
>([]);
const [reloadPage, setReloadPage] = useState<boolean>(false);
const [reportedDates, setReportedDates] = useState<string[]>([]);
const confirm: ({}) => any = useConfirm();

let today = new Date();
const absenceFrom: Date = new Date(new Date().setMonth(today.getMonth() - 1));
Expand Down Expand Up @@ -213,20 +214,32 @@ export const AbsencePlanner = () => {
};

const onRemoveEntriesButton = async (entryIds: number[]) => {
toggleLoadingPage(true);
const removed: boolean = await removeTimeEntries(entryIds);
if (removed) {
setToastList([
...toastList,
{
type: "info",
timeout: 8000,
message: "Absence period was successfully removed",
},
]);
// Open the dialog first, using the confirm function
const isConfirmed = await confirm({
title: "Deleting absence period",
content: "Do you really want to delete the whole absence period?",
confirmButtonLabel: "Yes",
});
// The confirm function will return a boolean indicating if the user aborts (false) or confirms (true)
if (!isConfirmed) {
return;
} else {
toggleLoadingPage(true);
const removed: boolean = await removeTimeEntries(entryIds);
if (removed) {
setToastList([
...toastList,
{
type: "info",
timeout: 8000,
message: "Absence period was successfully removed",
},
]);
}
toggleLoadingPage(false);
setReloadPage(!reloadPage);
return;
}
toggleLoadingPage(false);
setReloadPage(!reloadPage);
};

const getAbsenceRanges = (entries: FetchedTimeEntry[]) => {
Expand Down