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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import { withJsonFormsControlProps } from '@jsonforms/react';
import { StatusProcessCircles } from './StatusCircles.component';
import { VideoRecordInterface } from './VideoRecordInterface.component';
import { useEffect, useState, useRef } from 'react';
import { useApolloClient } from '@apollo/client';
import {
SaveVideoFieldDocument,
SaveVideoFieldMutation,
SaveVideoFieldMutationVariables
} from '../../../graphql/tag/tag';
import { useTag } from '../../../context/Tag.context';
import axios from 'axios';

const VideoRecordField: React.FC<ControlProps> = (props) => {
const [maxVideos, setMaxVideos] = useState<number>(0);
Expand All @@ -13,8 +21,16 @@ const VideoRecordField: React.FC<ControlProps> = (props) => {
const [activeIndex, setActiveIndex] = useState<number>(0);
const [blobs, setBlobs] = useState<(Blob | null)[]>([]);
const [recording, setRecording] = useState<boolean>(false);
const stateRef = useRef<{ validVideos: boolean[]; blobs: (Blob | null)[]; activeIndex: number }>();
stateRef.current = { validVideos, blobs, activeIndex };
const [videoFragmentID, setVideoFragmentID] = useState<string[]>([]);
const stateRef = useRef<{
validVideos: boolean[];
blobs: (Blob | null)[];
activeIndex: number;
videoFragmentID: string[];
}>();
stateRef.current = { validVideos, blobs, activeIndex, videoFragmentID };
const client = useApolloClient();
const { tag } = useTag();

useEffect(() => {
if (!props.uischema.options?.minimumRequired) {
Expand All @@ -34,6 +50,50 @@ const VideoRecordField: React.FC<ControlProps> = (props) => {
setBlobs(Array.from({ length: maxVideos }, (_, _i) => null));
}, [props.uischema]);

/** Handles saving the video fragment to the database and updating the JSON Forms representation of the data */
const saveVideoFragment = async (blob: Blob) => {
// Save the video fragment
const result = await client.mutate<SaveVideoFieldMutation, SaveVideoFieldMutationVariables>({
mutation: SaveVideoFieldDocument,
variables: {
tag: tag!._id,
field: props.path,
index: stateRef.current!.activeIndex
}
});

if (!result.data?.saveVideoField) {
console.error('Failed to save video fragment');
return;
}

// Upload the video to the provided URL
await axios.put(result.data.saveVideoField.uploadURL, blob, {
headers: {
'Content-Type': 'video/webm'
}
});

// Update the JSON Forms representation of the data to be the ID of the video fragment

// If the index is longer than the current videoFragmentID array, then add the new ID to the end
if (stateRef.current!.activeIndex >= stateRef.current!.videoFragmentID.length) {
const updatedVideoFragmentID = [...stateRef.current!.videoFragmentID, result.data!.saveVideoField._id];
setVideoFragmentID(updatedVideoFragmentID);
props.handleChange(props.path, updatedVideoFragmentID);
} else {
const updatedVideoFragmentID = stateRef.current!.videoFragmentID.map((id, index) => {
if (index === stateRef.current!.activeIndex) {
return result.data!.saveVideoField._id;
}
return id;
});
setVideoFragmentID(updatedVideoFragmentID);
props.handleChange(props.path, updatedVideoFragmentID);
}
};

/** Store the blob and check if the video needs to be saved */
const handleVideoRecord = (video: Blob | null) => {
const updatedBlobs = stateRef.current!.blobs.map((blob, index) => {
if (index === stateRef.current!.activeIndex) {
Expand All @@ -48,6 +108,9 @@ const VideoRecordField: React.FC<ControlProps> = (props) => {
return valid;
});

if (video !== null) {
saveVideoFragment(video);
}
setBlobs(updatedBlobs);
setValidVideos(updateValidVideos);
};
Expand Down
44 changes: 44 additions & 0 deletions packages/client/src/context/Tag.context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { ReactNode, FC, createContext, useContext, useEffect, useState } from 'react';
import { useStudy } from './Study.context';
import { AssignTagMutation, useAssignTagMutation } from '../graphql/tag/tag';

export interface TagContextProps {
tag: AssignTagMutation['assignTag'] | null;
requestTag: () => void;
}

const TagContext = createContext<TagContextProps>({} as TagContextProps);

export interface TagProviderProps {
children: ReactNode;
}

export const TagProvider: FC<TagProviderProps> = ({ children }) => {
const { study } = useStudy();
const [tag, setTag] = useState<AssignTagMutation['assignTag'] | null>(null);
const [assignTag, assignTagResult] = useAssignTagMutation();

useEffect(() => {
requestTag();
}, [study]);

useEffect(() => {
if (!assignTagResult.data?.assignTag) {
return;
}
setTag(assignTagResult.data.assignTag);
}, [assignTagResult.data]);

const requestTag = () => {
if (!study) {
setTag(null);
return;
}

assignTag({ variables: { study: study._id } });
};

return <TagContext.Provider value={{ tag, requestTag }}>{children}</TagContext.Provider>;
};

export const useTag = () => useContext(TagContext);
14 changes: 14 additions & 0 deletions packages/client/src/graphql/graphql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export type Mutation = {
refresh: AccessToken;
resendInvite: InviteModel;
resetPassword: Scalars['Boolean']['output'];
saveVideoField: VideoField;
setEntryEnabled: Scalars['Boolean']['output'];
signLabCreateProject: Project;
signup: AccessToken;
Expand Down Expand Up @@ -414,6 +415,13 @@ export type MutationResetPasswordArgs = {
};


export type MutationSaveVideoFieldArgs = {
field: Scalars['String']['input'];
index: Scalars['Int']['input'];
tag: Scalars['ID']['input'];
};


export type MutationSetEntryEnabledArgs = {
enabled: Scalars['Boolean']['input'];
entry: Scalars['ID']['input'];
Expand Down Expand Up @@ -813,3 +821,9 @@ export type UsernameLoginDto = {
projectId: Scalars['String']['input'];
username: Scalars['String']['input'];
};

export type VideoField = {
__typename?: 'VideoField';
_id: Scalars['String']['output'];
uploadURL: Scalars['String']['output'];
};
7 changes: 7 additions & 0 deletions packages/client/src/graphql/tag/tag.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,10 @@ mutation assignTag($study: ID!) {
mutation completeTag($tag: ID!, $data: JSON!) {
completeTag(tag: $tag, data: $data)
}

mutation saveVideoField($tag: ID!, $field: String!, $index: Int!) {
saveVideoField(tag: $tag, field: $field, index: $index) {
_id,
uploadURL
}
}
47 changes: 46 additions & 1 deletion packages/client/src/graphql/tag/tag.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,15 @@ export type CompleteTagMutationVariables = Types.Exact<{

export type CompleteTagMutation = { __typename?: 'Mutation', completeTag: boolean };

export type SaveVideoFieldMutationVariables = Types.Exact<{
tag: Types.Scalars['ID']['input'];
field: Types.Scalars['String']['input'];
index: Types.Scalars['Int']['input'];
}>;


export type SaveVideoFieldMutation = { __typename?: 'Mutation', saveVideoField: { __typename?: 'VideoField', _id: string, uploadURL: string } };


export const CreateTagsDocument = gql`
mutation createTags($study: ID!, $entries: [ID!]!) {
Expand Down Expand Up @@ -223,4 +232,40 @@ export function useCompleteTagMutation(baseOptions?: Apollo.MutationHookOptions<
}
export type CompleteTagMutationHookResult = ReturnType<typeof useCompleteTagMutation>;
export type CompleteTagMutationResult = Apollo.MutationResult<CompleteTagMutation>;
export type CompleteTagMutationOptions = Apollo.BaseMutationOptions<CompleteTagMutation, CompleteTagMutationVariables>;
export type CompleteTagMutationOptions = Apollo.BaseMutationOptions<CompleteTagMutation, CompleteTagMutationVariables>;
export const SaveVideoFieldDocument = gql`
mutation saveVideoField($tag: ID!, $field: String!, $index: Int!) {
saveVideoField(tag: $tag, field: $field, index: $index) {
_id
uploadURL
}
}
`;
export type SaveVideoFieldMutationFn = Apollo.MutationFunction<SaveVideoFieldMutation, SaveVideoFieldMutationVariables>;

/**
* __useSaveVideoFieldMutation__
*
* To run a mutation, you first call `useSaveVideoFieldMutation` within a React component and pass it any options that fit your needs.
* When your component renders, `useSaveVideoFieldMutation` 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 [saveVideoFieldMutation, { data, loading, error }] = useSaveVideoFieldMutation({
* variables: {
* tag: // value for 'tag'
* field: // value for 'field'
* index: // value for 'index'
* },
* });
*/
export function useSaveVideoFieldMutation(baseOptions?: Apollo.MutationHookOptions<SaveVideoFieldMutation, SaveVideoFieldMutationVariables>) {
const options = {...defaultOptions, ...baseOptions}
return Apollo.useMutation<SaveVideoFieldMutation, SaveVideoFieldMutationVariables>(SaveVideoFieldDocument, options);
}
export type SaveVideoFieldMutationHookResult = ReturnType<typeof useSaveVideoFieldMutation>;
export type SaveVideoFieldMutationResult = Apollo.MutationResult<SaveVideoFieldMutation>;
export type SaveVideoFieldMutationOptions = Apollo.BaseMutationOptions<SaveVideoFieldMutation, SaveVideoFieldMutationVariables>;
96 changes: 38 additions & 58 deletions packages/client/src/pages/contribute/TaggingInterface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,37 @@ 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 { useEffect, useState } from 'react';
import { useCompleteTagMutation } from '../../graphql/tag/tag';
import { NoTagNotification } from '../../components/contribute/NoTagNotification.component';
import { Study } from '../../graphql/graphql';
import { TagProvider, useTag } from '../../context/Tag.context';

export const TaggingInterface: React.FC = () => {
const { study } = useStudy();
const [tag, setTag] = useState<AssignTagMutation['assignTag'] | null>(null);
const [assignTag, assignTagResult] = useAssignTagMutation();
const [tagData, setTagData] = useState<any>({});
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]);
// TODO: View for when there is no study vs when there is no tag
return (
<>
<TagProvider>
{study && (
<>
<MainView study={study} />
</>
)}
</TagProvider>
</>
);
};

// Update to the assigned tag
useEffect(() => {
if (!assignTagResult.data) {
setTag(null);
return;
}
interface MainViewProps {
study: Study;
}

setTag(assignTagResult.data.assignTag);
}, [assignTagResult.data]);
const MainView: React.FC<MainViewProps> = (props) => {
const { tag, requestTag } = useTag();
const [completeTag, completeTagResult] = useCompleteTagMutation();
const [tagData, setTagData] = useState<any>({});

// Changes made to the tag data
useEffect(() => {
Expand All @@ -48,46 +45,29 @@ export const TaggingInterface: React.FC = () => {
// Tag submission result
// TODO: Handle errors
useEffect(() => {
if (completeTagResult.data && study) {
if (completeTagResult.data) {
// Assign a new tag
assignTag({ variables: { study: study._id } });
requestTag();
}
}, [completeTagResult.data]);

// TODO: View for when there is no study vs when there is no tag
return (
<>
{study && (
<>
{tag ? (
<MainView tag={tag} study={study} setTagData={setTagData} />
) : (
<NoTagNotification studyName={study.name} />
)}
</>
{tag ? (
<Box sx={{ justifyContent: 'space-between', display: 'flex', maxWidth: '80%', margin: 'auto' }}>
<EntryView
entry={tag.entry}
width={500}
autoPlay={true}
pauseFrame="start"
mouseOverControls={false}
displayControls={true}
/>
<TagForm study={props.study} setTagData={setTagData} />
</Box>
) : (
<NoTagNotification studyName={props.study.name} />
)}
</>
);
};

interface MainViewProps {
tag: NonNullable<AssignTagMutation['assignTag']>;
setTagData: Dispatch<SetStateAction<any>>;
study: Study;
}

const MainView: React.FC<MainViewProps> = (props) => {
return (
<Box sx={{ justifyContent: 'space-between', display: 'flex', maxWidth: '80%', margin: 'auto' }}>
<EntryView
entry={props.tag.entry}
width={500}
autoPlay={true}
pauseFrame="start"
mouseOverControls={false}
displayControls={true}
/>
<TagForm study={props.study} setTagData={props.setTagData} />
</Box>
);
};
6 changes: 6 additions & 0 deletions packages/server/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ type Tag {
enabled: Boolean!
}

type VideoField {
_id: String!
uploadURL: String!
}

type Query {
getOrganizations: [Organization!]!
exists(name: String!): Boolean!
Expand Down Expand Up @@ -184,6 +189,7 @@ type Mutation {
assignTag(study: ID!): Tag
completeTag(tag: ID!, data: JSON!): Boolean!
setEntryEnabled(study: ID!, entry: ID!, enabled: Boolean!): Boolean!
saveVideoField(tag: ID!, field: String!, index: Int!): VideoField!
}

input OrganizationCreate {
Expand Down
5 changes: 5 additions & 0 deletions packages/server/src/config/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,10 @@ export default () => ({
mongo: {
uri: process.env.CASBIN_MONGO_URI || 'mongodb://127.0.0.1:27017/casbin'
}
},
tag: {
videoFieldFolder: process.env.TAG_VIDEO_FIELD_FOLDER || 'video-fields',
videoRecordFileType: 'webm',
videoUploadExpiration: process.env.TAG_VIDEO_UPLOAD_EXPIRATION || 15 * 60 * 1000 // 15 minutes
}
});
Loading