From 8f09ef690c428f9e1816b3f95766bfec0dc20eda Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 9 Jan 2024 11:19:29 -0500 Subject: [PATCH 1/6] Resolve issues quering for entries for a dataset --- packages/server/src/entry/resolvers/entry.resolver.ts | 8 ++++---- .../src/entry/resolvers/upload-session.resolver.ts | 2 +- packages/server/src/entry/services/entry.service.ts | 7 +++++-- .../server/src/entry/services/upload-session.service.ts | 9 ++++----- 4 files changed, 14 insertions(+), 12 deletions(-) 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..eb9e5863 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 From 92f5871a4d1ac05e27e57ad4a51d47ad4d9e8b29 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 9 Jan 2024 11:49:07 -0500 Subject: [PATCH 2/6] Display of individual entries in table --- .../components/DatasetControl.component.tsx | 47 -------- .../src/components/DatasetTable.component.tsx | 57 ++++++++++ .../src/components/EntryView.component.tsx | 51 +++++++++ .../src/components/TagTraining.component.tsx | 2 +- packages/client/src/graphql/entry.graphql | 14 +++ packages/client/src/graphql/entry.ts | 59 ++++++++++ packages/client/src/graphql/graphql.ts | 2 +- .../src/pages/datasets/DatasetControls.tsx | 102 ++++-------------- 8 files changed, 202 insertions(+), 132 deletions(-) delete mode 100644 packages/client/src/components/DatasetControl.component.tsx create mode 100644 packages/client/src/components/DatasetTable.component.tsx create mode 100644 packages/client/src/components/EntryView.component.tsx create mode 100644 packages/client/src/graphql/entry.graphql create mode 100644 packages/client/src/graphql/entry.ts 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..e396c21d --- /dev/null +++ b/packages/client/src/components/DatasetTable.component.tsx @@ -0,0 +1,57 @@ +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; +} + +export const DatasetTable: React.FC = (props) => { + const [entries, setEntries] = useState([]); + + const entryForDatasetResult = useEntryForDatasetQuery({ variables: { dataset: props.dataset._id } }); + + useEffect(() => { + console.log(entryForDatasetResult.error); + if (entryForDatasetResult.data) { + console.log(props.dataset); + setEntries(entryForDatasetResult.data.entryForDataset); + } + }, [entryForDatasetResult.data, entryForDatasetResult.error]); + + return ( + 'auto'} + rows={entries} + columns={defaultColumns} + 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/EntryView.component.tsx b/packages/client/src/components/EntryView.component.tsx new file mode 100644 index 00000000..b236fc81 --- /dev/null +++ b/packages/client/src/components/EntryView.component.tsx @@ -0,0 +1,51 @@ +import {Box} from '@mui/material'; +import { Entry } from '../graphql/graphql'; + +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) => { + /** Start the video at the begining */ + const handleStart: React.MouseEventHandler = (event) => { + event.currentTarget.currentTime = 0; + event.currentTarget.play(); + }; + + /** Stop the video */ + const handleStop: React.MouseEventHandler = (event) => { + event.currentTarget.pause(); + }; + + 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..bc577a50 100644 --- a/packages/client/src/components/TagTraining.component.tsx +++ b/packages/client/src/components/TagTraining.component.tsx @@ -1,5 +1,5 @@ import { Box, Accordion, AccordionSummary, Typography, AccordionDetails, Container } from '@mui/material'; -import { DatasetControl } from './DatasetControl.component'; +import { DatasetTable } from './DatasetTable.component'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import { GridColDef } from '@mui/x-data-grid'; 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..799b2eb2 100644 --- a/packages/client/src/pages/datasets/DatasetControls.tsx +++ b/packages/client/src/pages/datasets/DatasetControls.tsx @@ -1,84 +1,25 @@ import { Accordion, AccordionDetails, AccordionSummary, Box, Container, 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 { DatasetTable } from '../../components/DatasetTable.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'; 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); + console.log(getDatasetsResults.data.getDatasets); + } + }, [getDatasetsResults.data]); const handleClick = (type: string) => { if (type === 'add') { @@ -96,6 +37,7 @@ export const DatasetControls: React.FC = () => { setUpload((upload) => !upload); }; + // TODO: Implement lazy loading on accordion open to prevent loading all datasets at once return ( <> Dataset Controls @@ -124,24 +66,18 @@ export const DatasetControls: React.FC = () => { - {controls.map((item: Control) => ( - + {datasets.map((dataset: Dataset) => ( + } aria-controls="panel1a-content" id="panel1a-header"> - {item.name} + {dataset.name} - {item.description} + {dataset.description} - - - - - + ))} From 4341093242bf4818852d329ae0e0dcc8689ae982 Mon Sep 17 00:00:00 2001 From: cbolles Date: Tue, 9 Jan 2024 12:10:48 -0500 Subject: [PATCH 3/6] Middle frame support in table view --- .../src/components/DatasetTable.component.tsx | 4 +- .../src/components/EntryView.component.tsx | 66 +++++++++++++++++-- 2 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/client/src/components/DatasetTable.component.tsx b/packages/client/src/components/DatasetTable.component.tsx index e396c21d..86ec1a12 100644 --- a/packages/client/src/components/DatasetTable.component.tsx +++ b/packages/client/src/components/DatasetTable.component.tsx @@ -14,12 +14,10 @@ export const DatasetTable: React.FC = (props) => { const entryForDatasetResult = useEntryForDatasetQuery({ variables: { dataset: props.dataset._id } }); useEffect(() => { - console.log(entryForDatasetResult.error); if (entryForDatasetResult.data) { - console.log(props.dataset); setEntries(entryForDatasetResult.data.entryForDataset); } - }, [entryForDatasetResult.data, entryForDatasetResult.error]); + }, [entryForDatasetResult.data]); return ( { }; const VideoEntryView: React.FC = (props) => { + const videoRef = useRef(null); + /** Start the video at the begining */ - const handleStart: React.MouseEventHandler = (event) => { - event.currentTarget.currentTime = 0; - event.currentTarget.play(); + const handleStart: React.MouseEventHandler = () => { + if (!videoRef.current) { + return; + } + videoRef.current.currentTime = 0; + videoRef.current?.play(); }; /** Stop the video */ - const handleStop: React.MouseEventHandler = (event) => { - event.currentTarget.pause(); + 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; + }; + + + useEffect(() => { + setMiddleFrame(); + }, [videoRef.current]); + return ( - - - {datasets.map((dataset: Dataset) => ( - - } aria-controls="panel1a-content" id="panel1a-header"> - - {dataset.name} - - - {dataset.description} - - - - - - - ))} - + ); };