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 });
}