diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 4a2d8cd5..6bc30d06 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -7,8 +7,8 @@ import { NewProject } from './pages/projects/NewProject'; import { ProjectControl } from './pages/projects/ProjectControl'; import { SuccessPage } from './pages/SuccessPage'; import { NewStudy } from './pages/studies/NewStudy'; -import { ContributePage } from './pages/contribute/Contribute'; -import { TagView } from './components/TagView.component'; +import { ContributeLanding } from './pages/contribute/ContributeLanding'; +import { TaggingInterface } from './pages/contribute/TaggingInterface'; import { StudyControl } from './pages/studies/StudyControl'; import { ProjectAccess } from './pages/datasets/ProjectAccess'; import { ProjectUserPermissions } from './pages/projects/ProjectUserPermissions'; @@ -131,8 +131,8 @@ const MyRoutes: FC = () => { } /> } /> } /> - } /> - } /> + } /> + } /> } /> diff --git a/packages/client/src/components/EntryView.component.tsx b/packages/client/src/components/EntryView.component.tsx index cb8b619b..2be4d788 100644 --- a/packages/client/src/components/EntryView.component.tsx +++ b/packages/client/src/components/EntryView.component.tsx @@ -1,32 +1,38 @@ +import { Box } from '@mui/material'; import { Entry } from '../graphql/graphql'; import { useEffect, useRef } from 'react'; export interface EntryViewProps { entry: Entry; width: number; + pauseFrame?: 'start' | 'end' | 'middle'; + autoPlay?: boolean; + mouseOverControls?: boolean; + displayControls?: boolean; } export const EntryView: React.FC = (props) => { - return getEntryView(props.entry); + return getEntryView(props); }; -const getEntryView = (entry: Entry) => { - if (entry.contentType.startsWith('video/')) { - return ; +const getEntryView = (props: EntryViewProps) => { + if (props.entry.contentType.startsWith('video/')) { + return ; } - if (entry.contentType.startsWith('image/')) { - return ; + if (props.entry.contentType.startsWith('image/')) { + return ; } console.error('Unknown entry type'); return

Placeholder

; }; +// TODO: Add in ability to control video play, pause, and middle frame selection const VideoEntryView: React.FC = (props) => { const videoRef = useRef(null); /** Start the video at the begining */ const handleStart: React.MouseEventHandler = () => { - if (!videoRef.current) { + if (!videoRef.current || (props.mouseOverControls != undefined && !props.mouseOverControls)) { return; } videoRef.current.currentTime = 0; @@ -35,20 +41,25 @@ const VideoEntryView: React.FC = (props) => { /** Stop the video */ const handleStop: React.MouseEventHandler = () => { - if (!videoRef.current) { + if (!videoRef.current || (props.mouseOverControls != undefined && !props.mouseOverControls)) { return; } videoRef.current.pause(); - setMiddleFrame(); + setPauseFrame(); }; /** Set the video to the middle frame */ - const setMiddleFrame = async () => { + const setPauseFrame = async () => { if (!videoRef.current) { return; } - const duration = await getDuration(); - videoRef.current.currentTime = duration / 2; + + if (!props.pauseFrame || props.pauseFrame === 'middle') { + const duration = await getDuration(); + videoRef.current.currentTime = duration / 2; + } else if (props.pauseFrame === 'start') { + videoRef.current.currentTime = 0; + } }; /** Get the duration, there is a known issue on Chrome with some audio/video durations */ @@ -81,16 +92,29 @@ const VideoEntryView: React.FC = (props) => { // Set the video to the middle frame when the video is loaded useEffect(() => { - setMiddleFrame(); + setPauseFrame(); }, [videoRef.current]); return ( - + + + ); }; const ImageEntryView: React.FC = (props) => { - return ; + return ( + + + + ); }; diff --git a/packages/client/src/components/SideBar.component.tsx b/packages/client/src/components/SideBar.component.tsx index 832e1960..91fcbdc4 100644 --- a/packages/client/src/components/SideBar.component.tsx +++ b/packages/client/src/components/SideBar.component.tsx @@ -50,7 +50,7 @@ export const SideBar: FC = ({ open, drawerWidth }) => { name: 'Contribute', action: () => {}, icon: , - subItems: [{ name: 'Tag in Study', action: () => navigate('/study/contribute') }] + subItems: [{ name: 'Tag in Study', action: () => navigate('/contribute/landing') }] }, { name: 'Logout', diff --git a/packages/client/src/components/TagView.component.tsx b/packages/client/src/components/TagView.component.tsx deleted file mode 100644 index 5d2fa274..00000000 --- a/packages/client/src/components/TagView.component.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { JsonForms } from '@jsonforms/react'; -import { materialRenderers, materialCells } from '@jsonforms/material-renderers'; -import { useLocation, useNavigate } from 'react-router-dom'; -import { Box, Button, Container, Typography } from '@mui/material'; -import { useState } from 'react'; - -export const TagView = () => { - const { state } = useLocation(); - console.log(state); - const [initialData, setData] = useState({ - name: '', - name_noDefault: '', - description: '', - done: true, - rating: 0, - cost: 3.14, - dueDate: '2019-05-01' - }); - - const navigate = useNavigate(); - - const handleNext = () => { - //first save tagged data by sending it to backend - //then tag the next entry - - setData({ name: '', name_noDefault: '', description: '', done: false, rating: 0, cost: 0, dueDate: '2023-07-24' }); - }; - - const handleClick = (route: string) => { - navigate('/' + route); - }; - - return ( - - {state ? ( - - - - - - - - - ) : ( - - No Entries Tagged - - - )} - - ); -}; diff --git a/packages/client/src/components/contribute/NoTagNotification.component.tsx b/packages/client/src/components/contribute/NoTagNotification.component.tsx new file mode 100644 index 00000000..05382cf9 --- /dev/null +++ b/packages/client/src/components/contribute/NoTagNotification.component.tsx @@ -0,0 +1,14 @@ +import { Stack, Typography } from '@mui/material'; + +export interface NoTagNotificationProps { + studyName: string; +} + +export const NoTagNotification: React.FC = ({ studyName }) => { + return ( + + No tags Remaining + No tags left for "{studyName}", please check back later + + ); +}; diff --git a/packages/client/src/components/contribute/TagForm.component.tsx b/packages/client/src/components/contribute/TagForm.component.tsx new file mode 100644 index 00000000..9d627d5a --- /dev/null +++ b/packages/client/src/components/contribute/TagForm.component.tsx @@ -0,0 +1,64 @@ +import { JsonForms } from '@jsonforms/react'; +import { Study } from '../../graphql/graphql'; +import { materialRenderers } from '@jsonforms/material-renderers'; +import { SetStateAction, useState, Dispatch } from 'react'; +import { Box, Stack, Button } from '@mui/material'; +import { ErrorObject } from 'ajv'; + +export interface TagFormProps { + study: Study; + setTagData: Dispatch>; +} + +export const TagForm: React.FC = (props) => { + const [data, setData] = useState({}); + const [dataValid, setDataValid] = useState(false); + + const handleFormChange = (data: any, errors: ErrorObject[] | undefined) => { + setData(data); + + // No errors, data could be submitted + if (!errors || errors.length === 0) { + setDataValid(true); + } else { + setDataValid(false); + } + }; + + const handleSubmit = () => { + // Ideally should not get here + if (!dataValid) { + return; + } + props.setTagData(data); + + // Get ready for the next tag + handleClear(); + }; + + const handleClear = () => { + setData({}); + }; + + return ( + + + handleFormChange(data, errors)} + renderers={materialRenderers} + /> + + + + + + + ); +}; diff --git a/packages/client/src/graphql/entry.ts b/packages/client/src/graphql/entry.ts index 4585bdab..6e170a4e 100644 --- a/packages/client/src/graphql/entry.ts +++ b/packages/client/src/graphql/entry.ts @@ -10,7 +10,7 @@ export type EntryForDatasetQueryVariables = Types.Exact<{ }>; -export type EntryForDatasetQuery = { __typename?: 'Query', entryForDataset: Array<{ __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator?: string | null, dateCreated: any, meta: any, signedUrl: string, signedUrlExpiration: number }> }; +export type EntryForDatasetQuery = { __typename?: 'Query', entryForDataset: Array<{ __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator: string, dateCreated: any, meta: any, signedUrl: string, signedUrlExpiration: number }> }; export const EntryForDatasetDocument = gql` diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index ef268a85..60097ca4 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -69,7 +69,7 @@ export type Entry = { __typename?: 'Entry'; _id: Scalars['String']['output']; contentType: Scalars['String']['output']; - creator?: Maybe; + creator: Scalars['ID']['output']; dataset: Scalars['ID']['output']; dateCreated: Scalars['DateTime']['output']; entryID: Scalars['String']['output']; diff --git a/packages/client/src/graphql/tag.graphql b/packages/client/src/graphql/tag.graphql deleted file mode 100644 index 9fff737a..00000000 --- a/packages/client/src/graphql/tag.graphql +++ /dev/null @@ -1,5 +0,0 @@ -mutation createTags($study: ID!, $entries: [ID!]!) { - createTags(study: $study, entries: $entries) { - _id - } -} diff --git a/packages/client/src/graphql/tag.ts b/packages/client/src/graphql/tag.ts deleted file mode 100644 index 444b38f6..00000000 --- a/packages/client/src/graphql/tag.ts +++ /dev/null @@ -1,50 +0,0 @@ -/* Generated File DO NOT EDIT. */ -/* tslint:disable */ -import * as Types from './graphql'; - -import { gql } from '@apollo/client'; -import * as Apollo from '@apollo/client'; -const defaultOptions = {} as const; -export type CreateTagsMutationVariables = Types.Exact<{ - study: Types.Scalars['ID']['input']; - entries: Array | Types.Scalars['ID']['input']; -}>; - - -export type CreateTagsMutation = { __typename?: 'Mutation', createTags: Array<{ __typename?: 'Tag', _id: string }> }; - - -export const CreateTagsDocument = gql` - mutation createTags($study: ID!, $entries: [ID!]!) { - createTags(study: $study, entries: $entries) { - _id - } -} - `; -export type CreateTagsMutationFn = Apollo.MutationFunction; - -/** - * __useCreateTagsMutation__ - * - * To run a mutation, you first call `useCreateTagsMutation` within a React component and pass it any options that fit your needs. - * When your component renders, `useCreateTagsMutation` 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 [createTagsMutation, { data, loading, error }] = useCreateTagsMutation({ - * variables: { - * study: // value for 'study' - * entries: // value for 'entries' - * }, - * }); - */ -export function useCreateTagsMutation(baseOptions?: Apollo.MutationHookOptions) { - const options = {...defaultOptions, ...baseOptions} - return Apollo.useMutation(CreateTagsDocument, options); - } -export type CreateTagsMutationHookResult = ReturnType; -export type CreateTagsMutationResult = Apollo.MutationResult; -export type CreateTagsMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/packages/client/src/graphql/tag/tag.graphql b/packages/client/src/graphql/tag/tag.graphql new file mode 100644 index 00000000..8d204df3 --- /dev/null +++ b/packages/client/src/graphql/tag/tag.graphql @@ -0,0 +1,27 @@ +mutation createTags($study: ID!, $entries: [ID!]!) { + createTags(study: $study, entries: $entries) { + _id + } +} + +mutation assignTag($study: ID!) { + assignTag(study: $study) { + _id, + entry { + _id, + organization, + entryID, + contentType, + dataset, + creator, + dateCreated, + meta, + signedUrl, + signedUrlExpiration + } + } +} + +mutation completeTag($tag: ID!, $data: JSON!) { + completeTag(tag: $tag, data: $data) +} diff --git a/packages/client/src/graphql/tag/tag.ts b/packages/client/src/graphql/tag/tag.ts new file mode 100644 index 00000000..05886d60 --- /dev/null +++ b/packages/client/src/graphql/tag/tag.ts @@ -0,0 +1,142 @@ +/* Generated File DO NOT EDIT. */ +/* tslint:disable */ +import * as Types from '../graphql'; + +import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; +const defaultOptions = {} as const; +export type CreateTagsMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; + entries: Array | Types.Scalars['ID']['input']; +}>; + + +export type CreateTagsMutation = { __typename?: 'Mutation', createTags: Array<{ __typename?: 'Tag', _id: string }> }; + +export type AssignTagMutationVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; +}>; + + +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, signedUrl: string, signedUrlExpiration: number } } | null }; + +export type CompleteTagMutationVariables = Types.Exact<{ + tag: Types.Scalars['ID']['input']; + data: Types.Scalars['JSON']['input']; +}>; + + +export type CompleteTagMutation = { __typename?: 'Mutation', completeTag: boolean }; + + +export const CreateTagsDocument = gql` + mutation createTags($study: ID!, $entries: [ID!]!) { + createTags(study: $study, entries: $entries) { + _id + } +} + `; +export type CreateTagsMutationFn = Apollo.MutationFunction; + +/** + * __useCreateTagsMutation__ + * + * To run a mutation, you first call `useCreateTagsMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateTagsMutation` 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 [createTagsMutation, { data, loading, error }] = useCreateTagsMutation({ + * variables: { + * study: // value for 'study' + * entries: // value for 'entries' + * }, + * }); + */ +export function useCreateTagsMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateTagsDocument, options); + } +export type CreateTagsMutationHookResult = ReturnType; +export type CreateTagsMutationResult = Apollo.MutationResult; +export type CreateTagsMutationOptions = Apollo.BaseMutationOptions; +export const AssignTagDocument = gql` + mutation assignTag($study: ID!) { + assignTag(study: $study) { + _id + entry { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + } + } +} + `; +export type AssignTagMutationFn = Apollo.MutationFunction; + +/** + * __useAssignTagMutation__ + * + * To run a mutation, you first call `useAssignTagMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useAssignTagMutation` 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 [assignTagMutation, { data, loading, error }] = useAssignTagMutation({ + * variables: { + * study: // value for 'study' + * }, + * }); + */ +export function useAssignTagMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(AssignTagDocument, options); + } +export type AssignTagMutationHookResult = ReturnType; +export type AssignTagMutationResult = Apollo.MutationResult; +export type AssignTagMutationOptions = Apollo.BaseMutationOptions; +export const CompleteTagDocument = gql` + mutation completeTag($tag: ID!, $data: JSON!) { + completeTag(tag: $tag, data: $data) +} + `; +export type CompleteTagMutationFn = Apollo.MutationFunction; + +/** + * __useCompleteTagMutation__ + * + * To run a mutation, you first call `useCompleteTagMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCompleteTagMutation` 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 [completeTagMutation, { data, loading, error }] = useCompleteTagMutation({ + * variables: { + * tag: // value for 'tag' + * data: // value for 'data' + * }, + * }); + */ +export function useCompleteTagMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CompleteTagDocument, options); + } +export type CompleteTagMutationHookResult = ReturnType; +export type CompleteTagMutationResult = Apollo.MutationResult; +export type CompleteTagMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file diff --git a/packages/client/src/pages/contribute/Contribute.tsx b/packages/client/src/pages/contribute/Contribute.tsx deleted file mode 100644 index dfc8c99d..00000000 --- a/packages/client/src/pages/contribute/Contribute.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import { Typography, Box, Stack, Button } from '@mui/material'; -import placeholder from './placeholder.png'; -import { useNavigate } from 'react-router-dom'; - -const schema = { - type: 'object', - properties: { - name: { - type: 'string', - default: 'foo' - }, - name_noDefault: { - type: 'string' - }, - description: { - type: 'string', - default: 'bar' - }, - done: { - type: 'boolean', - default: false - }, - rating: { - type: 'integer', - default: 5 - }, - cost: { - type: 'number', - default: 5.5 - }, - dueDate: { - type: 'string', - format: 'date', - default: '2019-04-01' - } - }, - required: ['name', 'name_noDefault'] -}; - -const uischema = { - type: 'VerticalLayout', - elements: [ - { - type: 'Control', - scope: '#/properties/name' - }, - { - type: 'Control', - scope: '#/properties/name_noDefault' - }, - { - type: 'Control', - label: false, - scope: '#/properties/done' - }, - { - type: 'Control', - scope: '#/properties/description', - options: { - multi: true - } - }, - { - type: 'Control', - scope: '#/properties/rating' - }, - { - type: 'Control', - scope: '#/properties/cost' - }, - { - type: 'Control', - scope: '#/properties/dueDate' - } - ] -}; - -export const ContributePage: React.FC = () => { - const initialData = { - image: placeholder, - name: 'Study 12', - description: 'This study focuses on the verb conjugation', - instructions: 'Analyze common verb conjugations and recognize a pattern', - complete: false - }; - const navigate = useNavigate(); - - const handleSubmit = () => { - //submit logic - //redirect to next page - navigate('/tagging', { state: { schema: schema, uischema: uischema } }); - }; - - return ( - - Study: {initialData.name} - - - {initialData.complete ? 'Study Training' : 'Study Tagging'} - Study: {initialData.name} - Description: {initialData.description} - Instructions: {initialData.instructions} - {initialData.complete ? ( - - Training Complete! Reach out to your study administrator to get access to tagging - - ) : ( - - )} - - - - ); -}; diff --git a/packages/client/src/pages/contribute/ContributeLanding.tsx b/packages/client/src/pages/contribute/ContributeLanding.tsx new file mode 100644 index 00000000..eb032f38 --- /dev/null +++ b/packages/client/src/pages/contribute/ContributeLanding.tsx @@ -0,0 +1,34 @@ +import { Typography, Box, Stack, Button } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import { useStudy } from '../../context/Study.context'; + +export const ContributeLanding: React.FC = () => { + const navigate = useNavigate(); + const { study } = useStudy(); + + const enterTagging = () => { + navigate('/contribute/tagging'); + }; + + // TODO: Add in check for training completion + return ( + <> + {study && ( + + Study: {study.name} + + + {false ? 'Study Training' : 'Study Tagging'} + Study: {study.name} + Description: {study.description} + Instructions: {study.instructions} + + + + + )} + + ); +}; diff --git a/packages/client/src/pages/contribute/TaggingInterface.tsx b/packages/client/src/pages/contribute/TaggingInterface.tsx new file mode 100644 index 00000000..81d0766f --- /dev/null +++ b/packages/client/src/pages/contribute/TaggingInterface.tsx @@ -0,0 +1,93 @@ +import { Box } from '@mui/material'; +import { EntryView } from '../../components/EntryView.component'; +import { TagForm } from '../../components/contribute/TagForm.component'; +import { useStudy } from '../../context/Study.context'; +import { Dispatch, SetStateAction, useEffect, useState } from 'react'; +import { AssignTagMutation, useAssignTagMutation } from '../../graphql/tag/tag'; +import { useCompleteTagMutation } from '../../graphql/tag/tag'; +import { NoTagNotification } from '../../components/contribute/NoTagNotification.component'; +import { Study } from '../../graphql/graphql'; + +export const TaggingInterface: React.FC = () => { + const { study } = useStudy(); + const [tag, setTag] = useState(null); + const [assignTag, assignTagResult] = useAssignTagMutation(); + const [tagData, setTagData] = useState({}); + const [completeTag, completeTagResult] = useCompleteTagMutation(); + + // Changes to study will trigger a new tag assignment + useEffect(() => { + // No study, then no tag + if (!study) { + setTag(null); + return; + } + + // Assign a tag + assignTag({ variables: { study: study._id } }); + }, [study]); + + // Update to the assigned tag + useEffect(() => { + if (!assignTagResult.data) { + setTag(null); + return; + } + + setTag(assignTagResult.data.assignTag); + }, [assignTagResult.data]); + + // Changes made to the tag data + useEffect(() => { + if (tagData && tag) { + // Submit the tag data + completeTag({ variables: { tag: tag._id, data: tagData } }); + } + }, [tagData]); + + // Tag submission result + // TODO: Handle errors + useEffect(() => { + if (completeTagResult.data && study) { + // Assign a new tag + assignTag({ variables: { study: study._id } }); + } + }, [completeTagResult.data]); + + // TODO: View for when there is no study vs when there is no tag + return ( + <> + {study && ( + <> + {tag ? ( + + ) : ( + + )} + + )} + + ); +}; + +interface MainViewProps { + tag: NonNullable; + setTagData: Dispatch>; + study: Study; +} + +const MainView: React.FC = (props) => { + return ( + + + + + ); +}; diff --git a/packages/client/src/pages/contribute/placeholder.png b/packages/client/src/pages/contribute/placeholder.png deleted file mode 100644 index 5a6e78be..00000000 Binary files a/packages/client/src/pages/contribute/placeholder.png and /dev/null differ diff --git a/packages/client/src/pages/studies/NewStudy.tsx b/packages/client/src/pages/studies/NewStudy.tsx index ba89b714..3b27075c 100644 --- a/packages/client/src/pages/studies/NewStudy.tsx +++ b/packages/client/src/pages/studies/NewStudy.tsx @@ -9,7 +9,7 @@ import { CreateStudyDocument } from '../../graphql/study/study'; import { useProject } from '../../context/Project.context'; import { useStudy } from '../../context/Study.context'; import { useApolloClient } from '@apollo/client'; -import { CreateTagsDocument } from '../../graphql/tag'; +import { CreateTagsDocument } from '../../graphql/tag/tag'; export const NewStudy: React.FC = () => { const [activeStep, setActiveStep] = useState(0);