diff --git a/packages/client/src/components/DatasetsView.component.tsx b/packages/client/src/components/DatasetsView.component.tsx index 6e964cc5..900a407d 100644 --- a/packages/client/src/components/DatasetsView.component.tsx +++ b/packages/client/src/components/DatasetsView.component.tsx @@ -2,13 +2,15 @@ import { Accordion, AccordionSummary, Typography, Stack, AccordionDetails } from import { Dataset } from '../graphql/graphql'; import { DatasetTable } from './DatasetTable.component'; import { ExpandMore } from '@mui/icons-material'; +import { GridColDef } from '@mui/x-data-grid'; export interface DatasetsViewProps { datasets: Dataset[]; + additionalColumns?: GridColDef[]; } // TODO: Implement lazy loading on accordion open to prevent loading all datasets at once -export const DatasetsView: React.FC = ({ datasets }) => { +export const DatasetsView: React.FC = ({ datasets, additionalColumns }) => { return ( <> {datasets.map((dataset: Dataset) => ( @@ -20,7 +22,7 @@ export const DatasetsView: React.FC = ({ datasets }) => { - + ))} diff --git a/packages/client/src/components/TagTraining.component.tsx b/packages/client/src/components/TagTraining.component.tsx index 3cd3e3a8..a2a46d0e 100644 --- a/packages/client/src/components/TagTraining.component.tsx +++ b/packages/client/src/components/TagTraining.component.tsx @@ -1,12 +1,68 @@ import { DatasetsView } from './DatasetsView.component'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, SetStateAction, Dispatch } from 'react'; import { useGetDatasetsQuery } from '../graphql/dataset/dataset'; -import { Dataset } from '../graphql/graphql'; +import { Dataset, Entry } from '../graphql/graphql'; +import { GridColDef } from '@mui/x-data-grid'; +import { Switch } from '@mui/material'; -export const TagTrainingComponent = () => { +export interface TagTrainingComponentProps { + setTrainingSet: Dispatch>; + setTaggingSet: Dispatch>; +} + +export const TagTrainingComponent: React.FC = (props) => { const [datasets, setDatasets] = useState([]); const getDatasetsResults = useGetDatasetsQuery(); + const trainingSet: Set = new Set(); + const fullSet: Set = new Set(); + + const additionalColumns: GridColDef[] = [ + { + field: 'training', + headerName: 'Training', + width: 200, + renderCell: (params) => ( + {}} + add={(entry) => { + trainingSet.add(entry._id); + props.setTrainingSet(Array.from(trainingSet)); + }} + remove={(entry) => { + trainingSet.delete(entry._id); + props.setTrainingSet(Array.from(trainingSet)); + }} + entry={params.row} + /> + ) + }, + { + field: 'full', + headerName: 'Available for Tagging', + width: 200, + renderCell: (params) => ( + { + fullSet.add(entry._id); + props.setTaggingSet(Array.from(fullSet)); + }} + add={(entry) => { + fullSet.add(entry._id); + props.setTaggingSet(Array.from(fullSet)); + }} + remove={(entry) => { + fullSet.delete(entry._id); + props.setTaggingSet(Array.from(fullSet)); + }} + entry={params.row} + /> + ) + } + ]; + // TODO: In the future, the datasets retrieved should only be datasets // accessible by the current project useEffect(() => { @@ -17,7 +73,34 @@ export const TagTrainingComponent = () => { return ( <> - + ); }; + +interface EditSwitchProps { + startingValue: boolean; + add: (entry: Entry) => void; + remove: (entry: Entry) => void; + onLoad: (entry: Entry) => void; + entry: Entry; +} + +const EditSetSwitch: React.FC = (props) => { + const [checked, setChecked] = useState(props.startingValue); + + useEffect(() => { + props.onLoad(props.entry); + }, [props.entry]); + + const handleChange = (event: React.ChangeEvent) => { + setChecked(event.target.checked); + if (event.target.checked) { + props.add(props.entry); + } else { + props.remove(props.entry); + } + }; + + return ; +}; diff --git a/packages/client/src/graphql/tag.graphql b/packages/client/src/graphql/tag.graphql new file mode 100644 index 00000000..9fff737a --- /dev/null +++ b/packages/client/src/graphql/tag.graphql @@ -0,0 +1,5 @@ +mutation createTags($study: ID!, $entries: [ID!]!) { + createTags(study: $study, entries: $entries) { + _id + } +} diff --git a/packages/client/src/graphql/tag.ts b/packages/client/src/graphql/tag.ts new file mode 100644 index 00000000..444b38f6 --- /dev/null +++ b/packages/client/src/graphql/tag.ts @@ -0,0 +1,50 @@ +/* Generated File DO NOT EDIT. */ +/* tslint:disable */ +import * as Types from './graphql'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type CreateTagsMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + entries: Array | Types.Scalars['ID']['input']; +}>; + + +export type CreateTagsMutation = { __typename?: 'Mutation', createTags: Array<{ __typename?: 'Tag', _id: string }> }; + + +export const CreateTagsDocument = gql` + mutation createTags($study: ID!, $entries: [ID!]!) { + createTags(study: $study, entries: $entries) { + _id + } +} + `; +export type CreateTagsMutationFn = Apollo.MutationFunction; + +/** + * __useCreateTagsMutation__ + * + * To run a mutation, you first call `useCreateTagsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateTagsMutation` 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 [createTagsMutation, { data, loading, error }] = useCreateTagsMutation({ + * variables: { + * study: // value for 'study' + * entries: // value for 'entries' + * }, + * }); + */ +export function useCreateTagsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateTagsDocument, options); + } +export type CreateTagsMutationHookResult = ReturnType; +export type CreateTagsMutationResult = Apollo.MutationResult; +export type CreateTagsMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/packages/client/src/pages/studies/NewStudy.tsx b/packages/client/src/pages/studies/NewStudy.tsx index ba801f4f..ba89b714 100644 --- a/packages/client/src/pages/studies/NewStudy.tsx +++ b/packages/client/src/pages/studies/NewStudy.tsx @@ -5,9 +5,11 @@ import { TagTrainingComponent } from '../../components/TagTraining.component'; import { useState, useEffect } from 'react'; import { StudyCreate, TagSchema } from '../../graphql/graphql'; import { PartialStudyCreate } from '../../types/study'; -import { useCreateStudyMutation } from '../../graphql/study/study'; +import { CreateStudyDocument } from '../../graphql/study/study'; import { useProject } from '../../context/Project.context'; import { useStudy } from '../../context/Study.context'; +import { useApolloClient } from '@apollo/client'; +import { CreateTagsDocument } from '../../graphql/tag'; export const NewStudy: React.FC = () => { const [activeStep, setActiveStep] = useState(0); @@ -16,8 +18,9 @@ export const NewStudy: React.FC = () => { const [tagSchema, setTagSchema] = useState(null); const { project } = useProject(); const { updateStudies } = useStudy(); - - const [createStudyMutation, createStudyResults] = useCreateStudyMutation(); + const [_trainingSet, setTrainingSet] = useState([]); + const [taggingSet, setTaggingSet] = useState([]); + const apolloClient = useApolloClient(); // Handles mantaining which step the user is on and the step limit useEffect(() => { @@ -35,7 +38,7 @@ export const NewStudy: React.FC = () => { setStepLimit(3); }, [partialNewStudy, tagSchema]); - const handleNext = () => { + const handleNext = async () => { if (activeStep === stepLimit) { return; } @@ -50,21 +53,33 @@ export const NewStudy: React.FC = () => { console.error('Reached submission with no project selected'); return; } + const study: StudyCreate = { ...partialNewStudy, project: project._id, tagSchema: tagSchema }; - createStudyMutation({ variables: { study } }); - } - setActiveStep((prevActiveStep: number) => prevActiveStep + 1); - }; - useEffect(() => { - if (createStudyResults.data) { + // Make the new study + const result = await apolloClient.mutate({ + mutation: CreateStudyDocument, + variables: { study: study } + }); + + if (result.errors || !result.data) { + console.error('Failed to create study'); + return; + } + + // Create the corresponding tags + await apolloClient.mutate({ + mutation: CreateTagsDocument, + variables: { study: result.data.createStudy._id, entries: taggingSet } + }); updateStudies(); } - }, [createStudyResults.data]); + setActiveStep((prevActiveStep: number) => prevActiveStep + 1); + }; const handleBack = () => { if (activeStep === 0) { @@ -86,7 +101,7 @@ export const NewStudy: React.FC = () => { case 1: return ; case 2: - return ; + return ; default: return null; } diff --git a/packages/server/src/tag/tag.module.ts b/packages/server/src/tag/tag.module.ts index 48b937a1..4c020e52 100644 --- a/packages/server/src/tag/tag.module.ts +++ b/packages/server/src/tag/tag.module.ts @@ -7,9 +7,16 @@ import { StudyModule } from '../study/study.module'; import { EntryModule } from '../entry/entry.module'; import { TagPipe } from './pipes/tag.pipe'; import { SharedModule } from '../shared/shared.module'; +import { PermissionModule } from '../permission/permission.module'; @Module({ - imports: [MongooseModule.forFeature([{ name: Tag.name, schema: TagSchema }]), StudyModule, EntryModule, SharedModule], + imports: [ + MongooseModule.forFeature([{ name: Tag.name, schema: TagSchema }]), + StudyModule, + EntryModule, + SharedModule, + PermissionModule + ], providers: [TagService, TagResolver, TagPipe] }) export class TagModule {} diff --git a/packages/server/src/tag/tag.resolver.ts b/packages/server/src/tag/tag.resolver.ts index 44cb49fc..224060c3 100644 --- a/packages/server/src/tag/tag.resolver.ts +++ b/packages/server/src/tag/tag.resolver.ts @@ -7,8 +7,13 @@ import { EntriesPipe, EntryPipe } from '../entry/pipes/entry.pipe'; import { Entry } from '../entry/models/entry.model'; import { TagPipe } from './pipes/tag.pipe'; import JSON from 'graphql-type-json'; -import { UseGuards } from '@nestjs/common'; +import { Inject, UseGuards, UnauthorizedException } from '@nestjs/common'; import { JwtAuthGuard } from '../jwt/jwt.guard'; +import { CASBIN_PROVIDER } from 'src/permission/casbin.provider'; +import * as casbin from 'casbin'; +import { TokenContext } from '../jwt/token.context'; +import { TokenPayload } from '../jwt/token.dto'; +import { StudyPermissions } from '../permission/permissions/study'; // TODO: Add permissioning @UseGuards(JwtAuthGuard) @@ -17,21 +22,28 @@ export class TagResolver { constructor( private readonly tagService: TagService, private readonly entryPipe: EntryPipe, - private readonly studyPipe: StudyPipe + private readonly studyPipe: StudyPipe, + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer ) {} @Mutation(() => [Tag]) async createTags( @Args('study', { type: () => ID }, StudyPipe) study: Study, - @Args('entries', { type: () => [ID] }, EntriesPipe) entries: Entry[] + @Args('entries', { type: () => [ID] }, EntriesPipe) entries: Entry[], + @TokenContext() user: TokenPayload ) { + if (!(await this.enforcer.enforce(user.id, StudyPermissions.CREATE, study._id.toString()))) { + throw new UnauthorizedException('User cannot add tags to this study'); + } return this.tagService.createTags(study, entries); } @Mutation(() => Tag, { nullable: true }) - async assignTag(@Args('study', { type: () => ID }, StudyPipe) study: Study): Promise { - // TODO: Add user context - return this.tagService.assignTag(study, '1'); + async assignTag( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @TokenContext() user: TokenPayload + ): Promise { + return this.tagService.assignTag(study, user.id); } @Mutation(() => Boolean)