added tanstack query keys and hooks

This commit is contained in:
Oliver Bryan
2026-01-20 17:00:33 +00:00
parent 45343571f5
commit 8f11805bab
10 changed files with 459 additions and 0 deletions

View File

@@ -0,0 +1,16 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 mins
gcTime: 10 * 60 * 1000, // 10 mins
retry: 1,
refetchOnWindowFocus: true,
refetchOnReconnect: true,
},
mutations: {
retry: 0,
},
},
});

View File

@@ -0,0 +1,35 @@
import { useMemo } from "react";
import { useSelection } from "@/components/selection-provider";
import { useIssues } from "@/lib/query/hooks/issues";
import { useOrganisations } from "@/lib/query/hooks/organisations";
import { useProjects } from "@/lib/query/hooks/projects";
export function useSelectedOrganisation() {
const { selectedOrganisationId } = useSelection();
const { data: organisations = [] } = useOrganisations();
return useMemo(
() => organisations.find((org) => org.Organisation.id === selectedOrganisationId) ?? null,
[organisations, selectedOrganisationId],
);
}
export function useSelectedProject() {
const { selectedOrganisationId, selectedProjectId } = useSelection();
const { data: projects = [] } = useProjects(selectedOrganisationId);
return useMemo(
() => projects.find((project) => project.Project.id === selectedProjectId) ?? null,
[projects, selectedProjectId],
);
}
export function useSelectedIssue() {
const { selectedProjectId, selectedIssueId } = useSelection();
const { data: issues = [] } = useIssues(selectedProjectId);
return useMemo(
() => issues.find((issue) => issue.Issue.id === selectedIssueId) ?? null,
[issues, selectedIssueId],
);
}

View File

@@ -0,0 +1,7 @@
export * from "@/lib/query/hooks/derived";
export * from "@/lib/query/hooks/issues";
export * from "@/lib/query/hooks/organisations";
export * from "@/lib/query/hooks/projects";
export * from "@/lib/query/hooks/sprints";
export * from "@/lib/query/hooks/timers";
export * from "@/lib/query/hooks/users";

View File

@@ -0,0 +1,78 @@
import type {
IssueCreateRequest,
IssueRecord,
IssueResponse,
IssuesReplaceStatusRequest,
IssueUpdateRequest,
StatusCountResponse,
SuccessResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { issue } from "@/lib/server";
export function useIssues(projectId?: number | null) {
return useQuery<IssueResponse[]>({
queryKey: queryKeys.issues.byProject(projectId ?? 0),
queryFn: () => issue.byProject(projectId ?? 0),
enabled: Boolean(projectId),
});
}
export function useIssueStatusCount(organisationId?: number | null, status?: string | null) {
return useQuery<StatusCountResponse>({
queryKey: queryKeys.issues.statusCount(organisationId ?? 0, status ?? ""),
queryFn: () => issue.statusCount(organisationId ?? 0, status ?? ""),
enabled: Boolean(organisationId && status),
});
}
export function useCreateIssue() {
const queryClient = useQueryClient();
return useMutation<IssueRecord, Error, IssueCreateRequest>({
mutationKey: ["issues", "create"],
mutationFn: issue.create,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.issues.byProject(variables.projectId),
});
},
});
}
export function useUpdateIssue() {
const queryClient = useQueryClient();
return useMutation<IssueRecord, Error, IssueUpdateRequest>({
mutationKey: ["issues", "update"],
mutationFn: issue.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
});
}
export function useDeleteIssue() {
const queryClient = useQueryClient();
return useMutation<SuccessResponse, Error, number>({
mutationKey: ["issues", "delete"],
mutationFn: issue.delete,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
});
}
export function useReplaceIssueStatus() {
const queryClient = useQueryClient();
return useMutation<unknown, Error, IssuesReplaceStatusRequest>({
mutationKey: ["issues", "replace-status"],
mutationFn: issue.replaceStatus,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.issues.all });
},
});
}

View File

@@ -0,0 +1,101 @@
import type {
OrgAddMemberRequest,
OrganisationMemberRecord,
OrganisationMemberResponse,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { organisation } from "@/lib/server";
export function useOrganisations() {
return useQuery({
queryKey: queryKeys.organisations.byUser(),
queryFn: organisation.byUser,
});
}
export function useOrganisationMembers(organisationId?: number | null) {
return useQuery<OrganisationMemberResponse[]>({
queryKey: queryKeys.organisations.members(organisationId ?? 0),
queryFn: () => organisation.members(organisationId ?? 0),
enabled: Boolean(organisationId),
});
}
export function useCreateOrganisation() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["organisations", "create"],
mutationFn: organisation.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},
});
}
export function useUpdateOrganisation() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["organisations", "update"],
mutationFn: organisation.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},
});
}
export function useDeleteOrganisation() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["organisations", "delete"],
mutationFn: organisation.remove,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.organisations.byUser() });
},
});
}
export function useAddOrganisationMember() {
const queryClient = useQueryClient();
return useMutation<OrganisationMemberRecord, Error, OrgAddMemberRequest>({
mutationKey: ["organisations", "members", "add"],
mutationFn: organisation.addMember,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId),
});
},
});
}
export function useRemoveOrganisationMember() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["organisations", "members", "remove"],
mutationFn: organisation.removeMember,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId),
});
},
});
}
export function useUpdateOrganisationMemberRole() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["organisations", "members", "update-role"],
mutationFn: organisation.updateMemberRole,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.organisations.members(variables.organisationId),
});
},
});
}

View File

