diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx
index a51e646f..5d4d8e61 100644
--- a/packages/client/src/App.tsx
+++ b/packages/client/src/App.tsx
@@ -26,7 +26,8 @@ import { SideBar } from './components/SideBar.component';
import { ProjectProvider } from './context/Project.context';
import { ApolloClient, ApolloProvider, InMemoryCache, concat, createHttpLink } from '@apollo/client';
import { setContext } from '@apollo/client/link/context';
-import {StudyProvider} from './context/Study.context';
+import { StudyProvider } from './context/Study.context';
+import { ConfirmationProvider } from './context/Confirmation.context';
const drawerWidth = 256;
const Main = styled('main', { shouldForwardProp: (prop) => prop !== 'open' })<{
@@ -70,8 +71,10 @@ const App: FC = () => {
-
-
+
+
+
+
diff --git a/packages/client/src/context/Confirmation.context.tsx b/packages/client/src/context/Confirmation.context.tsx
new file mode 100644
index 00000000..e0fdaf1f
--- /dev/null
+++ b/packages/client/src/context/Confirmation.context.tsx
@@ -0,0 +1,69 @@
+import { createContext, useContext, useState } from 'react';
+import { Dialog, DialogContent, DialogTitle, DialogActions, Button, Typography } from '@mui/material';
+
+export interface ConfirmationRequest {
+ message: string;
+ title: string;
+ onConfirm: () => void;
+ onCancel: () => void;
+}
+
+export interface ConfirmationContextProps {
+ pushConfirmationRequest: (confirmationRequest: ConfirmationRequest) => void;
+}
+
+const ConfirmationContext = createContext({} as ConfirmationContextProps);
+
+export interface ConfirmationProviderProps {
+ children: React.ReactNode;
+}
+
+export const ConfirmationProvider: React.FC = ({ children }) => {
+ const [open, setOpen] = useState(false);
+ const [confirmationRequest, setConfirmationRequest] = useState(null);
+
+
+ const pushConfirmationRequest = (confirmationRequest: ConfirmationRequest) => {
+ setConfirmationRequest(confirmationRequest);
+ setOpen(true);
+ };
+
+ const handleConfirm = () => {
+ if (confirmationRequest) {
+ confirmationRequest.onConfirm();
+ }
+ setOpen(false);
+ };
+
+ const handleCancel = () => {
+ if (confirmationRequest) {
+ confirmationRequest.onCancel();
+ }
+ setOpen(false);
+ };
+
+
+ return (
+
+
+ {children}
+
+ );
+}
+
+export const useConfirmation = () => useContext(ConfirmationContext);
diff --git a/packages/client/src/context/Study.context.tsx b/packages/client/src/context/Study.context.tsx
index 2e1d64dc..b2d0ff07 100644
--- a/packages/client/src/context/Study.context.tsx
+++ b/packages/client/src/context/Study.context.tsx
@@ -7,6 +7,7 @@ export interface StudyContextProps {
study: Study | null;
setStudy: Dispatch>;
studies: Study[];
+ updateStudies: () => void;
}
const StudyContext = createContext({} as StudyContextProps);
@@ -23,6 +24,12 @@ export const StudyProvider: FC = (props) => {
const { project } = useProject();
+ const updateStudies = () => {
+ if (project) {
+ findStudiesResults.refetch({ project: project._id });
+ }
+ };
+
// Effect to re-query for studies
useEffect(() => {
if (!project) {
@@ -39,7 +46,7 @@ export const StudyProvider: FC = (props) => {
}
}, [findStudiesResults]);
- return {props.children};
+ return {props.children};
};
export const useStudy = () => useContext(StudyContext);
diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts
index 38b86997..5dd5ba12 100644
--- a/packages/client/src/graphql/graphql.ts
+++ b/packages/client/src/graphql/graphql.ts
@@ -296,6 +296,11 @@ export type MutationCreateTagsArgs = {
};
+export type MutationDeleteStudyArgs = {
+ study: Scalars['ID']['input'];
+};
+
+
export type MutationForgotPasswordArgs = {
user: ForgotDto;
};
diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql
index 1574ae94..c1f9c254 100644
--- a/packages/client/src/graphql/study/study.graphql
+++ b/packages/client/src/graphql/study/study.graphql
@@ -12,3 +12,7 @@ query findStudies($project: ID!) {
}
}
}
+
+mutation deleteStudy($study: ID!) {
+ deleteStudy(study: $study)
+}
diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts
index 1ed2cd74..c8fd2e53 100644
--- a/packages/client/src/graphql/study/study.ts
+++ b/packages/client/src/graphql/study/study.ts
@@ -12,6 +12,13 @@ export type FindStudiesQueryVariables = Types.Exact<{
export type FindStudiesQuery = { __typename?: 'Query', findStudies: Array<{ __typename?: 'Study', _id: string, name: string, description: string, instructions: string, project: string, tagsPerEntry: number, tagSchema: { __typename?: 'TagSchema', dataSchema: any, uiSchema: any } }> };
+export type DeleteStudyMutationVariables = Types.Exact<{
+ study: Types.Scalars['ID']['input'];
+}>;
+
+
+export type DeleteStudyMutation = { __typename?: 'Mutation', deleteStudy: boolean };
+
export const FindStudiesDocument = gql`
query findStudies($project: ID!) {
@@ -56,4 +63,35 @@ export function useFindStudiesLazyQuery(baseOptions?: Apollo.LazyQueryHookOption
}
export type FindStudiesQueryHookResult = ReturnType;
export type FindStudiesLazyQueryHookResult = ReturnType;
-export type FindStudiesQueryResult = Apollo.QueryResult;
\ No newline at end of file
+export type FindStudiesQueryResult = Apollo.QueryResult;
+export const DeleteStudyDocument = gql`
+ mutation deleteStudy($study: ID!) {
+ deleteStudy(study: $study)
+}
+ `;
+export type DeleteStudyMutationFn = Apollo.MutationFunction;
+
+/**
+ * __useDeleteStudyMutation__
+ *
+ * To run a mutation, you first call `useDeleteStudyMutation` within a React component and pass it any options that fit your needs.
+ * When your component renders, `useDeleteStudyMutation` 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 [deleteStudyMutation, { data, loading, error }] = useDeleteStudyMutation({
+ * variables: {
+ * study: // value for 'study'
+ * },
+ * });
+ */
+export function useDeleteStudyMutation(baseOptions?: Apollo.MutationHookOptions) {
+ const options = {...defaultOptions, ...baseOptions}
+ return Apollo.useMutation(DeleteStudyDocument, options);
+ }
+export type DeleteStudyMutationHookResult = ReturnType;
+export type DeleteStudyMutationResult = Apollo.MutationResult;
+export type DeleteStudyMutationOptions = Apollo.BaseMutationOptions;
\ No newline at end of file
diff --git a/packages/client/src/hooks/useProject.tsx b/packages/client/src/hooks/useProject.tsx
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/client/src/hooks/useVerifyEntry.tsx b/packages/client/src/hooks/useVerifyEntry.tsx
deleted file mode 100644
index e69de29b..00000000
diff --git a/packages/client/src/pages/studies/StudyControl.tsx b/packages/client/src/pages/studies/StudyControl.tsx
index c5b414d7..e22a1b79 100644
--- a/packages/client/src/pages/studies/StudyControl.tsx
+++ b/packages/client/src/pages/studies/StudyControl.tsx
@@ -1,13 +1,37 @@
import { Typography, Box } from '@mui/material';
import { useStudy } from '../../context/Study.context';
-import { DataGrid, GridColDef } from '@mui/x-data-grid';
+import { DataGrid, GridColDef, GridRowId } from '@mui/x-data-grid';
import DeleteIcon from '@mui/icons-material/DeleteOutlined';
import { GridActionsCellItem } from '@mui/x-data-grid-pro';
import { Study } from '../../graphql/graphql';
-
+import { useDeleteStudyMutation } from '../../graphql/study/study';
+import { useEffect } from 'react';
+import { useConfirmation } from '../../context/Confirmation.context';
export const StudyControl: React.FC = () => {
- const { studies } = useStudy();
+ const { studies, updateStudies } = useStudy();
+
+ const [deleteStudyMutation, deleteStudyResults] = useDeleteStudyMutation();
+ const confirmation = useConfirmation();
+
+ const handleDelete = async (id: GridRowId) => {
+ // Execute delete mutation
+ confirmation.pushConfirmationRequest({
+ title: 'Delete Study',
+ message: 'Are you sure you want to delete this study?',
+ onConfirm: () => {
+ deleteStudyMutation({ variables: { study: id.toString() } });
+ },
+ onCancel: () => {}
+ });
+ };
+
+ useEffect(() => {
+ if (deleteStudyResults.called && deleteStudyResults.data) {
+ updateStudies();
+ }
+ }, [deleteStudyResults.called, deleteStudyResults.data]);
+
const columns: GridColDef[] = [
{
field: 'name',
@@ -28,8 +52,8 @@ export const StudyControl: React.FC = () => {
width: 120,
maxWidth: 120,
cellClassName: 'delete',
- getActions: () => {
- return [} label="Delete" />];
+ getActions: (params) => {
+ return [} label="Delete" onClick={() => handleDelete(params.id)}/>];
}
}
];
diff --git a/packages/server/schema.gql b/packages/server/schema.gql
index 92f4564d..a90b0988 100644
--- a/packages/server/schema.gql
+++ b/packages/server/schema.gql
@@ -99,7 +99,7 @@ type Mutation {
signLabCreateProject(project: ProjectCreate!): Project!
deleteProject: Boolean!
createStudy(study: StudyCreate!): Study!
- deleteStudy: Boolean!
+ deleteStudy(study: ID!): Boolean!
changeStudyName(study: ID!, newName: String!): Study!
changeStudyDescription(study: ID!, newDescription: String!): Study!
createEntry(entry: EntryCreate!, dataset: ID!): Entry!
diff --git a/packages/server/src/app.module.ts b/packages/server/src/app.module.ts
index 2bec1f7d..1e049ca6 100644
--- a/packages/server/src/app.module.ts
+++ b/packages/server/src/app.module.ts
@@ -10,6 +10,7 @@ import { ProjectModule } from './project/project.module';
import { StudyModule } from './study/study.module';
import { EntryModule } from './entry/entry.module';
import { TagModule } from './tag/tag.module';
+import { SharedModule } from './shared/shared.module';
@Module({
imports: [
@@ -36,7 +37,8 @@ import { TagModule } from './tag/tag.module';
ProjectModule,
StudyModule,
EntryModule,
- TagModule
+ TagModule,
+ SharedModule
],
})
export class AppModule {}
diff --git a/packages/server/src/shared/service/mongoose-callback.service.ts b/packages/server/src/shared/service/mongoose-callback.service.ts
new file mode 100644
index 00000000..f0de004b
--- /dev/null
+++ b/packages/server/src/shared/service/mongoose-callback.service.ts
@@ -0,0 +1,78 @@
+import { Injectable } from '@nestjs/common';
+
+// TODO: The data is really generic and depends on the model
+type MiddlewareOperations = (data: any) => Promise;
+type SupportedOperations = 'deleteOne' | 'updateOne' | 'save';
+
+/**
+ * Mongoose supports middleware which can execute when certain operations
+ * take place. The downside is that the middleware needs to be registerd
+ * before the schema is compiled into the model. With NestJS, the model
+ * compilation takes place at the module level.
+ *
+ * For example, say a middleware is needed for the "deleteOne" operation on
+ * a study. The middleware could ideally be registered with the context of
+ * the study service. However, the study service would not be available
+ * yet since the middleware is registerd **before** the rest of the module
+ * is loaded.
+ *
+ * This serivce allows for middleware to be registerd independetly by only
+ * storing the callbacks. This service is then called by the Mongoose
+ * middleware.
+ *
+ *
+ * Mongoose Middleware:
+ * https://mongoosejs.com/docs/middleware.html
+ *
+ * NestJS MongooseModule:
+ * https://docs.nestjs.com/techniques/mongodb#hooks-middleware
+ */
+@Injectable()
+export class MongooseMiddlewareService {
+ /**
+ * Supports storing middleware operations. For example
+ *
+ * middlewareOperations = {
+ * 'Study': {
+ * 'deleteOne': [callback1, callback2],
+ * 'updateOne': [callback3]
+ * },
+ * 'Project': {
+ * 'deleteOne': [callback4]
+ * }
+ * }
+ */
+ private middlewareOperations: Map> = new Map();
+
+ register(modelName: string, operation: SupportedOperations, callback: MiddlewareOperations) {
+ // If the model is not already registered, create a new map
+ if (!this.middlewareOperations.has(modelName)) {
+ this.middlewareOperations.set(modelName, new Map());
+ }
+
+ // Get all supported operations for the model
+ const modelOperations = this.middlewareOperations.get(modelName)!;
+
+ // Update the list of callbacks for the operation
+ const callbacks = modelOperations.get(operation) || [];
+ callbacks.push(callback);
+ modelOperations.set(operation, callbacks);
+ }
+
+ async apply(modelName: string, operation: SupportedOperations, data: any): Promise {
+ // If there isn't any callbacks for the model, don't continue
+ if (!this.middlewareOperations.has(modelName)) {
+ return;
+ }
+
+ // Get the list of callbacks for the operation if there aren't any, don't continue
+ const modelOperations = this.middlewareOperations.get(modelName)!;
+ if (!modelOperations.has(operation)) {
+ return;
+ }
+
+ // Apply all callbacks
+ const callbacks = modelOperations.get(operation) || [];
+ await Promise.all(callbacks.map((callback) => callback(data)));
+ }
+}
diff --git a/packages/server/src/shared/shared.module.ts b/packages/server/src/shared/shared.module.ts
new file mode 100644
index 00000000..cb5cdc75
--- /dev/null
+++ b/packages/server/src/shared/shared.module.ts
@@ -0,0 +1,8 @@
+import { Module } from '@nestjs/common';
+import { MongooseMiddlewareService } from './service/mongoose-callback.service';
+
+@Module({
+ providers: [MongooseMiddlewareService],
+ exports: [MongooseMiddlewareService]
+})
+export class SharedModule {}
diff --git a/packages/server/src/study/study.module.ts b/packages/server/src/study/study.module.ts
index 90f73156..be1b9b29 100644
--- a/packages/server/src/study/study.module.ts
+++ b/packages/server/src/study/study.module.ts
@@ -6,9 +6,27 @@ import { Study, StudySchema } from './study.model';
import { ProjectModule } from '../project/project.module';
import { StudyPipe } from './pipes/study.pipe';
import { StudyCreatePipe } from './pipes/create.pipe';
+import { MongooseMiddlewareService } from '../shared/service/mongoose-callback.service';
+import { SharedModule } from '../shared/shared.module';
@Module({
- imports: [MongooseModule.forFeature([{ name: Study.name, schema: StudySchema }]), ProjectModule],
+ imports: [MongooseModule.forFeatureAsync([
+ {
+ name: Study.name,
+ useFactory: (middlewareService: MongooseMiddlewareService) => {
+ const schema = StudySchema;
+
+ schema.pre('deleteOne', async function () {
+ const study = await this.model.findOne(this.getQuery());
+ await middlewareService.apply(Study.name, 'deleteOne', study);
+ });
+
+ return schema;
+ },
+ imports: [SharedModule],
+ inject: [MongooseMiddlewareService],
+ }
+ ]), ProjectModule, SharedModule],
providers: [StudyService, StudyResolver, StudyPipe, StudyCreatePipe],
exports: [StudyService, StudyPipe]
})
diff --git a/packages/server/src/study/study.resolver.ts b/packages/server/src/study/study.resolver.ts
index 2d95a3a1..8bac41d5 100644
--- a/packages/server/src/study/study.resolver.ts
+++ b/packages/server/src/study/study.resolver.ts
@@ -29,7 +29,8 @@ export class StudyResolver {
}
@Mutation(() => Boolean)
- async deleteStudy(): Promise {
+ async deleteStudy(@Args('study', { type: () => ID }, StudyPipe) study: Study): Promise {
+ await this.studyService.delete(study);
return true;
}
diff --git a/packages/server/src/study/study.service.ts b/packages/server/src/study/study.service.ts
index 274cd54b..2726e68e 100644
--- a/packages/server/src/study/study.service.ts
+++ b/packages/server/src/study/study.service.ts
@@ -54,4 +54,8 @@ export class StudyService {
await this.studyModel.updateOne({ _id: study._id }, { $set: { description: newDescription } });
return (await this.findById(study._id))!;
}
+
+ async delete(study: Study): Promise {
+ await this.studyModel.deleteOne({ _id: study._id });
+ }
}
diff --git a/packages/server/src/tag/tag.module.ts b/packages/server/src/tag/tag.module.ts
index 3d7ca7e7..4fcd12ae 100644
--- a/packages/server/src/tag/tag.module.ts
+++ b/packages/server/src/tag/tag.module.ts
@@ -6,12 +6,14 @@ import { Tag, TagSchema } from './tag.model';
import { StudyModule } from '../study/study.module';
import { EntryModule } from '../entry/entry.module';
import { TagPipe } from './pipes/tag.pipe';
+import { SharedModule } from '../shared/shared.module';
@Module({
imports: [
MongooseModule.forFeature([{ name: Tag.name, schema: TagSchema }]),
StudyModule,
- EntryModule
+ EntryModule,
+ SharedModule
],
providers: [TagService, TagResolver, TagPipe]
})
diff --git a/packages/server/src/tag/tag.service.ts b/packages/server/src/tag/tag.service.ts
index 0d74098b..0deb844d 100644
--- a/packages/server/src/tag/tag.service.ts
+++ b/packages/server/src/tag/tag.service.ts
@@ -5,11 +5,18 @@ import { Model } from 'mongoose';
import { Study } from '../study/study.model';
import { Entry } from '../entry/entry.model';
import { StudyService } from '../study/study.service';
+import { MongooseMiddlewareService } from '../shared/service/mongoose-callback.service';
@Injectable()
export class TagService {
- constructor(@InjectModel(Tag.name) private readonly tagModel: Model, private readonly studyService: StudyService) {}
+ constructor(@InjectModel(Tag.name) private readonly tagModel: Model, private readonly studyService: StudyService,
+ middlewareService: MongooseMiddlewareService) {
+ // Subscribe to study delete events
+ middlewareService.register(Study.name, 'deleteOne', async (study: Study) => {
+ await this.removeByStudy(study);
+ });
+ }
async find(id: string): Promise {
return this.tagModel.findOne({ _id: id });
@@ -107,4 +114,8 @@ export class TagService {
private async getIncomplete(study: Study, user: string): Promise {
return this.tagModel.findOne({ study: study._id, user, complete: false, enabled: true });
}
+
+ private async removeByStudy(study: Study): Promise {
+ await this.tagModel.deleteMany({ study: study._id });
+ }
}