From 49c8b63250039b34a34658e8ea14c85bc233e3fa Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Thu, 8 Dec 2022 10:33:39 +0100 Subject: [PATCH] Add filters --- api/issues.ts | 5 +- api/issues.types.ts | 12 ++ features/issues/api/use-get-issues.tsx | 18 +- .../components/filters/filters.styled.ts | 31 ++++ .../issues/components/filters/filters.tsx | 162 ++++++++++++++++++ features/issues/components/filters/index.ts | 1 + .../components/issue-list/issue-list.tsx | 86 +++++----- features/issues/context/filters-context.tsx | 46 +++++ features/issues/context/index.ts | 1 + features/issues/hooks/use-filters.ts | 4 + features/issues/index.ts | 1 + .../layout/page-container/page-container.tsx | 33 ++-- .../sidebar-navigation/navigation-context.tsx | 8 + features/ui/index.ts | 1 + features/ui/input/index.ts | 1 + features/ui/input/input.styled.ts | 92 ++++++++++ features/ui/input/input.tsx | 69 ++++++++ pages/dashboard/issues.tsx | 15 +- public/icons/search-icon.svg | 3 + 19 files changed, 517 insertions(+), 72 deletions(-) create mode 100644 features/issues/components/filters/filters.styled.ts create mode 100644 features/issues/components/filters/filters.tsx create mode 100644 features/issues/components/filters/index.ts create mode 100644 features/issues/context/filters-context.tsx create mode 100644 features/issues/context/index.ts create mode 100644 features/issues/hooks/use-filters.ts create mode 100644 features/ui/input/index.ts create mode 100644 features/ui/input/input.styled.ts create mode 100644 features/ui/input/input.tsx create mode 100644 public/icons/search-icon.svg diff --git a/api/issues.ts b/api/issues.ts index 31c68e5..3a45a94 100644 --- a/api/issues.ts +++ b/api/issues.ts @@ -1,15 +1,16 @@ import { axios } from "./axios"; -import type { Issue } from "./issues.types"; +import type { Issue, IssueFilters } from "./issues.types"; import type { Page } from "@typings/page.types"; const ENDPOINT = "/issue"; export async function getIssues( page: number, + filters: IssueFilters, options?: { signal?: AbortSignal } ) { const { data } = await axios.get>(ENDPOINT, { - params: { page }, + params: { page, ...filters }, signal: options?.signal, }); return data; diff --git a/api/issues.types.ts b/api/issues.types.ts index 2512962..205d28e 100644 --- a/api/issues.types.ts +++ b/api/issues.types.ts @@ -1,3 +1,8 @@ +export enum IssueStatus { + open = "open", + resolved = "resolved", +} + export enum IssueLevel { info = "info", warning = "warning", @@ -11,5 +16,12 @@ export type Issue = { message: string; stack: string; level: IssueLevel; + status: IssueStatus; numEvents: number; }; + +export type IssueFilters = { + level?: IssueLevel; + status?: IssueStatus; + project?: string; +}; diff --git a/features/issues/api/use-get-issues.tsx b/features/issues/api/use-get-issues.tsx index dfcb4e3..c69bc1f 100644 --- a/features/issues/api/use-get-issues.tsx +++ b/features/issues/api/use-get-issues.tsx @@ -2,21 +2,23 @@ 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"; +import type { Issue, IssueFilters } from "@api/issues.types"; +import { useFilters } from "../hooks/use-filters"; const QUERY_KEY = "issues"; -export function getQueryKey(page?: number) { +export function getQueryKey(page?: number, filters?: IssueFilters) { if (page === undefined) { return [QUERY_KEY]; } - return [QUERY_KEY, page]; + return [QUERY_KEY, page, filters]; } export function useGetIssues(page: number) { + const { filters } = useFilters(); const query = useQuery, Error>( - getQueryKey(page), - ({ signal }) => getIssues(page, { signal }), + getQueryKey(page, filters), + ({ signal }) => getIssues(page, filters, { signal }), { keepPreviousData: true } ); @@ -24,10 +26,10 @@ export function useGetIssues(page: number) { const queryClient = useQueryClient(); useEffect(() => { if (query.data?.meta.hasNextPage) { - queryClient.prefetchQuery(getQueryKey(page + 1), ({ signal }) => - getIssues(page + 1, { signal }) + queryClient.prefetchQuery(getQueryKey(page + 1, filters), ({ signal }) => + getIssues(page + 1, filters, { signal }) ); } - }, [query.data, page, queryClient]); + }, [query.data, page, filters, queryClient]); return query; } diff --git a/features/issues/components/filters/filters.styled.ts b/features/issues/components/filters/filters.styled.ts new file mode 100644 index 0000000..0b71021 --- /dev/null +++ b/features/issues/components/filters/filters.styled.ts @@ -0,0 +1,31 @@ +import styled from "styled-components"; +import { breakpoint } from "@styles/theme"; + +export const Container = styled.div` + display: flex; + flex-direction: column; + justify-content: center; + margin-block: 1rem; + gap: 1rem; + width: 100%; + @media (min-width: ${breakpoint("desktop")}) { + flex-direction: row; + justify-content: flex-end; + order: initial; + gap: 3rem; + flex-wrap: wrap; + } +`; + +export const RightContainer = styled.div` + margin-bottom: 1rem; + display: flex; + flex-direction: column; + gap: 1rem; + order: -1; + @media (min-width: ${breakpoint("desktop")}) { + flex-direction: row; + gap: 3rem; + order: initial; + } +`; diff --git a/features/issues/components/filters/filters.tsx b/features/issues/components/filters/filters.tsx new file mode 100644 index 0000000..df4e6b5 --- /dev/null +++ b/features/issues/components/filters/filters.tsx @@ -0,0 +1,162 @@ +import React, { + useState, + useEffect, + useCallback, + useRef, + useContext, +} from "react"; +import { useRouter } from "next/router"; +import { useWindowSize } from "react-use"; +import { Select, Option, Input } from "@features/ui"; +import { NavigationContext } from "@features/layout"; +import { useFilters } from "../../hooks/use-filters"; +import { IssueLevel, IssueStatus } from "@api/issues.types"; +import { useProjects } from "@features/projects"; +import * as S from "./filters.styled"; + +export function Filters() { + const { handleFilters, filters } = useFilters(); + const { data: projects } = useProjects(); + const router = useRouter(); + const routerQueryProjectName = + (router.query.projectName as string)?.toLowerCase() || undefined; + const [inputValue, setInputValue] = useState(""); + const projectNames = projects?.map((project) => project.name.toLowerCase()); + const isFirst = useRef(true); + const { width } = useWindowSize(); + const isMobileScreen = width <= 1023; + const { isMobileMenuOpen } = useContext(NavigationContext); + const handleChange = (input: string) => { + setInputValue(input); + + if (inputValue?.length < 2) { + handleProjectName(undefined); + return; + } + + const name = projectNames?.find((name) => + name?.toLowerCase().includes(inputValue.toLowerCase()) + ); + + if (name) { + handleProjectName(name); + } + }; + + const handleLevel = (level?: string) => { + if (level) { + level = level.toLowerCase(); + } + handleFilters({ level: level as IssueLevel }); + }; + + const handleStatus = (status?: string) => { + if (status === "Unresolved") { + status = "open"; + } + if (status) { + status = status.toLowerCase(); + } + handleFilters({ status: status as IssueStatus }); + }; + + const handleProjectName = useCallback( + (projectName?: string) => + handleFilters({ project: projectName?.toLowerCase() }), + [handleFilters] + ); + + useEffect(() => { + const newObj: { [key: string]: string } = { + ...filters, + }; + + Object.keys(newObj).forEach((key) => { + if (newObj[key] === undefined) { + delete newObj[key]; + } + }); + + const url = { + pathname: router.pathname, + query: { + page: router.query.page || 1, + ...newObj, + }, + }; + + if (routerQueryProjectName && isFirst) { + handleProjectName(routerQueryProjectName); + setInputValue(routerQueryProjectName || ""); + isFirst.current = false; + } + + router.push(url, undefined, { shallow: false }); + }, [filters.level, filters.status, filters.project, router.query.page]); + + return ( + + + + + + + + ); +} diff --git a/features/issues/components/filters/index.ts b/features/issues/components/filters/index.ts new file mode 100644 index 0000000..302e3a1 --- /dev/null +++ b/features/issues/components/filters/index.ts @@ -0,0 +1 @@ +export * from "./filters"; diff --git a/features/issues/components/issue-list/issue-list.tsx b/features/issues/components/issue-list/issue-list.tsx index c7f092a..591dce4 100644 --- a/features/issues/components/issue-list/issue-list.tsx +++ b/features/issues/components/issue-list/issue-list.tsx @@ -5,6 +5,7 @@ import { ProjectLanguage } from "@api/projects.types"; import { useProjects } from "@features/projects"; import { useGetIssues } from "../../api"; import { IssueRow } from "./issue-row"; +import { Filters } from "../filters"; const Container = styled.div` background: white; @@ -98,46 +99,49 @@ export function IssueList() { const { items, meta } = issuesPage.data || {}; return ( - - - - - Issue - Level - Events - Users - - - - {(items || []).map((issue) => ( - - ))} - -
- -
- navigateToPage(page - 1)} - disabled={page === 1} - > - Previous - - navigateToPage(page + 1)} - disabled={page === meta?.totalPages} - > - Next - -
- - Page {meta?.currentPage} of{" "} - {meta?.totalPages} - -
-
+ <> + + + + + + Issue + Level + Events + Users + + + + {(items || []).map((issue) => ( + + ))} + +
+ +
+ navigateToPage(page - 1)} + disabled={page === 1} + > + Previous + + navigateToPage(page + 1)} + disabled={page === meta?.totalPages} + > + Next + +
+ + Page {meta?.currentPage} of{" "} + {meta?.totalPages} + +
+
+ ); } diff --git a/features/issues/context/filters-context.tsx b/features/issues/context/filters-context.tsx new file mode 100644 index 0000000..a7472ee --- /dev/null +++ b/features/issues/context/filters-context.tsx @@ -0,0 +1,46 @@ +import React, { + useState, + useMemo, + useCallback, + createContext, + ReactNode, +} from "react"; +import { IssueFilters } from "@api/issues.types"; + +export const FiltersContext = createContext<{ + filters: IssueFilters; + handleFilters: (filter: IssueFilters) => unknown; +}>({ + filters: { status: undefined, level: undefined, project: undefined }, + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-empty-function + handleFilters: (_filter: IssueFilters) => {}, +}); + +type FiltersProviderProps = { + children: ReactNode | ReactNode[]; +}; + +export function FiltersProvider({ children }: FiltersProviderProps) { + const [filters, setFilters] = useState({ + status: undefined, + level: undefined, + project: undefined, + }); + + const handleFilters = useCallback( + (filter: any) => + setFilters((prevFilters) => ({ ...prevFilters, ...filter })), + [] + ); + + const memoizedValue = useMemo( + () => ({ filters, handleFilters }), + [filters, handleFilters] + ); + + return ( + + {children} + + ); +} diff --git a/features/issues/context/index.ts b/features/issues/context/index.ts new file mode 100644 index 0000000..cfeccc8 --- /dev/null +++ b/features/issues/context/index.ts @@ -0,0 +1 @@ +export * from "./filters-context"; diff --git a/features/issues/hooks/use-filters.ts b/features/issues/hooks/use-filters.ts new file mode 100644 index 0000000..8ebce02 --- /dev/null +++ b/features/issues/hooks/use-filters.ts @@ -0,0 +1,4 @@ +import { useContext } from "react"; +import { FiltersContext } from "../context/filters-context"; + +export const useFilters = () => useContext(FiltersContext); diff --git a/features/issues/index.ts b/features/issues/index.ts index 8e6318f..4818906 100644 --- a/features/issues/index.ts +++ b/features/issues/index.ts @@ -1,2 +1,3 @@ export * from "./api"; export * from "./components/issue-list"; +export * from "./context"; diff --git a/features/layout/page-container/page-container.tsx b/features/layout/page-container/page-container.tsx index 4f97f80..d5e132d 100644 --- a/features/layout/page-container/page-container.tsx +++ b/features/layout/page-container/page-container.tsx @@ -3,6 +3,7 @@ import Head from "next/head"; import styled from "styled-components"; import { SidebarNavigation } from "../sidebar-navigation"; import { color, displayFont, textFont, space, breakpoint } from "@styles/theme"; +import { FiltersProvider } from "@features/issues"; type PageContainerProps = { children: React.ReactNode; @@ -57,21 +58,23 @@ export function PageContainer({ children, title, info }: PageContainerProps) { // "Warning: A title element received an array with more than 1 element as children." const documentTitle = `ProLog - ${title}`; return ( - - - {documentTitle} - - - + + + + {documentTitle} + + + - -
- - {title} - {info} - {children} - -
-
+ +
+ + {title} + {info} + {children} + +
+
+ ); } diff --git a/features/layout/sidebar-navigation/navigation-context.tsx b/features/layout/sidebar-navigation/navigation-context.tsx index 9d11167..0edbbe7 100644 --- a/features/layout/sidebar-navigation/navigation-context.tsx +++ b/features/layout/sidebar-navigation/navigation-context.tsx @@ -6,6 +6,9 @@ type NavigationContextProviderProps = { const defaultContext = { isSidebarCollapsed: false, + isMobileMenuOpen: false, + // eslint-disable-next-line @typescript-eslint/no-empty-function + setMobileMenuOpen: () => {}, // eslint-disable-next-line @typescript-eslint/no-empty-function toggleSidebar: () => {}, }; @@ -18,11 +21,16 @@ export function NavigationProvider({ const [isSidebarCollapsed, setSidebarCollapsed] = useState( defaultContext.isSidebarCollapsed ); + const [isMobileMenuOpen, setMobileMenuOpen] = useState( + defaultContext.isMobileMenuOpen + ); return ( setMobileMenuOpen((isOpen) => !isOpen), toggleSidebar: () => setSidebarCollapsed((isCollapsed) => !isCollapsed), }} > diff --git a/features/ui/index.ts b/features/ui/index.ts index 612a8e8..4f4c0b5 100644 --- a/features/ui/index.ts +++ b/features/ui/index.ts @@ -1,3 +1,4 @@ export * from "./badge"; export * from "./button"; export * from "./select"; +export * from "./input"; diff --git a/features/ui/input/index.ts b/features/ui/input/index.ts new file mode 100644 index 0000000..4ce4a88 --- /dev/null +++ b/features/ui/input/index.ts @@ -0,0 +1 @@ +export * from "./input"; diff --git a/features/ui/input/input.styled.ts b/features/ui/input/input.styled.ts new file mode 100644 index 0000000..c5ce947 --- /dev/null +++ b/features/ui/input/input.styled.ts @@ -0,0 +1,92 @@ +import styled, { css } from "styled-components"; +import { color, textFont, space } from "@styles/theme"; + +export const Container = styled.div` + position: relative; +`; + +export const InputContainer = styled.input<{ + errorMessage: string; + error: boolean; + isIconPresent: boolean; +}>` + display: block; + border: 1px solid; + border-color: ${({ errorMessage, error }) => + errorMessage || error ? color("error", 300) : color("gray", 300)}; + border-radius: 7px; + width: calc(${space(20)} * 4 - ${space(6)}); + padding: ${space(2, 3)}; + letter-spacing: 0.05rem; + color: ${color("gray", 900)}; + ${textFont("md", "regular")}; + ${({ isIconPresent }) => + isIconPresent && + css` + padding-left: ${space(10)}; + width: calc(${space(20)} * 4 - ${space(12)} - ${space(2)}); + `} + ::placeholder { + color: ${color("gray", 400)}; + } + &:focus { + outline: 3px solid; + outline-color: ${({ errorMessage, error }) => + errorMessage || error ? color("error", 100) : color("primary", 200)}; + } + &:disable { + color: ${color("gray", 500)}; + background-color: ${color("gray", 50)}; + } +`; + +export const InputIcon = styled.img<{ + iconSrc?: string; + displayLabel?: boolean; +}>` + display: block; + height: ${space(5)}; + width: ${space(5)}; + margin-inline: ${space(2)}; + position: absolute; + box-sizing: border-box; + top: 50%; + left: 5px; + transform: translateY(-50%); + transform: ${({ displayLabel }) => displayLabel && "translateY(12%)"}; +`; + +export const Label = styled.label<{ + displayLabel?: boolean; +}>` + display: ${({ displayLabel }) => (displayLabel ? "block" : "none")}; + margin: 0; + margin-bottom: ${space(1)}; + color: ${color("gray", 700)}; + ${textFont("md", "regular")}; +`; + +export const Hint = styled.p` + margin: 0; + margin-top: ${space(1)}; + color: ${color("gray", 500)}; + ${textFont("sm", "regular")}; + letter-spacing: 0.045rem; +`; + +export const ErrorMessage = styled.p` + margin: 0; + margin-top: ${space(1)}; + color: ${color("error", 500)}; + ${textFont("sm", "regular")}; + letter-spacing: 0.05rem; +`; + +export const ErrorIcon = styled(InputIcon)<{ + displayLabel: boolean; +}>` + height: ${space(4)}; + width: ${space(4)}; + left: 280px; + transform: ${({ displayLabel }) => displayLabel && "translateY(40%)"}; +`; diff --git a/features/ui/input/input.tsx b/features/ui/input/input.tsx new file mode 100644 index 0000000..66eb685 --- /dev/null +++ b/features/ui/input/input.tsx @@ -0,0 +1,69 @@ +import { InputHTMLAttributes } from "react"; +import * as S from "./input.styled"; + +type InputProps = InputHTMLAttributes & { + label: string; + value: string; + handleChange: (input: string) => unknown; + disabled?: boolean; + displayLabel?: boolean; + iconSrc?: string; + placeholder?: string; + hint?: string; + error?: boolean; + errorMessage?: string; +}; + +export function Input({ + label, + value, + handleChange, + disabled = false, + iconSrc = "", + placeholder = "", + displayLabel = false, + hint = "", + error = false, + errorMessage = "", + ...props +}: InputProps) { + const isIconPresent = iconSrc.length > 3; + return ( + <> + + + {label} + + {iconSrc && ( + + )} + {(error || errorMessage) && ( + + )} + ) => { + e.preventDefault(); + handleChange(e.target.value); + }} + disabled={disabled} + value={value} + isIconPresent={isIconPresent} + placeholder={placeholder} + errorMessage={errorMessage} + error={error} + {...props} + /> + + {hint && !errorMessage && {hint}} + {errorMessage && {errorMessage}} + + ); +} diff --git a/pages/dashboard/issues.tsx b/pages/dashboard/issues.tsx index 2b452b3..1d2cb65 100644 --- a/pages/dashboard/issues.tsx +++ b/pages/dashboard/issues.tsx @@ -1,15 +1,18 @@ import { PageContainer } from "@features/layout"; import { IssueList } from "@features/issues"; +import { FiltersProvider } from "@features/issues"; import type { NextPage } from "next"; const IssuesPage: NextPage = () => { return ( - - - + + + + + ); }; diff --git a/public/icons/search-icon.svg b/public/icons/search-icon.svg new file mode 100644 index 0000000..6a50ed4 --- /dev/null +++ b/public/icons/search-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file