diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 6bc30d06..cd080f84 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -29,6 +29,7 @@ import { setContext } from '@apollo/client/link/context'; import { StudyProvider } from './context/Study.context'; import { ConfirmationProvider } from './context/Confirmation.context'; import { DatasetProvider } from './context/Dataset.context'; +import { EntryControls } from './pages/studies/EntryControls'; const drawerWidth = 256; const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{ @@ -127,6 +128,7 @@ const MyRoutes: FC = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/client/src/components/SideBar.component.tsx b/packages/client/src/components/SideBar.component.tsx index ab1cc280..25f98c6c 100644 --- a/packages/client/src/components/SideBar.component.tsx +++ b/packages/client/src/components/SideBar.component.tsx @@ -52,6 +52,7 @@ export const SideBar: FC = ({ open, drawerWidth }) => { { name: 'Study Control', action: () => navigate('/study/controls'), visible: (p) => p!.projectAdmin }, { name: 'User Permissions', action: () => navigate('/study/permissions'), visible: (p) => p!.studyAdmin }, { name: 'Entry Controls', action: () => navigate('/study/controls'), visible: (p) => p!.studyAdmin }, + { name: 'Entry Controls', action: () => navigate('/study/entries'), visible: (p) => p!.studyAdmin }, { name: 'Download Tags', action: () => navigate('/study/tags'), visible: (p) => p!.studyAdmin } ] }, diff --git a/packages/client/src/components/ToggleEntryEnabled.component.tsx b/packages/client/src/components/ToggleEntryEnabled.component.tsx new file mode 100644 index 00000000..6df73f12 --- /dev/null +++ b/packages/client/src/components/ToggleEntryEnabled.component.tsx @@ -0,0 +1,65 @@ +import { Switch } from '@mui/material'; +import { useEffect } from 'react'; +import { useIsEntryEnabledLazyQuery, useSetEntryEnabledMutation } from '../graphql/tag/tag'; +import { useStudy } from '../context/Study.context'; +import { useConfirmation } from '../context/Confirmation.context'; + +export default function ToggleEntryEnabled(props: { entryId: string }) { + const [isEntryEnabled, isEntryEnabledResults] = useIsEntryEnabledLazyQuery(); + const [setEntryEnabledMutation, setEntryEnabledResults] = useSetEntryEnabledMutation(); + + const { study } = useStudy(); + const confirmation = useConfirmation(); + + useEffect(() => { + if (study) { + isEntryEnabled({ + variables: { + study: study._id, + entry: props.entryId + }, + fetchPolicy: 'network-only' + }); + } + }, [study, setEntryEnabledResults.data]); + + useEffect(() => { + if (setEntryEnabledResults.called) { + if (setEntryEnabledResults.error) { + //show error message + console.error('error toggling entry', setEntryEnabledResults.error); + } + } + }, [setEntryEnabledResults.error]); + + const handleToggleEnabled = async (entryId: string, checked: boolean) => { + if (study) { + if (!checked) { + confirmation.pushConfirmationRequest({ + title: 'Disable Entry', + message: + 'Are you sure you want to disable this entry? Doing so will exclude this entry from the current study.', + onConfirm: () => { + setEntryEnabledMutation({ + variables: { study: study._id, entry: entryId, enabled: checked } + }); + }, + onCancel: () => {} + }); + } else { + setEntryEnabledMutation({ + variables: { study: study._id, entry: entryId, enabled: checked } + }); + } + } + }; + + return ( + handleToggleEnabled(props.entryId, event.target.checked)} + inputProps={{ 'aria-label': 'controlled' }} + /> + ); +} diff --git a/packages/client/src/graphql/dataset/dataset.graphql b/packages/client/src/graphql/dataset/dataset.graphql index 95991352..f57196a0 100644 --- a/packages/client/src/graphql/dataset/dataset.graphql +++ b/packages/client/src/graphql/dataset/dataset.graphql @@ -1,7 +1,15 @@ query getDatasets { getDatasets { - _id, - name, + _id + name + description + } +} + +query getDatasetsByProject($project: ID!) { + getDatasetsByProject(project: $project) { + _id + name description } } diff --git a/packages/client/src/graphql/dataset/dataset.ts b/packages/client/src/graphql/dataset/dataset.ts index a716295f..8081d99d 100644 --- a/packages/client/src/graphql/dataset/dataset.ts +++ b/packages/client/src/graphql/dataset/dataset.ts @@ -10,6 +10,13 @@ export type GetDatasetsQueryVariables = Types.Exact<{ [key: string]: never; }>; export type GetDatasetsQuery = { __typename?: 'Query', getDatasets: Array<{ __typename?: 'Dataset', _id: string, name: string, description: string }> }; +export type GetDatasetsByProjectQueryVariables = Types.Exact<{ + project: Types.Scalars['ID']['input']; +}>; + + +export type GetDatasetsByProjectQuery = { __typename?: 'Query', getDatasetsByProject: Array<{ __typename?: 'Dataset', _id: string, name: string, description: string }> }; + export const GetDatasetsDocument = gql` query getDatasets { @@ -46,4 +53,41 @@ export function useGetDatasetsLazyQuery(baseOptions?: Apollo.LazyQueryHookOption } export type GetDatasetsQueryHookResult = ReturnType; export type GetDatasetsLazyQueryHookResult = ReturnType; -export type GetDatasetsQueryResult = Apollo.QueryResult; \ No newline at end of file +export type GetDatasetsQueryResult = Apollo.QueryResult; +export const GetDatasetsByProjectDocument = gql` + query getDatasetsByProject($project: ID!) { + getDatasetsByProject(project: $project) { + _id + name + description + } +} + `; + +/** + * __useGetDatasetsByProjectQuery__ + * + * To run a query within a React component, call `useGetDatasetsByProjectQuery` and pass it any options that fit your needs. + * When your component renders, `useGetDatasetsByProjectQuery` 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 } = useGetDatasetsByProjectQuery({ + * variables: { + * project: // value for 'project' + * }, + * }); + */ +export function useGetDatasetsByProjectQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetDatasetsByProjectDocument, options); + } +export function useGetDatasetsByProjectLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetDatasetsByProjectDocument, options); + } +export type GetDatasetsByProjectQueryHookResult = ReturnType; +export type GetDatasetsByProjectLazyQueryHookResult = ReturnType; +export type GetDatasetsByProjectQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index ced0238e..5cd2f0fe 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -212,6 +212,7 @@ export type Mutation = { refresh: AccessToken; resendInvite: InviteModel; resetPassword: Scalars['Boolean']['output']; + setEntryEnabled: Scalars['Boolean']['output']; signLabCreateProject: Project; signup: AccessToken; updateProject: ProjectModel; @@ -413,6 +414,13 @@ export type MutationResetPasswordArgs = { }; +export type MutationSetEntryEnabledArgs = { + enabled: Scalars['Boolean']['input']; + entry: Scalars['ID']['input']; + study: Scalars['ID']['input']; +}; + + export type MutationSignLabCreateProjectArgs = { project: ProjectCreate; }; @@ -562,6 +570,7 @@ export type Query = { getUser: UserModel; invite: InviteModel; invites: Array; + isEntryEnabled: Scalars['Boolean']['output']; lexFindAll: Array; lexiconByKey: LexiconEntry; lexiconSearch: Array; @@ -649,6 +658,12 @@ export type QueryInvitesArgs = { }; +export type QueryIsEntryEnabledArgs = { + entry: Scalars['ID']['input']; + study: Scalars['ID']['input']; +}; + + export type QueryLexiconByKeyArgs = { key: Scalars['String']['input']; lexicon: Scalars['String']['input']; diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index aff0536f..2d880667 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -1,13 +1,13 @@ query findStudies($project: ID!) { findStudies(project: $project) { - _id, - name, - description, - instructions, - project, - tagsPerEntry, + _id + name + description + instructions + project + tagsPerEntry tagSchema { - dataSchema, + dataSchema uiSchema } } @@ -19,14 +19,14 @@ mutation deleteStudy($study: ID!) { mutation createStudy($study: StudyCreate!) { createStudy(study: $study) { - _id, - name, - description, - instructions, - project, - tagsPerEntry, + _id + name + description + instructions + project + tagsPerEntry tagSchema { - dataSchema, + dataSchema uiSchema } } diff --git a/packages/client/src/graphql/tag/tag.graphql b/packages/client/src/graphql/tag/tag.graphql index 8d204df3..aa6cc4f6 100644 --- a/packages/client/src/graphql/tag/tag.graphql +++ b/packages/client/src/graphql/tag/tag.graphql @@ -4,19 +4,27 @@ mutation createTags($study: ID!, $entries: [ID!]!) { } } +mutation setEntryEnabled($study: ID!, $entry: ID!, $enabled: Boolean!) { + setEntryEnabled(study: $study, entry: $entry, enabled: $enabled) +} + +query isEntryEnabled($study: ID!, $entry: ID!) { + isEntryEnabled(study: $study, entry: $entry) +} + mutation assignTag($study: ID!) { assignTag(study: $study) { - _id, + _id entry { - _id, - organization, - entryID, - contentType, - dataset, - creator, - dateCreated, - meta, - signedUrl, + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl signedUrlExpiration } } diff --git a/packages/client/src/graphql/tag/tag.ts b/packages/client/src/graphql/tag/tag.ts index 05886d60..a299e24c 100644 --- a/packages/client/src/graphql/tag/tag.ts +++ b/packages/client/src/graphql/tag/tag.ts @@ -13,6 +13,23 @@ export type CreateTagsMutationVariables = Types.Exact<{ export type CreateTagsMutation = { __typename?: 'Mutation', createTags: Array<{ __typename?: 'Tag', _id: string }> }; +export type SetEntryEnabledMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + entry: Types.Scalars['ID']['input']; + enabled: Types.Scalars['Boolean']['input']; +}>; + + +export type SetEntryEnabledMutation = { __typename?: 'Mutation', setEntryEnabled: boolean }; + +export type IsEntryEnabledQueryVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + entry: Types.Scalars['ID']['input']; +}>; + + +export type IsEntryEnabledQuery = { __typename?: 'Query', isEntryEnabled: boolean }; + export type AssignTagMutationVariables = Types.Exact<{ study: Types.Scalars['ID']['input']; }>; @@ -63,6 +80,73 @@ export function useCreateTagsMutation(baseOptions?: Apollo.MutationHookOptions; export type CreateTagsMutationResult = Apollo.MutationResult; export type CreateTagsMutationOptions = Apollo.BaseMutationOptions; +export const SetEntryEnabledDocument = gql` + mutation setEntryEnabled($study: ID!, $entry: ID!, $enabled: Boolean!) { + setEntryEnabled(study: $study, entry: $entry, enabled: $enabled) +} + `; +export type SetEntryEnabledMutationFn = Apollo.MutationFunction; + +/** + * __useSetEntryEnabledMutation__ + * + * To run a mutation, you first call `useSetEntryEnabledMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useSetEntryEnabledMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [setEntryEnabledMutation, { data, loading, error }] = useSetEntryEnabledMutation({ + * variables: { + * study: // value for 'study' + * entry: // value for 'entry' + * enabled: // value for 'enabled' + * }, + * }); + */ +export function useSetEntryEnabledMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(SetEntryEnabledDocument, options); + } +export type SetEntryEnabledMutationHookResult = ReturnType; +export type SetEntryEnabledMutationResult = Apollo.MutationResult; +export type SetEntryEnabledMutationOptions = Apollo.BaseMutationOptions; +export const IsEntryEnabledDocument = gql` + query isEntryEnabled($study: ID!, $entry: ID!) { + isEntryEnabled(study: $study, entry: $entry) +} + `; + +/** + * __useIsEntryEnabledQuery__ + * + * To run a query within a React component, call `useIsEntryEnabledQuery` and pass it any options that fit your needs. + * When your component renders, `useIsEntryEnabledQuery` 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 } = useIsEntryEnabledQuery({ + * variables: { + * study: // value for 'study' + * entry: // value for 'entry' + * }, + * }); + */ +export function useIsEntryEnabledQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(IsEntryEnabledDocument, options); + } +export function useIsEntryEnabledLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(IsEntryEnabledDocument, options); + } +export type IsEntryEnabledQueryHookResult = ReturnType; +export type IsEntryEnabledLazyQueryHookResult = ReturnType; +export type IsEntryEnabledQueryResult = Apollo.QueryResult; export const AssignTagDocument = gql` mutation assignTag($study: ID!) { assignTag(study: $study) { diff --git a/packages/client/src/pages/studies/EntryControls.tsx b/packages/client/src/pages/studies/EntryControls.tsx index e69de29b..f99688f4 100644 --- a/packages/client/src/pages/studies/EntryControls.tsx +++ b/packages/client/src/pages/studies/EntryControls.tsx @@ -0,0 +1,45 @@ +import { Typography } from '@mui/material'; +import { useEffect, useState } from 'react'; +import { GridColDef } from '@mui/x-data-grid'; +import { Dataset } from '../../graphql/graphql'; +import { DatasetsView } from '../../components/DatasetsView.component'; +import { useGetDatasetsByProjectQuery } from '../../graphql/dataset/dataset'; +import { useProject } from '../../context/Project.context'; +import ToggleEntryEnabled from '../../components/ToggleEntryEnabled.component'; + +export const EntryControls: React.FC = () => { + const { project } = useProject(); + const [datasets, setDatasets] = useState([]); + const getDatasetsByProjectResults = useGetDatasetsByProjectQuery({ + variables: { + project: project ? project._id : '' + } + }); + + useEffect(() => { + if (getDatasetsByProjectResults.data) { + setDatasets(getDatasetsByProjectResults.data.getDatasetsByProject); + } + }, [getDatasetsByProjectResults.data]); + + const additionalColumns: GridColDef[] = [ + { + field: 'enabled', + type: 'actions', + headerName: 'Enable', + width: 120, + maxWidth: 120, + cellClassName: 'enabled', + getActions: (params) => { + return []; + } + } + ]; + + return ( + <> + Entry Control + + + ); +}; diff --git a/packages/server/schema.gql b/packages/server/schema.gql index aab1322f..569e693b 100644 --- a/packages/server/schema.gql +++ b/packages/server/schema.gql @@ -156,6 +156,7 @@ type Query { getCSVUploadURL(session: ID!): String! validateCSV(session: ID!): UploadResult! getEntryUploadURL(session: ID!, filename: String!, contentType: String!): String! + isEntryEnabled(study: ID!, entry: ID!): Boolean! } type Mutation { @@ -181,6 +182,7 @@ type Mutation { createTags(study: ID!, entries: [ID!]!): [Tag!]! assignTag(study: ID!): Tag completeTag(tag: ID!, data: JSON!): Boolean! + setEntryEnabled(study: ID!, entry: ID!, enabled: Boolean!): Boolean! } input OrganizationCreate { diff --git a/packages/server/src/project/pipes/project.pipe.ts b/packages/server/src/project/pipes/project.pipe.ts index 5bc67ee8..c393ebac 100644 --- a/packages/server/src/project/pipes/project.pipe.ts +++ b/packages/server/src/project/pipes/project.pipe.ts @@ -7,6 +7,9 @@ export class ProjectPipe implements PipeTransform> { constructor(private readonly projectService: ProjectService) {} async transform(value: string): Promise { + if (!value) { + throw new BadRequestException(`Invalid project ID ${value}given`); + } const project = await this.projectService.findById(value); if (!project) { throw new BadRequestException(`Project with ID ${value} does not exist`); diff --git a/packages/server/src/tag/tag.resolver.ts b/packages/server/src/tag/tag.resolver.ts index 224060c3..02519613 100644 --- a/packages/server/src/tag/tag.resolver.ts +++ b/packages/server/src/tag/tag.resolver.ts @@ -1,4 +1,4 @@ -import { Resolver, Mutation, Args, ID, ResolveField, Parent } from '@nestjs/graphql'; +import { Resolver, Mutation, Query, Args, ID, ResolveField, Parent } from '@nestjs/graphql'; import { TagService } from './tag.service'; import { Tag } from './tag.model'; import { StudyPipe } from '../study/pipes/study.pipe'; @@ -14,6 +14,7 @@ import * as casbin from 'casbin'; import { TokenContext } from '../jwt/token.context'; import { TokenPayload } from '../jwt/token.dto'; import { StudyPermissions } from '../permission/permissions/study'; +import { TagPermissions } from 'src/permission/permissions/tag'; // TODO: Add permissioning @UseGuards(JwtAuthGuard) @@ -56,6 +57,32 @@ export class TagResolver { return true; } + @Mutation(() => Boolean) + async setEntryEnabled( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @Args('entry', { type: () => ID }, EntryPipe) entry: Entry, + @Args('enabled', { type: () => Boolean }) enabled: boolean, + @TokenContext() user: TokenPayload + ): Promise { + if (!(await this.enforcer.enforce(user.id, TagPermissions.UPDATE, study._id.toString()))) { + throw new UnauthorizedException('User cannot update tags in this study'); + } + await this.tagService.setEnabled(study, entry, enabled); + return true; + } + + @Query(() => Boolean) + async isEntryEnabled( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @Args('entry', { type: () => ID }, EntryPipe) entry: Entry, + @TokenContext() user: TokenPayload + ): Promise { + if (!(await this.enforcer.enforce(user.id, TagPermissions.READ, study._id.toString()))) { + throw new UnauthorizedException('User cannot read tags in this study'); + } + return this.tagService.isEntryEnabled(study, entry); + } + @ResolveField(() => Entry) async entry(@Parent() tag: Tag): Promise { return this.entryPipe.transform(tag.entry); diff --git a/packages/server/src/tag/tag.service.ts b/packages/server/src/tag/tag.service.ts index 089af8d9..5efe6d4d 100644 --- a/packages/server/src/tag/tag.service.ts +++ b/packages/server/src/tag/tag.service.ts @@ -115,6 +115,29 @@ export class TagService { await this.tagModel.findOneAndUpdate({ _id: tag._id }, { $set: { data, complete: true } }); } + async isEntryEnabled(study: Study, entry: Entry) { + const existingTag = await this.tagModel.findOne({ entry: entry._id, study: study._id }); + return existingTag ? existingTag.enabled : false; + } + + async setEnabled(study: Study, entry: Entry, enabled: boolean): Promise { + const existingTag = await this.tagModel.findOne({ entry: entry._id, study: study._id }); + if (existingTag) { + await this.tagModel.updateMany({ entry: entry._id, study: study._id }, { $set: { enabled: enabled } }); + } else { + for (let order = 0; order < study.tagsPerEntry; order++) { + await this.tagModel.create({ + entry: entry._id, + study: study._id, + complete: false, + order, + enabled: enabled + }); + } + } + return true; + } + private async getIncomplete(study: Study, user: string): Promise { return this.tagModel.findOne({ study: study._id, user, complete: false, enabled: true }); }