diff --git a/src/components/FilteredPage.tsx b/src/components/FilteredPage.tsx new file mode 100644 index 000000000..63ed279cb --- /dev/null +++ b/src/components/FilteredPage.tsx @@ -0,0 +1,145 @@ +import { useCallback } from 'react'; +import styled from 'styled-components'; +import { Button, nullable } from '@taskany/bricks'; +import dynamic from 'next/dynamic'; +import { useRouter } from 'next/router'; + +import { useUrlFilterParams } from '../hooks/useUrlFilterParams'; +import { useFilterResource } from '../hooks/useFilterResource'; +import { dispatchModalEvent, ModalEvent } from '../utils/dispatchModal'; +import { createFilterKeys } from '../utils/hotkeys'; +import { filtersPanelResetButton } from '../utils/domObjects'; +import { FilterById } from '../../trpc/inferredTypes'; + +import { PageContent } from './Page'; +import { FiltersPanel } from './FiltersPanel/FiltersPanel'; +import { PresetDropdown } from './PresetDropdown'; + +const ModalOnEvent = dynamic(() => import('./ModalOnEvent')); +const FilterCreateForm = dynamic(() => import('./FilterCreateForm/FilterCreateForm')); +const FilterDeleteForm = dynamic(() => import('./FilterDeleteForm/FilterDeleteForm')); + +interface FilteredPageProps { + isLoading: boolean; + counter?: number; + total?: number; + onFilterStar: () => Promise; + filterPreset?: FilterById; + userFilters?: React.ComponentProps['presets']; + filterControls?: React.ReactNode; +} + +const StyledFilterControls = styled.div` + display: flex; + align-items: center; + justify-content: flex-start; + flex: 1 0 0; +`; + +const StyledResetButton = styled(Button)` + margin-left: auto; +`; + +export const FilteredPage: React.FC> = ({ + counter, + total, + children, + isLoading, + onFilterStar, + filterPreset, + userFilters, + filterControls, +}) => { + const router = useRouter(); + const { toggleFilterStar } = useFilterResource(); + + const { + currentPreset, + queryState, + queryString, + setStarredFilter, + setWatchingFilter, + setFulltextFilter, + resetQueryState, + setPreset, + batchQueryState, + } = useUrlFilterParams({ + preset: filterPreset, + }); + + const filterStarHandler = useCallback(async () => { + if (currentPreset) { + if (currentPreset._isOwner) { + dispatchModalEvent(ModalEvent.FilterDeleteModal)(); + } else { + await toggleFilterStar({ + id: currentPreset.id, + direction: !currentPreset._isStarred, + }); + await onFilterStar(); + } + } else { + dispatchModalEvent(ModalEvent.FilterCreateModal)(); + } + }, [currentPreset, toggleFilterStar, onFilterStar]); + + const onFilterCreated = useCallback( + (id: string) => { + dispatchModalEvent(ModalEvent.FilterCreateModal)(); + setPreset(id); + }, + [setPreset], + ); + + const onFilterDeleteCanceled = useCallback(() => { + dispatchModalEvent(ModalEvent.FilterDeleteModal)(); + }, []); + + const onFilterDeleted = useCallback( + (params: string) => { + router.push(`${router.route}?${params}`); + }, + [router], + ); + + return ( + <> + + + {filterControls} + {nullable(queryString || filterPreset, () => ( + + ))} + + + + {children} + + {nullable(queryString, (params) => ( + + + + ))} + + {nullable(currentPreset, (cP) => ( + + + + ))} + + ); +}; diff --git a/src/components/FlatGoalList.tsx b/src/components/FlatGoalList.tsx new file mode 100644 index 000000000..9509ccd34 --- /dev/null +++ b/src/components/FlatGoalList.tsx @@ -0,0 +1,101 @@ +import { MouseEventHandler, useCallback, useEffect, useMemo, useState } from 'react'; +import { Table, nullable } from '@taskany/bricks'; + +import { GoalByIdReturnType } from '../../trpc/inferredTypes'; +import { QueryState, useUrlFilterParams } from '../hooks/useUrlFilterParams'; +import { trpc } from '../utils/trpcClient'; +import { refreshInterval } from '../utils/config'; +import { useFMPMetric } from '../utils/telemetry'; + +import { useGoalPreview } from './GoalPreview/GoalPreviewProvider'; +import { GoalListItem } from './GoalListItem'; +import { LoadMoreButton } from './LoadMoreButton/LoadMoreButton'; + +interface GoalListProps { + queryState?: QueryState; + setTagFilterOutside: ReturnType['setTagsFilterOutside']; +} + +const pageSize = 20; + +export const FlatGoalList: React.FC = ({ queryState, setTagFilterOutside }) => { + const { preview, setPreview } = useGoalPreview(); + + const [, setPage] = useState(0); + const { data, fetchNextPage, hasNextPage } = trpc.goal.getBatch.useInfiniteQuery( + { + limit: pageSize, + query: queryState, + }, + { + getNextPageParam: (p) => p.nextCursor, + keepPreviousData: true, + staleTime: refreshInterval, + }, + ); + + useFMPMetric(!!data); + + const pages = data?.pages; + const goalsOnScreen = useMemo(() => pages?.flatMap((p) => p.items), [pages]); + + const onFetchNextPage = useCallback(() => { + fetchNextPage(); + setPage((prev) => prev++); + }, [fetchNextPage]); + + useEffect(() => { + const isGoalDeletedAlready = preview && !goalsOnScreen?.some((g) => g.id === preview.id); + + if (isGoalDeletedAlready) setPreview(null); + }, [goalsOnScreen, preview, setPreview]); + + const selectedGoalResolver = useCallback((id: string) => id === preview?.id, [preview]); + + const onGoalPrewiewShow = useCallback( + (goal: GoalByIdReturnType): MouseEventHandler => + (e) => { + if (e.metaKey || e.ctrlKey || !goal?._shortId) return; + + e.preventDefault(); + setPreview(goal._shortId, goal); + }, + [setPreview], + ); + return ( + <> + + {goalsOnScreen?.map((g) => ( + + ))} +
+ + {nullable(hasNextPage, () => ( + + ))} + + ); +}; diff --git a/src/components/GroupedGoalList.tsx b/src/components/GroupedGoalList.tsx new file mode 100644 index 000000000..c3383d67d --- /dev/null +++ b/src/components/GroupedGoalList.tsx @@ -0,0 +1,74 @@ +import { MouseEventHandler, useCallback, useMemo } from 'react'; +import { nullable } from '@taskany/bricks'; + +import { QueryState, useUrlFilterParams } from '../hooks/useUrlFilterParams'; +import { refreshInterval } from '../utils/config'; +import { GoalByIdReturnType } from '../../trpc/inferredTypes'; +import { trpc } from '../utils/trpcClient'; +import { useFMPMetric } from '../utils/telemetry'; + +import { LoadMoreButton } from './LoadMoreButton/LoadMoreButton'; +import { useGoalPreview } from './GoalPreview/GoalPreviewProvider'; +import { ProjectListItemConnected } from './ProjectListItemConnected'; + +interface GroupedGoalListProps { + queryState?: QueryState; + setTagFilterOutside: ReturnType['setTagsFilterOutside']; +} + +export const projectsSize = 20; + +export const GroupedGoalList: React.FC = ({ queryState, setTagFilterOutside }) => { + const { preview, setPreview } = useGoalPreview(); + const { data, fetchNextPage, hasNextPage } = trpc.project.getAll.useInfiniteQuery( + { + limit: projectsSize, + goalsQuery: queryState, + }, + { + getNextPageParam: (p) => p.nextCursor, + keepPreviousData: true, + staleTime: refreshInterval, + }, + ); + + useFMPMetric(!!data); + + const onGoalPrewiewShow = useCallback( + (goal: GoalByIdReturnType): MouseEventHandler => + (e) => { + if (e.metaKey || e.ctrlKey || !goal?._shortId) return; + + e.preventDefault(); + setPreview(goal._shortId, goal); + }, + [setPreview], + ); + + const selectedGoalResolver = useCallback((id: string) => id === preview?.id, [preview]); + + const projectsOnScreen = useMemo(() => { + const pages = data?.pages || []; + + return pages.flatMap((page) => page.projects); + }, [data]); + + return ( + <> + {projectsOnScreen.map((project) => ( + + ))} + + {nullable(hasNextPage, () => ( + fetchNextPage()} /> + ))} + + ); +};