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
2 changes: 0 additions & 2 deletions src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import { Manrope } from "next/font/google";
import { UserProvider } from "@/lib/client/userContext";
import { AppToaster } from "@/components/ui/AppToaster";
import { TopNavBar } from "@/components/ui/TopNavBar";
import { Toaster } from "react-hot-toast";
import "./globals.css";

const manrope = Manrope({
Expand Down Expand Up @@ -41,7 +40,6 @@ export default function RootLayout({
suppressHydrationWarning
>
<UserProvider>
<Toaster position="top-center" toastOptions={{ duration: 4000 }} />
<TopNavBar />
{children}
<AppToaster />
Expand Down
27 changes: 26 additions & 1 deletion src/components/ui/AppToaster.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
"use client";

import type { JSX } from "react";
import { Toaster } from "react-hot-toast";
import { AlertCircle, CheckCircle2, Loader2 } from "lucide-react";

export function AppToaster(): React.JSX.Element {
const iconClass = "h-5 w-5 shrink-0";

export function AppToaster(): JSX.Element {
return (
<Toaster
position="top-center"
Expand All @@ -11,6 +15,27 @@ export function AppToaster(): React.JSX.Element {
style: {
fontFamily: "var(--font-manrope), system-ui, sans-serif",
},
success: {
icon: (
<CheckCircle2
className={`${iconClass} text-emerald-600`}
aria-hidden
/>
),
},
error: {
icon: (
<AlertCircle className={`${iconClass} text-red-600`} aria-hidden />
),
},
loading: {
icon: (
<Loader2
className={`${iconClass} animate-spin text-gray-600`}
aria-hidden
/>
),
},
}}
/>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/volunteers/EditableCell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { CellContext } from "@tanstack/react-table";
import toast from "react-hot-toast";
import { Volunteer } from "./types";
import { VolunteerTag } from "./VolunteerTag";
import { ArrowRight } from "lucide-react";
import { ArrowRight, Tag } from "lucide-react";
import { NotesDisplay } from "./NotesDisplay";

const SCREEN_EDGE_PADDING_PX = 16;
Expand Down Expand Up @@ -204,7 +204,7 @@ export const EditableCell = ({
const toastId = `new-tag-${info.column.id}-${newTag.toLowerCase()}`;
toast(`New tag "${newTag}" created — remember to save your changes.`, {
id: toastId,
icon: "tag",
icon: <Tag className="h-5 w-5 shrink-0 text-gray-700" aria-hidden />,
duration: 4000,
});
}
Expand Down
8 changes: 4 additions & 4 deletions src/components/volunteers/TableToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export const TableToolbar = ({
const ro = new ResizeObserver(() => updateToolsScrollHints());
ro.observe(el);
return (): void => ro.disconnect();
}, [updateToolsScrollHints, filters.length, sorting.length, role]);
}, [updateToolsScrollHints, filters.length, sorting.length, role, hasEdits]);

useEffect(() => {
if (!isCopyMenuOpen) return;
Expand Down Expand Up @@ -317,7 +317,7 @@ export const TableToolbar = ({
/>
</div>

{role === "admin" && (
{role === "admin" && !hasEdits && (
<button
type="button"
onClick={onOpenShortcuts}
Expand All @@ -328,7 +328,7 @@ export const TableToolbar = ({
</button>
)}

{role === "admin" && (
{role === "admin" && !hasEdits && (
<button
type="button"
onClick={onOpenAddVolunteer}
Expand All @@ -350,7 +350,7 @@ export const TableToolbar = ({
</button>
)}

{role === "admin" && (
{role === "admin" && !hasEdits && (
<button
type="button"
onClick={onOpenManageTags}
Expand Down
8 changes: 7 additions & 1 deletion src/components/volunteers/VolunteersTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,9 @@
setRowSelection,
debouncedGlobalFilter,
fetchInitialData,
bumpDisplayRefresh,
refreshRolesAndCohorts,
setAllVolunteers,
debouncedFilters,
} = useVolunteersData({ isAdmin, editedRowsRef });

Expand All @@ -162,8 +165,11 @@
allRoles,
allCohorts,
setData,
setLoading,
setAllVolunteers,
bumpDisplayRefresh,
refreshRolesAndCohorts,
fetchInitialData,
syncedEditedRowsRef: editedRowsRef,
});

const filterOptions = useMemo(() => {
Expand Down Expand Up @@ -377,7 +383,7 @@
/** Filtered rows in table sort order (matches column headers), for shortcuts like copy-all emails/phones. */
const visibleVolunteersSorted = useMemo(
() => table.getSortedRowModel().rows.map((r) => r.original),
[table, data, sorting, debouncedGlobalFilter]

Check warning on line 386 in src/components/volunteers/VolunteersTable.tsx

View workflow job for this annotation

GitHub Actions / test

React Hook useMemo has unnecessary dependencies: 'data', 'debouncedGlobalFilter', and 'sorting'. Either exclude them or remove the dependency array
);

const {
Expand Down
94 changes: 75 additions & 19 deletions src/components/volunteers/useVolunteerEdits.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { useState, useCallback, useRef } from "react";
import { createElement, useState, useCallback, useRef } from "react";
import { flushSync } from "react-dom";
import toast from "react-hot-toast";
import { Pencil } from "lucide-react";
import { Volunteer, RoleRow, CohortRow } from "./types";
import { updateVolunteer } from "@/lib/api/updateVolunteer";
import { createRole } from "@/lib/api/createRole";
Expand All @@ -15,8 +17,12 @@ interface UseVolunteerEditsProps {
allRoles: RoleRow[];
allCohorts: CohortRow[];
setData: React.Dispatch<React.SetStateAction<Volunteer[]>>;
setLoading: (l: boolean) => void;
setAllVolunteers: React.Dispatch<React.SetStateAction<Volunteer[]>>;
bumpDisplayRefresh: () => void;
refreshRolesAndCohorts: () => Promise<void>;
fetchInitialData: () => Promise<void>;
/** Ref mirrored to `editedRows` for filter pipeline (useVolunteersData); cleared on cancel before display refresh. */
syncedEditedRowsRef: React.RefObject<Record<number, Partial<Volunteer>>>;
}

interface EditStep {
Expand Down Expand Up @@ -48,6 +54,22 @@ export interface UseVolunteerEditsReturn {

const MAX_HISTORY = 100;

function mergeSavedVolunteersIntoAll(
prev: Volunteer[],
editsSnapshot: Record<number, Partial<Volunteer>>,
remainingEdits: Record<number, Partial<Volunteer>>
): Volunteer[] {
const failedIds = new Set(
Object.keys(remainingEdits).map((k) => Number.parseInt(k, 10))
);
return prev.map((v) => {
if (failedIds.has(v.id) || !editsSnapshot[v.id]) {
return v;
}
return { ...v, ...editsSnapshot[v.id] };
});
}

const normalizeValue = (colId: string, value: unknown): unknown => {
if (colId === "pronouns") {
if (Array.isArray(value)) {
Expand Down Expand Up @@ -79,8 +101,11 @@ export const useVolunteerEdits = ({
allRoles,
allCohorts,
setData,
setLoading,
setAllVolunteers,
bumpDisplayRefresh,
refreshRolesAndCohorts,
fetchInitialData,
syncedEditedRowsRef,
}: UseVolunteerEditsProps): UseVolunteerEditsReturn => {
const [isSaving, setIsSaving] = useState<boolean>(false);
const [saveErrors, setSaveErrors] = useState<string[]>([]);
Expand Down Expand Up @@ -174,7 +199,10 @@ export const useVolunteerEdits = ({
if (showTrackingToast) {
queueMicrotask((): void => {
toast("Tracking changes — save when ready", {
icon: "edit",
icon: createElement(Pencil, {
className: "h-5 w-5 shrink-0 text-gray-700",
"aria-hidden": true,
}),
id: "tracking-edits",
});
});
Expand Down Expand Up @@ -294,11 +322,13 @@ export const useVolunteerEdits = ({

const handleSaveEdits = async (): Promise<void> => {
setIsSaving(true);
setLoading(true);
setSaveErrors([]);
const savingToast = toast.loading("Saving changes...");
const currentErrors: string[] = [];
const remainingEdits = { ...editedRows };
const editsSnapshot = { ...editedRows };
let referenceDataDirty = false;
let usedFullRefetchAfterFatalError = false;

try {
const knownCohortTerms = new Set(
Expand Down Expand Up @@ -326,6 +356,7 @@ export const useVolunteerEdits = ({
is_active: true,
});
knownCohortTerms.add(cohortKey);
referenceDataDirty = true;
}
}
}
Expand All @@ -345,6 +376,7 @@ export const useVolunteerEdits = ({
`Failed to create role ${cleanName}: ${res.error}`
);
knownRoles.add(roleKey);
referenceDataDirty = true;
}
}
};
Expand Down Expand Up @@ -444,36 +476,60 @@ export const useVolunteerEdits = ({
? err.message
: "A fatal error occurred during tag creation.";
currentErrors.push(errorMessage);
try {
await fetchInitialData();
usedFullRefetchAfterFatalError = true;
} catch (fetchErr) {
console.error("Refetch after save failure failed:", fetchErr);
}
}

setEditedRows(remainingEdits);
syncedEditedRowsRef.current = remainingEdits;
if (currentErrors.length > 0) setSaveErrors(currentErrors);
else setSaveErrors([]);

await fetchInitialData();
setIsSaving(false);
hadEditsRef.current = Object.keys(remainingEdits).length > 0;
undoStackRef.current = [];
redoStackRef.current = [];
syncHistoryStacks();
try {
if (!usedFullRefetchAfterFatalError) {
setAllVolunteers((prev) =>
mergeSavedVolunteersIntoAll(prev, editsSnapshot, remainingEdits)
);
if (referenceDataDirty) {
try {
await refreshRolesAndCohorts();
} catch (e) {
console.error("Error refreshing roles/cohorts after save:", e);
}
}
}
} finally {
setIsSaving(false);
hadEditsRef.current = Object.keys(remainingEdits).length > 0;
undoStackRef.current = [];
redoStackRef.current = [];
syncHistoryStacks();

if (currentErrors.length > 0) {
toast.error(`${currentErrors.length} update(s) failed`, {
id: savingToast,
});
} else {
toast.success("All changes saved", { id: savingToast });
if (currentErrors.length > 0) {
toast.error(`${currentErrors.length} update(s) failed`, {
id: savingToast,
});
} else {
toast.success("All changes saved", { id: savingToast });
}
}
};

const handleCancelEdits = (): void => {
setEditedRows({});
flushSync(() => {
setEditedRows({});
});
syncedEditedRowsRef.current = {};
setSaveErrors([]);
hadEditsRef.current = false;
undoStackRef.current = [];
redoStackRef.current = [];
syncHistoryStacks();
setData(allVolunteers.map((v) => v));
bumpDisplayRefresh();
toast("Changes discarded");
};

Expand Down
40 changes: 38 additions & 2 deletions src/components/volunteers/useVolunteersData.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react";
import {
useState,
useEffect,
useCallback,
useRef,
type Dispatch,
type SetStateAction,
} from "react";
import { Volunteer, CohortRow, RoleRow } from "./types";
import {
FilterTuple,
Expand Down Expand Up @@ -63,6 +70,10 @@ export interface UseVolunteersDataReturn {
rowSelection: RowSelectionState;
setRowSelection: React.Dispatch<React.SetStateAction<RowSelectionState>>;
fetchInitialData: () => Promise<void>;
/** Increment to re-run filter → display sync (e.g. after discarding edits) without refetching all volunteers. */
bumpDisplayRefresh: () => void;
refreshRolesAndCohorts: () => Promise<void>;
setAllVolunteers: Dispatch<SetStateAction<Volunteer[]>>;
debouncedFilters: FilterTuple[];
}

Expand All @@ -72,6 +83,7 @@ export const useVolunteersData = ({
}: UseVolunteersDataProps): UseVolunteersDataReturn => {
const [data, setData] = useState<Volunteer[]>([]);
const [allVolunteers, setAllVolunteers] = useState<Volunteer[]>([]);
const [displayRefreshEpoch, setDisplayRefreshEpoch] = useState(0);
const [loading, setLoading] = useState<boolean>(true);

const [allRoles, setAllRoles] = useState<RoleRow[]>([]);
Expand Down Expand Up @@ -136,6 +148,21 @@ export const useVolunteersData = ({
}
}, [isAdmin]);

const bumpDisplayRefresh = useCallback((): void => {
setDisplayRefreshEpoch((e) => e + 1);
}, []);

const refreshRolesAndCohorts = useCallback(async (): Promise<void> => {
if (!isAdmin) return;
try {
const [roles, cohorts] = await Promise.all([getRoles(), getCohorts()]);
setAllRoles(roles);
setAllCohorts(cohorts);
} catch (e) {
console.error("Error refreshing roles/cohorts:", e);
}
}, [isAdmin]);

useEffect(() => {
setLoading(true);
}, [filters, globalOp]);
Expand Down Expand Up @@ -250,7 +277,13 @@ export const useVolunteersData = ({
return (): void => {
ignore = true;
};
}, [debouncedFilters, debouncedGlobalOp, allVolunteers, editedRowsRef]);
}, [
debouncedFilters,
debouncedGlobalOp,
allVolunteers,
editedRowsRef,
displayRefreshEpoch,
]);

return {
data,
Expand All @@ -272,6 +305,9 @@ export const useVolunteersData = ({
rowSelection,
setRowSelection,
fetchInitialData,
bumpDisplayRefresh,
refreshRolesAndCohorts,
setAllVolunteers,
debouncedFilters,
};
};
Loading