diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..69f8728 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +NEXT_PUBLIC_API_BASE_URL=https://prolog-api.profy.dev/v2 +NEXT_PUBLIC_API_TOKEN=my-access-token diff --git a/.gitignore b/.gitignore index 5e105e9..fa97ed2 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ yarn-error.log* .vscode .eslintcache +.env # Cypress test videos cypress/videos/ diff --git a/api/axios.ts b/api/axios.ts new file mode 100644 index 0000000..7059ca7 --- /dev/null +++ b/api/axios.ts @@ -0,0 +1,29 @@ +import assert from "assert"; +import Axios, { AxiosRequestConfig } from "axios"; + +assert( + process.env.NEXT_PUBLIC_API_BASE_URL, + "env variable not set: NEXT_PUBLIC_API_BASE_URL" +); + +assert( + process.env.NEXT_PUBLIC_API_TOKEN, + "env variable not set: NEXT_PUBLIC_API_TOKEN" +); + +function authRequestInterceptor(config: AxiosRequestConfig) { + if (!config.headers) { + config.headers = {}; + } + + // assertion that env variable exists was already done outside the function + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + config.headers.authorization = process.env.NEXT_PUBLIC_API_TOKEN!; + return config; +} + +export const axios = Axios.create({ + baseURL: process.env.NEXT_PUBLIC_API_BASE_URL, +}); + +axios.interceptors.request.use(authRequestInterceptor); diff --git a/api/issues.ts b/api/issues.ts new file mode 100644 index 0000000..0f6c954 --- /dev/null +++ b/api/issues.ts @@ -0,0 +1,28 @@ +import { axios } from "./axios"; +import type { Issue } from "./issues.types"; +import type { Page } from "@typings/page.types"; + +type IssueFilters = { + status?: "open" | "resolved"; +}; + +const ENDPOINT = "/issue"; + +export async function getIssues( + page: number, + filters: IssueFilters = {}, + options?: { signal?: AbortSignal } +) { + const { data } = await axios.get>(ENDPOINT, { + params: { page, ...filters }, + signal: options?.signal, + }); + return data; +} + +export async function resolveIssue(issueId: string) { + const { data } = await axios.patch(`${ENDPOINT}/${issueId}`, { + status: "resolved", + }); + return data; +} diff --git a/features/issues/types/issue.types.ts b/api/issues.types.ts similarity index 100% rename from features/issues/types/issue.types.ts rename to api/issues.types.ts diff --git a/api/projects.ts b/api/projects.ts new file mode 100644 index 0000000..ed1aead --- /dev/null +++ b/api/projects.ts @@ -0,0 +1,9 @@ +import { axios } from "./axios"; +import type { Project } from "./projects.types"; + +const ENDPOINT = "/project"; + +export async function getProjects() { + const { data } = await axios.get(ENDPOINT); + return data; +} diff --git a/features/projects/types/project.types.ts b/api/projects.types.ts similarity index 100% rename from features/projects/types/project.types.ts rename to api/projects.types.ts diff --git a/api/query-client.ts b/api/query-client.ts new file mode 100644 index 0000000..064d607 --- /dev/null +++ b/api/query-client.ts @@ -0,0 +1,7 @@ +import { QueryClient } from "@tanstack/react-query"; + +const defaultQueryConfig = { staleTime: 60000 }; + +export const queryClient = new QueryClient({ + defaultOptions: { queries: defaultQueryConfig }, +}); diff --git a/features/issues/api/index.ts b/features/issues/api/index.ts new file mode 100644 index 0000000..7122906 --- /dev/null +++ b/features/issues/api/index.ts @@ -0,0 +1,2 @@ +export * from "./use-get-issues"; +export * from "./use-resolve-issue"; diff --git a/features/issues/api/use-get-issues.tsx b/features/issues/api/use-get-issues.tsx new file mode 100644 index 0000000..db82676 --- /dev/null +++ b/features/issues/api/use-get-issues.tsx @@ -0,0 +1,33 @@ +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { getIssues } from "@api/issues"; +import type { Page } from "@typings/page.types"; +import type { Issue } from "@api/issues.types"; + +const QUERY_KEY = "issues"; + +export function getQueryKey(page?: number) { + if (page === undefined) { + return [QUERY_KEY]; + } + return [QUERY_KEY, page]; +} + +export function useGetIssues(page: number) { + const query = useQuery, Error>( + getQueryKey(page), + ({ signal }) => getIssues(page, { status: "open" }, { signal }), + { keepPreviousData: true } + ); + + // Prefetch the next page! + const queryClient = useQueryClient(); + useEffect(() => { + if (query.data?.meta.hasNextPage) { + queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) => + getIssues(page + 1, { status: "open" }, { signal }) + ); + } + }, [query.data, page, queryClient]); + return query; +} diff --git a/features/issues/api/use-issues.tsx b/features/issues/api/use-issues.tsx deleted file mode 100644 index fec9631..0000000 --- a/features/issues/api/use-issues.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { useEffect } from "react"; -import { useQuery, useQueryClient } from "@tanstack/react-query"; -import axios from "axios"; -import type { Page } from "@typings/page.types"; -import type { Issue } from "../types/issue.types"; - -async function getIssues(page: number) { - const { data } = await axios.get( - `https://prolog-api.profy.dev/issue?page=${page}` - ); - return data; -} - -const commonQueryOptions = { - staleTime: 60000, -}; - -export function useIssues(page: number) { - const query = useQuery, Error>( - ["issues", page], - () => getIssues(page), - { ...commonQueryOptions, staleTime: 60000 } - ); - - // Prefetch the next page! - const queryClient = useQueryClient(); - useEffect(() => { - if (query.data?.meta.hasNextPage) { - queryClient.prefetchQuery( - ["issues", page + 1], - () => getIssues(page + 1), - commonQueryOptions - ); - } - }, [query.data, page, queryClient]); - return query; -} diff --git a/features/issues/api/use-resolve-issue.tsx b/features/issues/api/use-resolve-issue.tsx new file mode 100644 index 0000000..b402130 --- /dev/null +++ b/features/issues/api/use-resolve-issue.tsx @@ -0,0 +1,62 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useRef } from "react"; +import { resolveIssue } from "@api/issues"; +import * as GetIssues from "./use-get-issues"; +import type { Issue } from "@api/issues.types"; + +export function useResolveIssue(page: number) { + const queryClient = useQueryClient(); + const ongoingMutationCount = useRef(0); + return useMutation((issueId) => resolveIssue(issueId), { + onMutate: async (issueId: string) => { + ongoingMutationCount.current += 1; + + await queryClient.cancelQueries(GetIssues.getQueryKey()); + + const currentPage = queryClient.getQueryData<{ items: Issue[] }>( + GetIssues.getQueryKey(page) + ); + const nextPage = queryClient.getQueryData<{ items: Issue[] }>( + GetIssues.getQueryKey(page + 1) + ); + + if (!currentPage) { + return; + } + + const newItems = currentPage.items.filter(({ id }) => id !== issueId); + + if (nextPage?.items.length) { + const lastIssueOnPage = currentPage.items[currentPage.items.length - 1]; + const indexOnNextPage = nextPage.items.findIndex( + (issue) => issue.id === lastIssueOnPage.id + ); + const nextIssue = nextPage.items[indexOnNextPage + 1]; + if (nextIssue) { + newItems.push(nextIssue); + } + } + + queryClient.setQueryData(GetIssues.getQueryKey(page), { + ...currentPage, + items: newItems, + }); + + return { currentIssuesPage: currentPage }; + }, + onError: (err, issueId, context) => { + if (context?.currentIssuesPage) { + queryClient.setQueryData( + GetIssues.getQueryKey(page), + context.currentIssuesPage + ); + } + }, + onSettled: () => { + ongoingMutationCount.current -= 1; + if (ongoingMutationCount.current === 0) { + queryClient.invalidateQueries(GetIssues.getQueryKey()); + } + }, + }); +} diff --git a/features/issues/components/issue-list/issue-list.tsx b/features/issues/components/issue-list/issue-list.tsx index d576363..59ede79 100644 --- a/features/issues/components/issue-list/issue-list.tsx +++ b/features/issues/components/issue-list/issue-list.tsx @@ -1,9 +1,9 @@ -import { useRouter } from "next/router"; +import { useState } from "react"; import styled from "styled-components"; -import { useIssues } from "@features/issues"; -import { ProjectLanguage, useProjects } from "@features/projects"; +import { ProjectLanguage } from "@api/projects.types"; import { color, space, textFont } from "@styles/theme"; import { IssueRow } from "./issue-row"; +import { useGetIssues, useResolveIssue } from "../../api"; const Container = styled.div` background: white; @@ -62,39 +62,12 @@ const PageNumber = styled.span` `; export function IssueList() { - const router = useRouter(); - const page = Number(router.query.page || 1); - const navigateToPage = (newPage: number) => - router.push({ - pathname: router.pathname, - query: { page: newPage }, - }); + const [page, setPage] = useState(1); - const issuesPage = useIssues(page); - const projects = useProjects(); + const issuePage = useGetIssues(page); + const resolveIssue = useResolveIssue(page); - if (projects.isLoading || issuesPage.isLoading) { - return
Loading
; - } - - if (projects.isError) { - console.error(projects.error); - return
Error loading projects: {projects.error.message}
; - } - - if (issuesPage.isError) { - console.error(issuesPage.error); - return
Error loading issues: {issuesPage.error.message}
; - } - - const projectIdToLanguage = (projects.data || []).reduce( - (prev, project) => ({ - ...prev, - [project.id]: project.language, - }), - {} as Record - ); - const { items, meta } = issuesPage.data || {}; + const { items, meta } = issuePage.data || {}; return ( @@ -112,7 +85,8 @@ export function IssueList() { resolveIssue.mutate(issue.id)} /> ))} @@ -120,13 +94,13 @@ export function IssueList() {
navigateToPage(page - 1)} + onClick={() => setPage(page - 1)} disabled={page === 1} > Previous navigateToPage(page + 1)} + onClick={() => setPage(page + 1)} disabled={page === meta?.totalPages} > Next diff --git a/features/issues/components/issue-list/issue-row.tsx b/features/issues/components/issue-list/issue-row.tsx index ce5da00..1f030eb 100644 --- a/features/issues/components/issue-list/issue-row.tsx +++ b/features/issues/components/issue-list/issue-row.tsx @@ -2,13 +2,14 @@ import styled from "styled-components"; import capitalize from "lodash/capitalize"; import { color, space, textFont } from "@styles/theme"; import { Badge, BadgeColor, BadgeSize } from "@features/ui"; -import { IssueLevel } from "../../types/issue.types"; -import { ProjectLanguage } from "@features/projects"; -import type { Issue } from "../../types/issue.types"; +import { ProjectLanguage } from "@api/projects.types"; +import { IssueLevel } from "@api/issues.types"; +import type { Issue } from "@api/issues.types"; type IssueRowProps = { projectLanguage: ProjectLanguage; issue: Issue; + resolveIssue: () => void; }; const levelColors = { @@ -47,9 +48,30 @@ const ErrorType = styled.span` ${textFont("sm", "medium")} `; -export function IssueRow({ projectLanguage, issue }: IssueRowProps) { +const ResolveButton = styled.button` + width: 1.5rem; + height: 1.5rem; + font-size: 0.8rem; + display: flex; + align-items: center; + justify-content: center; + color: #aaa; + border: 1px solid #aaa; + background: none; + border-radius: 50%; + padding: none; + margin: none; + cursor: pointer; +`; + +export function IssueRow({ + projectLanguage, + issue, + resolveIssue, +}: IssueRowProps) { const { name, message, stack, level, numEvents } = issue; const firstLineOfStackTrace = stack.split("\n")[1]; + return ( @@ -72,6 +94,16 @@ export function IssueRow({ projectLanguage, issue }: IssueRowProps) { {numEvents} {numEvents} + + + + + + + ); } diff --git a/features/issues/index.ts b/features/issues/index.ts index aefa94b..8e6318f 100644 --- a/features/issues/index.ts +++ b/features/issues/index.ts @@ -1,3 +1,2 @@ -export * from "./api/use-issues"; +export * from "./api"; export * from "./components/issue-list"; -export * from "./types/issue.types"; diff --git a/features/projects/api/index.ts b/features/projects/api/index.ts new file mode 100644 index 0000000..4d090e6 --- /dev/null +++ b/features/projects/api/index.ts @@ -0,0 +1 @@ +export * from "./use-projects"; diff --git a/features/projects/api/use-projects.tsx b/features/projects/api/use-projects.tsx index d8182dd..9544b73 100644 --- a/features/projects/api/use-projects.tsx +++ b/features/projects/api/use-projects.tsx @@ -1,14 +1,7 @@ import { useQuery } from "@tanstack/react-query"; -import axios from "axios"; -import { Project } from "../types/project.types"; - -async function getProjects() { - const { data } = await axios.get("https://prolog-api.profy.dev/project"); - return data; -} +import { getProjects } from "@api/projects"; +import type { Project } from "@api/projects.types"; export function useProjects() { - return useQuery(["projects"], getProjects, { - staleTime: 60000, - }); + return useQuery(["projects"], getProjects); } diff --git a/features/projects/components/project-card/project-card.stories.tsx b/features/projects/components/project-card/project-card.stories.tsx index 9cc9457..c0fb30b 100644 --- a/features/projects/components/project-card/project-card.stories.tsx +++ b/features/projects/components/project-card/project-card.stories.tsx @@ -1,7 +1,7 @@ import React from "react"; import { ComponentStory, ComponentMeta } from "@storybook/react"; import { ProjectCard } from "./project-card"; -import { ProjectLanguage, ProjectStatus } from "@features/projects"; +import { ProjectLanguage, ProjectStatus } from "@api/projects.types"; export default { title: "Project/ProjectCard", diff --git a/features/projects/components/project-card/project-card.tsx b/features/projects/components/project-card/project-card.tsx index 5df1a54..4034fc2 100644 --- a/features/projects/components/project-card/project-card.tsx +++ b/features/projects/components/project-card/project-card.tsx @@ -2,13 +2,10 @@ import Link from "next/link"; import styled from "styled-components"; import capitalize from "lodash/capitalize"; import { Badge, BadgeColor } from "@features/ui"; -import { - Project, - ProjectLanguage, - ProjectStatus, -} from "../../types/project.types"; import { color, displayFont, space, textFont } from "@styles/theme"; import { Routes } from "@config/routes"; +import { ProjectLanguage, ProjectStatus } from "@api/projects.types"; +import type { Project } from "@api/projects.types"; type ProjectCardProps = { project: Project; diff --git a/features/projects/components/project-list/project-list.tsx b/features/projects/components/project-list/project-list.tsx index 06dee53..3fd8042 100644 --- a/features/projects/components/project-list/project-list.tsx +++ b/features/projects/components/project-list/project-list.tsx @@ -1,7 +1,7 @@ import styled from "styled-components"; +import { breakpoint, space } from "@styles/theme"; import { ProjectCard } from "../project-card"; import { useProjects } from "../../api/use-projects"; -import { breakpoint, space } from "@styles/theme"; const List = styled.ul` display: grid; diff --git a/features/projects/index.ts b/features/projects/index.ts index 654d652..4be9eef 100644 --- a/features/projects/index.ts +++ b/features/projects/index.ts @@ -1,3 +1,2 @@ -export * from "./api/use-projects"; +export * from "./api"; export * from "./components/project-list"; -export * from "./types/project.types"; diff --git a/pages/_app.tsx b/pages/_app.tsx index 0c49dfd..2542272 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -3,13 +3,12 @@ import "@fontsource/inter/500.css"; import "@fontsource/inter/600.css"; import type { AppProps } from "next/app"; import { ThemeProvider } from "styled-components"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClientProvider } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { NavigationProvider } from "@features/ui"; import { GlobalStyle } from "@styles/global-style"; import { theme } from "@styles/theme"; - -const queryClient = new QueryClient(); +import { queryClient } from "@api/query-client"; function MyApp({ Component, pageProps }: AppProps) { return ( diff --git a/tsconfig.json b/tsconfig.json index 4ed3e82..c35d661 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,9 +16,10 @@ "types": ["jest"], "baseUrl": ".", "paths": { - "@styles/*": ["styles/*"], + "@api/*": ["api/*"], "@config/*": ["config/*"], "@features/*": ["features/*"], + "@styles/*": ["styles/*"], "@typings/*": ["typings/*"] }, "noEmit": true