@@ -0,0 +1,55 @@
import type {
ProjectCreateRequest,
ProjectRecord,
ProjectResponse,
ProjectUpdateRequest,
} from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { project } from "@/lib/server";
export function useProjects(organisationId?: number | null) {
return useQuery<ProjectResponse[]>({
queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0),
queryFn: () => project.byOrganisation(organisationId ?? 0),
enabled: Boolean(organisationId),
});
}
export function useCreateProject() {
const queryClient = useQueryClient();
return useMutation<ProjectRecord, Error, ProjectCreateRequest>({
mutationKey: ["projects", "create"],
mutationFn: project.create,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({
queryKey: queryKeys.projects.byOrganisation(variables.organisationId),
});
},
});
}
export function useUpdateProject() {
const queryClient = useQueryClient();
return useMutation<ProjectRecord, Error, ProjectUpdateRequest>({
mutationKey: ["projects", "update"],
mutationFn: project.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
},
});
}
export function useDeleteProject() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["projects", "delete"],
mutationFn: project.remove,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all });
},
});
}

View File

@@ -0,0 +1,48 @@
import type { SprintCreateRequest, SprintRecord, SprintUpdateRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { sprint } from "@/lib/server";
export function useSprints(projectId?: number | null) {
return useQuery<SprintRecord[]>({
queryKey: queryKeys.sprints.byProject(projectId ?? 0),
queryFn: () => sprint.byProject(projectId ?? 0),
enabled: Boolean(projectId),
});
}
export function useCreateSprint() {
const queryClient = useQueryClient();
return useMutation<SprintRecord, Error, SprintCreateRequest>({
mutationKey: ["sprints", "create"],
mutationFn: sprint.create,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.byProject(variables.projectId) });
},
});
}
export function useUpdateSprint() {
const queryClient = useQueryClient();
return useMutation<SprintRecord, Error, SprintUpdateRequest>({
mutationKey: ["sprints", "update"],
mutationFn: sprint.update,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
},
});
}
export function useDeleteSprint() {
const queryClient = useQueryClient();
return useMutation({
mutationKey: ["sprints", "delete"],
mutationFn: sprint.remove,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.sprints.all });
},
});
}

View File

@@ -0,0 +1,50 @@
import type { TimerEndRequest, TimerState, TimerToggleRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { timer } from "@/lib/server";
export function useTimerState(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState>({
queryKey: queryKeys.timers.active(issueId ?? 0),
queryFn: () => timer.get(issueId ?? 0),
enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false,
});
}
export function useInactiveTimers(issueId?: number | null, options?: { refetchInterval?: number }) {
return useQuery<TimerState[]>({
queryKey: queryKeys.timers.inactive(issueId ?? 0),
queryFn: () => timer.getInactive(issueId ?? 0),
enabled: Boolean(issueId),
refetchInterval: options?.refetchInterval,
refetchIntervalInBackground: false,
});
}
export function useToggleTimer() {
const queryClient = useQueryClient();
return useMutation<TimerState, Error, TimerToggleRequest>({
mutationKey: ["timers", "toggle"],
mutationFn: timer.toggle,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.active(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
},
});
}
export function useEndTimer() {
const queryClient = useQueryClient();
return useMutation<TimerState, Error, TimerEndRequest>({
mutationKey: ["timers", "end"],
mutationFn: timer.end,
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.timers.active(variables.issueId) });
queryClient.invalidateQueries({ queryKey: queryKeys.timers.inactive(variables.issueId) });
},
});
}

View File

@@ -0,0 +1,36 @@
import type { UserRecord, UserUpdateRequest } from "@sprint/shared";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { queryKeys } from "@/lib/query/keys";
import { user } from "@/lib/server";
export function useUserByUsername(username?: string | null) {
return useQuery<UserRecord>({
queryKey: queryKeys.users.byUsername(username ?? ""),
queryFn: () => user.byUsername(username ?? ""),
enabled: Boolean(username),
});
}
export function useUpdateUser() {
const queryClient = useQueryClient();
return useMutation<UserRecord, Error, UserUpdateRequest>({
mutationKey: ["users", "update"],
mutationFn: user.update,
onSuccess: (_data) => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
},
});
}
export function useUploadAvatar() {
const queryClient = useQueryClient();
return useMutation<string, Error, File>({
mutationKey: ["users", "upload-avatar"],
mutationFn: user.uploadAvatar,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.all });
},
});
}

View File

@@ -0,0 +1,33 @@
// query key factory for granular cache invalidation
export const queryKeys = {
organisations: {
all: ["organisations"] as const,
byUser: () => [...queryKeys.organisations.all, "by-user"] as const,
members: (orgId: number) => [...queryKeys.organisations.all, orgId, "members"] as const,
},
projects: {
all: ["projects"] as const,
byOrganisation: (orgId: number) => [...queryKeys.projects.all, "by-org", orgId] as const,
},
issues: {
all: ["issues"] as const,
byProject: (projectId: number) => [...queryKeys.issues.all, "by-project", projectId] as const,
statusCount: (organisationId: number, status: string) =>
[...queryKeys.issues.all, "status-count", organisationId, status] as const,
},
sprints: {
all: ["sprints"] as const,
byProject: (projectId: number) => [...queryKeys.sprints.all, "by-project", projectId] as const,
},
timers: {
all: ["timers"] as const,
active: (issueId: number) => [...queryKeys.timers.all, "active", issueId] as const,
inactive: (issueId: number) => [...queryKeys.timers.all, "inactive", issueId] as const,
list: (issueId: number) => [...queryKeys.timers.all, "list", issueId] as const,
},
users: {
all: ["users"] as const,
byUsername: (username: string) => [...queryKeys.users.all, "by-username", username] as const,
},
};