diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index 377d922a..f7831145 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -129,7 +129,8 @@ "redirectToOrg": "Redirect to Organization Login" }, "tagView": { - "originalEntry": "Original Entry" + "originalEntry": "Original Entry", + "export": "Export" } }, "errors": { diff --git a/packages/client/src/components/tag/view/AslLexGridView.component.tsx b/packages/client/src/components/tag/view/AslLexGridView.component.tsx index fae73e6a..b1aa86d5 100644 --- a/packages/client/src/components/tag/view/AslLexGridView.component.tsx +++ b/packages/client/src/components/tag/view/AslLexGridView.component.tsx @@ -1,21 +1,9 @@ import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; -import { useLexiconByKeyQuery } from '../../../graphql/lex'; -import { useEffect, useState } from 'react'; import { VideoEntryView } from '../../VideoView.component'; import i18next from 'i18next'; const AslLexGridViewVideo: React.FC = ({ data }) => { - const [videoUrl, setVideoUrl] = useState(null); - - const lexiconByKeyResult = useLexiconByKeyQuery({ - variables: { lexicon: import.meta.env.VITE_ASL_LEXICON_ID, key: data } - }); - - useEffect(() => { - if (lexiconByKeyResult.data) { - setVideoUrl(lexiconByKeyResult.data.lexiconByKey.video); - } - }, [lexiconByKeyResult]); + const videoUrl = data as string; return ( <> @@ -38,18 +26,7 @@ const AslLexGridViewKey: React.FC = ({ data }) => { }; const AslLexGridViewPrimary: React.FC = ({ data }) => { - const [primary, setPrimary] = useState(null); - - const lexiconByKeyResult = useLexiconByKeyQuery({ - variables: { lexicon: import.meta.env.VITE_ASL_LEXICON_ID, key: data } - }); - - useEffect(() => { - if (lexiconByKeyResult.data) { - setPrimary(lexiconByKeyResult.data.lexiconByKey.primary); - } - }, [lexiconByKeyResult]); - + const primary = data as string; return primary || ''; }; @@ -69,30 +46,24 @@ export const getAslLexCols: GetGridColDefs = (uischema, schema, property) => { { field: `${property}-video`, headerName: `${property}: ${i18next.t('common.video')}`, - width: 300, + width: 350, + valueGetter: (params) => params.row.data[property]?.field?.lexiconEntry.video, renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && }, { field: `${property}-key`, headerName: `${property}: ${i18next.t('common.key')}`, + valueGetter: (params) => params.row.data[property]?.field?.lexiconEntry.key, renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && }, { field: `${property}-primary`, headerName: `${property}: ${i18next.t('common.primary')}`, + valueGetter: (params) => params.row.data[property]?.field?.lexiconEntry.primary, renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && } ]; }; diff --git a/packages/client/src/components/tag/view/BooleanGridView.component.tsx b/packages/client/src/components/tag/view/BooleanGridView.component.tsx index 74a3b834..5f74876d 100644 --- a/packages/client/src/components/tag/view/BooleanGridView.component.tsx +++ b/packages/client/src/components/tag/view/BooleanGridView.component.tsx @@ -19,11 +19,9 @@ export const getBoolCols: GetGridColDefs = (uischema, schema, property) => { { field: property, headerName: property, + valueGetter: (params) => params.row.data[property]?.field?.boolValue, renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && } ]; }; diff --git a/packages/client/src/components/tag/view/FreeTextGridView.component.tsx b/packages/client/src/components/tag/view/FreeTextGridView.component.tsx index 509441c1..05d361b8 100644 --- a/packages/client/src/components/tag/view/FreeTextGridView.component.tsx +++ b/packages/client/src/components/tag/view/FreeTextGridView.component.tsx @@ -18,11 +18,9 @@ export const getTextCols: GetGridColDefs = (uischema, schema, property) => { { field: property, headerName: property, + valueGetter: (params) => params.row.data[property]?.field?.textValue, renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && } ]; }; diff --git a/packages/client/src/components/tag/view/NumericGridView.component.tsx b/packages/client/src/components/tag/view/NumericGridView.component.tsx index 2aa8b549..6a434363 100644 --- a/packages/client/src/components/tag/view/NumericGridView.component.tsx +++ b/packages/client/src/components/tag/view/NumericGridView.component.tsx @@ -18,11 +18,9 @@ export const getNumericCols: GetGridColDefs = (uischema, schema, property) => { { field: property, headerName: property, + valueGetter: (params) => params.row.data[property]?.field?.numericValue, renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && } ]; }; diff --git a/packages/client/src/components/tag/view/SliderGridView.component.tsx b/packages/client/src/components/tag/view/SliderGridView.component.tsx index 8b125950..df4c5325 100644 --- a/packages/client/src/components/tag/view/SliderGridView.component.tsx +++ b/packages/client/src/components/tag/view/SliderGridView.component.tsx @@ -18,11 +18,9 @@ export const getSliderCols: GetGridColDefs = (uischema, schema, property) => { { field: property, headerName: property, + valueGetter: (params) => params.row.data && params.row.data[property], renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + params.value && } ]; }; diff --git a/packages/client/src/components/tag/view/TagGridView.component.tsx b/packages/client/src/components/tag/view/TagGridView.component.tsx index 0c3e4d35..c0230897 100644 --- a/packages/client/src/components/tag/view/TagGridView.component.tsx +++ b/packages/client/src/components/tag/view/TagGridView.component.tsx @@ -1,7 +1,14 @@ import { useTranslation } from 'react-i18next'; import { GetGridColDefs, TagViewTest } from '../../../types/TagColumnView'; import { Entry, Study } from '../../../graphql/graphql'; -import { GridColDef, GridRenderCellParams, GridToolbar } from '@mui/x-data-grid'; +import { + GridColDef, + GridRenderCellParams, + GridToolbarColumnsButton, + GridToolbarContainer, + GridToolbarExport, + GridToolbarFilterButton +} from '@mui/x-data-grid'; import { DataGrid } from '@mui/x-data-grid'; import { GetTagsQuery, useRemoveTagMutation } from '../../../graphql/tag/tag'; import { freeTextTest, getTextCols } from './FreeTextGridView.component'; @@ -12,6 +19,7 @@ import { getSliderCols, sliderTest } from './SliderGridView.component'; import { getBoolCols, booleanTest } from './BooleanGridView.component'; import { aslLexTest, getAslLexCols } from './AslLexGridView.component'; import { getVideoCols, videoViewTest } from './VideoGridView.component'; +import { useEffect, useState } from 'react'; export interface TagGridViewProps { study: Study; @@ -19,9 +27,39 @@ export interface TagGridViewProps { refetchTags: () => void; } +/** + * The GridData represents how to get the tag into the grid view. The data type + * itself matches the tag query except the data field is represented as key value + * fields instead of a list of fields. + * + * So + * + * { + * data: [ + * { name: "property name a", ...fields } + * ] + * } + * + * Becomes + * + * { + * data: { + * "property name 1": { + * name: "property name 1", + * ...fields + * } + * } + * } + */ +interface GridData extends Omit { + data: { [property: string]: any } | null; +} + export const TagGridView: React.FC = ({ tags, study, refetchTags }) => { const { t } = useTranslation(); + const [gridData, setGridData] = useState<(GridData | null)[]>([]); + const tagColumnViews: { tester: TagViewTest; getGridColDefs: GetGridColDefs }[] = [ { tester: freeTextTest, getGridColDefs: getTextCols }, { tester: numericTest, getGridColDefs: getNumericCols }, @@ -31,11 +69,31 @@ export const TagGridView: React.FC = ({ tags, study, refetchTa { tester: videoViewTest, getGridColDefs: getVideoCols } ]; + useEffect(() => { + const newGridData: (GridData | null)[] = []; + + // This logic justs pulls out the fields from an array into an object + for (const tag of tags) { + const properties = {} as any; + + for (const property of Object.getOwnPropertyNames(study.tagSchema.dataSchema.properties)) { + properties[property] = tag.data!.find((row) => row.name === property); + } + + newGridData.push({ + ...tag, + data: properties + }); + } + + setGridData(newGridData); + }, [tags]); + const entryColumns: GridColDef[] = [ { field: 'entryView', headerName: t('components.tagView.originalEntry'), - width: 300, + width: 350, renderCell: (params: GridRenderCellParams) => } ]; @@ -101,10 +159,20 @@ export const TagGridView: React.FC = ({ tags, study, refetchTa return ( 'auto'} - rows={tags} + rows={gridData} columns={entryColumns.concat(tagMetaColumns).concat(dataColunms).concat(tagRedoColumns)} getRowId={(row) => row._id} - slots={{ toolbar: GridToolbar }} + slots={{ toolbar: TagToolbar }} /> ); }; + +const TagToolbar: React.FC = () => { + return ( + + + + + + ); +}; diff --git a/packages/client/src/components/tag/view/VideoGridView.component.tsx b/packages/client/src/components/tag/view/VideoGridView.component.tsx index 314b5c65..f28f2500 100644 --- a/packages/client/src/components/tag/view/VideoGridView.component.tsx +++ b/packages/client/src/components/tag/view/VideoGridView.component.tsx @@ -1,20 +1,11 @@ import { GridColDef } from '@mui/x-data-grid'; import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; import i18next from 'i18next'; -import { useEntryFromIdQuery } from '../../../graphql/entry/entry'; -import { Entry } from '../../../graphql/graphql'; -import { useEffect, useState } from 'react'; import { VideoEntryView } from '../../VideoView.component'; +import { Entry } from '../../../graphql/graphql'; const VideoGridView: React.FC = ({ data }) => { - const [entry, setEntry] = useState(null); - const entryFromIdResult = useEntryFromIdQuery({ variables: { entry: data } }); - - useEffect(() => { - if (entryFromIdResult.data) { - setEntry(entryFromIdResult.data.entryFromID); - } - }, [entryFromIdResult]); + const entry = data as Entry; return ( <> @@ -54,11 +45,8 @@ export const getVideoCols: GetGridColDefs = (uischema, schema, property) => { field: `${property}-video-${i + 1}`, headerName: `${property}: ${i18next.t('common.video')} ${i + 1}`, width: 350, - renderCell: (params) => - params.row.data && - params.row.data[property] && ( - - ) + valueGetter: (params) => params.row.data[property]?.field?.entries[i], + renderCell: (params) => params.value && }); } diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 5b994fe3..dfc746d2 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -18,6 +18,16 @@ export type Scalars = { JSON: { input: any; output: any; } }; +export type AslLexField = { + __typename?: 'AslLexField'; + lexiconEntry: LexiconEntry; +}; + +export type BooleanField = { + __typename?: 'BooleanField'; + value: Scalars['Boolean']['output']; +}; + export type Dataset = { __typename?: 'Dataset'; _id: Scalars['ID']['output']; @@ -52,6 +62,11 @@ export type Entry = { signedUrlExpiration: Scalars['Float']['output']; }; +export type FreeTextField = { + __typename?: 'FreeTextField'; + value: Scalars['String']['output']; +}; + /** Represents an entier lexicon */ export type Lexicon = { __typename?: 'Lexicon'; @@ -129,7 +144,7 @@ export type Mutation = { lexiconClearEntries: Scalars['Boolean']['output']; lexiconCreate: Lexicon; removeTag: Scalars['Boolean']['output']; - saveVideoField: VideoField; + saveVideoField: VideoFieldIntermediate; setEntryEnabled: Scalars['Boolean']['output']; signLabCreateProject: Project; }; @@ -300,6 +315,11 @@ export type MutationSignLabCreateProjectArgs = { project: ProjectCreate; }; +export type NumericField = { + __typename?: 'NumericField'; + value: Scalars['Float']['output']; +}; + export type Organization = { __typename?: 'Organization'; _id: Scalars['ID']['output']; @@ -485,6 +505,11 @@ export type QueryValidateCsvArgs = { session: Scalars['ID']['input']; }; +export type SliderField = { + __typename?: 'SliderField'; + value: Scalars['Float']['output']; +}; + export type Study = { __typename?: 'Study'; _id: Scalars['ID']['output']; @@ -520,8 +545,8 @@ export type Tag = { __typename?: 'Tag'; _id: Scalars['String']['output']; complete: Scalars['Boolean']['output']; - /** The data stored in the tag, not populated until a colaborator has tagged */ - data?: Maybe; + /** The data stored in the tag, not populated until a contributor has tagged */ + data?: Maybe>; /** If the tag is enabled as part of the study, way to disable certain tags */ enabled: Scalars['Boolean']['output']; entry: Entry; @@ -534,6 +559,26 @@ export type Tag = { user?: Maybe; }; +export type TagField = { + __typename?: 'TagField'; + field?: Maybe; + name: Scalars['String']['output']; + type: TagFieldType; +}; + +export enum TagFieldType { + AslLex = 'ASL_LEX', + Autocomplete = 'AUTOCOMPLETE', + Boolean = 'BOOLEAN', + Embedded = 'EMBEDDED', + FreeText = 'FREE_TEXT', + Numeric = 'NUMERIC', + Slider = 'SLIDER', + VideoRecord = 'VIDEO_RECORD' +} + +export type TagFieldUnion = AslLexField | BooleanField | FreeTextField | NumericField | SliderField | VideoField; + export type TagSchema = { __typename?: 'TagSchema'; dataSchema: Scalars['JSON']['output']; @@ -575,6 +620,11 @@ export type User = { export type VideoField = { __typename?: 'VideoField'; + entries: Array; +}; + +export type VideoFieldIntermediate = { + __typename?: 'VideoFieldIntermediate'; _id: Scalars['String']['output']; uploadURL: Scalars['String']['output']; }; diff --git a/packages/client/src/graphql/tag/tag.graphql b/packages/client/src/graphql/tag/tag.graphql index 37603904..27acf093 100644 --- a/packages/client/src/graphql/tag/tag.graphql +++ b/packages/client/src/graphql/tag/tag.graphql @@ -65,7 +65,55 @@ query getTags($study: ID!) { signedUrlExpiration isTraining } - data + data { + type + name + field { + __typename + + ... on AslLexField { + lexiconEntry { + key + primary + video + lexicon + associates + fields + } + } + + ... on VideoField { + entries { + _id + organization + entryID + contentType + creator + dateCreated + meta + signedUrl + signedUrlExpiration + isTraining + } + } + + ... on BooleanField { + boolValue: value + } + + ... on FreeTextField { + textValue: value + } + + ... on NumericField { + numericValue: value + } + + ... on SliderField { + sliderValue: value + } + } + } complete } } @@ -85,7 +133,55 @@ query getTrainingTags($study: ID!, $user: String!) { signedUrlExpiration isTraining } - data + data { + type + name + field { + __typename + + ... on AslLexField { + lexiconEntry { + key + primary + video + lexicon + associates + fields + } + } + + ... on VideoField { + entries { + _id + organization + entryID + contentType + creator + dateCreated + meta + signedUrl + signedUrlExpiration + isTraining + } + } + + ... on BooleanField { + boolValue: value + } + + ... on FreeTextField { + textValue: value + } + + ... on NumericField { + numericValue: value + } + + ... on SliderField { + sliderValue: value + } + } + } complete } } diff --git a/packages/client/src/graphql/tag/tag.ts b/packages/client/src/graphql/tag/tag.ts index 753448c9..27184d8d 100644 --- a/packages/client/src/graphql/tag/tag.ts +++ b/packages/client/src/graphql/tag/tag.ts @@ -67,14 +67,14 @@ export type SaveVideoFieldMutationVariables = Types.Exact<{ }>; -export type SaveVideoFieldMutation = { __typename?: 'Mutation', saveVideoField: { __typename?: 'VideoField', _id: string, uploadURL: string } }; +export type SaveVideoFieldMutation = { __typename?: 'Mutation', saveVideoField: { __typename?: 'VideoFieldIntermediate', _id: string, uploadURL: string } }; export type GetTagsQueryVariables = Types.Exact<{ study: Types.Scalars['ID']['input']; }>; -export type GetTagsQuery = { __typename?: 'Query', getTags: Array<{ __typename?: 'Tag', _id: string, data?: any | null, complete: boolean, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean } }> }; +export type GetTagsQuery = { __typename?: 'Query', getTags: Array<{ __typename?: 'Tag', _id: string, complete: boolean, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean }, data?: Array<{ __typename?: 'TagField', type: Types.TagFieldType, name: string, field?: { __typename: 'AslLexField', lexiconEntry: { __typename?: 'LexiconEntry', key: string, primary: string, video: string, lexicon: string, associates: Array, fields: any } } | { __typename: 'BooleanField', boolValue: boolean } | { __typename: 'FreeTextField', textValue: string } | { __typename: 'NumericField', numericValue: number } | { __typename: 'SliderField', sliderValue: number } | { __typename: 'VideoField', entries: Array<{ __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean }> } | null }> | null }> }; export type GetTrainingTagsQueryVariables = Types.Exact<{ study: Types.Scalars['ID']['input']; @@ -82,7 +82,7 @@ export type GetTrainingTagsQueryVariables = Types.Exact<{ }>; -export type GetTrainingTagsQuery = { __typename?: 'Query', getTrainingTags: Array<{ __typename?: 'Tag', _id: string, data?: any | null, complete: boolean, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean } }> }; +export type GetTrainingTagsQuery = { __typename?: 'Query', getTrainingTags: Array<{ __typename?: 'Tag', _id: string, complete: boolean, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean }, data?: Array<{ __typename?: 'TagField', type: Types.TagFieldType, name: string, field?: { __typename: 'AslLexField', lexiconEntry: { __typename?: 'LexiconEntry', key: string, primary: string, video: string, lexicon: string, associates: Array, fields: any } } | { __typename: 'BooleanField', boolValue: boolean } | { __typename: 'FreeTextField', textValue: string } | { __typename: 'NumericField', numericValue: number } | { __typename: 'SliderField', sliderValue: number } | { __typename: 'VideoField', entries: Array<{ __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number, isTraining: boolean }> } | null }> | null }> }; export const CreateTagsDocument = gql` @@ -379,7 +379,49 @@ export const GetTagsDocument = gql` signedUrlExpiration isTraining } - data + data { + type + name + field { + __typename + ... on AslLexField { + lexiconEntry { + key + primary + video + lexicon + associates + fields + } + } + ... on VideoField { + entries { + _id + organization + entryID + contentType + creator + dateCreated + meta + signedUrl + signedUrlExpiration + isTraining + } + } + ... on BooleanField { + boolValue: value + } + ... on FreeTextField { + textValue: value + } + ... on NumericField { + numericValue: value + } + ... on SliderField { + sliderValue: value + } + } + } complete } } @@ -428,7 +470,49 @@ export const GetTrainingTagsDocument = gql` signedUrlExpiration isTraining } - data + data { + type + name + field { + __typename + ... on AslLexField { + lexiconEntry { + key + primary + video + lexicon + associates + fields + } + } + ... on VideoField { + entries { + _id + organization + entryID + contentType + creator + dateCreated + meta + signedUrl + signedUrlExpiration + isTraining + } + } + ... on BooleanField { + boolValue: value + } + ... on FreeTextField { + textValue: value + } + ... on NumericField { + numericValue: value + } + ... on SliderField { + sliderValue: value + } + } + } complete } } diff --git a/packages/server/src/config/configuration.ts b/packages/server/src/config/configuration.ts index 82b28685..5c86526c 100644 --- a/packages/server/src/config/configuration.ts +++ b/packages/server/src/config/configuration.ts @@ -36,5 +36,8 @@ export default () => ({ videoRecordFileType: 'webm', videoUploadExpiration: process.env.TAG_VIDEO_UPLOAD_EXPIRATION || 15 * 60 * 1000, // 15 minutes trainingPrefix: process.env.TAG_TRAINING_PREFIX || 'training' + }, + lexicon: { + aslLexID: process.env.ASL_LEX_LEXICON_ID || '64e4e63ecade2ec090d6765e' } }); diff --git a/packages/server/src/tag/models/asl-lex-field.model.ts b/packages/server/src/tag/models/asl-lex-field.model.ts new file mode 100644 index 00000000..f8408843 --- /dev/null +++ b/packages/server/src/tag/models/asl-lex-field.model.ts @@ -0,0 +1,29 @@ +import { Field, ObjectType, Directive } from '@nestjs/graphql'; + +@ObjectType() +@Directive('@key(fields: "key, lexicon")') +@Directive('@extends') +export class LexiconEntry { + @Field() + @Directive('@external') + key: string; + + @Field() + @Directive('@external') + lexicon: string; + + constructor(key: string, lexicon: string) { + this.key = key; + this.lexicon = lexicon; + } +} + +@ObjectType() +export class AslLexField { + @Field(() => LexiconEntry) + lexiconEntry: LexiconEntry; + + constructor(lexiconEntry: LexiconEntry) { + this.lexiconEntry = lexiconEntry; + } +} diff --git a/packages/server/src/tag/models/autocomplete-field.model.ts b/packages/server/src/tag/models/autocomplete-field.model.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/server/src/tag/models/boolean-field.model.ts b/packages/server/src/tag/models/boolean-field.model.ts new file mode 100644 index 00000000..4bf796a5 --- /dev/null +++ b/packages/server/src/tag/models/boolean-field.model.ts @@ -0,0 +1,11 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class BooleanField { + @Field() + value: boolean; + + constructor(value: boolean) { + this.value = value; + } +} diff --git a/packages/server/src/tag/models/embedded-field.model.ts b/packages/server/src/tag/models/embedded-field.model.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/server/src/tag/models/free-text-field.model.ts b/packages/server/src/tag/models/free-text-field.model.ts new file mode 100644 index 00000000..3abdeb02 --- /dev/null +++ b/packages/server/src/tag/models/free-text-field.model.ts @@ -0,0 +1,11 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class FreeTextField { + @Field() + value: string; + + constructor(value: string) { + this.value = value; + } +} diff --git a/packages/server/src/tag/models/numeric-field.model.ts b/packages/server/src/tag/models/numeric-field.model.ts new file mode 100644 index 00000000..f3ec2c02 --- /dev/null +++ b/packages/server/src/tag/models/numeric-field.model.ts @@ -0,0 +1,11 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class NumericField { + @Field() + value: number; + + constructor(value: number) { + this.value = value; + } +} diff --git a/packages/server/src/tag/models/slider-field.model.ts b/packages/server/src/tag/models/slider-field.model.ts new file mode 100644 index 00000000..b2f4a80a --- /dev/null +++ b/packages/server/src/tag/models/slider-field.model.ts @@ -0,0 +1,11 @@ +import { ObjectType, Field } from '@nestjs/graphql'; + +@ObjectType() +export class SliderField { + @Field() + value: number; + + constructor(value: number) { + this.value = value; + } +} diff --git a/packages/server/src/tag/models/tag-field.model.ts b/packages/server/src/tag/models/tag-field.model.ts new file mode 100644 index 00000000..298181f1 --- /dev/null +++ b/packages/server/src/tag/models/tag-field.model.ts @@ -0,0 +1,56 @@ +import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; +import { createUnionType, Field, ObjectType, registerEnumType } from '@nestjs/graphql'; +import mongoose from 'mongoose'; +import { VideoField } from './video-field.model'; +import { FreeTextField } from './free-text-field.model'; +import { BooleanField } from './boolean-field.model'; +import { NumericField } from './numeric-field.model'; +import { SliderField } from './slider-field.model'; +import { AslLexField } from './asl-lex-field.model'; + +export enum TagFieldType { + ASL_LEX = 'ASL_LEX', + AUTOCOMPLETE = 'AUTOCOMPLETE', + BOOLEAN = 'BOOLEAN', + EMBEDDED = 'EMBEDDED', + FREE_TEXT = 'FREE_TEXT', + NUMERIC = 'NUMERIC', + SLIDER = 'SLIDER', + VIDEO_RECORD = 'VIDEO_RECORD' +} + +registerEnumType(TagFieldType, { + name: 'TagFieldType' +}); + +export const TagFieldUnion = createUnionType({ + name: 'TagFieldUnion', + types: () => [AslLexField, BooleanField, FreeTextField, NumericField, SliderField, VideoField] as const +}); + +@Schema() +@ObjectType() +export class TagField { + /** + * Used to determine what kind of field this tag field represents + */ + @Prop({ required: true, enum: TagFieldType }) + @Field(() => TagFieldType) + type: TagFieldType; + + @Prop({ required: true }) + @Field() + name: string; + + /** + * Holds the data itself, this can be an ID referencing a more complex + * object or be the value itself. A factory method exists for converting + * the data into the field the user is querying for + */ + @Prop({ required: false, type: mongoose.Schema.Types.Mixed }) + data: any; + + /* Not shown is a resolve field for the representation of the data */ +} + +export const TagFieldSchema = SchemaFactory.createForClass(TagField); diff --git a/packages/server/src/tag/models/tag.model.ts b/packages/server/src/tag/models/tag.model.ts index 78408a0c..738642e5 100644 --- a/packages/server/src/tag/models/tag.model.ts +++ b/packages/server/src/tag/models/tag.model.ts @@ -1,9 +1,9 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; import { Field, ObjectType } from '@nestjs/graphql'; -import JSON from 'graphql-type-json'; -import mongoose, { Document } from 'mongoose'; +import { Document } from 'mongoose'; import { Study } from '../../study/study.model'; import { Entry } from '../../entry/models/entry.model'; +import { TagFieldSchema, TagField } from './tag-field.model'; @Schema() @ObjectType() @@ -27,12 +27,12 @@ export class Tag { @Field({ nullable: true, description: 'The user assigned to the tag ' }) user?: string; - @Prop({ requried: false, type: mongoose.Schema.Types.Mixed }) - @Field(() => JSON, { + @Prop({ requried: false, type: [TagFieldSchema] }) + @Field(() => [TagField], { nullable: true, - description: 'The data stored in the tag, not populated until a colaborator has tagged' + description: 'The data stored in the tag, not populated until a contributor has tagged' }) - data?: any; + data?: TagField[]; @Prop() @Field({ description: 'Way to rank tags based on order to be tagged' }) diff --git a/packages/server/src/tag/models/video-field-inter.model.ts b/packages/server/src/tag/models/video-field-inter.model.ts new file mode 100644 index 00000000..6df544c0 --- /dev/null +++ b/packages/server/src/tag/models/video-field-inter.model.ts @@ -0,0 +1,37 @@ +import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; +import { Field, ObjectType } from '@nestjs/graphql'; +import { Document } from 'mongoose'; + +/** + * Represents a single video field in a study. This is used for temporarily + * storing the recording video data before the tag is submitted and the video + * is turned into an Entry. + */ +@Schema() +@ObjectType() +export class VideoFieldIntermediate { + @Field() + _id: string; + + /** The tag the video field is a part of */ + @Prop() + tag: string; + + /** The field of the tag the video field is a part of */ + @Prop() + field: string; + + /** The index of the video field in the tag */ + @Prop() + index: number; + + /** Where within the bucket the video is stored */ + @Prop() + bucketLocation: string; + + @Prop() + organization: string; +} + +export type VideoFieldIntermediateDocument = VideoFieldIntermediate & Document; +export const VideoFieldIntermediateSchema = SchemaFactory.createForClass(VideoFieldIntermediate); diff --git a/packages/server/src/tag/models/video-field.model.ts b/packages/server/src/tag/models/video-field.model.ts index 8fb1f084..0f2f5b8b 100644 --- a/packages/server/src/tag/models/video-field.model.ts +++ b/packages/server/src/tag/models/video-field.model.ts @@ -1,36 +1,20 @@ import { Schema, Prop, SchemaFactory } from '@nestjs/mongoose'; -import { Field, ObjectType } from '@nestjs/graphql'; +import { ObjectType, Field } from '@nestjs/graphql'; +import { Entry } from 'src/entry/models/entry.model'; import { Document } from 'mongoose'; @Schema() @ObjectType() -/** - * Represents a single video field in a study. This is used for temporarily - * storing the recording video data before the tag is submitted and the video - * is turned into an Entry. - */ export class VideoField { - @Field() _id: string; - /** The tag the video field is a part of */ + @Field(() => [Entry]) @Prop() - tag: string; + entries: string[]; - /** The field of the tag the video field is a part of */ - @Prop() - field: string; - - /** The index of the video field in the tag */ - @Prop() - index: number; - - /** Where within the bucket the video is stored */ - @Prop() - bucketLocation: string; - - @Prop() - organization: string; + constructor(obj: any) { + Object.assign(this, obj); + } } export type VideoFieldDocument = VideoField & Document; diff --git a/packages/server/src/tag/resolvers/tag-field.resolver.ts b/packages/server/src/tag/resolvers/tag-field.resolver.ts new file mode 100644 index 00000000..03abebf2 --- /dev/null +++ b/packages/server/src/tag/resolvers/tag-field.resolver.ts @@ -0,0 +1,16 @@ +import { TagField, TagFieldType, TagFieldUnion } from '../models/tag-field.model'; +import { ResolveField, Parent, Resolver } from '@nestjs/graphql'; +import { UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../../jwt/jwt.guard'; +import { TagFieldService } from '../services/tag-field.service'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => TagField) +export class TagFieldResolver { + constructor(private readonly tagFieldService: TagFieldService) {} + + @ResolveField(() => TagFieldUnion, { nullable: true }) + async field(@Parent() tagField: TagField): Promise { + return this.tagFieldService.produceField(tagField); + } +} diff --git a/packages/server/src/tag/resolvers/video-field-inter.resolver.ts b/packages/server/src/tag/resolvers/video-field-inter.resolver.ts new file mode 100644 index 00000000..54257949 --- /dev/null +++ b/packages/server/src/tag/resolvers/video-field-inter.resolver.ts @@ -0,0 +1,55 @@ +import { Resolver, Args, Mutation, ID, ResolveField, Parent, Int } from '@nestjs/graphql'; +import { VideoFieldIntermediateService } from '../services/video-field-inter.service'; +import { TagPipe } from '../pipes/tag.pipe'; +import { Tag } from '../models/tag.model'; +import { VideoFieldIntermediate } from '../models/video-field-inter.model'; +import { TokenContext } from '../../jwt/token.context'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Inject, UnauthorizedException, UseGuards } from '@nestjs/common'; +import { CASBIN_PROVIDER } from '../../permission/casbin.provider'; +import * as casbin from 'casbin'; +import { TagPermissions } from '../../permission/permissions/tag'; +import { JwtAuthGuard } from '../../jwt/jwt.guard'; + +@UseGuards(JwtAuthGuard) +@Resolver(() => VideoFieldIntermediate) +export class VideoFieldIntermediateResolver { + constructor( + private readonly videoFieldService: VideoFieldIntermediateService, + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer, + private readonly tagPipe: TagPipe + ) {} + + @Mutation(() => VideoFieldIntermediate) + async saveVideoField( + @Args('tag', { type: () => ID }, TagPipe) tag: Tag, + @Args('field') field: string, + @Args('index', { type: () => Int }) index: number, + @TokenContext() user: TokenPayload + ): Promise { + // Make sure the user first has permission to create video fields for this tag + if (!(await this.enforcer.enforce(user.user_id, TagPermissions.CREATE, tag.study.toString()))) { + throw new UnauthorizedException('User does not have permission to create video fields for this tag'); + } + + // Make sure its the user assigned to the tag + if (user.user_id !== tag.user?.toString()) { + throw new UnauthorizedException('User does not have permission to create video fields for this tag'); + } + + return this.videoFieldService.saveVideoField(tag, field, index); + } + + @ResolveField(() => String) + async uploadURL(@Parent() videoField: VideoFieldIntermediate, @TokenContext() user: TokenPayload): Promise { + const tag = await this.tagPipe.transform(videoField.tag); + if (!tag) { + throw new Error(`Tag ${videoField.tag} not found`); + } + if (!(await this.enforcer.enforce(user.user_id, TagPermissions.CREATE, tag.study.toString()))) { + throw new UnauthorizedException('User does not have permission to create video fields for this tag'); + } + + return this.videoFieldService.getUploadURL(videoField); + } +} diff --git a/packages/server/src/tag/resolvers/video-field.resolver.ts b/packages/server/src/tag/resolvers/video-field.resolver.ts index b905b2cf..240863d6 100644 --- a/packages/server/src/tag/resolvers/video-field.resolver.ts +++ b/packages/server/src/tag/resolvers/video-field.resolver.ts @@ -1,55 +1,22 @@ -import { Resolver, Args, Mutation, ID, ResolveField, Parent, Int } from '@nestjs/graphql'; -import { VideoFieldService } from '../services/video-field.service'; -import { TagPipe } from '../pipes/tag.pipe'; -import { Tag } from '../models/tag.model'; +import { Parent, ResolveField, Resolver } from '@nestjs/graphql'; +import { Entry } from 'src/entry/models/entry.model'; +import { EntryService } from 'src/entry/services/entry.service'; import { VideoField } from '../models/video-field.model'; -import { TokenContext } from '../../jwt/token.context'; -import { TokenPayload } from '../../jwt/token.dto'; -import { Inject, UnauthorizedException, UseGuards } from '@nestjs/common'; -import { CASBIN_PROVIDER } from '../../permission/casbin.provider'; -import * as casbin from 'casbin'; -import { TagPermissions } from '../../permission/permissions/tag'; -import { JwtAuthGuard } from '../../jwt/jwt.guard'; -@UseGuards(JwtAuthGuard) @Resolver(() => VideoField) export class VideoFieldResolver { - constructor( - private readonly videoFieldService: VideoFieldService, - @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer, - private readonly tagPipe: TagPipe - ) {} + constructor(private readonly entryService: EntryService) {} - @Mutation(() => VideoField) - async saveVideoField( - @Args('tag', { type: () => ID }, TagPipe) tag: Tag, - @Args('field') field: string, - @Args('index', { type: () => Int }) index: number, - @TokenContext() user: TokenPayload - ): Promise { - // Make sure the user first has permission to create video fields for this tag - if (!(await this.enforcer.enforce(user.user_id, TagPermissions.CREATE, tag.study.toString()))) { - throw new UnauthorizedException('User does not have permission to create video fields for this tag'); - } - - // Make sure its the user assigned to the tag - if (user.user_id !== tag.user?.toString()) { - throw new UnauthorizedException('User does not have permission to create video fields for this tag'); - } - - return this.videoFieldService.saveVideoField(tag, field, index); - } - - @ResolveField(() => String) - async uploadURL(@Parent() videoField: VideoField, @TokenContext() user: TokenPayload): Promise { - const tag = await this.tagPipe.transform(videoField.tag); - if (!tag) { - throw new Error(`Tag ${videoField.tag} not found`); - } - if (!(await this.enforcer.enforce(user.user_id, TagPermissions.CREATE, tag.study.toString()))) { - throw new UnauthorizedException('User does not have permission to create video fields for this tag'); - } - - return this.videoFieldService.getUploadURL(videoField); + @ResolveField(() => [Entry]) + async entries(@Parent() videoField: VideoField): Promise { + return Promise.all( + videoField.entries.map(async (id) => { + const entry = await this.entryService.find(id); + if (!entry) { + throw new Error(`Invalid entry id: ${id}`); + } + return entry; + }) + ); } } diff --git a/packages/server/src/tag/services/tag-field.service.ts b/packages/server/src/tag/services/tag-field.service.ts new file mode 100644 index 00000000..d405d598 --- /dev/null +++ b/packages/server/src/tag/services/tag-field.service.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { TagField, TagFieldUnion, TagFieldType } from '../models/tag-field.model'; +import { BooleanField } from '../models/boolean-field.model'; +import { FreeTextField } from '../models/free-text-field.model'; +import { NumericField } from '../models/numeric-field.model'; +import { SliderField } from '../models/slider-field.model'; +import { VideoField } from '../models/video-field.model'; +import { AslLexField, LexiconEntry } from '../models/asl-lex-field.model'; +import { ConfigService } from '@nestjs/config'; +import { VideoFieldService } from './video-field.service'; + +/** + * Handles turning the rawdata fields into TagFields + */ +@Injectable() +export class TagFieldService { + private readonly aslLexID = this.configService.getOrThrow('lexicon.aslLexID'); + + constructor(private readonly configService: ConfigService, private readonly videoFieldService: VideoFieldService) {} + + async produceField(tagField: TagField): Promise { + if (!tagField.data) { + return null; + } + switch (tagField.type) { + case TagFieldType.ASL_LEX: + return this.getAslLexField(tagField); + case TagFieldType.BOOLEAN: + return new BooleanField(tagField.data); + case TagFieldType.FREE_TEXT: + return new FreeTextField(tagField.data); + case TagFieldType.NUMERIC: + return new NumericField(tagField.data); + case TagFieldType.SLIDER: + return new SliderField(tagField.data); + case TagFieldType.VIDEO_RECORD: + return this.getVideoField(tagField); + default: + throw new Error(`Unsupported tag field type: ${tagField.type}`); + } + } + + private async getVideoField(tagField: TagField): Promise { + // The GraphQL union is resolved based on the class name, so a concrete object + // needs to be made from the document result + const videoFieldRaw = await this.videoFieldService.find(tagField.data); + if (!videoFieldRaw) { + return null; + } + const videoField = new VideoField((videoFieldRaw as any).toObject()); + + return videoField; + } + + private async getAslLexField(tagField: TagField): Promise { + const key = tagField.data as string; + const lexicon = this.aslLexID; + return new AslLexField(new LexiconEntry(key, lexicon)); + } +} diff --git a/packages/server/src/tag/services/tag-transformer.service.ts b/packages/server/src/tag/services/tag-transformer.service.ts index eb9a0162..1beb667c 100644 --- a/packages/server/src/tag/services/tag-transformer.service.ts +++ b/packages/server/src/tag/services/tag-transformer.service.ts @@ -3,6 +3,7 @@ import { Study } from '../../study/study.model'; import { FieldTransformerFactory } from '../transformers/field-transformer-factory'; import { TokenPayload } from '../../jwt/token.dto'; import { Tag } from '../../tag/models/tag.model'; +import { TagField } from '../models/tag-field.model'; @Injectable() export class TagTransformer { @@ -12,9 +13,7 @@ export class TagTransformer { * Transforms the tag data. Takes in the whole tag and produces the modified * tag data. */ - async transformTagData(tag: Tag, data: any, study: Study, user: TokenPayload): Promise { - const transformedData: { [property: string]: any } = {}; - + async transformTagData(tag: Tag, data: any, study: Study, user: TokenPayload): Promise { const schema = study.tagSchema.dataSchema; const uischema = study.tagSchema.uiSchema; @@ -22,6 +21,8 @@ export class TagTransformer { return data; } + const fields: TagField[] = []; + for (const field in schema.properties) { // Get the schema and ui schema for the field const fieldSchema = schema.properties[field]; @@ -35,13 +36,15 @@ export class TagTransformer { // Try to get the transformer for the field const transformer = this.fieldTransformerFactory.getTransformer(fieldUiSchema, fieldSchema); + if (!transformer) { + throw new Error(`Unsupported field type for field ${field}`); + } + // Apply the transformation if present, otherwise just return the data - const transformed = transformer - ? await transformer.transformField(tag, data[field], fieldUiSchema, fieldSchema, user) - : data[field]; - transformedData[field] = transformed; + const transformed = await transformer.transformField(tag, data[field], fieldUiSchema, fieldSchema, user, field); + fields.push(transformed); } - return transformedData; + return fields; } } diff --git a/packages/server/src/tag/services/video-field-inter.service.ts b/packages/server/src/tag/services/video-field-inter.service.ts new file mode 100644 index 00000000..27c5ef36 --- /dev/null +++ b/packages/server/src/tag/services/video-field-inter.service.ts @@ -0,0 +1,132 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { VideoFieldIntermediate, VideoFieldIntermediateDocument } from '../models/video-field-inter.model'; +import { Model } from 'mongoose'; +import { Tag } from '../models/tag.model'; +import { StudyService } from '../../study/study.service'; +import { ConfigService } from '@nestjs/config'; +import { Entry } from '../../entry/models/entry.model'; +import { EntryService } from '../../entry/services/entry.service'; +import { DatasetPipe } from '../../dataset/pipes/dataset.pipe'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Dataset } from '../../dataset/dataset.model'; +import { BucketFactory } from 'src/bucket/bucket-factory.service'; +import { BucketObjectAction } from 'src/bucket/bucket'; + +@Injectable() +export class VideoFieldIntermediateService { + private readonly bucketPrefix = this.configService.getOrThrow('tag.videoFieldFolder'); + private readonly videoRecordFileType = this.configService.getOrThrow('tag.videoRecordFileType'); + private readonly expiration = this.configService.getOrThrow('tag.videoUploadExpiration'); + private readonly trainingPrefix = this.configService.getOrThrow('tag.trainingPrefix'); + + constructor( + @InjectModel(VideoFieldIntermediate.name) private readonly videoFieldModel: Model, + private readonly studyService: StudyService, + private readonly configService: ConfigService, + private readonly entryService: EntryService, + private readonly datasetPipe: DatasetPipe, + private readonly bucketFactory: BucketFactory + ) {} + + async saveVideoField(tag: Tag, field: string, index: number): Promise { + // First do a correctness check to make sure the field shows up in the tag + // TODO: Can do a correctness check on the index and using the UI schema as well + const study = await this.studyService.findById(tag.study); + if (!study) { + // Unexpected error, got a tag with an invalid study + throw new Error(`Study ${tag.study} not found on tag ${tag._id}`); + } + const dataSchema = study.tagSchema.dataSchema; + if (!dataSchema.properties || !dataSchema.properties[field]) { + // User is trying to save a video field that doesn't exist in the tag + throw new BadRequestException(`Field ${field} not found in tag ${tag._id}`); + } + + // Check if the video field already exists, if so return it + const existingVideoField = await this.getVideoField(tag, field, index); + if (existingVideoField) { + return existingVideoField; + } + + // Otherwise make a new one and return it + return this.videoFieldModel.create({ + tag: tag._id, + field, + index, + bucketLocation: this.getVideoFieldBucketLocation(tag._id, field, index), + organization: study.organization + }); + } + + async getUploadURL(videoField: VideoFieldIntermediate): Promise { + const bucket = await this.bucketFactory.getBucket(videoField.organization); + if (!bucket) { + throw new Error('Could not find bucket for video field'); + } + + const file = this.getVideoFieldBucketLocation(videoField.tag, videoField.field, videoField.index); + const url = await bucket.getSignedUrl( + file, + BucketObjectAction.WRITE, + new Date(Date.now() + this.expiration), + 'video/webm' + ); + return url; + } + + /** + * Move the video itself to the permanent storage location and create the + * cooresponding entry. + */ + async markComplete(videoFieldID: string, datasetID: string, user: TokenPayload, tag: Tag): Promise { + const videoField = await this.videoFieldModel.findById(videoFieldID); + if (!videoField) { + throw new BadRequestException(`Video field ${videoFieldID} not found`); + } + const bucket = await this.bucketFactory.getBucket(videoField.organization); + if (!bucket) { + throw new Error('Could not find bucket for video field'); + } + + // The dataset that the entry would be associated with + const dataset: Dataset = await this.datasetPipe.transform(datasetID); + + // Make the entry + const entry = await this.entryService.create( + { + entryID: 'TODO: Generate entry ID', + contentType: 'video/webm', + meta: {} + }, + dataset, + user, + tag.training + ); + + // Where to move the entry video + let newLocation = `${dataset.bucketPrefix}/${entry._id}.webm`; + if (tag.training) { + newLocation = `${this.trainingPrefix}/${dataset.organization}/${tag.study}/${entry._id}.webm`; + } + + // Move the video to the permanent location + await bucket.move(videoField.bucketLocation, newLocation); + await this.entryService.setBucketLocation(entry, newLocation); + entry.bucketLocation = newLocation; + + // Remove the video field + await this.videoFieldModel.deleteOne({ _id: videoField._id }); + + // Return the completed entry + return entry; + } + + private getVideoFieldBucketLocation(tagID: string, field: string, index: number): string { + return `${this.bucketPrefix}/${tagID}/${field}/${index}.${this.videoRecordFileType}`; + } + + private async getVideoField(tag: Tag, field: string, index: number): Promise { + return this.videoFieldModel.findOne({ tag: tag._id, field, index }).exec(); + } +} diff --git a/packages/server/src/tag/services/video-field.service.ts b/packages/server/src/tag/services/video-field.service.ts index 8e658681..62e65a8b 100644 --- a/packages/server/src/tag/services/video-field.service.ts +++ b/packages/server/src/tag/services/video-field.service.ts @@ -1,132 +1,22 @@ -import { BadRequestException, Injectable } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; -import { VideoField, VideoFieldDocument } from '../models/video-field.model'; import { Model } from 'mongoose'; -import { Tag } from '../models/tag.model'; -import { StudyService } from '../../study/study.service'; -import { ConfigService } from '@nestjs/config'; -import { Entry } from '../../entry/models/entry.model'; -import { EntryService } from '../../entry/services/entry.service'; -import { DatasetPipe } from '../../dataset/pipes/dataset.pipe'; -import { TokenPayload } from '../../jwt/token.dto'; -import { Dataset } from '../../dataset/dataset.model'; -import { BucketFactory } from 'src/bucket/bucket-factory.service'; -import { BucketObjectAction } from 'src/bucket/bucket'; +import { Entry } from 'src/entry/models/entry.model'; +import { VideoField, VideoFieldDocument } from '../models/video-field.model'; @Injectable() export class VideoFieldService { - private readonly bucketPrefix = this.configService.getOrThrow('tag.videoFieldFolder'); - private readonly videoRecordFileType = this.configService.getOrThrow('tag.videoRecordFileType'); - private readonly expiration = this.configService.getOrThrow('tag.videoUploadExpiration'); - private readonly trainingPrefix = this.configService.getOrThrow('tag.trainingPrefix'); - - constructor( - @InjectModel(VideoField.name) private readonly videoFieldModel: Model, - private readonly studyService: StudyService, - private readonly configService: ConfigService, - private readonly entryService: EntryService, - private readonly datasetPipe: DatasetPipe, - private readonly bucketFactory: BucketFactory - ) {} + constructor(@InjectModel(VideoField.name) private readonly videoFieldModel: Model) {} - async saveVideoField(tag: Tag, field: string, index: number): Promise { - // First do a correctness check to make sure the field shows up in the tag - // TODO: Can do a correctness check on the index and using the UI schema as well - const study = await this.studyService.findById(tag.study); - if (!study) { - // Unexpected error, got a tag with an invalid study - throw new Error(`Study ${tag.study} not found on tag ${tag._id}`); - } - const dataSchema = study.tagSchema.dataSchema; - if (!dataSchema.properties || !dataSchema.properties[field]) { - // User is trying to save a video field that doesn't exist in the tag - throw new BadRequestException(`Field ${field} not found in tag ${tag._id}`); - } + async create(entries: Entry[]): Promise { + const entryIDs = entries.map((entry) => entry._id); - // Check if the video field already exists, if so return it - const existingVideoField = await this.getVideoField(tag, field, index); - if (existingVideoField) { - return existingVideoField; - } - - // Otherwise make a new one and return it return this.videoFieldModel.create({ - tag: tag._id, - field, - index, - bucketLocation: this.getVideoFieldBucketLocation(tag._id, field, index), - organization: study.organization + entries: entryIDs }); } - async getUploadURL(videoField: VideoField): Promise { - const bucket = await this.bucketFactory.getBucket(videoField.organization); - if (!bucket) { - throw new Error('Could not find bucket for video field'); - } - - const file = this.getVideoFieldBucketLocation(videoField.tag, videoField.field, videoField.index); - const url = await bucket.getSignedUrl( - file, - BucketObjectAction.WRITE, - new Date(Date.now() + this.expiration), - 'video/webm' - ); - return url; - } - - /** - * Move the video itself to the permanent storage location and create the - * cooresponding entry. - */ - async markComplete(videoFieldID: string, datasetID: string, user: TokenPayload, tag: Tag): Promise { - const videoField = await this.videoFieldModel.findById(videoFieldID); - if (!videoField) { - throw new BadRequestException(`Video field ${videoFieldID} not found`); - } - const bucket = await this.bucketFactory.getBucket(videoField.organization); - if (!bucket) { - throw new Error('Could not find bucket for video field'); - } - - // The dataset that the entry would be associated with - const dataset: Dataset = await this.datasetPipe.transform(datasetID); - - // Make the entry - const entry = await this.entryService.create( - { - entryID: 'TODO: Generate entry ID', - contentType: 'video/webm', - meta: {} - }, - dataset, - user, - tag.training - ); - - // Where to move the entry video - let newLocation = `${dataset.bucketPrefix}/${entry._id}.webm`; - if (tag.training) { - newLocation = `${this.trainingPrefix}/${dataset.organization}/${tag.study}/${entry._id}.webm`; - } - - // Move the video to the permanent location - await bucket.move(videoField.bucketLocation, newLocation); - await this.entryService.setBucketLocation(entry, newLocation); - entry.bucketLocation = newLocation; - - // Remove the video field - await this.videoFieldModel.deleteOne({ _id: videoField._id }); - - // Return the completed entry - return entry; - } - - private getVideoFieldBucketLocation(tagID: string, field: string, index: number): string { - return `${this.bucketPrefix}/${tagID}/${field}/${index}.${this.videoRecordFileType}`; - } - - private async getVideoField(tag: Tag, field: string, index: number): Promise { - return this.videoFieldModel.findOne({ tag: tag._id, field, index }).exec(); + async find(id: string): Promise { + return this.videoFieldModel.findById(id); } } diff --git a/packages/server/src/tag/tag.module.ts b/packages/server/src/tag/tag.module.ts index 09b06125..8c4eae56 100644 --- a/packages/server/src/tag/tag.module.ts +++ b/packages/server/src/tag/tag.module.ts @@ -8,9 +8,9 @@ import { EntryModule } from '../entry/entry.module'; import { TagPipe } from './pipes/tag.pipe'; import { SharedModule } from '../shared/shared.module'; import { PermissionModule } from '../permission/permission.module'; -import { VideoField, VideoFieldSchema } from './models/video-field.model'; -import { VideoFieldService } from './services/video-field.service'; -import { VideoFieldResolver } from './resolvers/video-field.resolver'; +import { VideoFieldIntermediate, VideoFieldIntermediateSchema } from './models/video-field-inter.model'; +import { VideoFieldIntermediateService } from './services/video-field-inter.service'; +import { VideoFieldIntermediateResolver } from './resolvers/video-field-inter.resolver'; import { GcpModule } from '../gcp/gcp.module'; import { TagTransformer } from './services/tag-transformer.service'; import { FieldTransformerFactory } from './transformers/field-transformer-factory'; @@ -20,13 +20,24 @@ import { TrainingSet, TrainingSetSchema } from './models/training-set'; import { TrainingSetResolver } from './resolvers/training-set.resolver'; import { TrainingSetService } from './services/training-set.service'; import { BucketModule } from 'src/bucket/bucket.module'; +import { BooleanFieldTransformer } from './transformers/boolean-transformer'; +import { FreeTextFieldTransformer } from './transformers/free-text.transformer'; +import { NumericFieldTransformer } from './transformers/numeric-transformer'; +import { SliderFieldTransformer } from './transformers/slider-transformer'; +import { TagFieldResolver } from './resolvers/tag-field.resolver'; +import { TagFieldService } from './services/tag-field.service'; +import { AslLexFieldTransformer } from './transformers/asl-lex-transformer'; +import { VideoField, VideoFieldSchema } from './models/video-field.model'; +import { VideoFieldService } from './services/video-field.service'; +import { VideoFieldResolver } from './resolvers/video-field.resolver'; @Module({ imports: [ MongooseModule.forFeature([ { name: Tag.name, schema: TagSchema }, - { name: VideoField.name, schema: VideoFieldSchema }, - { name: TrainingSet.name, schema: TrainingSetSchema } + { name: VideoFieldIntermediate.name, schema: VideoFieldIntermediateSchema }, + { name: TrainingSet.name, schema: TrainingSetSchema }, + { name: VideoField.name, schema: VideoFieldSchema } ]), StudyModule, EntryModule, @@ -39,14 +50,23 @@ import { BucketModule } from 'src/bucket/bucket.module'; providers: [ TagService, TagResolver, + TagFieldResolver, TagPipe, - VideoFieldService, - VideoFieldResolver, + VideoFieldIntermediateService, + VideoFieldIntermediateResolver, TagTransformer, FieldTransformerFactory, VideoFieldTransformer, TrainingSetResolver, - TrainingSetService + TrainingSetService, + BooleanFieldTransformer, + FreeTextFieldTransformer, + NumericFieldTransformer, + SliderFieldTransformer, + TagFieldService, + AslLexFieldTransformer, + VideoFieldService, + VideoFieldResolver ] }) export class TagModule {} diff --git a/packages/server/src/tag/transformers/asl-lex-transformer.ts b/packages/server/src/tag/transformers/asl-lex-transformer.ts new file mode 100644 index 00000000..d6b8a7e0 --- /dev/null +++ b/packages/server/src/tag/transformers/asl-lex-transformer.ts @@ -0,0 +1,35 @@ +import { Injectable } from '@nestjs/common'; +import { FieldTransformer } from './field-transformer'; +import { JsonSchema, UISchemaElement } from '@jsonforms/core'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Tag } from '../models/tag.model'; +import { TagField, TagFieldType } from '../models/tag-field.model'; + +@Injectable() +export class AslLexFieldTransformer implements FieldTransformer { + async transformField( + _tag: Tag, + data: string, + _uischema: UISchemaElement, + _schema: JsonSchema, + _user: TokenPayload, + property: string + ): Promise { + return { + name: property, + data, + type: TagFieldType.ASL_LEX + }; + } +} + +export const AslLexFieldTransformerTest = (uischema: UISchemaElement, _schema: JsonSchema) => { + if ( + uischema.options != undefined && + uischema.options.customType != undefined && + uischema.options.customType == 'asl-lex' + ) { + return 15; + } + return -1; +}; diff --git a/packages/server/src/tag/transformers/autocomplete-transformer.ts b/packages/server/src/tag/transformers/autocomplete-transformer.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/server/src/tag/transformers/boolean-transformer.ts b/packages/server/src/tag/transformers/boolean-transformer.ts new file mode 100644 index 00000000..2dc4e16a --- /dev/null +++ b/packages/server/src/tag/transformers/boolean-transformer.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { FieldTransformer } from './field-transformer'; +import { JsonSchema, UISchemaElement, isBooleanControl } from '@jsonforms/core'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Tag } from '../models/tag.model'; +import { TagField, TagFieldType } from '../models/tag-field.model'; + +@Injectable() +export class BooleanFieldTransformer implements FieldTransformer { + async transformField( + _tag: Tag, + data: string, + _uischema: UISchemaElement, + _schema: JsonSchema, + _user: TokenPayload, + property: string + ): Promise { + return { + name: property, + data, + type: TagFieldType.BOOLEAN + }; + } +} + +export const BooleanFieldTransformerTest = (uischema: UISchemaElement, schema: JsonSchema) => { + if (isBooleanControl(uischema, schema, {} as any)) { + return 10; + } + return -1; +}; diff --git a/packages/server/src/tag/transformers/embedded-transformer.ts b/packages/server/src/tag/transformers/embedded-transformer.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/server/src/tag/transformers/field-transformer-factory.ts b/packages/server/src/tag/transformers/field-transformer-factory.ts index 77d6b8cc..50abe3dd 100644 --- a/packages/server/src/tag/transformers/field-transformer-factory.ts +++ b/packages/server/src/tag/transformers/field-transformer-factory.ts @@ -1,17 +1,34 @@ import { JsonSchema, UISchemaElement } from '@jsonforms/core'; import { Injectable } from '@nestjs/common'; +import { BooleanFieldTransformer, BooleanFieldTransformerTest } from './boolean-transformer'; import { FieldTransformer, FieldTransformerTest, NOT_APPLICABLE } from './field-transformer'; import { VideoFieldTransformer, VideoFieldTransformerTest } from './video-field-transformer'; +import { NumericFieldTransformerTest, NumericFieldTransformer } from './numeric-transformer'; +import { FreeTextFieldTransformer, FreeTextFieldTransformerTest } from './free-text.transformer'; +import { SliderFieldTransformerTest, SliderFieldTransformer } from './slider-transformer'; +import { AslLexFieldTransformer, AslLexFieldTransformerTest } from './asl-lex-transformer'; type FieldTransformerOptions = { tester: FieldTransformerTest; transformer: FieldTransformer }; @Injectable() export class FieldTransformerFactory { private readonly transformers: FieldTransformerOptions[] = [ + { tester: AslLexFieldTransformerTest, transformer: this.aslLexFieldTransformer }, + { tester: BooleanFieldTransformerTest, transformer: this.booleanFieldTransformer }, + { tester: FreeTextFieldTransformerTest, transformer: this.freeTextFieldTransformer }, + { tester: NumericFieldTransformerTest, transformer: this.numericFieldTransformer }, + { tester: SliderFieldTransformerTest, transformer: this.sliderFieldTransformer }, { tester: VideoFieldTransformerTest, transformer: this.videoFieldTransformer } ]; - constructor(private readonly videoFieldTransformer: VideoFieldTransformer) {} + constructor( + private readonly aslLexFieldTransformer: AslLexFieldTransformer, + private readonly booleanFieldTransformer: BooleanFieldTransformer, + private readonly freeTextFieldTransformer: FreeTextFieldTransformer, + private readonly numericFieldTransformer: NumericFieldTransformer, + private readonly sliderFieldTransformer: SliderFieldTransformer, + private readonly videoFieldTransformer: VideoFieldTransformer + ) {} /** Get the transformer for the given field */ getTransformer(uischema: UISchemaElement, schema: JsonSchema): FieldTransformer | null { diff --git a/packages/server/src/tag/transformers/field-transformer.ts b/packages/server/src/tag/transformers/field-transformer.ts index 129db94e..864a0a74 100644 --- a/packages/server/src/tag/transformers/field-transformer.ts +++ b/packages/server/src/tag/transformers/field-transformer.ts @@ -1,5 +1,6 @@ import { JsonSchema, UISchemaElement } from '@jsonforms/core'; import { TokenPayload } from '../../jwt/token.dto'; +import { TagField } from '../models/tag-field.model'; import { Tag } from '../models/tag.model'; /** @@ -8,7 +9,14 @@ import { Tag } from '../models/tag.model'; * and ensuring that the data meets any additional formatting requirements. */ export interface FieldTransformer { - transformField(tag: Tag, data: any, uischema: UISchemaElement, schema: JsonSchema, user: TokenPayload): Promise; + transformField( + tag: Tag, + data: any, + uischema: UISchemaElement, + schema: JsonSchema, + user: TokenPayload, + property: string + ): Promise; } /** diff --git a/packages/server/src/tag/transformers/free-text.transformer.ts b/packages/server/src/tag/transformers/free-text.transformer.ts new file mode 100644 index 00000000..5f3d17b7 --- /dev/null +++ b/packages/server/src/tag/transformers/free-text.transformer.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { FieldTransformer } from './field-transformer'; +import { JsonSchema, UISchemaElement, isStringControl } from '@jsonforms/core'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Tag } from '../models/tag.model'; +import { TagField, TagFieldType } from '../models/tag-field.model'; + +@Injectable() +export class FreeTextFieldTransformer implements FieldTransformer { + async transformField( + _tag: Tag, + data: string, + _uischema: UISchemaElement, + _schema: JsonSchema, + _user: TokenPayload, + property: string + ): Promise { + return { + name: property, + data, + type: TagFieldType.FREE_TEXT + }; + } +} + +export const FreeTextFieldTransformerTest = (uischema: UISchemaElement, schema: JsonSchema) => { + if (isStringControl(uischema, schema, {} as any)) { + return 10; + } + return -1; +}; diff --git a/packages/server/src/tag/transformers/numeric-transformer.ts b/packages/server/src/tag/transformers/numeric-transformer.ts new file mode 100644 index 00000000..73bddbcf --- /dev/null +++ b/packages/server/src/tag/transformers/numeric-transformer.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { FieldTransformer } from './field-transformer'; +import { JsonSchema, UISchemaElement, isNumberControl } from '@jsonforms/core'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Tag } from '../models/tag.model'; +import { TagField, TagFieldType } from '../models/tag-field.model'; + +@Injectable() +export class NumericFieldTransformer implements FieldTransformer { + async transformField( + _tag: Tag, + data: number, + _uischema: UISchemaElement, + _schema: JsonSchema, + _user: TokenPayload, + property: string + ): Promise { + return { + name: property, + data, + type: TagFieldType.NUMERIC + }; + } +} + +export const NumericFieldTransformerTest = (uischema: UISchemaElement, schema: JsonSchema) => { + if (isNumberControl(uischema, schema, {} as any)) { + return 10; + } + return -1; +}; diff --git a/packages/server/src/tag/transformers/slider-transformer.ts b/packages/server/src/tag/transformers/slider-transformer.ts new file mode 100644 index 00000000..175965c3 --- /dev/null +++ b/packages/server/src/tag/transformers/slider-transformer.ts @@ -0,0 +1,31 @@ +import { Injectable } from '@nestjs/common'; +import { FieldTransformer } from './field-transformer'; +import { JsonSchema, UISchemaElement, isNumberControl } from '@jsonforms/core'; +import { TokenPayload } from '../../jwt/token.dto'; +import { Tag } from '../models/tag.model'; +import { TagField, TagFieldType } from '../models/tag-field.model'; + +@Injectable() +export class SliderFieldTransformer implements FieldTransformer { + async transformField( + _tag: Tag, + data: number, + _uischema: UISchemaElement, + _schema: JsonSchema, + _user: TokenPayload, + property: string + ): Promise { + return { + name: property, + data, + type: TagFieldType.SLIDER + }; + } +} + +export const SliderFieldTransformerTest = (uischema: UISchemaElement, schema: JsonSchema) => { + if (isNumberControl(uischema, schema, {} as any)) { + return 10; + } + return -1; +}; diff --git a/packages/server/src/tag/transformers/video-field-transformer.ts b/packages/server/src/tag/transformers/video-field-transformer.ts index a130f996..9f1e3419 100644 --- a/packages/server/src/tag/transformers/video-field-transformer.ts +++ b/packages/server/src/tag/transformers/video-field-transformer.ts @@ -1,34 +1,47 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { FieldTransformer } from './field-transformer'; import { JsonSchema, UISchemaElement } from '@jsonforms/core'; -import { VideoFieldService } from '../services/video-field.service'; +import { VideoFieldIntermediateService } from '../services/video-field-inter.service'; import { TokenPayload } from '../../jwt/token.dto'; import { Tag } from '../models/tag.model'; +import { TagField, TagFieldType } from '../models/tag-field.model'; +import { VideoFieldService } from '../services/video-field.service'; @Injectable() export class VideoFieldTransformer implements FieldTransformer { - constructor(private readonly videoFieldService: VideoFieldService) {} + constructor( + private readonly videoFieldIntermediateService: VideoFieldIntermediateService, + private readonly vidoeFieldService: VideoFieldService + ) {} async transformField( tag: Tag, data: string[], uischema: UISchemaElement, _schema: JsonSchema, - user: TokenPayload - ): Promise { + user: TokenPayload, + property: string + ): Promise { const datasetID = uischema.options?.dataset; if (!datasetID) { throw new BadRequestException('Dataset ID not provided'); } - const videoFields = await Promise.all( + // Mark the intermediate video fields as complete + const entries = await Promise.all( data.map(async (videoFieldId) => { - const entry = await this.videoFieldService.markComplete(videoFieldId, datasetID, user, tag); - return entry._id; + return this.videoFieldIntermediateService.markComplete(videoFieldId, datasetID, user, tag); }) ); - return videoFields; + // Create the complete video field + const videoField = await this.vidoeFieldService.create(entries); + + return { + name: property, + data: videoField._id, + type: TagFieldType.VIDEO_RECORD + }; } }