diff --git a/packages/client/src/components/DatasetControl.component.tsx b/packages/client/src/components/DatasetControl.component.tsx deleted file mode 100644 index e58660d9..00000000 --- a/packages/client/src/components/DatasetControl.component.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { DataGrid, GridColDef } from '@mui/x-data-grid'; -import { GridRowModesModel } from '@mui/x-data-grid-pro'; -import { useState } from 'react'; - -interface Row { - id: number; - view: string; - entry: string; - responder: string; - access?: boolean; - partOf?: boolean; - available?: boolean; -} - -interface Table { - tableRows: Row[]; - columns: GridColDef[]; -} - -export const DatasetControl: React.FC = ({ tableRows, columns }: Table) => { - const [rows] = useState(tableRows); - const [rowModesModel, setRowModesModel] = useState({}); - - const handleRowModesModelChange = (newRowModesModel: GridRowModesModel) => { - setRowModesModel(newRowModesModel); - }; - - return ( - 'auto'} - rows={rows} - columns={columns} - rowModesModel={rowModesModel} - onRowModesModelChange={handleRowModesModelChange} - initialState={{ - pagination: { - paginationModel: { - pageSize: 5 - } - } - }} - pageSizeOptions={[5, 10, 15]} - checkboxSelection - disableRowSelectionOnClick - /> - ); -}; diff --git a/packages/client/src/components/DatasetTable.component.tsx b/packages/client/src/components/DatasetTable.component.tsx new file mode 100644 index 00000000..49819a79 --- /dev/null +++ b/packages/client/src/components/DatasetTable.component.tsx @@ -0,0 +1,58 @@ +import { DataGrid, GridColDef } from '@mui/x-data-grid'; +import { useState, useEffect } from 'react'; +import { Dataset, Entry } from '../graphql/graphql'; +import { useEntryForDatasetQuery } from '../graphql/entry'; +import { EntryView } from './EntryView.component'; + +export interface DatasetTableProps { + dataset: Dataset; + additionalColumns?: GridColDef[]; +} + +export const DatasetTable: React.FC = (props) => { + const [entries, setEntries] = useState([]); + const columns = [...defaultColumns, ...(props.additionalColumns ?? [])]; + + const entryForDatasetResult = useEntryForDatasetQuery({ variables: { dataset: props.dataset._id } }); + + // TODO: Add in logic to re-fetch data when the presigned URL expires + useEffect(() => { + if (entryForDatasetResult.data) { + setEntries(entryForDatasetResult.data.entryForDataset); + } + }, [entryForDatasetResult.data]); + + return ( + 'auto'} + rows={entries} + columns={columns} + initialState={{ + pagination: { + paginationModel: { + pageSize: 5 + } + } + }} + getRowId={(row) => row._id} + pageSizeOptions={[5, 10, 15]} + checkboxSelection + disableRowSelectionOnClick + /> + ); +}; + +const defaultColumns: GridColDef[] = [ + { + field: 'view', + headerName: 'View', + width: 300, + renderCell: (params) => + }, + { + field: 'entryID', + headerName: 'Entry ID', + width: 150, + editable: false + } +]; diff --git a/packages/client/src/components/DatasetsView.component.tsx b/packages/client/src/components/DatasetsView.component.tsx new file mode 100644 index 00000000..6e964cc5 --- /dev/null +++ b/packages/client/src/components/DatasetsView.component.tsx @@ -0,0 +1,29 @@ +import { Accordion, AccordionSummary, Typography, Stack, AccordionDetails } from '@mui/material'; +import { Dataset } from '../graphql/graphql'; +import { DatasetTable } from './DatasetTable.component'; +import { ExpandMore } from '@mui/icons-material'; + +export interface DatasetsViewProps { + datasets: Dataset[]; +} + +// TODO: Implement lazy loading on accordion open to prevent loading all datasets at once +export const DatasetsView: React.FC = ({ datasets }) => { + return ( + <> + {datasets.map((dataset: Dataset) => ( + + }> + + {dataset.name} + {dataset.description} + + + + + + + ))} + + ); +}; diff --git a/packages/client/src/components/EntryView.component.tsx b/packages/client/src/components/EntryView.component.tsx new file mode 100644 index 00000000..cb8b619b --- /dev/null +++ b/packages/client/src/components/EntryView.component.tsx @@ -0,0 +1,96 @@ +import { Entry } from '../graphql/graphql'; +import { useEffect, useRef } from 'react'; + +export interface EntryViewProps { + entry: Entry; + width: number; +} + +export const EntryView: React.FC = (props) => { + return getEntryView(props.entry); +}; + +const getEntryView = (entry: Entry) => { + if (entry.contentType.startsWith('video/')) { + return ; + } + if (entry.contentType.startsWith('image/')) { + return ; + } + console.error('Unknown entry type'); + return

Placeholder

; +}; + +const VideoEntryView: React.FC = (props) => { + const videoRef = useRef(null); + + /** Start the video at the begining */ + const handleStart: React.MouseEventHandler = () => { + if (!videoRef.current) { + return; + } + videoRef.current.currentTime = 0; + videoRef.current?.play(); + }; + + /** Stop the video */ + const handleStop: React.MouseEventHandler = () => { + if (!videoRef.current) { + return; + } + videoRef.current.pause(); + setMiddleFrame(); + }; + + /** Set the video to the middle frame */ + const setMiddleFrame = async () => { + if (!videoRef.current) { + return; + } + const duration = await getDuration(); + videoRef.current.currentTime = duration / 2; + }; + + /** Get the duration, there is a known issue on Chrome with some audio/video durations */ + const getDuration = async () => { + if (!videoRef.current) { + return 0; + } + + const video = videoRef.current!; + + // If the duration is infinity, this is part of a Chrome bug that causes + // some durations to not load for audio and video. The StackOverflow + // link below discusses the issues and possible solutions + // Then, wait for the update event to be triggered + await new Promise((resolve, _reject) => { + video.ontimeupdate = () => { + // Remove the callback + video.ontimeupdate = () => {}; + // Reset the time + video.currentTime = 0; + resolve(); + }; + + video.currentTime = 1e101; + }); + + // Now try to get the duration again + return video.duration; + }; + + // Set the video to the middle frame when the video is loaded + useEffect(() => { + setMiddleFrame(); + }, [videoRef.current]); + + return ( + + ); +}; + +const ImageEntryView: React.FC = (props) => { + return ; +}; diff --git a/packages/client/src/components/TagTraining.component.tsx b/packages/client/src/components/TagTraining.component.tsx index 878ebf16..3cd3e3a8 100644 --- a/packages/client/src/components/TagTraining.component.tsx +++ b/packages/client/src/components/TagTraining.component.tsx @@ -1,111 +1,23 @@ -import { Box, Accordion, AccordionSummary, Typography, AccordionDetails, Container } from '@mui/material'; -import { DatasetControl } from './DatasetControl.component'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { GridColDef } from '@mui/x-data-grid'; +import { DatasetsView } from './DatasetsView.component'; +import { useState, useEffect } from 'react'; +import { useGetDatasetsQuery } from '../graphql/dataset/dataset'; +import { Dataset } from '../graphql/graphql'; -interface Control { - name: string; - description: string; -} - -const rows = [ - { - id: 1, - view: '12', - entry: '41', - responder: '0', - partOf: true, - available: true - }, - { - id: 2, - view: '5', - entry: '9', - responder: '0', - partOf: true, - available: true - }, - { - id: 3, - view: '11', - entry: '9', - responder: '2', - partOf: true, - available: true - }, - { - id: 4, - view: '0', - entry: '10', - responder: '5', - partOf: true, - available: true - } -]; +export const TagTrainingComponent = () => { + const [datasets, setDatasets] = useState([]); + const getDatasetsResults = useGetDatasetsQuery(); -const columns: GridColDef[] = [ - { field: 'id', headerName: 'ID', flex: 0.2 }, - { - field: 'view', - headerName: 'View', - flex: 0.3, - editable: true - }, - { - field: 'entry', - headerName: 'Entry ID', - flex: 0.3, - editable: true - }, - { - field: 'responder', - headerName: 'Responder ID', - flex: 0.5, - editable: true - }, - { - field: 'partOf', - headerName: 'Is Part of Training Set', - flex: 0.75, - editable: true - }, - { - field: 'available', - headerName: 'Available for Tagging', - flex: 0.75, - editable: true - } -]; + // TODO: In the future, the datasets retrieved should only be datasets + // accessible by the current project + useEffect(() => { + if (getDatasetsResults.data) { + setDatasets(getDatasetsResults.data.getDatasets); + } + }, [getDatasetsResults.data]); -export const TagTrainingComponent = () => { - const controls = [ - { name: 'First', description: 'The description of the object' }, - { name: 'Second', description: 'Description of the second object' } - ]; return ( - - {controls.map((item: Control) => ( - - } aria-controls="panel1a-content" id="panel1a-header"> - - {item.name} - - - {item.description} - - - - - - - - - - - ))} -
-
+ <> + + ); }; diff --git a/packages/client/src/graphql/entry.graphql b/packages/client/src/graphql/entry.graphql new file mode 100644 index 00000000..4ae0aed6 --- /dev/null +++ b/packages/client/src/graphql/entry.graphql @@ -0,0 +1,14 @@ +query entryForDataset($dataset: ID!) { + entryForDataset(dataset: $dataset) { + _id, + organization, + entryID, + contentType, + dataset, + creator, + dateCreated, + meta, + signedUrl, + signedUrlExpiration + } +} diff --git a/packages/client/src/graphql/entry.ts b/packages/client/src/graphql/entry.ts new file mode 100644 index 00000000..4585bdab --- /dev/null +++ b/packages/client/src/graphql/entry.ts @@ -0,0 +1,59 @@ +/* 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 EntryForDatasetQueryVariables = Types.Exact<{ + dataset: Types.Scalars['ID']['input']; +}>; + + +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 const EntryForDatasetDocument = gql` + query entryForDataset($dataset: ID!) { + entryForDataset(dataset: $dataset) { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + } +} + `; + +/** + * __useEntryForDatasetQuery__ + * + * To run a query within a React component, call `useEntryForDatasetQuery` and pass it any options that fit your needs. + * When your component renders, `useEntryForDatasetQuery` returns an object from Apollo Client that contains loading, error, and data properties + * you can use to render your UI. + * + * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; + * + * @example + * const { data, loading, error } = useEntryForDatasetQuery({ + * variables: { + * dataset: // value for 'dataset' + * }, + * }); + */ +export function useEntryForDatasetQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(EntryForDatasetDocument, options); + } +export function useEntryForDatasetLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(EntryForDatasetDocument, options); + } +export type EntryForDatasetQueryHookResult = ReturnType; +export type EntryForDatasetLazyQueryHookResult = ReturnType; +export type EntryForDatasetQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 60097ca4..ef268a85 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: Scalars['ID']['output']; + creator?: Maybe; dataset: Scalars['ID']['output']; dateCreated: Scalars['DateTime']['output']; entryID: Scalars['String']['output']; diff --git a/packages/client/src/pages/datasets/DatasetControls.tsx b/packages/client/src/pages/datasets/DatasetControls.tsx index 8f98bf20..667d40ee 100644 --- a/packages/client/src/pages/datasets/DatasetControls.tsx +++ b/packages/client/src/pages/datasets/DatasetControls.tsx @@ -1,84 +1,23 @@ -import { Accordion, AccordionDetails, AccordionSummary, Box, Container, IconButton, Typography } from '@mui/material'; +import { Box, IconButton, Typography } from '@mui/material'; import AddCircleOutlineTwoToneIcon from '@mui/icons-material/AddCircleOutlineTwoTone'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import { DatasetControl } from '../../components/DatasetControl.component'; import { AddDataset } from '../../components/AddDataset.component'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { UploadEntries } from '../../components/UploadEntries.component'; -import { GridColDef } from '@mui/x-data-grid'; - -const controls = [ - { name: 'First', description: 'The description of the object' }, - { name: 'Second', description: 'Description of the second object' } -]; - -const rows = [ - { - id: 1, - view: '', - entry: '', - responder: '', - access: true - }, - { - id: 2, - view: '', - entry: '', - responder: '', - access: true - }, - { - id: 3, - view: '', - entry: '', - responder: '', - access: true - }, - { - id: 4, - view: '', - entry: '', - responder: '', - access: false - } -]; - -const columns: GridColDef[] = [ - { field: 'id', headerName: 'ID', flex: 0.3 }, - { - field: 'view', - headerName: 'View', - flex: 0.75, - editable: true - }, - { - field: 'entry', - headerName: 'Entry ID', - flex: 1, - editable: true - }, - { - field: 'responder', - headerName: 'Responder ID', - flex: 1, - editable: true - }, - { - field: 'access', - type: 'boolean', - headerName: 'Access', - flex: 0.75 - } -]; - -interface Control { - name: string; - description: string; -} +import { Dataset } from '../../graphql/graphql'; +import { useGetDatasetsQuery } from '../../graphql/dataset/dataset'; +import { DatasetsView } from '../../components/DatasetsView.component'; export const DatasetControls: React.FC = () => { const [add, setAdd] = useState(false); const [upload, setUpload] = useState(false); + const [datasets, setDatasets] = useState([]); + const getDatasetsResults = useGetDatasetsQuery(); + + useEffect(() => { + if (getDatasetsResults.data) { + setDatasets(getDatasetsResults.data.getDatasets); + } + }, [getDatasetsResults.data]); const handleClick = (type: string) => { if (type === 'add') { @@ -123,29 +62,7 @@ export const DatasetControls: React.FC = () => { - - {controls.map((item: Control) => ( - - } aria-controls="panel1a-content" id="panel1a-header"> - - {item.name} - - - {item.description} - - - - - - - - - - - ))} - + ); }; diff --git a/packages/server/src/entry/resolvers/entry.resolver.ts b/packages/server/src/entry/resolvers/entry.resolver.ts index f2a76ba8..87d98475 100644 --- a/packages/server/src/entry/resolvers/entry.resolver.ts +++ b/packages/server/src/entry/resolvers/entry.resolver.ts @@ -32,8 +32,8 @@ export class EntryResolver { } @ResolveField(() => String) - async signedUrl(@Parent() entry: Entry): Promise { - if (!(await this.enforcer.enforce(entry.dataset, DatasetPermissions.READ, entry.dataset))) { + async signedUrl(@Parent() entry: Entry, @TokenContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.READ, entry.dataset))) { throw new UnauthorizedException('User cannot read entries on this dataset'); } @@ -43,8 +43,8 @@ export class EntryResolver { // NOTE: With the current implementation, this is only really helpful // if the request to `signedUrl` is made. @ResolveField(() => Number, { description: 'Get the number of milliseconds the signed URL is valid for.' }) - async signedUrlExpiration(@Parent() entry: Entry): Promise { - if (!(await this.enforcer.enforce(entry.dataset, DatasetPermissions.READ, entry.dataset))) { + async signedUrlExpiration(@Parent() entry: Entry, @TokenContext() user: TokenPayload): Promise { + if (!(await this.enforcer.enforce(user.id, DatasetPermissions.READ, entry.dataset))) { throw new UnauthorizedException('User cannot read entries on this dataset'); } diff --git a/packages/server/src/entry/resolvers/upload-session.resolver.ts b/packages/server/src/entry/resolvers/upload-session.resolver.ts index 48f5db74..81e02c78 100644 --- a/packages/server/src/entry/resolvers/upload-session.resolver.ts +++ b/packages/server/src/entry/resolvers/upload-session.resolver.ts @@ -44,7 +44,7 @@ export class UploadSessionResolver { throw new UnauthorizedException('User cannot write entries on this dataset'); } - return this.uploadSessionService.complete(uploadSession); + return this.uploadSessionService.complete(uploadSession, user); } @Query(() => String, { description: 'Get the presigned URL for where to upload the CSV against' }) diff --git a/packages/server/src/entry/services/entry.service.ts b/packages/server/src/entry/services/entry.service.ts index 309d8e4c..2c055e6a 100644 --- a/packages/server/src/entry/services/entry.service.ts +++ b/packages/server/src/entry/services/entry.service.ts @@ -7,6 +7,7 @@ import { Dataset } from '../../dataset/dataset.model'; import { GCP_STORAGE_PROVIDER } from '../../gcp/providers/storage.provider'; import { Bucket, Storage } from '@google-cloud/storage'; import { ConfigService } from '@nestjs/config'; +import { TokenPayload } from 'src/jwt/token.dto'; @Injectable() export class EntryService { @@ -24,12 +25,14 @@ export class EntryService { return this.entryMode.findOne({ _id: entryID }); } - async create(entryCreate: EntryCreate, dataset: Dataset): Promise { + async create(entryCreate: EntryCreate, dataset: Dataset, user: TokenPayload): Promise { return this.entryMode.create({ ...entryCreate, dataset: dataset._id, organization: dataset.organization, - recordedInSignLab: false + recordedInSignLab: false, + dateCreated: new Date(), + creator: user.id }); } diff --git a/packages/server/src/entry/services/upload-session.service.ts b/packages/server/src/entry/services/upload-session.service.ts index e900d7b8..f45db9fa 100644 --- a/packages/server/src/entry/services/upload-session.service.ts +++ b/packages/server/src/entry/services/upload-session.service.ts @@ -11,6 +11,7 @@ import { DatasetService } from '../../dataset/dataset.service'; import { EntryUploadService } from '../services/entry-upload.service'; import { UploadStatus, UploadResult } from '../dtos/upload-result.dto'; import { EntryService } from './entry.service'; +import { TokenPayload } from '../../jwt/token.dto'; @Injectable() export class UploadSessionService { @@ -52,7 +53,7 @@ export class UploadSessionService { return uploadSession; } - async complete(uploadSession: UploadSession): Promise { + async complete(uploadSession: UploadSession, user: TokenPayload): Promise { // Verify the CSV is in the bucket if (!uploadSession.csvURL) { throw new BadRequestException('CSV URL not found'); @@ -86,16 +87,14 @@ export class UploadSessionService { } // Create the entry object - // TODO: Remove media URL - // Determine media type - const contentType = entryFile.metadata.contentType; const entry = await this.entryService.create( { entryID: entryUpload.entryID, contentType: entryFile.metadata.contentType, meta: entryUpload.metadata }, - dataset + dataset, + user ); // Move the entry to the dataset