diff --git a/packages/client/src/components/tag/view/TagGridView.component.tsx b/packages/client/src/components/tag/view/TagGridView.component.tsx index c023089..8211e13 100644 --- a/packages/client/src/components/tag/view/TagGridView.component.tsx +++ b/packages/client/src/components/tag/view/TagGridView.component.tsx @@ -3,6 +3,7 @@ import { GetGridColDefs, TagViewTest } from '../../../types/TagColumnView'; import { Entry, Study } from '../../../graphql/graphql'; import { GridColDef, + GridPaginationModel, GridRenderCellParams, GridToolbarColumnsButton, GridToolbarContainer, @@ -19,12 +20,15 @@ import { getSliderCols, sliderTest } from './SliderGridView.component'; import { getBoolCols, booleanTest } from './BooleanGridView.component'; import { aslLexTest, getAslLexCols } from './AslLexGridView.component'; import { getVideoCols, videoViewTest } from './VideoGridView.component'; -import { useEffect, useState } from 'react'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; export interface TagGridViewProps { study: Study; tags: GetTagsQuery['getTags']; refetchTags: () => void; + paginationModel: GridPaginationModel; + setPaginationModel: Dispatch>; + totalTags: number; } /** @@ -55,7 +59,14 @@ interface GridData extends Omit { data: { [property: string]: any } | null; } -export const TagGridView: React.FC = ({ tags, study, refetchTags }) => { +export const TagGridView: React.FC = ({ + tags, + study, + refetchTags, + paginationModel, + setPaginationModel, + totalTags +}) => { const { t } = useTranslation(); const [gridData, setGridData] = useState<(GridData | null)[]>([]); @@ -163,6 +174,18 @@ export const TagGridView: React.FC = ({ tags, study, refetchTa columns={entryColumns.concat(tagMetaColumns).concat(dataColunms).concat(tagRedoColumns)} getRowId={(row) => row._id} slots={{ toolbar: TagToolbar }} + paginationMode="server" + paginationModel={paginationModel} + initialState={{ + pagination: { + paginationModel: { + pageSize: 5 + } + } + }} + pageSizeOptions={[5, 10, 15]} + onPaginationModelChange={setPaginationModel} + rowCount={totalTags} /> ); }; diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 4ed9648..5f37e1f 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -469,6 +469,8 @@ export type ProjectPermissionModel = { export type Query = { __typename?: 'Query'; countEntryForDataset: Scalars['Int']['output']; + countTagForStudy: Scalars['Int']['output']; + countTrainingTagForStudy: Scalars['Int']['output']; datasetExists: Scalars['Boolean']['output']; entryForDataset: Array; entryFromID: Entry; @@ -506,6 +508,17 @@ export type QueryCountEntryForDatasetArgs = { }; +export type QueryCountTagForStudyArgs = { + study: Scalars['ID']['input']; +}; + + +export type QueryCountTrainingTagForStudyArgs = { + study: Scalars['ID']['input']; + user: Scalars['String']['input']; +}; + + export type QueryDatasetExistsArgs = { name: Scalars['String']['input']; }; @@ -582,11 +595,15 @@ export type QueryGetStudyPermissionsArgs = { export type QueryGetTagsArgs = { + page?: InputMaybe; + pageSize?: InputMaybe; study: Scalars['ID']['input']; }; export type QueryGetTrainingTagsArgs = { + page?: InputMaybe; + pageSize?: InputMaybe; study: Scalars['ID']['input']; user: Scalars['String']['input']; }; diff --git a/packages/client/src/graphql/tag/tag.graphql b/packages/client/src/graphql/tag/tag.graphql index 27acf09..043b27c 100644 --- a/packages/client/src/graphql/tag/tag.graphql +++ b/packages/client/src/graphql/tag/tag.graphql @@ -50,8 +50,8 @@ mutation saveVideoField($tag: ID!, $field: String!, $index: Int!) { } } -query getTags($study: ID!) { - getTags(study: $study) { +query getTags($study: ID!, $page: Int, $pageSize: Int) { + getTags(study: $study, page: $page, pageSize: $pageSize) { _id entry { _id @@ -118,8 +118,16 @@ query getTags($study: ID!) { } } -query getTrainingTags($study: ID!, $user: String!) { - getTrainingTags(study: $study, user: $user) { +query countTagForStudy($study: ID!) { + countTagForStudy(study: $study) +} + +query countTrainingTagForStudy($study: ID!, $user: String!) { + countTrainingTagForStudy(study: $study, user: $user) +} + +query getTrainingTags($study: ID!, $user: String!, $page: Int, $pageSize: Int) { + getTrainingTags(study: $study, user: $user, page: $page, pageSize: $pageSize) { _id entry { _id diff --git a/packages/client/src/graphql/tag/tag.ts b/packages/client/src/graphql/tag/tag.ts index 27184d8..a7789b9 100644 --- a/packages/client/src/graphql/tag/tag.ts +++ b/packages/client/src/graphql/tag/tag.ts @@ -71,14 +71,33 @@ export type SaveVideoFieldMutation = { __typename?: 'Mutation', saveVideoField: export type GetTagsQueryVariables = Types.Exact<{ study: Types.Scalars['ID']['input']; + page?: Types.InputMaybe; + pageSize?: Types.InputMaybe; }>; export type GetTagsQuery = { __typename?: 'Query', getTags: Array<{ __typename?: 'Tag', _id: string, complete: boolean, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean }, data?: Array<{ __typename?: 'TagField', type: Types.TagFieldType, name: string, field?: { __typename: 'AslLexField', lexiconEntry: { __typename?: 'LexiconEntry', key: string, primary: string, video: string, lexicon: string, associates: Array, fields: any } } | { __typename: 'BooleanField', boolValue: boolean } | { __typename: 'FreeTextField', textValue: string } | { __typename: 'NumericField', numericValue: number } | { __typename: 'SliderField', sliderValue: number } | { __typename: 'VideoField', entries: Array<{ __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean }> } | null }> | null }> }; +export type CountTagForStudyQueryVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; +}>; + + +export type CountTagForStudyQuery = { __typename?: 'Query', countTagForStudy: number }; + +export type CountTrainingTagForStudyQueryVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + user: Types.Scalars['String']['input']; +}>; + + +export type CountTrainingTagForStudyQuery = { __typename?: 'Query', countTrainingTagForStudy: number }; + export type GetTrainingTagsQueryVariables = Types.Exact<{ study: Types.Scalars['ID']['input']; user: Types.Scalars['String']['input']; + page?: Types.InputMaybe; + pageSize?: Types.InputMaybe; }>; @@ -364,8 +383,8 @@ export type SaveVideoFieldMutationHookResult = ReturnType; export type SaveVideoFieldMutationOptions = Apollo.BaseMutationOptions; export const GetTagsDocument = gql` - query getTags($study: ID!) { - getTags(study: $study) { + query getTags($study: ID!, $page: Int, $pageSize: Int) { + getTags(study: $study, page: $page, pageSize: $pageSize) { _id entry { _id @@ -440,6 +459,8 @@ export const GetTagsDocument = gql` * const { data, loading, error } = useGetTagsQuery({ * variables: { * study: // value for 'study' + * page: // value for 'page' + * pageSize: // value for 'pageSize' * }, * }); */ @@ -454,9 +475,76 @@ export function useGetTagsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions; export type GetTagsLazyQueryHookResult = ReturnType; export type GetTagsQueryResult = Apollo.QueryResult; +export const CountTagForStudyDocument = gql` + query countTagForStudy($study: ID!) { + countTagForStudy(study: $study) +} + `; + +/** + * __useCountTagForStudyQuery__ + * + * To run a query within a React component, call `useCountTagForStudyQuery` and pass it any options that fit your needs. + * When your component renders, `useCountTagForStudyQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useCountTagForStudyQuery({ + * variables: { + * study: // value for 'study' + * }, + * }); + */ +export function useCountTagForStudyQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(CountTagForStudyDocument, options); + } +export function useCountTagForStudyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(CountTagForStudyDocument, options); + } +export type CountTagForStudyQueryHookResult = ReturnType; +export type CountTagForStudyLazyQueryHookResult = ReturnType; +export type CountTagForStudyQueryResult = Apollo.QueryResult; +export const CountTrainingTagForStudyDocument = gql` + query countTrainingTagForStudy($study: ID!, $user: String!) { + countTrainingTagForStudy(study: $study, user: $user) +} + `; + +/** + * __useCountTrainingTagForStudyQuery__ + * + * To run a query within a React component, call `useCountTrainingTagForStudyQuery` and pass it any options that fit your needs. + * When your component renders, `useCountTrainingTagForStudyQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useCountTrainingTagForStudyQuery({ + * variables: { + * study: // value for 'study' + * user: // value for 'user' + * }, + * }); + */ +export function useCountTrainingTagForStudyQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(CountTrainingTagForStudyDocument, options); + } +export function useCountTrainingTagForStudyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(CountTrainingTagForStudyDocument, options); + } +export type CountTrainingTagForStudyQueryHookResult = ReturnType; +export type CountTrainingTagForStudyLazyQueryHookResult = ReturnType; +export type CountTrainingTagForStudyQueryResult = Apollo.QueryResult; export const GetTrainingTagsDocument = gql` - query getTrainingTags($study: ID!, $user: String!) { - getTrainingTags(study: $study, user: $user) { + query getTrainingTags($study: ID!, $user: String!, $page: Int, $pageSize: Int) { + getTrainingTags(study: $study, user: $user, page: $page, pageSize: $pageSize) { _id entry { _id @@ -532,6 +620,8 @@ export const GetTrainingTagsDocument = gql` * variables: { * study: // value for 'study' * user: // value for 'user' + * page: // value for 'page' + * pageSize: // value for 'pageSize' * }, * }); */ diff --git a/packages/client/src/pages/studies/TagTrainingView.tsx b/packages/client/src/pages/studies/TagTrainingView.tsx index b1ffc71..e806e1e 100644 --- a/packages/client/src/pages/studies/TagTrainingView.tsx +++ b/packages/client/src/pages/studies/TagTrainingView.tsx @@ -1,11 +1,12 @@ import { useLocation } from 'react-router-dom'; import { User, Study } from '../../graphql/graphql'; -import { GetTagsQuery, useGetTrainingTagsQuery } from '../../graphql/tag/tag'; +import { GetTagsQuery, useCountTrainingTagForStudyLazyQuery, useGetTrainingTagsLazyQuery } from '../../graphql/tag/tag'; import { useEffect, useState } from 'react'; import { TagGridView } from '../../components/tag/view/TagGridView.component'; import { Typography } from '@mui/material'; import { useTranslation } from 'react-i18next'; import { useSnackbar } from '../../context/Snackbar.context'; +import { GridPaginationModel } from '@mui/x-data-grid'; export const TagTrainingView: React.FC = () => { const state = useLocation().state; @@ -14,20 +15,42 @@ export const TagTrainingView: React.FC = () => { const [tags, setTags] = useState([]); const { t } = useTranslation(); const { pushSnackbarMessage } = useSnackbar(); + const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 10 }); + const [trainingTagsQuery, trainingTagsResult] = useGetTrainingTagsLazyQuery(); + const [totalTags, setTotalTags] = useState(0); + const [tagCount, tagCountResult] = useCountTrainingTagForStudyLazyQuery(); - const trainingTags = useGetTrainingTagsQuery({ variables: { study: study._id, user: user.uid } }); + useEffect(() => { + trainingTagsQuery({ + variables: { + study: study._id, + user: user.uid, + page: paginationModel.page, + pageSize: paginationModel.pageSize + } + }); + tagCount({ variables: { study: study._id, user: user.uid } }); + }, [paginationModel]); useEffect(() => { - if (trainingTags.data) { - setTags(trainingTags.data.getTrainingTags); - } else if (trainingTags.error) { + if (trainingTagsResult.data) { + setTags(trainingTagsResult.data.getTrainingTags); + } else if (trainingTagsResult.error) { pushSnackbarMessage(t('errors.tagsQuery'), 'error'); - console.error(trainingTags.error); + console.error(trainingTagsResult.error); + } + }, [trainingTagsResult]); + + useEffect(() => { + if (tagCountResult.data) { + setTotalTags(tagCountResult.data.countTrainingTagForStudy); + } else if (tagCountResult.error) { + console.error(tagCountResult.error); } - }, [trainingTags]); + }, [tagCountResult]); const refetchTags = () => { - trainingTags.refetch(); + trainingTagsResult.refetch(); }; return ( @@ -35,7 +58,14 @@ export const TagTrainingView: React.FC = () => { {!tags || tags.length === 0 ? ( {t('components.userPermissions.noTrainingTags')} ) : ( - + )} ); diff --git a/packages/client/src/pages/studies/TagView.tsx b/packages/client/src/pages/studies/TagView.tsx index 8c1c49a..b196df4 100644 --- a/packages/client/src/pages/studies/TagView.tsx +++ b/packages/client/src/pages/studies/TagView.tsx @@ -3,21 +3,34 @@ import { useTranslation } from 'react-i18next'; import { useStudy } from '../../context/Study.context'; import { TagGridView } from '../../components/tag/view/TagGridView.component'; import { useEffect, useState } from 'react'; -import { GetTagsQuery, useGetTagsLazyQuery } from '../../graphql/tag/tag'; +import { GetTagsQuery, useCountTagForStudyLazyQuery, useGetTagsLazyQuery } from '../../graphql/tag/tag'; +import { GridPaginationModel } from '@mui/x-data-grid'; export const TagView: React.FC = () => { const { t } = useTranslation(); const { study } = useStudy(); const [tags, setTags] = useState([]); const [getTagQuery, getTagResult] = useGetTagsLazyQuery(); + const [paginationModel, setPaginationModel] = useState({ page: 0, pageSize: 10 }); + const [totalTags, setTotalTags] = useState(0); + const [tagCount, tagCountResult] = useCountTagForStudyLazyQuery(); useEffect(() => { if (!study) { return; } - getTagQuery({ variables: { study: study._id } }); - }, [study]); + getTagQuery({ variables: { study: study._id, page: paginationModel.page, pageSize: paginationModel.pageSize } }); + tagCount({ variables: { study: study._id } }); + }, [study, paginationModel]); + + useEffect(() => { + if (tagCountResult.data) { + setTotalTags(tagCountResult.data.countTagForStudy); + } else if (tagCountResult.error) { + console.error(tagCountResult.error); + } + }, [tagCountResult]); useEffect(() => { if (!getTagResult.data) { @@ -33,7 +46,16 @@ export const TagView: React.FC = () => { return ( <> {t('menu.viewTags')} - {study && } + {study && ( + + )} ); }; diff --git a/packages/server/src/tag/resolvers/tag.resolver.ts b/packages/server/src/tag/resolvers/tag.resolver.ts index 742ab68..315e8b1 100644 --- a/packages/server/src/tag/resolvers/tag.resolver.ts +++ b/packages/server/src/tag/resolvers/tag.resolver.ts @@ -1,4 +1,4 @@ -import { Resolver, Mutation, Query, Args, ID, ResolveField, Parent } from '@nestjs/graphql'; +import { Resolver, Mutation, Query, Args, ID, ResolveField, Parent, Int } from '@nestjs/graphql'; import { TagService } from '../services/tag.service'; import { Tag } from '../models/tag.model'; import { StudyPipe } from '../../study/pipes/study.pipe'; @@ -111,27 +111,55 @@ export class TagResolver { @Query(() => [Tag]) async getTags( @Args('study', { type: () => ID }, StudyPipe) study: Study, - @TokenContext() user: TokenPayload + @TokenContext() user: TokenPayload, + @Args('page', { type: () => Int, nullable: true }) page?: number, + @Args('pageSize', { type: () => Int, nullable: true }) pageSize?: number ): Promise { if (!(await this.enforcer.enforce(user.user_id, TagPermissions.READ, study._id.toString()))) { throw new UnauthorizedException('User cannot read tags in this study'); } - return this.tagService.getTags(study); + return this.tagService.getTags(study, page, pageSize); + } + + @Query(() => Int) + async countTagForStudy( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @TokenContext() user: TokenPayload + ): Promise { + if (!(await this.enforcer.enforce(user.user_id, TagPermissions.READ, study._id.toString()))) { + throw new UnauthorizedException('User cannot read tags in this study'); + } + + return this.tagService.countForStudy(study); } @Query(() => [Tag]) async getTrainingTags( @Args('study', { type: () => ID }, StudyPipe) study: Study, @Args('user') user: string, - @TokenContext() requestingUser: TokenPayload + @TokenContext() requestingUser: TokenPayload, + @Args('page', { type: () => Int, nullable: true }) page?: number, + @Args('pageSize', { type: () => Int, nullable: true }) pageSize?: number ): Promise { if (!(await this.enforcer.enforce(requestingUser.user_id, TagPermissions.READ, study._id.toString()))) { throw new UnauthorizedException('User cannot read tags in this study'); } - return this.tagService.getTrainingTags(study, user); + return this.tagService.getTrainingTags(study, user, page, pageSize); } + @Query(() => Int) + async countTrainingTagForStudy( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @Args('user') user: string, + @TokenContext() requestingUser: TokenPayload + ): Promise { + if (!(await this.enforcer.enforce(requestingUser.user_id, TagPermissions.READ, study._id.toString()))) { + throw new UnauthorizedException('User cannot read tags in this study'); + } + + return this.tagService.countTrainingTagForStudy(study, user); + } @ResolveField(() => Entry) async entry(@Parent() tag: Tag): Promise { return this.entryPipe.transform(tag.entry); diff --git a/packages/server/src/tag/services/tag.service.ts b/packages/server/src/tag/services/tag.service.ts index 6077fd1..8c0a372 100644 --- a/packages/server/src/tag/services/tag.service.ts +++ b/packages/server/src/tag/services/tag.service.ts @@ -73,12 +73,24 @@ export class TagService { return isTrained ? this.assignTagFull(study, user) : this.assignTrainingTag(study, user); } - async getTrainingTags(study: Study, user: string): Promise { - return this.tagModel.find({ + async getTrainingTags(study: Study, user: string, page?: number, pageSize?: number): Promise { + const query = this.tagModel.find({ user, study: study._id, training: true }); + + // Pagination support + if (page !== undefined && pageSize != undefined) { + const offset = page * pageSize; + return await query.skip(offset).limit(pageSize); + } + + return await query; + } + + async countTrainingTagForStudy(study: Study, user: string): Promise { + return this.tagModel.count({ study: study._id, user: user, training: true }); } /** @@ -289,14 +301,33 @@ export class TagService { return true; } - async getTags(study: Study | string): Promise { + async getTags(study: Study | string, page?: number, pageSize?: number): Promise { let studyID = ''; if (typeof study === 'string') { studyID = study; } else { studyID = study._id; } - return this.tagModel.find({ study: studyID, training: false }); + const query = this.tagModel.find({ study: studyID, training: false }); + + // Pagination support + if (page !== undefined && pageSize != undefined) { + const offset = page * pageSize; + return await query.skip(offset).limit(pageSize); + } + + return await query; + } + + async countForStudy(study: Study | string): Promise { + let studyID = ''; + if (typeof study === 'string') { + studyID = study; + } else { + studyID = study._id; + } + + return this.tagModel.count({ study: studyID, training: false }); } async getCompleteTags(study: Study | string): Promise {