diff --git a/packages/frontend/src/lib/query/client.ts b/packages/frontend/src/lib/query/client.ts new file mode 100644 index 0000000..6f50228 --- /dev/null +++ b/packages/frontend/src/lib/query/client.ts @@ -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, + }, + }, +}); diff --git a/packages/frontend/src/lib/query/hooks/derived.ts b/packages/frontend/src/lib/query/hooks/derived.ts new file mode 100644 index 0000000..b825165 --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/derived.ts @@ -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], + ); +} diff --git a/packages/frontend/src/lib/query/hooks/index.ts b/packages/frontend/src/lib/query/hooks/index.ts new file mode 100644 index 0000000..b30da5b --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/index.ts @@ -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"; diff --git a/packages/frontend/src/lib/query/hooks/issues.ts b/packages/frontend/src/lib/query/hooks/issues.ts new file mode 100644 index 0000000..404581f --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/issues.ts @@ -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({ + 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({ + 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({ + 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({ + mutationKey: ["issues", "update"], + mutationFn: issue.update, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); + }, + }); +} + +export function useDeleteIssue() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["issues", "delete"], + mutationFn: issue.delete, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); + }, + }); +} + +export function useReplaceIssueStatus() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["issues", "replace-status"], + mutationFn: issue.replaceStatus, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.issues.all }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/organisations.ts b/packages/frontend/src/lib/query/hooks/organisations.ts new file mode 100644 index 0000000..412d3bb --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/organisations.ts @@ -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({ + 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({ + 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), + }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/projects.ts b/packages/frontend/src/lib/query/hooks/projects.ts new file mode 100644 index 0000000..eb30d64 --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/projects.ts @@ -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({ + queryKey: queryKeys.projects.byOrganisation(organisationId ?? 0), + queryFn: () => project.byOrganisation(organisationId ?? 0), + enabled: Boolean(organisationId), + }); +} + +export function useCreateProject() { + const queryClient = useQueryClient(); + + return useMutation({ + 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({ + 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 }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/sprints.ts b/packages/frontend/src/lib/query/hooks/sprints.ts new file mode 100644 index 0000000..14b432c --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/sprints.ts @@ -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({ + queryKey: queryKeys.sprints.byProject(projectId ?? 0), + queryFn: () => sprint.byProject(projectId ?? 0), + enabled: Boolean(projectId), + }); +} + +export function useCreateSprint() { + const queryClient = useQueryClient(); + + return useMutation({ + 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({ + 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 }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/timers.ts b/packages/frontend/src/lib/query/hooks/timers.ts new file mode 100644 index 0000000..347794d --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/timers.ts @@ -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({ + 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({ + 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({ + 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({ + 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) }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/hooks/users.ts b/packages/frontend/src/lib/query/hooks/users.ts new file mode 100644 index 0000000..deefe87 --- /dev/null +++ b/packages/frontend/src/lib/query/hooks/users.ts @@ -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({ + queryKey: queryKeys.users.byUsername(username ?? ""), + queryFn: () => user.byUsername(username ?? ""), + enabled: Boolean(username), + }); +} + +export function useUpdateUser() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["users", "update"], + mutationFn: user.update, + onSuccess: (_data) => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + }, + }); +} + +export function useUploadAvatar() { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ["users", "upload-avatar"], + mutationFn: user.uploadAvatar, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.users.all }); + }, + }); +} diff --git a/packages/frontend/src/lib/query/keys.ts b/packages/frontend/src/lib/query/keys.ts new file mode 100644 index 0000000..1a05850 --- /dev/null +++ b/packages/frontend/src/lib/query/keys.ts @@ -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, + }, +};