Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' })<{
Expand Down Expand Up @@ -70,8 +71,10 @@ const App: FC = () => {
<BrowserRouter>
<ApolloProvider client={apolloClient}>
<AuthProvider>
<CssBaseline />
<AppInternal />
<ConfirmationProvider>
<CssBaseline />
<AppInternal />
</ConfirmationProvider>
</AuthProvider>
</ApolloProvider>
</BrowserRouter>
Expand Down
69 changes: 69 additions & 0 deletions packages/client/src/context/Confirmation.context.tsx
Original file line number Diff line number Diff line change
@@ -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<ConfirmationContextProps>({} as ConfirmationContextProps);

export interface ConfirmationProviderProps {
children: React.ReactNode;
}

export const ConfirmationProvider: React.FC<ConfirmationProviderProps> = ({ children }) => {
const [open, setOpen] = useState(false);
const [confirmationRequest, setConfirmationRequest] = useState<ConfirmationRequest | null>(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 (
<ConfirmationContext.Provider value={{ pushConfirmationRequest }}>
<Dialog
sx={{ '& .MuiDialog-paper': { width: '80%', maxHeight: 435 } }}
maxWidth="xs"
open={open}
>
<DialogTitle>{confirmationRequest && confirmationRequest.title}</DialogTitle>
<DialogContent>
<Typography>{confirmationRequest && confirmationRequest.message}</Typography>
</DialogContent>
<DialogActions>
<Button autoFocus onClick={handleCancel}>
Cancel
</Button>
<Button onClick={handleConfirm}>Ok</Button>
</DialogActions>
</Dialog>
{children}
</ConfirmationContext.Provider>
);
}

export const useConfirmation = () => useContext(ConfirmationContext);
9 changes: 8 additions & 1 deletion packages/client/src/context/Study.context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export interface StudyContextProps {
study: Study | null;
setStudy: Dispatch<SetStateAction<Study | null>>;
studies: Study[];
updateStudies: () => void;
}

const StudyContext = createContext<StudyContextProps>({} as StudyContextProps);
Expand All @@ -23,6 +24,12 @@ export const StudyProvider: FC<StudyProviderProps> = (props) => {

const { project } = useProject();

const updateStudies = () => {
if (project) {
findStudiesResults.refetch({ project: project._id });
}
};

// Effect to re-query for studies
useEffect(() => {
if (!project) {
Expand All @@ -39,7 +46,7 @@ export const StudyProvider: FC<StudyProviderProps> = (props) => {
}
}, [findStudiesResults]);

return <StudyContext.Provider value={{ study, setStudy, studies }}>{props.children}</StudyContext.Provider>;
return <StudyContext.Provider value={{ study, setStudy, studies, updateStudies }}>{props.children}</StudyContext.Provider>;
};

export const useStudy = () => useContext(StudyContext);
5 changes: 5 additions & 0 deletions packages/client/src/graphql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,11 @@ export type MutationCreateTagsArgs = {
};


export type MutationDeleteStudyArgs = {
study: Scalars['ID']['input'];
};


export type MutationForgotPasswordArgs = {
user: ForgotDto;
};
Expand Down
4 changes: 4 additions & 0 deletions packages/client/src/graphql/study/study.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,7 @@ query findStudies($project: ID!) {
}
}
}

mutation deleteStudy($study: ID!) {
deleteStudy(study: $study)
}
40 changes: 39 additions & 1 deletion packages/client/src/graphql/study/study.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!) {
Expand Down Expand Up @@ -56,4 +63,35 @@ export function useFindStudiesLazyQuery(baseOptions?: Apollo.LazyQueryHookOption
}
export type FindStudiesQueryHookResult = ReturnType<typeof useFindStudiesQuery>;
export type FindStudiesLazyQueryHookResult = ReturnType<typeof useFindStudiesLazyQuery>;
export type FindStudiesQueryResult = Apollo.QueryResult<FindStudiesQuery, FindStudiesQueryVariables>;
export type FindStudiesQueryResult = Apollo.QueryResult<FindStudiesQuery, FindStudiesQueryVariables>;
export const DeleteStudyDocument = gql`
mutation deleteStudy($study: ID!) {
deleteStudy(study: $study)
}
`;
export type DeleteStudyMutationFn = Apollo.MutationFunction<DeleteStudyMutation, DeleteStudyMutationVariables>;

/**
* __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<DeleteStudyMutation, DeleteStudyMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<DeleteStudyMutation, DeleteStudyMutationVariables>(DeleteStudyDocument, options);
}
export type DeleteStudyMutationHookResult = ReturnType<typeof useDeleteStudyMutation>;
export type DeleteStudyMutationResult = Apollo.MutationResult<DeleteStudyMutation>;
export type DeleteStudyMutationOptions = Apollo.BaseMutationOptions<DeleteStudyMutation, DeleteStudyMutationVariables>;
Empty file.
Empty file.
34 changes: 29 additions & 5 deletions packages/client/src/pages/studies/StudyControl.tsx
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -28,8 +52,8 @@ export const StudyControl: React.FC = () => {
width: 120,
maxWidth: 120,
cellClassName: 'delete',
getActions: () => {
return [<GridActionsCellItem icon={<DeleteIcon />} label="Delete" />];
getActions: (params) => {
return [<GridActionsCellItem icon={<DeleteIcon />} label="Delete" onClick={() => handleDelete(params.id)}/>];
}
}
];
Expand Down
2 changes: 1 addition & 1 deletion packages/server/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down
4 changes: 3 additions & 1 deletion packages/server/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -36,7 +37,8 @@ import { TagModule } from './tag/tag.module';
ProjectModule,
StudyModule,
EntryModule,
TagModule
TagModule,
SharedModule
],
})
export class AppModule {}
78 changes: 78 additions & 0 deletions packages/server/src/shared/service/mongoose-callback.service.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
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<string, Map<SupportedOperations, MiddlewareOperations[]>> = 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<void> {
// 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)));
}
}
8 changes: 8 additions & 0 deletions packages/server/src/shared/shared.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { MongooseMiddlewareService } from './service/mongoose-callback.service';

@Module({
providers: [MongooseMiddlewareService],
exports: [MongooseMiddlewareService]
})
export class SharedModule {}
20 changes: 19 additions & 1 deletion packages/server/src/study/study.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]
})
Expand Down
Loading