From 2aa977162cf05a3308bfdf4933f1ed4cef228f81 Mon Sep 17 00:00:00 2001 From: Maksim Sviridov Date: Tue, 30 Jul 2024 16:56:11 +0300 Subject: [PATCH] feat(GrouppedGoals): rewrite prisma with kysely for get project list --- src/components/GoalsPage/GoalsPage.tsx | 2 +- src/components/GrouppedGoalListV2.tsx | 77 +++++++++++ .../ProjectListItemCollapsable.tsx | 6 +- src/components/ProjectListItemConnected.tsx | 12 +- trpc/queries/projectV2.ts | 120 +++++++++++++++++- trpc/router/projectV2.ts | 34 ++++- 6 files changed, 238 insertions(+), 13 deletions(-) create mode 100644 src/components/GrouppedGoalListV2.tsx diff --git a/src/components/GoalsPage/GoalsPage.tsx b/src/components/GoalsPage/GoalsPage.tsx index 75c6f96ee..8707dcf81 100644 --- a/src/components/GoalsPage/GoalsPage.tsx +++ b/src/components/GoalsPage/GoalsPage.tsx @@ -7,7 +7,7 @@ import { useUrlFilterParams } from '../../hooks/useUrlFilterParams'; import { useFiltersPreset } from '../../hooks/useFiltersPreset'; import { Page } from '../Page/Page'; import { getPageTitle } from '../../utils/getPageTitle'; -import { GroupedGoalList } from '../GroupedGoalList'; +import { GroupedGoalList } from '../GrouppedGoalListV2'; import { FlatGoalList } from '../FlatGoalList'; import { PresetModals } from '../PresetModals'; import { FiltersPanel } from '../FiltersPanel/FiltersPanel'; diff --git a/src/components/GrouppedGoalListV2.tsx b/src/components/GrouppedGoalListV2.tsx new file mode 100644 index 000000000..299d51609 --- /dev/null +++ b/src/components/GrouppedGoalListV2.tsx @@ -0,0 +1,77 @@ +import { useCallback, useEffect, useMemo } from 'react'; +import { nullable } from '@taskany/bricks'; +import { ListView } from '@taskany/bricks/harmony'; + +import { refreshInterval } from '../utils/config'; +import { trpc } from '../utils/trpcClient'; +import { useFMPMetric } from '../utils/telemetry'; +import { FilterById, GoalByIdReturnType } from '../../trpc/inferredTypes'; +import { useUrlFilterParams } from '../hooks/useUrlFilterParams'; + +import { LoadMoreButton } from './LoadMoreButton/LoadMoreButton'; +import { useGoalPreview } from './GoalPreview/GoalPreviewProvider'; +import { ProjectListItemConnected } from './ProjectListItemConnected'; + +interface GroupedGoalListProps { + filterPreset?: FilterById; +} + +export const projectsSize = 20; + +export const GroupedGoalList: React.FC = ({ filterPreset }) => { + const { setPreview, on } = useGoalPreview(); + + const { queryState } = useUrlFilterParams({ + preset: filterPreset, + }); + + const utils = trpc.useContext(); + const { data, fetchNextPage, hasNextPage } = trpc.v2.project.getAll.useInfiniteQuery( + { + limit: projectsSize, + goalsQuery: queryState, + firstLevel: !!queryState?.project?.length, + }, + { + getNextPageParam: ({ pagination }) => pagination.offset, + staleTime: refreshInterval, + }, + ); + + useEffect(() => { + const unsubUpdate = on('on:goal:update', () => utils.v2.project.getAll.invalidate()); + const unsubDelete = on('on:goal:delete', () => utils.v2.project.getAll.invalidate()); + + return () => { + unsubUpdate(); + unsubDelete(); + }; + }, [on, utils.v2.project.getAll]); + + useFMPMetric(!!data); + + const projectsOnScreen = useMemo(() => { + const pages = data?.pages || []; + + return pages.flatMap((page) => page.projects); + }, [data]); + + const handleItemEnter = useCallback( + (goal: NonNullable) => { + setPreview(goal._shortId, goal); + }, + [setPreview], + ); + + return ( + + {projectsOnScreen.map((project) => ( + + ))} + + {nullable(hasNextPage, () => ( + fetchNextPage()} /> + ))} + + ); +}; diff --git a/src/components/ProjectListItemCollapsable/ProjectListItemCollapsable.tsx b/src/components/ProjectListItemCollapsable/ProjectListItemCollapsable.tsx index 6cead9eaa..573a608f6 100644 --- a/src/components/ProjectListItemCollapsable/ProjectListItemCollapsable.tsx +++ b/src/components/ProjectListItemCollapsable/ProjectListItemCollapsable.tsx @@ -4,7 +4,7 @@ import { nullable } from '@taskany/bricks'; import { TreeView, TreeViewNode, Text, Link } from '@taskany/bricks/harmony'; import { IconServersOutline } from '@taskany/icons'; -import { DashboardProject } from '../../../trpc/inferredTypes'; +import { DashboardProjectV2 } from '../../../trpc/inferredTypes'; import { ProjectListItem } from '../ProjectListItem/ProjectListItem'; import { projectListItem, projectListItemTitle } from '../../utils/domObjects'; import { TableRowItem, TableRowItemTitle } from '../TableRowItem/TableRowItem'; @@ -13,8 +13,8 @@ import s from './ProjectListItemCollapsable.module.css'; interface ProjectListItemCollapsableProps extends Omit, 'title'> { href?: string; - project: NonNullable; - parent?: NonNullable; + project: NonNullable; + parent?: NonNullable; goals?: ReactNode; children?: React.ReactNode; onClick?: MouseEventHandler; diff --git a/src/components/ProjectListItemConnected.tsx b/src/components/ProjectListItemConnected.tsx index 18f988d96..06af1e9c2 100644 --- a/src/components/ProjectListItemConnected.tsx +++ b/src/components/ProjectListItemConnected.tsx @@ -52,22 +52,22 @@ export const ProjectListItemConnected: FC = ({ }, ); - const ids = useMemo(() => project?.children.map(({ id }) => id) || [], [project]); + const ids = useMemo(() => project?.children?.map(({ id }) => id) || [], [project]); const { data: childrenProjects = [], isLoading } = trpc.project.getByIds.useQuery({ ids, goalsQuery: queryState }); useEffect(() => { const unsubUpdate = on('on:goal:update', (updatedId) => { const idInList = projectDeepInfo?.goals.find(({ _shortId }) => _shortId === updatedId); - if (idInList) { - utils.project.getDeepInfo.invalidate(); - utils.project.getByIds.invalidate(); + if (idInList?.projectId != null) { + utils.project.getDeepInfo.invalidate({ id: idInList.projectId }); + utils.project.getByIds.invalidate({ ids: [idInList.projectId] }); } }); const unsubDelete = on('on:goal:delete', (updatedId) => { const idInList = projectDeepInfo?.goals.find(({ _shortId }) => _shortId === updatedId); - if (idInList) { - utils.project.getDeepInfo.invalidate(); + if (idInList?.projectId != null) { + utils.project.getDeepInfo.invalidate({ id: idInList.projectId }); } }); diff --git a/trpc/queries/projectV2.ts b/trpc/queries/projectV2.ts index 28cce8230..f4b7b0a95 100644 --- a/trpc/queries/projectV2.ts +++ b/trpc/queries/projectV2.ts @@ -47,8 +47,6 @@ export const getProjectsByIds = (params: { in: Array<{ id: string }>; activityId activityId: ref('user.activityId'), user: fn.toJson('user'), }).as('activity'), - ]) - .select([ sql`(select count("B")::int from "_parentChildren" where "A" = "Project".id)`.as('children'), sql` case @@ -623,3 +621,121 @@ export const getProjectSuggestions = ({ .orderBy(sql`CHAR_LENGTH(title)`) .limit(limit); }; + +interface GetAllProjectsQueryParams { + activityId: string; + role: Role; + firstLevel: boolean; + limit: number; + cursor: number; + ids?: string[]; +} + +export const getAllProjectsQuery = ({ + activityId, + role, + firstLevel, + ids = [], + limit, + cursor, +}: GetAllProjectsQueryParams) => { + return db + .selectFrom(({ selectFrom }) => + selectFrom('Project') + .leftJoinLateral( + ({ selectFrom }) => + getUserActivity() + .whereRef( + 'Activity.id', + 'in', + selectFrom('_projectParticipants').select('A').whereRef('B', '=', 'Project.id'), + ) + .as('participants'), + (join) => join.onTrue(), + ) + .leftJoinLateral( + ({ selectFrom }) => + selectFrom('Project as childrenProject') + .selectAll('childrenProject') + .where( + 'childrenProject.id', + 'in', + selectFrom('_parentChildren').select('B').whereRef('A', '=', 'Project.id'), + ) + .as('ch'), + (join) => join.onTrue(), + ) + .selectAll('Project') + .select(({ case: caseFn, fn }) => [ + caseFn() + .when(fn.count('participants.id'), '>', 0) + .then(fn.agg('array_agg', [fn.toJson('participants')])) + .else(null) + .end() + .as('participants'), + caseFn() + .when(fn.count('ch.id'), '>', 0) + .then(fn.agg('array_agg', [fn.toJson('ch')])) + .else(null) + .end() + .as('children'), + jsonBuildObject({ + stargizers: sql`(select count("A") from "_projectStargizers" where "B" = "Project".id)`, + watchers: sql`(select count("A") from "_projectWatchers" where "B" = "Project".id)`, + children: fn.count('ch.id'), + participants: sql`(select count("A") from "_projectParticipants" where "B" = "Project".id)`, + goals: sql`(select count("Goal".id) from "Goal" where "Goal"."projectId" = "Project".id)`, + }).as('_count'), + ]) + .where('Project.archived', 'is not', true) + .$if(ids && ids.length > 0, (qb) => qb.where('Project.id', 'in', ids)) + .$if(role === Role.USER, (qb) => + qb.where(({ or, eb, not, exists }) => + or([ + eb('Project.id', 'in', ({ selectFrom }) => + selectFrom('_projectAccess').select('B').where('A', '=', activityId), + ), + not( + exists(({ selectFrom }) => + selectFrom('_projectAccess').select('B').whereRef('B', '=', 'Project.id'), + ), + ), + ]), + ), + ) + .$if(firstLevel, (qb) => + qb.where(({ not, exists, selectFrom }) => + not(exists(selectFrom('_parentChildren').select('A').whereRef('B', '=', 'Project.id'))), + ), + ) + .limit(limit) + .offset(cursor) + .orderBy(['Project.updatedAt desc', 'Project.id desc']) + .groupBy(['Project.id']) + .as('projects'), + ) + .innerJoinLateral( + () => getUserActivity().as('activity'), + (join) => join.onRef('activity.id', '=', 'projects.activityId'), + ) + .selectAll('projects') + .select(({ fn, selectFrom, exists, val }) => [ + fn.toJson('activity').as('activity'), + exists( + selectFrom('_projectWatchers') + .select('B') + .where('A', '=', activityId) + .whereRef('B', '=', 'projects.id'), + ).as('_isWatching'), + exists( + selectFrom('_projectStargizers') + .select('B') + .where('A', '=', activityId) + .whereRef('B', '=', 'projects.id'), + ).as('_isStarred'), + sql`("projects"."activityId" = ${val(activityId)})`.as('_isOwner'), + sql`((${val(role === Role.ADMIN)} or "projects"."activityId" = ${val( + activityId, + )}) and not "projects"."personal")`.as('_isEditable'), + ]); +}; diff --git a/trpc/router/projectV2.ts b/trpc/router/projectV2.ts index 8590a86e1..fd83b4801 100644 --- a/trpc/router/projectV2.ts +++ b/trpc/router/projectV2.ts @@ -12,6 +12,7 @@ import { getUserProjectsWithGoals, getWholeGoalCountByProjectIds, getDeepChildrenProjectsId, + getAllProjectsQuery, } from '../queries/projectV2'; import { queryWithFiltersSchema } from '../../src/schema/common'; import { @@ -41,7 +42,7 @@ type ProjectResponse = ExtractTypeFromGenerated & { activity: ProjectActivity; participants: ProjectActivity[]; goals?: any[]; // this prop is overrides below - children: any[]; // TODO: rly need this on Dashboard Page + children: ExtractTypeFromGenerated[] | null; }; interface ProjectsWithGoals extends Pick { @@ -224,4 +225,35 @@ export const project = router({ totalGoalsCount: goalsCountsByProjects?.wholeGoalsCount ?? 0, }; }), + + getAll: protectedProcedure + .input( + z.object({ + limit: z.number().optional(), + goalsQuery: queryWithFiltersSchema.optional(), + firstLevel: z.boolean(), + cursor: z.number().optional(), + }), + ) + .query(async ({ input, ctx }) => { + const { limit = 20, cursor = 0, goalsQuery, firstLevel: _ = true } = input; + + const projects = await getAllProjectsQuery({ + ...ctx.session.user, + firstLevel: goalsQuery?.project == null, + limit: limit + 1, + cursor, + ids: goalsQuery?.project, + }) + .$castTo>() + .execute(); + + return { + projects: projects.slice(0, limit), + pagination: { + limit, + offset: projects.length < limit + 1 ? undefined : cursor + (limit ?? 0), + }, + }; + }), });