From 3d54b03c81a48cb54c76cc53b55d7102f0d868f0 Mon Sep 17 00:00:00 2001 From: Tony Vi Date: Thu, 20 Apr 2023 15:17:48 +0300 Subject: [PATCH] feat: integrate custom filters on dashboard --- src/components/FiltersPanel.tsx | 115 +++++++-- src/components/pages/GoalsPage/GoalsPage.tsx | 150 ++++++++++-- src/hooks/useUrlFilterParams.ts | 241 +++++++++---------- 3 files changed, 340 insertions(+), 166 deletions(-) diff --git a/src/components/FiltersPanel.tsx b/src/components/FiltersPanel.tsx index 21a2c7e41..ee792e180 100644 --- a/src/components/FiltersPanel.tsx +++ b/src/components/FiltersPanel.tsx @@ -1,8 +1,11 @@ import React, { useCallback } from 'react'; import styled from 'styled-components'; import { useTranslations } from 'next-intl'; -import { gapM, gapS, gray5, textColor } from '@taskany/colors'; -import { Badge, Text, Input, nullable } from '@taskany/bricks'; +import { gapM, gapS, gray5, gray6, gray9, textColor } from '@taskany/colors'; +import { Badge, Text, Input, StarIcon, nullable, StarFilledIcon } from '@taskany/bricks'; + +import { Filter } from '../../graphql/@generated/genql'; +import type { QueryState } from '../hooks/useUrlFilterParams'; import { PageContent } from './Page'; import { StateFilterDropdown } from './StateFilterDropdown'; @@ -12,8 +15,11 @@ import { LimitFilterDropdown } from './LimitFilterDropdown'; import { PriorityFilterDropdown } from './PriorityFilterDropdown'; import { EstimateFilterDropdown } from './EstimateFilterDropdown'; import { ProjectFilterDropdown } from './ProjectFilterDropdown'; +import { PresetFilterDropdown } from './PresetFilterDropdown'; interface FiltersPanelProps { + queryState: QueryState; + queryString?: string; count?: number; filteredCount?: number; priority?: React.ComponentProps['priority']; @@ -22,7 +28,9 @@ interface FiltersPanelProps { projects?: React.ComponentProps['projects']; tags?: React.ComponentProps['tags']; estimates?: React.ComponentProps['estimates']; - filterValues: [string[], string[], string[], string[], string[], string[], string, number | undefined]; + presets?: Filter[]; + currentPreset?: Filter; + loading?: boolean; children?: React.ReactNode; onSearchChange: (search: string) => void; @@ -33,13 +41,41 @@ interface FiltersPanelProps { onTagChange?: React.ComponentProps['onChange']; onEstimateChange?: React.ComponentProps['onChange']; onLimitChange?: React.ComponentProps['onChange']; + onPresetChange?: React.ComponentProps['onChange']; + onFilterStar?: () => void; } -const StyledFiltersPanel = styled.div` +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const StyledFiltersPanel = styled(({ loading, ...props }) =>
)<{ loading?: boolean }>` margin: ${gapM} 0; padding: ${gapS} 0; background-color: ${gray5}; + + animation-name: bkgChange; + animation-timing-function: ease-in-out; + animation-iteration-count: infinite; + + transition: background-color 200ms ease-in-out; + + @keyframes bkgChange { + 0% { + background-color: ${gray5}; + } + 50.0% { + background-color: ${gray6}; + } + 100.0% { + background-color: ${gray5}; + } + } + + ${({ loading }) => + loading && + ` + animation-play-state: running; + animation-duration: 1s; + `} `; const StyledFiltersContent = styled(PageContent)` @@ -61,7 +97,17 @@ const StyledFiltersMenu = styled.div` padding-left: ${gapM}; `; +const StyledFiltersAction = styled.div` + display: inline-block; + padding-left: ${gapS}; + padding-right: ${gapS}; + vertical-align: middle; + cursor: pointer; +`; + export const FiltersPanel: React.FC = ({ + queryState, + queryString, count, filteredCount, states, @@ -70,7 +116,10 @@ export const FiltersPanel: React.FC = ({ projects, tags, estimates, - filterValues, + presets, + currentPreset, + loading, + children, onPriorityChange, onSearchChange, onStateChange, @@ -79,21 +128,11 @@ export const FiltersPanel: React.FC = ({ onTagChange, onEstimateChange, onLimitChange, - children, + onPresetChange, + onFilterStar, }) => { const t = useTranslations('FiltersPanel'); - const [ - priorityFilter, - stateFilter, - tagsFilter, - estimateFilter, - ownerFilter, - projectFilter, - searchFilter, - limitFilter, - ] = filterValues; - const onSearchInputChange = useCallback( (e: React.ChangeEvent) => { onSearchChange(e.currentTarget.value); @@ -102,9 +141,9 @@ export const FiltersPanel: React.FC = ({ ); return ( - + - + {nullable(count, () => ( @@ -129,7 +168,7 @@ export const FiltersPanel: React.FC = ({ ))} @@ -140,7 +179,7 @@ export const FiltersPanel: React.FC = ({ ))} @@ -151,7 +190,7 @@ export const FiltersPanel: React.FC = ({ ))} @@ -162,7 +201,7 @@ export const FiltersPanel: React.FC = ({ ))} @@ -173,7 +212,7 @@ export const FiltersPanel: React.FC = ({ ))} @@ -184,15 +223,37 @@ export const FiltersPanel: React.FC = ({ ))} {onLimitChange && - nullable(limitFilter, (lf) => ( + nullable(queryState.limitFilter, (lf) => ( ))} + + {Boolean(presets?.length) && + nullable(presets, (pr) => ( + + ))} + + {Boolean(queryString) && !currentPreset && ( + + + + )} + + {currentPreset && ( + + + + )} @@ -203,3 +264,5 @@ export const FiltersPanel: React.FC = ({ ); }; + +FiltersPanel.whyDidYouRender = true; diff --git a/src/components/pages/GoalsPage/GoalsPage.tsx b/src/components/pages/GoalsPage/GoalsPage.tsx index 1a79131c3..ef7e7f898 100644 --- a/src/components/pages/GoalsPage/GoalsPage.tsx +++ b/src/components/pages/GoalsPage/GoalsPage.tsx @@ -1,10 +1,11 @@ /* eslint-disable react-hooks/rules-of-hooks */ import React, { MouseEventHandler, useCallback, useEffect, useState } from 'react'; -import useSWR from 'swr'; +import useSWR, { unstable_serialize } from 'swr'; import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; import { nullable } from '@taskany/bricks'; -import { Goal, GoalsMetaOutput, Project } from '../../../../graphql/@generated/genql'; +import { Filter, Goal, GoalsMetaOutput, Project } from '../../../../graphql/@generated/genql'; import { createFetcher, refreshInterval } from '../../../utils/createFetcher'; import { declareSsrProps, ExternalPageProps } from '../../../utils/declareSsrProps'; import { Page, PageContent } from '../../Page'; @@ -12,10 +13,16 @@ import { CommonHeader } from '../../CommonHeader'; import { FiltersPanel } from '../../FiltersPanel'; import { parseFilterValues, useUrlFilterParams } from '../../../hooks/useUrlFilterParams'; import { GoalsGroup, GoalsGroupProjectTitle } from '../../GoalsGroup'; +import { ModalEvent, dispatchModalEvent } from '../../../utils/dispatchModal'; +import { createFilterKeys } from '../../../utils/hotkeys'; +import { PageTitle } from '../../PageTitle'; import { tr } from './GoalsPage.i18n'; const GoalPreview = dynamic(() => import('../../GoalPreview')); +const ModalOnEvent = dynamic(() => import('../../ModalOnEvent')); +const FilterCreateForm = dynamic(() => import('../../FilterCreateForm/FilterCreateForm')); +const FilterDeleteForm = dynamic(() => import('../../FilterDeleteForm/FilterDeleteForm')); const fetcher = createFetcher( (_, priority = [], states = [], tags = [], estimates = [], owner = [], projects = [], query = '') => ({ @@ -126,25 +133,62 @@ const fetcher = createFetcher( }, }, ], + userFilters: { + id: true, + title: true, + description: true, + mode: true, + params: true, + }, }), ); -export const getServerSideProps = declareSsrProps( - async ({ user, query }) => ({ - fallback: { - 'goals/index': await fetcher(user, ...parseFilterValues(query)), +const filterFetcher = createFetcher((_, id = '') => ({ + filter: [ + { + data: { + id, + }, }, - }), + { + id: true, + title: true, + description: true, + mode: true, + params: true, + }, + ], +})); + +export const getServerSideProps = declareSsrProps( + async ({ user, query }) => { + const { filter: preset } = query.filter ? await filterFetcher(user, query.filter) : { filter: null }; + + return { + preset, + fallback: { + [unstable_serialize(query)]: await fetcher( + user, + ...Object.values( + parseFilterValues(preset ? Object.fromEntries(new URLSearchParams(preset.params)) : query), + ), + ), + }, + }; + }, { private: true, }, ); -export const GoalsPage = ({ user, ssrTime, locale, fallback }: ExternalPageProps) => { +export const GoalsPage = ({ user, ssrTime, locale, fallback, preset }: ExternalPageProps) => { + const router = useRouter(); const [preview, setPreview] = useState(null); const { - filterValues, + currentPreset, + queryState, + queryString, setPriorityFilter, setStateFilter, setTagsFilter, @@ -153,17 +197,25 @@ export const GoalsPage = ({ user, ssrTime, locale, fallback }: ExternalPageProps setOwnerFilter, setProjectFilter, setFulltextFilter, - } = useUrlFilterParams(); - - const { data } = useSWR('goals/index', () => fetcher(user, ...filterValues), { - fallback, - refreshInterval, + setPreset, + } = useUrlFilterParams({ + preset, }); + const { data, isLoading } = useSWR( + unstable_serialize(router.query), + () => fetcher(user, ...Object.values(queryState)), + { + fallback, + keepPreviousData: true, + refreshInterval, + }, + ); + const goals = data?.userGoals?.goals; const meta: GoalsMetaOutput | undefined = data?.userGoals?.meta; - - if (!data?.userGoals) return null; + const userFilters = data?.userFilters; + const shadowPreset = userFilters?.filter((f) => f.params === queryString)[0]; const groupsMap = goals?.reduce<{ [key: string]: { project?: Project; goals: Goal[] } }>((r, g: Goal) => { @@ -206,9 +258,51 @@ export const GoalsPage = ({ user, ssrTime, locale, fallback }: ExternalPageProps const selectedGoalResolver = useCallback((id: string) => id === preview?.id, [preview]); + const onFilterStar = useCallback(() => { + currentPreset + ? dispatchModalEvent(ModalEvent.FilterDeleteModal)() + : dispatchModalEvent(ModalEvent.FilterCreateModal)(); + }, [currentPreset]); + + const onFilterCreated = useCallback( + (data: Partial) => { + dispatchModalEvent(ModalEvent.FilterCreateModal)(); + setPreset(data.id); + }, + [setPreset], + ); + + const onFilterDeleteCanceled = useCallback(() => { + dispatchModalEvent(ModalEvent.FilterDeleteModal)(); + }, []); + + const onFilterDeleted = useCallback( + (filter: Filter) => { + router.push(`${router.route}?${filter.params}`); + }, + [router], + ); + + const defaultTitle = ; + const presetTitle = ; + + const onShadowPresetTitleClick = useCallback(() => { + if (shadowPreset) setPreset(shadowPreset.id); + }, [setPreset, shadowPreset]); + const shadowPresetTitle = ( + + ); + // eslint-disable-next-line no-nested-ternary + const title = currentPreset ? presetTitle : shadowPreset ? shadowPresetTitle : defaultTitle; + + const description = + currentPreset && currentPreset.description + ? currentPreset.description + : tr('This is your personal goals bundle'); + return ( - + @@ -250,6 +350,20 @@ export const GoalsPage = ({ user, ssrTime, locale, fallback }: ExternalPageProps {nullable(preview, (p) => ( ))} + + {nullable(queryString, (params) => ( + + + + ))} + + {nullable(currentPreset, (cP) => ( + + + + ))} ); }; + +GoalsPage.whyDidYouRender = true; diff --git a/src/hooks/useUrlFilterParams.ts b/src/hooks/useUrlFilterParams.ts index 49e22c4be..7dbec579a 100644 --- a/src/hooks/useUrlFilterParams.ts +++ b/src/hooks/useUrlFilterParams.ts @@ -1,51 +1,48 @@ import { useRouter } from 'next/router'; import { ParsedUrlQuery } from 'querystring'; -import { MouseEventHandler, useCallback, useEffect, useState } from 'react'; +import { MouseEventHandler, useCallback, useMemo, useState } from 'react'; -import { Tag } from '../../graphql/@generated/genql'; +import { Filter, Tag } from '../../graphql/@generated/genql'; + +export interface QueryState { + priorityFilter: string[]; + stateFilter: string[]; + tagsFilter: string[]; + estimateFilter: string[]; + ownerFilter: string[]; + projectFilter: string[]; + fulltextFilter: string; + limitFilter?: number; +} const parseQueryParam = (param = '') => param.split(',').filter(Boolean); -export const parseFilterValues = (query: ParsedUrlQuery) => [ - parseQueryParam(query.priority?.toString()), - parseQueryParam(query.state?.toString()), - parseQueryParam(query.tags?.toString()), - parseQueryParam(query.estimates?.toString()), - parseQueryParam(query.user?.toString()), - parseQueryParam(query.projects?.toString()).map((p) => Number(p)), - parseQueryParam(query.search?.toString()).toString(), - query.limit ? Number(query.limit) : undefined, -]; - -export const useUrlFilterParams = () => { +export const parseFilterValues = (query: ParsedUrlQuery): QueryState => ({ + priorityFilter: parseQueryParam(query.priority?.toString()), + stateFilter: parseQueryParam(query.state?.toString()), + tagsFilter: parseQueryParam(query.tags?.toString()), + estimateFilter: parseQueryParam(query.estimates?.toString()), + ownerFilter: parseQueryParam(query.user?.toString()), + projectFilter: parseQueryParam(query.projects?.toString()), + fulltextFilter: parseQueryParam(query.search?.toString()).toString(), + limitFilter: query.limit ? Number(query.limit) : undefined, +}); + +export const useUrlFilterParams = ({ preset }: { preset?: Filter }) => { const router = useRouter(); - - const [priorityFilter, setPriorityFilter] = useState(parseQueryParam(router.query.priority?.toString())); - const [stateFilter, setStateFilter] = useState(parseQueryParam(router.query.state?.toString())); - const [tagsFilter, setTagsFilter] = useState(parseQueryParam(router.query.tags?.toString())); - const [estimateFilter, setEstimateFilter] = useState(parseQueryParam(router.query.estimates?.toString())); - const [ownerFilter, setOwnerFilter] = useState(parseQueryParam(router.query.user?.toString())); - const [projectFilter, setProjectFilter] = useState(parseQueryParam(router.query.projects?.toString())); - const [fulltextFilter, setFulltextFilter] = useState( - parseQueryParam(router.query.search?.toString()).toString(), - ); - const [limitFilter, setLimitFilter] = useState(router.query.limit ? Number(router.query.limit) : undefined); - - const [filterValues, setFilterValues] = useState< - [string[], string[], string[], string[], string[], string[], string, number | undefined] - >([ - priorityFilter, - stateFilter, - tagsFilter, - estimateFilter, - ownerFilter, - projectFilter, - fulltextFilter, - limitFilter, - ]); - - useEffect(() => { - setFilterValues([ + const [currentPreset, setCurrentPreset] = useState(preset); + const [prevPreset, setPrevPreset] = useState(preset); + const query = currentPreset ? Object.fromEntries(new URLSearchParams(currentPreset.params)) : router.query; + const queryState = useMemo(() => parseFilterValues(query), [query]); + const queryString = router.asPath.split('?')[1]; + + if (prevPreset !== preset) { + setPrevPreset(preset); + setCurrentPreset(preset); + } + + const pushNewState = useCallback( + ({ priorityFilter, stateFilter, tagsFilter, @@ -54,105 +51,105 @@ export const useUrlFilterParams = () => { projectFilter, fulltextFilter, limitFilter, - ]); - }, [ - priorityFilter, - stateFilter, - tagsFilter, - estimateFilter, - ownerFilter, - projectFilter, - fulltextFilter, - limitFilter, - ]); + }: QueryState) => { + const newurl = router.route; + const urlParams = new URLSearchParams(); - const setTagsFilterOutside = useCallback( - (t: Tag): MouseEventHandler => - (e) => { - e.preventDefault(); - e.stopPropagation(); + priorityFilter.length > 0 + ? urlParams.set('priority', Array.from(priorityFilter).toString()) + : urlParams.delete('priority'); - const [ - priorityFilter, - stateFilter, - tagsFilter, - estimateFilter, - ownerFilter, - projectFilter, - searchFilter, - limitFilter, - ] = filterValues; + stateFilter.length > 0 + ? urlParams.set('state', Array.from(stateFilter).toString()) + : urlParams.delete('state'); - const newTagsFilterValue = new Set(tagsFilter); + tagsFilter.length > 0 ? urlParams.set('tags', Array.from(tagsFilter).toString()) : urlParams.delete('tags'); - newTagsFilterValue.has(t.id) ? newTagsFilterValue.delete(t.id) : newTagsFilterValue.add(t.id); + estimateFilter.length > 0 + ? urlParams.set('estimates', Array.from(estimateFilter).toString()) + : urlParams.delete('estimates'); - const newSelected = Array.from(newTagsFilterValue); + ownerFilter.length > 0 + ? urlParams.set('user', Array.from(ownerFilter).toString()) + : urlParams.delete('user'); - setTagsFilter(newSelected); - setFilterValues([ - priorityFilter, - stateFilter, - newSelected, - estimateFilter, - ownerFilter, - projectFilter, - searchFilter, - limitFilter, - ]); - }, - [filterValues, setTagsFilter], - ); + projectFilter.length > 0 + ? urlParams.set('projects', Array.from(projectFilter).toString()) + : urlParams.delete('projects'); - useEffect(() => { - const newurl = `${window.location.protocol}//${window.location.host}${window.location.pathname}`; - const urlParams = new URLSearchParams(); + fulltextFilter.length > 0 ? urlParams.set('search', fulltextFilter.toString()) : urlParams.delete('search'); - priorityFilter.length > 0 - ? urlParams.set('priority', Array.from(priorityFilter).toString()) - : urlParams.delete('priority'); + limitFilter ? urlParams.set('limit', limitFilter.toString()) : urlParams.delete('limit'); - stateFilter.length > 0 ? urlParams.set('state', Array.from(stateFilter).toString()) : urlParams.delete('state'); + router.push(Array.from(urlParams.keys()).length ? `${newurl}?${urlParams}` : newurl); + }, + [router], + ); - tagsFilter.length > 0 ? urlParams.set('tags', Array.from(tagsFilter).toString()) : urlParams.delete('tags'); + const pushStateProvider = useCallback( + (key: T) => + (value: QueryState[T]) => { + setCurrentPreset(undefined); + pushNewState({ + ...queryState, + [key]: value, + }); + }, + [pushNewState, queryState], + ); - estimateFilter.length > 0 - ? urlParams.set('estimates', Array.from(estimateFilter).toString()) - : urlParams.delete('estimates'); + const setTagsFilterOutside = useCallback( + (t: Tag): MouseEventHandler => + (e) => { + e.preventDefault(); + e.stopPropagation(); - ownerFilter.length > 0 ? urlParams.set('user', Array.from(ownerFilter).toString()) : urlParams.delete('user'); + const newTagsFilterValue = new Set(queryState.tagsFilter); - projectFilter.length > 0 - ? urlParams.set('projects', Array.from(projectFilter).toString()) - : urlParams.delete('projects'); + newTagsFilterValue.has(t.id) ? newTagsFilterValue.delete(t.id) : newTagsFilterValue.add(t.id); - fulltextFilter.length > 0 ? urlParams.set('search', fulltextFilter.toString()) : urlParams.delete('search'); + const newSelected = Array.from(newTagsFilterValue); + + pushNewState({ + ...queryState, + tagsFilter: newSelected, + }); + }, + [queryState, pushNewState], + ); - limitFilter ? urlParams.set('limit', limitFilter.toString()) : urlParams.delete('limit'); + const setPreset = useCallback( + (filter: string | undefined) => { + router.push({ + pathname: router.route, + query: { + filter, + }, + }); + }, + [router], + ); - window.history.replaceState({}, '', Array.from(urlParams.keys()).length ? `${newurl}?${urlParams}` : newurl); - }, [ - priorityFilter, - stateFilter, - ownerFilter, - projectFilter, - tagsFilter, - estimateFilter, - limitFilter, - fulltextFilter, - router.query, - ]); + const setters = useMemo( + () => ({ + setPriorityFilter: pushStateProvider('priorityFilter'), + setStateFilter: pushStateProvider('stateFilter'), + setTagsFilter: pushStateProvider('tagsFilter'), + setEstimateFilter: pushStateProvider('estimateFilter'), + setOwnerFilter: pushStateProvider('ownerFilter'), + setProjectFilter: pushStateProvider('projectFilter'), + setFulltextFilter: pushStateProvider('fulltextFilter'), + setLimitFilter: pushStateProvider('limitFilter'), + }), + [pushStateProvider], + ); return { - filterValues, - setPriorityFilter, - setStateFilter, - setTagsFilter, + queryState, + queryString, + currentPreset, setTagsFilterOutside, - setEstimateFilter, - setOwnerFilter, - setProjectFilter, - setFulltextFilter, - setLimitFilter, + setPreset, + ...setters, }; };