diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index 49ecdf72..ebc21d7f 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -101,6 +101,7 @@ "completed": "All steps completed - your new study is created", "createStudy": "Create New Study", "disableClear": "Disable Clear Button", + "showPriorCue": "Show Original Cue Sign", "disableSameUserTagging": "Disable users tagging entries they recorded", "formTitle": "Study Information", "noDatasets": "No datasets available, create a dataset and grant the project access under Datasets > Project Access", @@ -143,7 +144,9 @@ "succefullyCreated": "Successfully created!" }, "tagView": { - "originalEntry": "Original Entry" + "originalEntry": "Original Entry", + "originalCue": "Original Cue", + "responseToCue": "Response to Original Cue" }, "userPermissions": { "contributor": "Contributor", diff --git a/packages/client/src/components/EntryView.component.tsx b/packages/client/src/components/EntryView.component.tsx index 553ae1ef..5250e884 100644 --- a/packages/client/src/components/EntryView.component.tsx +++ b/packages/client/src/components/EntryView.component.tsx @@ -1,12 +1,17 @@ -import { Box } from '@mui/material'; -import { Entry } from '../graphql/graphql'; +import { Box, Grid, Typography } from '@mui/material'; import { VideoViewProps, VideoEntryView } from './VideoView.component'; +import { AssignTagMutation } from '../graphql/tag/tag'; +import { useTranslation } from 'react-i18next'; export interface EntryViewProps extends Omit { - entry: Entry; + entry: NonNullable['entry']; + showCue?: boolean; } export const EntryView: React.FC = (props) => { + if (props.showCue) { + return ; + } return getEntryView(props); }; @@ -28,3 +33,25 @@ const ImageEntryView: React.FC = (props) => { ); }; + +const ShowWithCue: React.FC = (props) => { + const { t } = useTranslation(); + const originalCue = props.entry.signlabRecording?.tag.entry; + + return ( + + + {t('components.tagView.originalCue')} + + + {originalCue ? getEntryView({ ...props, entry: originalCue }) : <>} + + + {t('components.tagView.responseToCue')} + + + {getEntryView({ ...props })} + + + ); +}; diff --git a/packages/client/src/components/NewStudyJsonForm.component.tsx b/packages/client/src/components/NewStudyJsonForm.component.tsx index 80ffc893..1a2a6ffb 100644 --- a/packages/client/src/components/NewStudyJsonForm.component.tsx +++ b/packages/client/src/components/NewStudyJsonForm.component.tsx @@ -47,7 +47,11 @@ export const NewStudyJsonForm: React.FC = (props) => { }, disableClear: { type: 'boolean', - default: 'false' + default: false + }, + showPriorCue: { + type: 'boolean', + default: false } } } @@ -93,6 +97,11 @@ export const NewStudyJsonForm: React.FC = (props) => { type: 'Control', label: t('components.newStudy.disableClear'), scope: '#/properties/studyConfig/properties/disableClear' + }, + { + type: 'Control', + label: t('components.newStudy.showPriorCue'), + scope: '#/properties/studyConfig/properties/showPriorCue' } ] }; diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index c57a0d6d..ea833ae6 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -651,6 +651,7 @@ export type QueryValidateCsvArgs = { export type SignLabRecorded = { __typename?: 'SignLabRecorded'; fieldName: Scalars['String']['output']; + tag: Tag; }; export type SliderField = { @@ -674,12 +675,14 @@ export type StudyConfig = { __typename?: 'StudyConfig'; disableClear?: Maybe; disableSameUserEntryTagging?: Maybe; + showPriorCue?: Maybe; sortByEntryID?: Maybe; }; export type StudyConfigInput = { disableClear?: InputMaybe; disableSameUserEntryTagging?: InputMaybe; + showPriorCue?: InputMaybe; sortByEntryID?: InputMaybe; }; diff --git a/packages/client/src/graphql/study/study.graphql b/packages/client/src/graphql/study/study.graphql index 8db1fb1a..87573756 100644 --- a/packages/client/src/graphql/study/study.graphql +++ b/packages/client/src/graphql/study/study.graphql @@ -14,6 +14,7 @@ query findStudies($project: ID!) { disableSameUserEntryTagging sortByEntryID disableClear + showPriorCue } } } diff --git a/packages/client/src/graphql/study/study.ts b/packages/client/src/graphql/study/study.ts index 551b8153..d4af5899 100644 --- a/packages/client/src/graphql/study/study.ts +++ b/packages/client/src/graphql/study/study.ts @@ -10,7 +10,7 @@ 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 }, studyConfig?: { __typename?: 'StudyConfig', disableSameUserEntryTagging?: boolean | null, sortByEntryID?: boolean | null, disableClear?: boolean | null } | null }> }; +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 }, studyConfig?: { __typename?: 'StudyConfig', disableSameUserEntryTagging?: boolean | null, sortByEntryID?: boolean | null, disableClear?: boolean | null, showPriorCue?: boolean | null } | null }> }; export type DeleteStudyMutationVariables = Types.Exact<{ study: Types.Scalars['ID']['input']; @@ -67,6 +67,7 @@ export const FindStudiesDocument = gql` disableSameUserEntryTagging sortByEntryID disableClear + showPriorCue } } } diff --git a/packages/client/src/graphql/tag/tag.graphql b/packages/client/src/graphql/tag/tag.graphql index 043b27cc..39598cdb 100644 --- a/packages/client/src/graphql/tag/tag.graphql +++ b/packages/client/src/graphql/tag/tag.graphql @@ -35,6 +35,24 @@ mutation assignTag($study: ID!) { signedUrl signedUrlExpiration isTraining + signlabRecording { + tag { + _id + entry { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + isTraining + } + } + } } } } diff --git a/packages/client/src/graphql/tag/tag.ts b/packages/client/src/graphql/tag/tag.ts index a7789b97..aaf24df6 100644 --- a/packages/client/src/graphql/tag/tag.ts +++ b/packages/client/src/graphql/tag/tag.ts @@ -50,7 +50,7 @@ export type AssignTagMutationVariables = Types.Exact<{ }>; -export type AssignTagMutation = { __typename?: 'Mutation', assignTag?: { __typename?: 'Tag', _id: string, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean } } | null }; +export type AssignTagMutation = { __typename?: 'Mutation', assignTag?: { __typename?: 'Tag', _id: string, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean, signlabRecording?: { __typename?: 'SignLabRecorded', tag: { __typename?: 'Tag', _id: string, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean } } } | null } } | null }; export type CompleteTagMutationVariables = Types.Exact<{ tag: Types.Scalars['ID']['input']; @@ -284,6 +284,24 @@ export const AssignTagDocument = gql` signedUrl signedUrlExpiration isTraining + signlabRecording { + tag { + _id + entry { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + isTraining + } + } + } } } } diff --git a/packages/client/src/pages/contribute/TaggingInterface.tsx b/packages/client/src/pages/contribute/TaggingInterface.tsx index 73fc8d34..7b2b7d80 100644 --- a/packages/client/src/pages/contribute/TaggingInterface.tsx +++ b/packages/client/src/pages/contribute/TaggingInterface.tsx @@ -1,4 +1,4 @@ -import { Box } from '@mui/material'; +import { Stack } from '@mui/material'; import { EntryView } from '../../components/EntryView.component'; import { TagForm } from '../../components/contribute/TagForm.component'; import { useStudy } from '../../context/Study.context'; @@ -58,7 +58,10 @@ const MainView: React.FC = (props) => { return ( <> {tag ? ( - + = (props) => { pauseFrame="start" mouseOverControls={false} displayControls={true} + showCue={!!props.study.studyConfig?.showPriorCue} /> - + ) : ( )} diff --git a/packages/server/src/entry/entry.module.ts b/packages/server/src/entry/entry.module.ts index 5a8d6148..90a1618e 100644 --- a/packages/server/src/entry/entry.module.ts +++ b/packages/server/src/entry/entry.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { Entry, EntrySchema } from './models/entry.model'; import { EntryResolver } from './resolvers/entry.resolver'; @@ -19,6 +19,8 @@ import { MongooseMiddlewareService } from '../shared/service/mongoose-callback.s import { SharedModule } from '../shared/shared.module'; import { OrganizationModule } from '../organization/organization.module'; import { BucketModule } from 'src/bucket/bucket.module'; +import { SignLabRecordingResolver } from './resolvers/signlab-recording.resolver'; +import { TagModule } from '../tag/tag.module'; @Module({ imports: [ @@ -46,7 +48,8 @@ import { BucketModule } from 'src/bucket/bucket.module'; PermissionModule, JwtModule, OrganizationModule, - BucketModule + BucketModule, + forwardRef(() => TagModule) ], providers: [ EntryResolver, @@ -57,7 +60,8 @@ import { BucketModule } from 'src/bucket/bucket.module'; UploadSessionResolver, UploadSessionPipe, EntryUploadService, - CsvValidationService + CsvValidationService, + SignLabRecordingResolver ], exports: [EntryPipe, EntriesPipe, EntryService] }) diff --git a/packages/server/src/entry/models/entry.model.ts b/packages/server/src/entry/models/entry.model.ts index 3e296045..606446e1 100644 --- a/packages/server/src/entry/models/entry.model.ts +++ b/packages/server/src/entry/models/entry.model.ts @@ -2,12 +2,14 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; import { ObjectType, Field, ID } from '@nestjs/graphql'; import mongoose, { Document } from 'mongoose'; import JSON from 'graphql-type-json'; +import { Tag } from '../../tag/models/tag.model'; @Schema() @ObjectType() export class SignLabRecorded { /** The tag the recording is associated with */ @Prop({ required: true }) + @Field(() => Tag) tag: string; /** The name of the field within the tag */ diff --git a/packages/server/src/entry/resolvers/entry.resolver.ts b/packages/server/src/entry/resolvers/entry.resolver.ts index 0089ab1f..c14e21dc 100644 --- a/packages/server/src/entry/resolvers/entry.resolver.ts +++ b/packages/server/src/entry/resolvers/entry.resolver.ts @@ -43,7 +43,7 @@ export class EntryResolver { async countEntryForDataset( @Args('dataset', { type: () => ID }, DatasetPipe) dataset: Dataset, @TokenContext() user: TokenPayload - ): Promise { + ): Promise { if (!(await this.enforcer.enforce(user.user_id, DatasetPermissions.READ, dataset._id.toString()))) { throw new UnauthorizedException('User cannot read entries on this dataset'); } diff --git a/packages/server/src/entry/resolvers/signlab-recording.resolver.ts b/packages/server/src/entry/resolvers/signlab-recording.resolver.ts new file mode 100644 index 00000000..351f3032 --- /dev/null +++ b/packages/server/src/entry/resolvers/signlab-recording.resolver.ts @@ -0,0 +1,21 @@ +import { NotFoundException, UseGuards } from '@nestjs/common'; +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Tag } from '../../tag/models/tag.model'; +import { JwtAuthGuard } from '../../jwt/jwt.guard'; +import { SignLabRecorded } from '../models/entry.model'; +import { TagService } from '../../tag/services/tag.service'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => SignLabRecorded) +export class SignLabRecordingResolver { + constructor(private readonly tagService: TagService) {} + + @ResolveField(() => Tag) + async tag(@Parent() signlabRecorded: SignLabRecorded): Promise { + const tag = await this.tagService.find(signlabRecorded.tag); + if (!tag) { + throw new NotFoundException(`Tag with id ${signlabRecorded.tag} not found`); + } + return tag; + } +} diff --git a/packages/server/src/entry/services/entry.service.ts b/packages/server/src/entry/services/entry.service.ts index 113e1bad..439c0634 100644 --- a/packages/server/src/entry/services/entry.service.ts +++ b/packages/server/src/entry/services/entry.service.ts @@ -49,7 +49,7 @@ export class EntryService { } async findForDataset(dataset: Dataset | string, page?: number, pageSize?: number): Promise { - let id: string = ''; + let id = ''; if (typeof dataset === 'string') { id = dataset; @@ -68,7 +68,7 @@ export class EntryService { } async countForDataset(dataset: Dataset | string) { - let id: string = ''; + let id = ''; if (typeof dataset === 'string') { id = dataset; diff --git a/packages/server/src/study/study.model.ts b/packages/server/src/study/study.model.ts index 90440f19..59ede4d7 100644 --- a/packages/server/src/study/study.model.ts +++ b/packages/server/src/study/study.model.ts @@ -43,6 +43,15 @@ export class StudyConfig { @Prop({ required: false }) @Field({ nullable: true }) disableClear?: boolean; + + /** + * If set, and if the entry prosented from the user was originally + * recorded in SignTag, the user will be presented with the cue + * entry that resulted in the entry they are now labeling + */ + @Prop({ required: false }) + @Field({ nullable: true }) + showPriorCue?: boolean; } const StudyConfigSchema = SchemaFactory.createForClass(StudyConfig); diff --git a/packages/server/src/study/study.resolver.ts b/packages/server/src/study/study.resolver.ts index cfac4afd..0f524718 100644 --- a/packages/server/src/study/study.resolver.ts +++ b/packages/server/src/study/study.resolver.ts @@ -43,7 +43,7 @@ export class StudyResolver { @Args('name') name: string, @Args('project', { type: () => ID }, ProjectPipe) project: Project, @TokenContext() user: TokenPayload - ): Promise { + ): Promise { if (!(await this.enforcer.enforce(user.user_id, StudyPermissions.READ, project._id.toString()))) { throw new UnauthorizedException('User cannot read studies on this project'); } diff --git a/packages/server/src/tag/resolvers/tag.resolver.ts b/packages/server/src/tag/resolvers/tag.resolver.ts index 315e8b14..c86ef74d 100644 --- a/packages/server/src/tag/resolvers/tag.resolver.ts +++ b/packages/server/src/tag/resolvers/tag.resolver.ts @@ -101,7 +101,7 @@ export class TagResolver { @Args('study', { type: () => ID }, StudyPipe) study: Study, @Args('entry', { type: () => ID }, EntryPipe) entry: Entry, @TokenContext() user: TokenPayload - ): Promise { + ): Promise { if (!(await this.enforcer.enforce(user.user_id, TagPermissions.READ, study._id.toString()))) { throw new UnauthorizedException('User cannot read tags in this study'); } @@ -125,7 +125,7 @@ export class TagResolver { async countTagForStudy( @Args('study', { type: () => ID }, StudyPipe) study: Study, @TokenContext() user: TokenPayload - ): Promise { + ): Promise { if (!(await this.enforcer.enforce(user.user_id, TagPermissions.READ, study._id.toString()))) { throw new UnauthorizedException('User cannot read tags in this study'); } @@ -153,7 +153,7 @@ export class TagResolver { @Args('study', { type: () => ID }, StudyPipe) study: Study, @Args('user') user: string, @TokenContext() requestingUser: TokenPayload - ): Promise { + ): Promise { if (!(await this.enforcer.enforce(requestingUser.user_id, TagPermissions.READ, study._id.toString()))) { throw new UnauthorizedException('User cannot read tags in this study'); } diff --git a/packages/server/src/tag/tag.module.ts b/packages/server/src/tag/tag.module.ts index d971b325..96d1f6c1 100644 --- a/packages/server/src/tag/tag.module.ts +++ b/packages/server/src/tag/tag.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { TagService } from './services/tag.service'; import { TagResolver } from './resolvers/tag.resolver'; import { MongooseModule } from '@nestjs/mongoose'; @@ -40,7 +40,7 @@ import { VideoFieldResolver } from './resolvers/video-field.resolver'; { name: VideoField.name, schema: VideoFieldSchema } ]), StudyModule, - EntryModule, + forwardRef(() => EntryModule), SharedModule, PermissionModule, GcpModule, diff --git a/packages/server/test/app.e2e-spec.ts b/packages/server/test/app.e2e-spec.ts index 50cda623..c3fb506a 100644 --- a/packages/server/test/app.e2e-spec.ts +++ b/packages/server/test/app.e2e-spec.ts @@ -8,7 +8,7 @@ describe('AppController (e2e)', () => { beforeEach(async () => { const moduleFixture: TestingModule = await Test.createTestingModule({ - imports: [AppModule], + imports: [AppModule] }).compile(); app = moduleFixture.createNestApplication(); @@ -16,9 +16,6 @@ describe('AppController (e2e)', () => { }); it('/ (GET)', () => { - return request(app.getHttpServer()) - .get('/') - .expect(200) - .expect('Hello World!'); + return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!'); }); });