From 2295f24622359872192dca382954781806c17f05 Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Tue, 13 Dec 2022 15:11:45 +0100 Subject: [PATCH 01/10] Use URL params as single source of truth --- .../issues/components/filters/filters.tsx | 44 ++---------------- features/issues/context/filters-context.tsx | 46 ------------------- features/issues/context/index.ts | 1 - features/issues/hooks/use-filters.ts | 21 +++++++-- features/issues/index.ts | 1 - features/ui/page-container/page-container.tsx | 33 ++++++------- pages/dashboard/issues.tsx | 15 +++--- 7 files changed, 43 insertions(+), 118 deletions(-) delete mode 100644 features/issues/context/filters-context.tsx delete mode 100644 features/issues/context/index.ts diff --git a/features/issues/components/filters/filters.tsx b/features/issues/components/filters/filters.tsx index 5b92d0d..4410de5 100644 --- a/features/issues/components/filters/filters.tsx +++ b/features/issues/components/filters/filters.tsx @@ -1,11 +1,4 @@ -import React, { - useState, - useEffect, - useCallback, - useRef, - useContext, -} from "react"; -import { useRouter } from "next/router"; +import React, { useState, useCallback, useContext } from "react"; import { useWindowSize } from "react-use"; import { Select, Option, Input, NavigationContext } from "@features/ui"; import { useFilters } from "../../hooks/use-filters"; @@ -16,15 +9,14 @@ 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); @@ -65,34 +57,6 @@ export function Filters() { [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 ( Date: Tue, 13 Dec 2022 16:01:45 +0100 Subject: [PATCH 03/10] Replace project input autocomplete with debounce --- .../issues/components/filters/filters.tsx | 35 +++++-------------- package-lock.json | 20 ++++++++++- package.json | 3 +- 3 files changed, 29 insertions(+), 29 deletions(-) diff --git a/features/issues/components/filters/filters.tsx b/features/issues/components/filters/filters.tsx index 3db0749..c5d03ed 100644 --- a/features/issues/components/filters/filters.tsx +++ b/features/issues/components/filters/filters.tsx @@ -1,11 +1,11 @@ -import React, { useState, useCallback, useContext } from "react"; +import React, { useState, useContext } from "react"; import { useWindowSize } from "react-use"; +import { useDebouncedCallback } from "use-debounce"; +import { capitalize } from "lodash"; import { Select, Option, Input, NavigationContext } from "@features/ui"; -import { useFilters } from "../../hooks/use-filters"; import { IssueFilters, IssueLevel, IssueStatus } from "@api/issues.types"; -import { useProjects } from "@features/projects"; +import { useFilters } from "../../hooks/use-filters"; import * as S from "./filters.styled"; -import { capitalize } from "lodash"; function getStatusDefaultValue(filters: IssueFilters) { if (!filters.status) { @@ -26,30 +26,17 @@ function getLevelDefaultValue(filters: IssueFilters) { export function Filters() { const { handleFilters, filters } = useFilters(); - const { data: projects } = useProjects(); + const debouncedHandleFilters = useDebouncedCallback(handleFilters, 300); const [inputValue, setInputValue] = useState(filters.project || ""); - const projectNames = projects?.map((project) => project.name.toLowerCase()); 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 handleChange = (project: string) => { + setInputValue(project); + debouncedHandleFilters({ project: project.toLowerCase() }); }; const handleLevel = (level?: string) => { @@ -69,12 +56,6 @@ export function Filters() { handleFilters({ status: status as IssueStatus }); }; - const handleProjectName = useCallback( - (projectName?: string) => - handleFilters({ project: projectName?.toLowerCase() }), - [handleFilters] - ); - return ( - + - + - Date: Wed, 7 Dec 2022 13:51:44 +0100 Subject: [PATCH 06/10] Migrate to controlled component --- features/ui/select/option.tsx | 27 +++---- features/ui/select/select.stories.tsx | 38 +++++---- features/ui/select/select.tsx | 109 ++++++++++++++------------ 3 files changed, 85 insertions(+), 89 deletions(-) diff --git a/features/ui/select/option.tsx b/features/ui/select/option.tsx index f21fdc7..cab34f6 100644 --- a/features/ui/select/option.tsx +++ b/features/ui/select/option.tsx @@ -1,32 +1,25 @@ import React, { ReactNode } from "react"; -import { useSelectContext } from "./select-context"; import * as S from "./option.styled"; type OptionProps = { - children: ReactNode | ReactNode[]; - value: any; - handleCallback?: (value: any) => unknown; + children: ReactNode; + value: unknown; + isSelected: boolean; + onClick: (value: unknown) => void; }; -export function Option({ children, value, handleCallback }: OptionProps) { - const { changeSelectedOption, selectedOption } = useSelectContext(); - const isCurrentlySelected = selectedOption === value; - +export function Option({ children, value, onClick, isSelected }: OptionProps) { return ( { - changeSelectedOption(value); - if (handleCallback) { - handleCallback(value); - } - }} + isCurrentlySelected={isSelected} + aria-selected={isSelected} + onClick={() => onClick(value)} role="option" + tabIndex={0} > {children} diff --git a/features/ui/select/select.stories.tsx b/features/ui/select/select.stories.tsx index 9a8637c..59443c5 100644 --- a/features/ui/select/select.stories.tsx +++ b/features/ui/select/select.stories.tsx @@ -1,7 +1,6 @@ -import React from "react"; +import React, { useState } from "react"; import { ComponentStory, ComponentMeta } from "@storybook/react"; -import { Select, Option } from "./"; - +import { Select } from "./select"; export default { title: "UI/Select", component: Select, @@ -9,30 +8,29 @@ export default { layout: "fullscreen", }, } as ComponentMeta; - -const selectData = [ - "Phoenix Baker", - "Olivia Rhye", - "Lana Steiner", - "Demi Wilkinson", - "Candice Wu", - "Natali Craig", - "Drew Cano", +const options = [ + { label: "Phoenix Baker", value: 1 }, + { label: "Olivia Rhye", value: 2 }, + { label: "Lana Steiner", value: 3 }, + { label: "Demi Wilkinson", value: 4 }, + { label: "Candice Wu", value: 5 }, + { label: "Natali Craig", value: 6 }, + { label: "Drew Cano", value: 7 }, ]; - const Template: ComponentStory = (props) => { + const [selectedValue, setSelectedValue] = useState(); return (
- + ` +export const Container = styled.div` position: relative; - display: block; - width: ${({ width }) => width || `calc(5rem * 4)`}; - background-color: #fff; + letter-spacing: 0.04rem; `; -export const SelectedOption = styled.div.attrs(() => ({ - tabIndex: 0, - ariaHasPopup: "listbox", -}))` - border: 1px solid; - border-color: ${({ disabled, errorMessage }) => - !disabled && errorMessage ? "#FDA29B" : "#D0D5DD"}; - border-radius: 7px; - width: ${({ width }) => width || `calc(5rem * 4 - 1.5rem)`}; - padding: 0.5rem 0.75rem; - color: ${({ selectedOption }) => (selectedOption ? "#101828" : "#667085")}; - cursor: pointer; +export const SelectedOption = styled.div<{ + disabled: boolean; + hasError: boolean; + hasValue: boolean; +}>` display: flex; - justify-content: space-between; - letter-spacing: 0.052rem; - font-size: 1rem; - line-height: 1.5rem; - font-weight: 400; + align-items: center; + padding: 0.6875rem 0.875rem; + border: 1px solid #d0d5dd; + border-radius: 8px; + cursor: pointer; + color: #667085; + box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05); &:focus { - outline: 3px solid; - outline-color: ${({ disabled, errorMessage }) => - !disabled && errorMessage ? "#FEE4E2" : "#E9D7FE"}; + outline: none; + box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05), 0px 0px 0px 4px #f4ebff; } - ${({ disabled }) => - disabled && - css` - color: #667085; - background-color: #f2f4f7; - pointer-events: none; - `} + ${({ hasValue, hasError, disabled }) => { + if (disabled) { + return css` + color: #667085; + background-color: #f2f4f7; + pointer-events: none; + `; + } + if (hasError) { + return css` + border-color: #fda29b; + &:focus { + box-shadow: 0px 1px 2px rgba(16, 24, 40, 0.05), + 0px 0px 0px 4px #fee4e2; + } + `; + } + if (hasValue) { + return css` + color: #101828; + `; + } + }} `; export const SelectArrowIcon = styled.img<{ @@ -44,73 +52,53 @@ export const SelectArrowIcon = styled.img<{ }>` transform: ${({ showDropdown }) => showDropdown ? "rotate(180deg)" : "none"}; - padding-inline: 0.25rem; `; export const OptionalIcon = styled.img` width: 1.25rem; height: 1.25rem; - padding-inline: 0.25rem 0.5rem; `; -export const LeftContainer = styled.div` - display: flex; - align-items: center; +export const SelectedText = styled.span` + flex: 1; + padding: 0 0.5rem; `; -export const Label = styled.p` - margin: 0; +export const Label = styled.div` margin-bottom: 0.25rem; color: #344054; - font-size: 1rem; - line-height: 1.5rem; - font-weight: 400; + font-size: 0.875rem; + line-height: 1.25rem; + letter-spacing: 0.025rem; `; export const List = styled.ul<{ showDropdown: boolean }>` - display: block; + display: none; + position: absolute; width: 100%; margin: 0.5rem 0 0; padding: 0; - position: absolute; + z-index: 1; background: white; - box-shadow: 0 7px 12px -6px #d0d5dd; box-shadow: 0px 12px 16px -4px rgba(16, 24, 40, 0.1), 0px 4px 6px -2px rgba(16, 24, 40, 0.05); border-radius: 8px; overflow: hidden; - ${({ showDropdown }) => - showDropdown - ? css` - opacity: 1; - visibility: visible; - position: absolute; - height: auto; - z-index: 200; - ` - : css` - opacity: 0; - visibility: hidden; - `}; + showDropdown && + css` + display: block; + `} `; -export const Hint = styled.p` - margin: 0; +export const Hint = styled.div` margin-top: 0.25rem; color: #667085; font-size: 0.875rem; line-height: 1.25rem; - font-weight: 400; - letter-spacing: 0.05rem; + letter-spacing: 0.025rem; `; -export const ErrorMessage = styled.p` - margin: 0; - margin-top: 0.25rem; +export const ErrorMessage = styled(Hint)` color: #f04438; - font-size: 0.875rem; - line-height: 1.25rem; - font-weight: 400; - letter-spacing: 0.05rem; `; diff --git a/features/ui/select/select.tsx b/features/ui/select/select.tsx index 9593bdd..3969a67 100644 --- a/features/ui/select/select.tsx +++ b/features/ui/select/select.tsx @@ -17,7 +17,6 @@ type SelectProps = Omit, "onChange"> & { placeholder?: string; disabled?: boolean; iconSrc?: string; - width?: string | number; label?: string; hint?: string; }; @@ -33,7 +32,6 @@ export function Select({ label = "", hint = "", errorMessage = "", - width = "", ...props }: SelectProps) { const [showDropdown, setShowDropdown] = useState(false); @@ -77,7 +75,7 @@ export function Select({ ); return ( - + {label && {label}} ({ hasError={!!errorMessage} disabled={disabled} aria-expanded={showDropdown} - width={width} onKeyDown={onKeyDown} + tabIndex={disabled ? -1 : 0} + aria-haspopup="listbox" > - - {iconSrc && } - {selectedOption?.label || placeholder} - + {iconSrc && } + {selectedOption?.label || placeholder} ({ /> - {hint && !showDropdown && !errorMessage && {hint}} + {hint && !errorMessage && {hint}} - {errorMessage && !showDropdown && !disabled && ( + {errorMessage && !disabled && ( {errorMessage} )} From 3a135894877778febf9f33e5f2b4a8066f7a555e Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Wed, 7 Dec 2022 14:08:32 +0100 Subject: [PATCH 09/10] Final clean up --- features/ui/select/select.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/features/ui/select/select.tsx b/features/ui/select/select.tsx index 3969a67..63bd4d0 100644 --- a/features/ui/select/select.tsx +++ b/features/ui/select/select.tsx @@ -26,12 +26,12 @@ export function Select({ value, onChange, placeholder = "Choose an option", - defaultValue = "", - iconSrc = "", + defaultValue, + iconSrc, disabled = false, - label = "", - hint = "", - errorMessage = "", + label, + hint, + errorMessage, ...props }: SelectProps) { const [showDropdown, setShowDropdown] = useState(false); From 0c7181ef8094a93872192a611219bca5c3c316fd Mon Sep 17 00:00:00 2001 From: Johannes Kettmann Date: Wed, 21 Dec 2022 16:51:34 +0100 Subject: [PATCH 10/10] Use refactored Select in Filters component --- .../components/filters/filters.styled.ts | 2 +- .../issues/components/filters/filters.tsx | 82 +++++-------------- 2 files changed, 21 insertions(+), 63 deletions(-) diff --git a/features/issues/components/filters/filters.styled.ts b/features/issues/components/filters/filters.styled.ts index 55190dd..6c70271 100644 --- a/features/issues/components/filters/filters.styled.ts +++ b/features/issues/components/filters/filters.styled.ts @@ -22,7 +22,7 @@ export const Select = styled(UnstyledSelect)` @media (min-width: ${breakpoint("desktop")}) { width: 10rem; } -`; +` as typeof UnstyledSelect; export const Input = styled(UnstyledInput)` width: 100%; diff --git a/features/issues/components/filters/filters.tsx b/features/issues/components/filters/filters.tsx index 9155675..02379da 100644 --- a/features/issues/components/filters/filters.tsx +++ b/features/issues/components/filters/filters.tsx @@ -1,27 +1,21 @@ import React, { useState } from "react"; import { useDebouncedCallback } from "use-debounce"; -import { capitalize } from "lodash"; -import { Option } from "@features/ui"; -import { IssueFilters, IssueLevel, IssueStatus } from "@api/issues.types"; +import { IssueLevel, IssueStatus } from "@api/issues.types"; import { useFilters } from "../../hooks/use-filters"; import * as S from "./filters.styled"; -function getStatusDefaultValue(filters: IssueFilters) { - if (!filters.status) { - return "Status"; - } - if (filters.status === IssueStatus.open) { - return "Unresolved"; - } - return "Resolved"; -} +const statusOptions = [ + { value: undefined, label: "--None--" }, + { value: IssueStatus.open, label: "Unresolved" }, + { value: IssueStatus.resolved, label: "Resolved" }, +]; -function getLevelDefaultValue(filters: IssueFilters) { - if (!filters.level) { - return "Level"; - } - return capitalize(filters.level); -} +const levelOptions = [ + { value: undefined, label: "--None--" }, + { value: IssueLevel.error, label: "Error" }, + { value: IssueLevel.warning, label: "Warning" }, + { value: IssueLevel.info, label: "Info" }, +]; export function Filters() { const { handleFilters, filters } = useFilters(); @@ -34,57 +28,21 @@ export function Filters() { debouncedHandleFilters({ project: project.toLowerCase() }); }; - 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 }); - }; - return ( - - - - + value={filters.status} + options={statusOptions} + onChange={(status) => handleFilters({ status })} + /> - - - - - + value={filters.level} + options={levelOptions} + onChange={(level) => handleFilters({ level })} + />