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