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
14 changes: 3 additions & 11 deletions client/src/components/support/BulkChangeModal.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useSupportCenterContext } from "../../context/SupportCenterContext";
import useSupportQueues from "../../hooks/useSupportQueues";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { User } from "../../types";
import useAssignableUsers from "../../hooks/useAssignableUsers";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import api from "../../api";
import { useForm } from "react-hook-form";
import {
Expand Down Expand Up @@ -49,15 +49,7 @@ const BulkChangeModal: React.FC<BulkChangeModalProps> = ({
withCount: false,
});

const { data: assignableUsers } = useQuery<
Pick<User, "uuid" | "firstName" | "lastName" | "email" | "avatar">[]
>({
queryKey: ["assignableUsers"],
queryFn: async () => {
const res = await api.getSupportAssignableUsers();
return res.data.users;
},
});
const { data: assignableUsers } = useAssignableUsers({ enabled: open });

useEffect(() => {
if (open) {
Expand Down
15 changes: 4 additions & 11 deletions client/src/components/support/QueueAutoAssignmentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ import { IconCheck, IconX } from "@tabler/icons-react";
import api from "../../api";
import { useNotifications } from "../../context/NotificationContext";
import { useSupportCenterContext } from "../../context/SupportCenterContext";
import { SupportQueueAutoAssignConfig, User } from "../../types";
import useAssignableUsers from "../../hooks/useAssignableUsers";
import { SupportQueueAutoAssignConfig } from "../../types";

interface QueueAutoAssignmentModalProps {
open: boolean;
Expand Down Expand Up @@ -39,16 +40,8 @@ const QueueAutoAssignmentModal: React.FC<QueueAutoAssignmentModalProps> = ({
enabled: open,
});

const { data: assignableUsers, isLoading: isAssignableUsersLoading } = useQuery<
Pick<User, "uuid" | "firstName" | "lastName" | "email" | "avatar">[]
>({
queryKey: ["assignableUsers"],
queryFn: async () => {
const res = await api.getSupportAssignableUsers();
return res.data.users;
},
enabled: open,
});
const { data: assignableUsers, isLoading: isAssignableUsersLoading } =
useAssignableUsers({ enabled: open });

// Default the selected queue to the one active in the sidebar, falling back to the first.
useEffect(() => {
Expand Down
48 changes: 32 additions & 16 deletions client/src/components/support/TicketAssigneePicker.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Popover, PopoverButton, PopoverPanel } from "@headlessui/react";
import { Avatar, Button, Input, Text } from "@libretexts/davis-react";
import { IconCheck, IconPlus, IconSearch, IconX } from "@tabler/icons-react";
import api from "../../api";
import { User } from "../../types";
import { SupportTicket } from "../../types";
import { useNotifications } from "../../context/NotificationContext";
import { useTypedSelector } from "../../state/hooks";
import useDebounce from "../../hooks/useDebounce";
import useAssignableUsers, {
AssignableUser,
} from "../../hooks/useAssignableUsers";

interface TicketAssigneePickerProps {
ticketId: string;
Expand All @@ -23,11 +26,6 @@ interface TicketAssigneePickerProps {
triggerId?: string;
}

type AssignableUser = Pick<
User,
"uuid" | "firstName" | "lastName" | "email" | "avatar"
>;

const fullName = (u: AssignableUser) => `${u.firstName} ${u.lastName}`;

const TicketAssigneePicker: React.FC<TicketAssigneePickerProps> = ({
Expand All @@ -50,12 +48,7 @@ const TicketAssigneePicker: React.FC<TicketAssigneePickerProps> = ({
setSelected(assignedUUIDs ?? []);
}, [assignedUUIDs]);

const { data: assignableUsers } = useQuery<AssignableUser[]>({
queryKey: ["assignableUsers"],
queryFn: async () => {
const res = await api.getSupportAssignableUsers();
return res.data.users;
},
const { data: assignableUsers } = useAssignableUsers({
enabled: !!ticketId && !disabled,
});

Expand All @@ -67,8 +60,31 @@ const TicketAssigneePicker: React.FC<TicketAssigneePickerProps> = ({
throw new Error(res.data.errMsg);
}
},
onSuccess: async () => {
await queryClient.invalidateQueries(["ticket", ticketId]);
onSuccess: (_data, assignees) => {
// Patch the cached ticket so the UI settles immediately; the background
// invalidation below reconciles with the server without blocking the user.
// Additions resolve against the cached staff roster (falling back to the
// records already on the ticket) so both chips and any assignedUsers-driven
// UI stay correct even if the refetch is slow or fails.
queryClient.setQueryData<SupportTicket | undefined>(
["ticket", ticketId],
(prev) => {
if (!prev) return prev;
const roster = assignableUsers ?? [];
return {
...prev,
assignedUUIDs: assignees,
assignedUsers: assignees
.map(
(uuid) =>
prev.assignedUsers?.find((u) => u.uuid === uuid) ??
roster.find((u) => u.uuid === uuid)
)
.filter((u): u is AssignableUser => !!u),
};
}
);
queryClient.invalidateQueries(["ticket", ticketId]);
addNotification({
type: "success",
message: "Successfully updated assignees.",
Expand All @@ -92,7 +108,7 @@ const TicketAssigneePicker: React.FC<TicketAssigneePickerProps> = ({
mutateRef.current = updateAssignedMutation.mutate;

const debouncedSave = useMemo(
() => debounce((assignees: string[]) => mutateRef.current(assignees), 500),
() => debounce((assignees: string[]) => mutateRef.current(assignees), 250),
[]
);

Expand Down
42 changes: 42 additions & 0 deletions client/src/hooks/useAssignableUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { AssignableUser } from "../types";
import api from "../api";

export type { AssignableUser };

/**
* Shared access to the support staff roster. The roster is small and near-static,
* so it is cached aggressively — every surface that offers assignment (ticket
* picker, bulk change, queue auto-assignment) reads the same cache entry instead
* of issuing its own request.
*/
const useAssignableUsers = ({ enabled = true }: { enabled?: boolean } = {}) => {
const QUERY_KEY = ['assignableUsers'];
const queryClient = useQueryClient();

const queryObj = useQuery<AssignableUser[]>({
queryKey: QUERY_KEY,
queryFn: async () => {
const res = await api.getSupportAssignableUsers();
if (res.data.err) {
throw new Error(res.data.errMsg);
}
return res.data.users;
},
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 30 * 60 * 1000, // survives navigation between the dashboard and a ticket
refetchOnWindowFocus: false,
meta: {
errorMessage: "Failed to fetch assignable users.",
}
})

const invalidate = () => {
return queryClient.invalidateQueries({ queryKey: QUERY_KEY });
}

return { ...queryObj, QUERY_KEY, invalidate };
}

export default useAssignableUsers
7 changes: 7 additions & 0 deletions client/src/screens/conductor/support/Ticket.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { useDocumentTitle } from "usehooks-ts";
import AuthHelper from "../../../components/util/AuthHelper";
import { Button, Heading, Stack } from "@libretexts/davis-react";
import { IconCheck, IconRefresh, IconTrash } from "@tabler/icons-react";
import useAssignableUsers from "../../../hooks/useAssignableUsers";

const getIdFromURL = (url: string) => {
if (!url) return "";
Expand Down Expand Up @@ -62,6 +63,12 @@ const SupportTicketView = () => {
enabled: !!id,
});

// Warm the staff roster cache while the ticket loads so the assignee picker
// has data on hand the first time it is opened.
useAssignableUsers({
enabled: !!id && !!(user.isSupport || user.isHarvester),
});

const updateTicketStatusMutation = useMutation({
mutationFn: (status: "open" | "in_progress" | "closed") =>
updateTicket({ status }),
Expand Down
10 changes: 10 additions & 0 deletions client/src/types/User.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,16 @@ export type AuthorizedApp = {
icon: string;
};

/**
* The narrow projection of a user returned by the support staff roster
* (`/support/assignable-users`). Assignment surfaces only ever need identity
* and avatar, so this is the shape they share.
*/
export type AssignableUser = Pick<
User,
"uuid" | "firstName" | "lastName" | "email" | "avatar"
>;

export type UserWCentralID = User & {
centralID?: string;
};
Expand Down
4 changes: 2 additions & 2 deletions client/src/types/support.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { SupportQueue } from "./supportqueues";
import { User, UserWCentralID } from "./User";
import { AssignableUser, User, UserWCentralID } from "./User";

export type SupportTicketGuest = {
firstName: string;
Expand Down Expand Up @@ -30,7 +30,7 @@ export type SupportTicket = {
category?: string;
capturedURL?: string;
assignedUUIDs?: string[]; // User uuids
assignedUsers?: UserWCentralID[];
assignedUsers?: AssignableUser[];
user?: UserWCentralID;
guest?: SupportTicketGuest;
ccedEmails?: {
Expand Down
3 changes: 2 additions & 1 deletion server/api/support.ts
Original file line number Diff line number Diff line change
Expand Up @@ -459,7 +459,8 @@ async function _getAssignableUsersInternal(): Promise<Pick<UserInterface, "uuid"
],
})
.select("uuid firstName lastName email avatar")
.sort({ firstName: 1 });
.sort({ firstName: 1 })
.lean();

return users;
} catch (err) {
Expand Down
Loading