diff --git a/packages/client/src/components/NewStudyJsonForm.component.tsx b/packages/client/src/components/NewStudyJsonForm.component.tsx index d88a5099..c40460b5 100644 --- a/packages/client/src/components/NewStudyJsonForm.component.tsx +++ b/packages/client/src/components/NewStudyJsonForm.component.tsx @@ -1,7 +1,73 @@ import { materialRenderers, materialCells } from '@jsonforms/material-renderers'; import { JsonForms } from '@jsonforms/react'; -import { Box } from '@mui/material'; -import { useState } from 'react'; +import { Dispatch, SetStateAction, useState, useEffect } from 'react'; +import { PartialStudyCreate } from '../types/study'; +import { ErrorObject } from 'ajv'; +import { useStudyExistsLazyQuery } from '../graphql/study/study'; +import { useProject } from '../context/Project.context'; + +export interface NewStudyFormProps { + newStudy: PartialStudyCreate | null; + setNewStudy: Dispatch>; +} + +export const NewStudyJsonForm: React.FC = (props) => { + const initialData = { + tagsPerEntry: schema.properties.tagsPerEntry.default + }; + + const [data, setData] = useState(initialData); + const [studyExistsQuery, studyExistsResults] = useStudyExistsLazyQuery(); + const { project } = useProject(); + const [additionalErrors, setAdditionalErrors] = useState([]); + + // Keep track of the new study internally to check to make sure the name is + // unique before submitting + const [potentialNewStudy, setPotentialNewStudy] = useState(null); + + const handleChange = (data: any, errors: ErrorObject[] | undefined) => { + setData(data); + if (!errors || errors.length === 0) { + // No errors in the format of the data, check if the study name is unique + setPotentialNewStudy({ ...data }); + studyExistsQuery({ variables: { name: data.name, project: project!._id } }); + } else { + setPotentialNewStudy(null); + } + }; + + useEffect(() => { + // If the study exists, notify the user of the error, otherwise the + // study is valid + if (studyExistsResults.data?.studyExists) { + setAdditionalErrors([ + { + instancePath: '/name', + keyword: 'uniqueStudyName', + message: 'A study with this name already exists', + schemaPath: '#/properties/name/name', + params: { keyword: 'uniqueStudyName' } + } + ]); + props.setNewStudy(null); + } else { + setAdditionalErrors([]); + props.setNewStudy(potentialNewStudy); + } + }, [studyExistsResults.data]); + + return ( + handleChange(data, errors)} + additionalErrors={additionalErrors} + /> + ); +}; const schema = { type: 'object', @@ -16,12 +82,12 @@ const schema = { instructions: { type: 'string' }, - times: { + tagsPerEntry: { type: 'number', default: 1 } }, - required: ['name', 'description', 'instructions'] + required: ['name', 'description', 'instructions', 'tagsPerEntry'] }; const uischema = { @@ -46,42 +112,7 @@ const uischema = { { type: 'Control', label: 'Number of times each entry needs to be tagged (default 1)', - scope: '#/properties/times' + scope: '#/properties/tagsPerEntry' } ] }; - -export const NewStudyJsonForm: React.FC = () => { - const initialData = { - name: '', - description: '', - instructions: '' - }; - - const [data, setData] = useState(initialData); - - const handleChange = (data: any) => { - setData(data); - }; - - return ( - - handleChange(data)} - /> - - ); -}; diff --git a/packages/client/src/components/TagsDisplay.component.tsx b/packages/client/src/components/TagsDisplay.component.tsx index 18a030ef..57b2e33f 100644 --- a/packages/client/src/components/TagsDisplay.component.tsx +++ b/packages/client/src/components/TagsDisplay.component.tsx @@ -12,8 +12,9 @@ import { materialRenderers } from '@jsonforms/material-renderers'; import { TagField, TagFieldType } from '../models/TagField'; import { TagFormPreviewDialog } from './TagFormPreview.component'; import { TagFieldGeneratorService } from '../services/tag-field-generator.service'; -import { useState } from 'react'; +import { useState, Dispatch, SetStateAction, useEffect } from 'react'; import { TagFieldView } from './TagField.component'; +import { TagSchema } from '../graphql/graphql'; type TagPreviewInformation = { previewDataSchema: any; @@ -21,7 +22,12 @@ type TagPreviewInformation = { renderers: any; }; -export const TagsDisplay: React.FC = () => { +export interface TagsDisplayProps { + tagSchema: TagSchema | null; + setTagSchema: Dispatch>; +} + +export const TagsDisplay: React.FC = (props) => { const [tagFields, setTagFields] = useState([]); const [data, setData] = useState({ previewDataSchema: {}, @@ -45,6 +51,18 @@ export const TagsDisplay: React.FC = () => { setValid([...valid]); }; + // Handling keeping track of complete tag schema + useEffect(() => { + if (valid.length === 0 || valid.includes(false)) { + return; + } + const schema = produceJSONForm(); + props.setTagSchema({ + dataSchema: schema.dataSchema, + uiSchema: schema.uiSchema + }); + }, [valid, tagFields]); + const produceJSONForm = () => { const dataSchema: { type: string; properties: any; required: string[] } = { type: 'object', diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index c1f9c254..aff0536f 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -16,3 +16,22 @@ query findStudies($project: ID!) { mutation deleteStudy($study: ID!) { deleteStudy(study: $study) } + +mutation createStudy($study: StudyCreate!) { + createStudy(study: $study) { + _id, + name, + description, + instructions, + project, + tagsPerEntry, + tagSchema { + dataSchema, + uiSchema + } + } +} + +query studyExists($name: String!, $project: ID!) { + studyExists(name: $name, project: $project) +} diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts index c8fd2e53..7d85de42 100644 --- a/packages/client/src/graphql/study/study.ts +++ b/packages/client/src/graphql/study/study.ts @@ -19,6 +19,21 @@ export type DeleteStudyMutationVariables = Types.Exact<{ export type DeleteStudyMutation = { __typename?: 'Mutation', deleteStudy: boolean }; +export type CreateStudyMutationVariables = Types.Exact<{ + study: Types.StudyCreate; +}>; + + +export type CreateStudyMutation = { __typename?: 'Mutation', createStudy: { __typename?: 'Study', _id: string, name: string, description: string, instructions: string, project: string, tagsPerEntry: number, tagSchema: { __typename?: 'TagSchema', dataSchema: any, uiSchema: any } } }; + +export type StudyExistsQueryVariables = Types.Exact<{ + name: Types.Scalars['String']['input']; + project: Types.Scalars['ID']['input']; +}>; + + +export type StudyExistsQuery = { __typename?: 'Query', studyExists: boolean }; + export const FindStudiesDocument = gql` query findStudies($project: ID!) { @@ -94,4 +109,80 @@ export function useDeleteStudyMutation(baseOptions?: Apollo.MutationHookOptions< } export type DeleteStudyMutationHookResult = ReturnType; export type DeleteStudyMutationResult = Apollo.MutationResult; -export type DeleteStudyMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file +export type DeleteStudyMutationOptions = Apollo.BaseMutationOptions; +export const CreateStudyDocument = gql` + mutation createStudy($study: StudyCreate!) { + createStudy(study: $study) { + _id + name + description + instructions + project + tagsPerEntry + tagSchema { + dataSchema + uiSchema + } + } +} + `; +export type CreateStudyMutationFn = Apollo.MutationFunction; + +/** + * __useCreateStudyMutation__ + * + * To run a mutation, you first call `useCreateStudyMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateStudyMutation` 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 [createStudyMutation, { data, loading, error }] = useCreateStudyMutation({ + * variables: { + * study: // value for 'study' + * }, + * }); + */ +export function useCreateStudyMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateStudyDocument, options); + } +export type CreateStudyMutationHookResult = ReturnType; +export type CreateStudyMutationResult = Apollo.MutationResult; +export type CreateStudyMutationOptions = Apollo.BaseMutationOptions; +export const StudyExistsDocument = gql` + query studyExists($name: String!, $project: ID!) { + studyExists(name: $name, project: $project) +} + `; + +/** + * __useStudyExistsQuery__ + * + * To run a query within a React component, call `useStudyExistsQuery` and pass it any options that fit your needs. + * When your component renders, `useStudyExistsQuery` 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 } = useStudyExistsQuery({ + * variables: { + * name: // value for 'name' + * project: // value for 'project' + * }, + * }); + */ +export function useStudyExistsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(StudyExistsDocument, options); + } +export function useStudyExistsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(StudyExistsDocument, options); + } +export type StudyExistsQueryHookResult = ReturnType; +export type StudyExistsLazyQueryHookResult = ReturnType; +export type StudyExistsQueryResult = Apollo.QueryResult; \ 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 d846a771..ba801f4f 100644 --- a/packages/client/src/pages/studies/NewStudy.tsx +++ b/packages/client/src/pages/studies/NewStudy.tsx @@ -2,17 +2,74 @@ import { Container, Typography, Button, Box, Stepper, Step, StepLabel } from '@m import { TagsDisplay } from '../../components/TagsDisplay.component'; import { NewStudyJsonForm } from '../../components/NewStudyJsonForm.component'; import { TagTrainingComponent } from '../../components/TagTraining.component'; -import { useState } from 'react'; +import { useState, useEffect } from 'react'; +import { StudyCreate, TagSchema } from '../../graphql/graphql'; +import { PartialStudyCreate } from '../../types/study'; +import { useCreateStudyMutation } from '../../graphql/study/study'; +import { useProject } from '../../context/Project.context'; +import { useStudy } from '../../context/Study.context'; export const NewStudy: React.FC = () => { - //all constants const [activeStep, setActiveStep] = useState(0); + const [stepLimit, setStepLimit] = useState(0); + const [partialNewStudy, setPartialNewStudy] = useState(null); + const [tagSchema, setTagSchema] = useState(null); + const { project } = useProject(); + const { updateStudies } = useStudy(); + + const [createStudyMutation, createStudyResults] = useCreateStudyMutation(); + + // Handles mantaining which step the user is on and the step limit + useEffect(() => { + if (!partialNewStudy) { + setStepLimit(0); + return; + } + + if (!tagSchema) { + setStepLimit(1); + return; + } + + // TODO: Future work will be done to add in the entry selection step + setStepLimit(3); + }, [partialNewStudy, tagSchema]); const handleNext = () => { + if (activeStep === stepLimit) { + return; + } + if (activeStep === steps.length - 1) { + // Make sure the required fields are present + if (!partialNewStudy || !tagSchema) { + console.error('Reached submission with invalid data'); + return; + } + // Make sure a project is selected + if (!project) { + 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) { + updateStudies(); + } + }, [createStudyResults.data]); + const handleBack = () => { + if (activeStep === 0) { + return; + } setActiveStep((prevActiveStep: number) => prevActiveStep - 1); }; @@ -22,18 +79,18 @@ export const NewStudy: React.FC = () => { const steps = ['Study Identification', 'Construct Tagging Interface', 'Select Tag Training Items']; - function getSectionComponent() { + const getSectionComponent = () => { switch (activeStep) { case 0: - return ; + return ; case 1: - return ; + return ; case 2: return ; default: return null; } - } + }; return ( @@ -67,7 +124,7 @@ export const NewStudy: React.FC = () => { - diff --git a/packages/client/src/types/study.ts b/packages/client/src/types/study.ts new file mode 100644 index 00000000..f4e37159 --- /dev/null +++ b/packages/client/src/types/study.ts @@ -0,0 +1,4 @@ +import { StudyCreate } from '../graphql/graphql'; + +/** Information to create a new study minus the schema */ +export interface PartialStudyCreate extends Omit {} diff --git a/packages/server/src/study/study.resolver.ts b/packages/server/src/study/study.resolver.ts index a26c2d3e..f2110f9c 100644 --- a/packages/server/src/study/study.resolver.ts +++ b/packages/server/src/study/study.resolver.ts @@ -37,9 +37,10 @@ export class StudyResolver { @Query(() => Boolean) async studyExists( @Args('name') name: string, - @Args('project', { type: () => ID }, ProjectPipe) project: Project + @Args('project', { type: () => ID }, ProjectPipe) project: Project, + @TokenContext() user: TokenPayload ): Promise { - if (!(await this.enforcer.enforce(name, StudyPermissions.READ, project))) { + if (!(await this.enforcer.enforce(user.id, StudyPermissions.READ, project._id.toString()))) { throw new UnauthorizedException('User cannot read studies on this project'); } @@ -53,8 +54,11 @@ export class StudyResolver { } @Mutation(() => Boolean) - async deleteStudy(@Args('study', { type: () => ID }, StudyPipe) study: Study): Promise { - if (!(await this.enforcer.enforce(study.name, StudyPermissions.DELETE, study._id))) { + async deleteStudy( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @TokenContext() user: TokenPayload + ): Promise { + if (!(await this.enforcer.enforce(user.id, StudyPermissions.DELETE, study._id))) { throw new UnauthorizedException('User cannot delete studies on this project'); } @@ -65,9 +69,10 @@ export class StudyResolver { @Mutation(() => Study) async changeStudyName( @Args('study', { type: () => ID }, StudyPipe) study: Study, - @Args('newName') newName: string + @Args('newName') newName: string, + @TokenContext() user: TokenPayload ): Promise { - if (!(await this.enforcer.enforce(study.name, StudyPermissions.UPDATE, study._id))) { + if (!(await this.enforcer.enforce(user.id, StudyPermissions.UPDATE, study._id))) { throw new UnauthorizedException('User cannot update studies on this project'); } @@ -77,9 +82,10 @@ export class StudyResolver { @Mutation(() => Study) async changeStudyDescription( @Args('study', { type: () => ID }, StudyPipe) study: Study, - @Args('newDescription') newDescription: string + @Args('newDescription') newDescription: string, + @TokenContext() user: TokenPayload ): Promise { - if (!(await this.enforcer.enforce(study.name, StudyPermissions.UPDATE, study._id))) { + if (!(await this.enforcer.enforce(user.id, StudyPermissions.UPDATE, study._id))) { throw new UnauthorizedException('User cannot update studies on this project'); }