diff --git a/packages/client/public/locales/en/translation.json b/packages/client/public/locales/en/translation.json index 1a4631d1..a2e6ef80 100644 --- a/packages/client/public/locales/en/translation.json +++ b/packages/client/public/locales/en/translation.json @@ -20,7 +20,11 @@ "view": "View", "entryId": "Entry ID", "login": "Login", - "clear": "clear" + "clear": "clear", + "complete": "complete", + "video": "video", + "key": "key", + "primary": "primay" }, "languages": { "en": "English", @@ -40,7 +44,7 @@ "newStudy": "New Study", "studyControl": "Study Control", "entryControl": "Entry Control", - "downloadTags": "Download Tags", + "viewTags": "Download Tags", "datasets": "Datasets", "datasetControl": "Dataset Control", "projectAccess": "Project Access", @@ -117,6 +121,9 @@ "login": { "selectOrg": "Select an Organization to Login", "redirectToOrg": "Redirect to Organization Login" + }, + "tagView": { + "originalEntry": "Original Entry" } } } diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 6984421e..dd1c9730 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -13,7 +13,7 @@ import { StudyControl } from './pages/studies/StudyControl'; import { ProjectAccess } from './pages/datasets/ProjectAccess'; import { ProjectUserPermissions } from './pages/projects/ProjectUserPermissions'; import { StudyUserPermissions } from './pages/studies/UserPermissions'; -import { DownloadTags } from './pages/studies/DownloadTags'; +import { TagView } from './pages/studies/TagView'; import { DatasetControls } from './pages/datasets/DatasetControls'; import { AuthProvider, useAuth, AUTH_TOKEN_STR } from './context/Auth.context'; import { AdminGuard } from './guards/AdminGuard'; @@ -125,7 +125,7 @@ const MyRoutes: FC = () => { } /> } /> } /> - } /> + } /> } /> } /> } /> diff --git a/packages/client/src/components/EntryView.component.tsx b/packages/client/src/components/EntryView.component.tsx index 2be4d788..553ae1ef 100644 --- a/packages/client/src/components/EntryView.component.tsx +++ b/packages/client/src/components/EntryView.component.tsx @@ -1,14 +1,9 @@ import { Box } from '@mui/material'; import { Entry } from '../graphql/graphql'; -import { useEffect, useRef } from 'react'; +import { VideoViewProps, VideoEntryView } from './VideoView.component'; -export interface EntryViewProps { +export interface EntryViewProps extends Omit { entry: Entry; - width: number; - pauseFrame?: 'start' | 'end' | 'middle'; - autoPlay?: boolean; - mouseOverControls?: boolean; - displayControls?: boolean; } export const EntryView: React.FC = (props) => { @@ -17,7 +12,7 @@ export const EntryView: React.FC = (props) => { const getEntryView = (props: EntryViewProps) => { if (props.entry.contentType.startsWith('video/')) { - return ; + return ; } if (props.entry.contentType.startsWith('image/')) { return ; @@ -26,91 +21,6 @@ const getEntryView = (props: EntryViewProps) => { 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 || (props.mouseOverControls != undefined && !props.mouseOverControls)) { - return; - } - videoRef.current.currentTime = 0; - videoRef.current?.play(); - }; - - /** Stop the video */ - const handleStop: React.MouseEventHandler = () => { - if (!videoRef.current || (props.mouseOverControls != undefined && !props.mouseOverControls)) { - return; - } - videoRef.current.pause(); - setPauseFrame(); - }; - - /** Set the video to the middle frame */ - const setPauseFrame = async () => { - if (!videoRef.current) { - return; - } - - 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 */ - 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(() => { - setPauseFrame(); - }, [videoRef.current]); - - return ( - - - - ); -}; - const ImageEntryView: React.FC = (props) => { return ( diff --git a/packages/client/src/components/SideBar.component.tsx b/packages/client/src/components/SideBar.component.tsx index 5144739d..6b466b46 100644 --- a/packages/client/src/components/SideBar.component.tsx +++ b/packages/client/src/components/SideBar.component.tsx @@ -63,7 +63,7 @@ export const SideBar: FC = ({ open, drawerWidth }) => { visible: (p) => p!.studyAdmin }, { name: t('menu.entryControl'), action: () => navigate('/study/entries'), visible: (p) => p!.studyAdmin }, - { name: t('menu.downloadTags'), action: () => navigate('/study/tags'), visible: (p) => p!.studyAdmin } + { name: t('menu.viewTags'), action: () => navigate('/study/tags'), visible: (p) => p!.studyAdmin } ] }, { diff --git a/packages/client/src/components/VideoView.component.tsx b/packages/client/src/components/VideoView.component.tsx new file mode 100644 index 00000000..faf0d5a4 --- /dev/null +++ b/packages/client/src/components/VideoView.component.tsx @@ -0,0 +1,95 @@ +import { useRef, useEffect } from 'react'; +import { Box } from '@mui/material'; + +export interface VideoViewProps { + url: string; + width: number; + pauseFrame?: 'start' | 'end' | 'middle'; + autoPlay?: boolean; + mouseOverControls?: boolean; + displayControls?: boolean; +} + +export const VideoEntryView: React.FC = (props) => { + const videoRef = useRef(null); + + /** Start the video at the begining */ + const handleStart: React.MouseEventHandler = () => { + if (!videoRef.current || (props.mouseOverControls != undefined && !props.mouseOverControls)) { + return; + } + videoRef.current.currentTime = 0; + videoRef.current?.play(); + }; + + /** Stop the video */ + const handleStop: React.MouseEventHandler = () => { + if (!videoRef.current || (props.mouseOverControls != undefined && !props.mouseOverControls)) { + return; + } + videoRef.current.pause(); + setPauseFrame(); + }; + + /** Set the video to the middle frame */ + const setPauseFrame = async () => { + if (!videoRef.current) { + return; + } + + 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 */ + 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(() => { + setPauseFrame(); + }, [videoRef.current]); + + return ( + + + + ); +}; diff --git a/packages/client/src/components/tag/view/AslLexGridView.component.tsx b/packages/client/src/components/tag/view/AslLexGridView.component.tsx new file mode 100644 index 00000000..fae73e6a --- /dev/null +++ b/packages/client/src/components/tag/view/AslLexGridView.component.tsx @@ -0,0 +1,98 @@ +import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; +import { useLexiconByKeyQuery } from '../../../graphql/lex'; +import { useEffect, useState } from 'react'; +import { VideoEntryView } from '../../VideoView.component'; +import i18next from 'i18next'; + +const AslLexGridViewVideo: React.FC = ({ data }) => { + const [videoUrl, setVideoUrl] = useState(null); + + const lexiconByKeyResult = useLexiconByKeyQuery({ + variables: { lexicon: import.meta.env.VITE_ASL_LEXICON_ID, key: data } + }); + + useEffect(() => { + if (lexiconByKeyResult.data) { + setVideoUrl(lexiconByKeyResult.data.lexiconByKey.video); + } + }, [lexiconByKeyResult]); + + return ( + <> + {videoUrl && ( + + )} + + ); +}; + +const AslLexGridViewKey: React.FC = ({ data }) => { + return data; +}; + +const AslLexGridViewPrimary: React.FC = ({ data }) => { + const [primary, setPrimary] = useState(null); + + const lexiconByKeyResult = useLexiconByKeyQuery({ + variables: { lexicon: import.meta.env.VITE_ASL_LEXICON_ID, key: data } + }); + + useEffect(() => { + if (lexiconByKeyResult.data) { + setPrimary(lexiconByKeyResult.data.lexiconByKey.primary); + } + }, [lexiconByKeyResult]); + + return primary || ''; +}; + +export const aslLexTest: TagViewTest = (uischema, _schema, _context) => { + if ( + uischema.options != undefined && + uischema.options.customType != undefined && + uischema.options.customType == 'asl-lex' + ) { + return 5; + } + return NOT_APPLICABLE; +}; + +export const getAslLexCols: GetGridColDefs = (uischema, schema, property) => { + return [ + { + field: `${property}-video`, + headerName: `${property}: ${i18next.t('common.video')}`, + width: 300, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + }, + { + field: `${property}-key`, + headerName: `${property}: ${i18next.t('common.key')}`, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + }, + { + field: `${property}-primary`, + headerName: `${property}: ${i18next.t('common.primary')}`, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + } + ]; +}; diff --git a/packages/client/src/components/tag/view/BooleanGridView.component.tsx b/packages/client/src/components/tag/view/BooleanGridView.component.tsx new file mode 100644 index 00000000..74a3b834 --- /dev/null +++ b/packages/client/src/components/tag/view/BooleanGridView.component.tsx @@ -0,0 +1,29 @@ +import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; +import { materialBooleanControlTester } from '@jsonforms/material-renderers'; +import { Checkbox } from '@mui/material'; + +/** Visualize basic text data in a grid view */ +const BooleanGridView: React.FC = ({ data }) => { + return ; +}; + +export const booleanTest: TagViewTest = (uischema, schema, context) => { + if (materialBooleanControlTester(uischema, schema, context) !== NOT_APPLICABLE) { + return 2; + } + return NOT_APPLICABLE; +}; + +export const getBoolCols: GetGridColDefs = (uischema, schema, property) => { + return [ + { + field: property, + headerName: property, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + } + ]; +}; diff --git a/packages/client/src/components/tag/view/CategoricalGridView.component.tsx b/packages/client/src/components/tag/view/CategoricalGridView.component.tsx new file mode 100644 index 00000000..e69de29b diff --git a/packages/client/src/components/tag/view/FreeTextGridView.component.tsx b/packages/client/src/components/tag/view/FreeTextGridView.component.tsx new file mode 100644 index 00000000..509441c1 --- /dev/null +++ b/packages/client/src/components/tag/view/FreeTextGridView.component.tsx @@ -0,0 +1,28 @@ +import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; +import { materialAnyOfStringOrEnumControlTester } from '@jsonforms/material-renderers'; + +/** Visualize basic text data in a grid view */ +const FreeTextGridView: React.FC = ({ data }) => { + return data; +}; + +export const freeTextTest: TagViewTest = (uischema, schema, context) => { + if (materialAnyOfStringOrEnumControlTester(uischema, schema, context) !== NOT_APPLICABLE) { + return 1; + } + return NOT_APPLICABLE; +}; + +export const getTextCols: GetGridColDefs = (uischema, schema, property) => { + return [ + { + field: property, + headerName: property, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + } + ]; +}; diff --git a/packages/client/src/components/tag/view/NumericGridView.component.tsx b/packages/client/src/components/tag/view/NumericGridView.component.tsx new file mode 100644 index 00000000..2aa8b549 --- /dev/null +++ b/packages/client/src/components/tag/view/NumericGridView.component.tsx @@ -0,0 +1,28 @@ +import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; +import { materialNumberControlTester } from '@jsonforms/material-renderers'; + +/** Visualize basic text data in a grid view */ +const NumericGridView: React.FC = ({ data }) => { + return data; +}; + +export const numericTest: TagViewTest = (uischema, schema, context) => { + if (materialNumberControlTester(uischema, schema, context) !== NOT_APPLICABLE) { + return 2; + } + return NOT_APPLICABLE; +}; + +export const getNumericCols: GetGridColDefs = (uischema, schema, property) => { + return [ + { + field: property, + headerName: property, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + } + ]; +}; diff --git a/packages/client/src/components/tag/view/SliderGridView.component.tsx b/packages/client/src/components/tag/view/SliderGridView.component.tsx new file mode 100644 index 00000000..8b125950 --- /dev/null +++ b/packages/client/src/components/tag/view/SliderGridView.component.tsx @@ -0,0 +1,28 @@ +import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; +import { materialSliderControlTester } from '@jsonforms/material-renderers'; + +/** Visualize basic text data in a grid view */ +const SliderGridView: React.FC = ({ data }) => { + return data; +}; + +export const sliderTest: TagViewTest = (uischema, schema, context) => { + if (materialSliderControlTester(uischema, schema, context) !== NOT_APPLICABLE) { + return 2; + } + return NOT_APPLICABLE; +}; + +export const getSliderCols: GetGridColDefs = (uischema, schema, property) => { + return [ + { + field: property, + headerName: property, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + } + ]; +}; diff --git a/packages/client/src/components/tag/view/TagGridView.component.tsx b/packages/client/src/components/tag/view/TagGridView.component.tsx new file mode 100644 index 00000000..426f356b --- /dev/null +++ b/packages/client/src/components/tag/view/TagGridView.component.tsx @@ -0,0 +1,92 @@ +import { useTranslation } from 'react-i18next'; +import { GetGridColDefs, TagViewTest } from '../../../types/TagColumnView'; +import { Study, Entry } from '../../../graphql/graphql'; +import { GridColDef, GridRenderCellParams } from '@mui/x-data-grid'; +import { DataGrid } from '@mui/x-data-grid'; +import { GetTagsQuery, useGetTagsQuery } from '../../../graphql/tag/tag'; +import { useEffect, useState } from 'react'; +import { freeTextTest, getTextCols } from './FreeTextGridView.component'; +import { EntryView } from '../../EntryView.component'; +import { Checkbox } from '@mui/material'; +import { getNumericCols, numericTest } from './NumericGridView.component'; +import { getSliderCols, sliderTest } from './SliderGridView.component'; +import { getBoolCols, booleanTest } from './BooleanGridView.component'; +import { aslLexTest, getAslLexCols } from './AslLexGridView.component'; +import { getVideoCols, videoViewTest } from './VideoGridView.component'; + +export interface TagGridViewProps { + study: Study; +} + +export const TagGridView: React.FC = ({ study }) => { + const { t } = useTranslation(); + const [tags, setTags] = useState([]); + + const tagColumnViews: { tester: TagViewTest; getGridColDefs: GetGridColDefs }[] = [ + { tester: freeTextTest, getGridColDefs: getTextCols }, + { tester: numericTest, getGridColDefs: getNumericCols }, + { tester: sliderTest, getGridColDefs: getSliderCols }, + { tester: booleanTest, getGridColDefs: getBoolCols }, + { tester: aslLexTest, getGridColDefs: getAslLexCols }, + { tester: videoViewTest, getGridColDefs: getVideoCols } + ]; + + const getTagsResults = useGetTagsQuery({ variables: { study: study._id } }); + + useEffect(() => { + if (getTagsResults.data) { + setTags(getTagsResults.data.getTags); + } + }, [getTagsResults.data]); + + const entryColumns: GridColDef[] = [ + { + field: 'entryView', + headerName: t('components.tagView.originalEntry'), + width: 300, + renderCell: (params: GridRenderCellParams) => + } + ]; + + const tagMetaColumns: GridColDef[] = [ + { + field: 'complete', + headerName: t('common.complete'), + renderCell: (params: GridRenderCellParams) => + } + ]; + + // Generate the dynamic columns for the grid + const dataColunms: GridColDef[] = Object.getOwnPropertyNames(study.tagSchema.dataSchema.properties) + .map((property: string) => { + const fieldSchema = study.tagSchema.dataSchema.properties[property]; + const fieldUiSchema = study.tagSchema.uiSchema.elements.find( + (element: any) => element.scope === `#/properties/${property}` + ); + + if (!fieldSchema || !fieldUiSchema) { + throw new Error(`Could not find schema for property ${property}`); + } + + const context = { rootSchema: study.tagSchema.dataSchema, config: {} }; + const reactNode = tagColumnViews + .filter((view) => view.tester(fieldUiSchema, fieldSchema, context)) + .sort((a, b) => b.tester(fieldUiSchema, fieldSchema, context) - a.tester(fieldUiSchema, fieldSchema, context)); + + if (reactNode.length === 0) { + throw new Error(`No matching view for property ${property}`); + } + + return reactNode[0].getGridColDefs(fieldUiSchema, fieldSchema, property); + }) + .flat(); + + return ( + 'auto'} + rows={tags} + columns={entryColumns.concat(tagMetaColumns).concat(dataColunms)} + getRowId={(row) => row._id} + /> + ); +}; diff --git a/packages/client/src/components/tag/view/VideoGridView.component.tsx b/packages/client/src/components/tag/view/VideoGridView.component.tsx new file mode 100644 index 00000000..b611d491 --- /dev/null +++ b/packages/client/src/components/tag/view/VideoGridView.component.tsx @@ -0,0 +1,66 @@ +import { GridColDef } from '@mui/x-data-grid'; +import { TagColumnViewProps, TagViewTest, NOT_APPLICABLE, GetGridColDefs } from '../../../types/TagColumnView'; +import i18next from 'i18next'; +import { useEntryFromIdQuery } from '../../../graphql/entry/entry'; +import { Entry } from '../../../graphql/graphql'; +import { useEffect, useState } from 'react'; +import { VideoEntryView } from '../../VideoView.component'; + +const VideoGridView: React.FC = ({ data }) => { + const [entry, setEntry] = useState(null); + const entryFromIdResult = useEntryFromIdQuery({ variables: { entry: data } }); + + useEffect(() => { + if (entryFromIdResult.data) { + setEntry(entryFromIdResult.data.entryFromID); + } + }, [entryFromIdResult]); + + return ( + <> + {entry && ( + + )} + + ); +}; + +export const videoViewTest: TagViewTest = (uischema, _schema, _context) => { + if (uischema.options && uischema.options.customType && uischema.options.customType === 'video') { + return 5; + } + return NOT_APPLICABLE; +}; + +export const getVideoCols: GetGridColDefs = (uischema, schema, property) => { + const minVideos = uischema.options!.minimumRequired!; + + let maxVideos = uischema.options!.maximumRequired; + if (!maxVideos) { + maxVideos = minVideos; + } + + const columns: GridColDef[] = []; + + for (let i = 0; i < maxVideos; i++) { + columns.push({ + field: `${property}-video-${i + 1}`, + headerName: `${property}: ${i18next.t('common.video')} ${i + 1}`, + width: 300, + renderCell: (params) => + params.row.data && + params.row.data[property] && ( + + ) + }); + } + + return columns; +}; diff --git a/packages/client/src/graphql/entry/entry.graphql b/packages/client/src/graphql/entry/entry.graphql index 37d74d90..c2cab931 100644 --- a/packages/client/src/graphql/entry/entry.graphql +++ b/packages/client/src/graphql/entry/entry.graphql @@ -13,6 +13,21 @@ query entryForDataset($dataset: ID!) { } } +query entryFromID($entry: ID!) { + entryFromID(entry: $entry) { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + } +} + mutation deleteEntry($entry: ID!) { deleteEntry(entry: $entry) } diff --git a/packages/client/src/graphql/entry/entry.ts b/packages/client/src/graphql/entry/entry.ts index fdd15e24..7d4c3acc 100644 --- a/packages/client/src/graphql/entry/entry.ts +++ b/packages/client/src/graphql/entry/entry.ts @@ -12,6 +12,13 @@ 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, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number }> }; +export type EntryFromIdQueryVariables = Types.Exact<{ + entry: Types.Scalars['ID']['input']; +}>; + + +export type EntryFromIdQuery = { __typename?: 'Query', entryFromID: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number } }; + export type DeleteEntryMutationVariables = Types.Exact<{ entry: Types.Scalars['ID']['input']; }>; @@ -64,6 +71,50 @@ export function useEntryForDatasetLazyQuery(baseOptions?: Apollo.LazyQueryHookOp export type EntryForDatasetQueryHookResult = ReturnType; export type EntryForDatasetLazyQueryHookResult = ReturnType; export type EntryForDatasetQueryResult = Apollo.QueryResult; +export const EntryFromIdDocument = gql` + query entryFromID($entry: ID!) { + entryFromID(entry: $entry) { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + } +} + `; + +/** + * __useEntryFromIdQuery__ + * + * To run a query within a React component, call `useEntryFromIdQuery` and pass it any options that fit your needs. + * When your component renders, `useEntryFromIdQuery` 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 } = useEntryFromIdQuery({ + * variables: { + * entry: // value for 'entry' + * }, + * }); + */ +export function useEntryFromIdQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(EntryFromIdDocument, options); + } +export function useEntryFromIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(EntryFromIdDocument, options); + } +export type EntryFromIdQueryHookResult = ReturnType; +export type EntryFromIdLazyQueryHookResult = ReturnType; +export type EntryFromIdQueryResult = Apollo.QueryResult; export const DeleteEntryDocument = gql` mutation deleteEntry($entry: ID!) { deleteEntry(entry: $entry) diff --git a/packages/client/src/graphql/graphql.ts b/packages/client/src/graphql/graphql.ts index 4833e4ce..e8007aa6 100644 --- a/packages/client/src/graphql/graphql.ts +++ b/packages/client/src/graphql/graphql.ts @@ -18,35 +18,6 @@ export type Scalars = { JSON: { input: any; output: any; } }; -/** Input type for accepting an invite */ -export type AcceptInviteModel = { - /** The email address of the user accepting the invite */ - email: Scalars['String']['input']; - /** The full name of the user accepting the invite */ - fullname: Scalars['String']['input']; - /** The invite code that was included in the invite email */ - inviteCode: Scalars['String']['input']; - /** The password for the new user account */ - password: Scalars['String']['input']; - /** The ID of the project the invite is associated with */ - projectId: Scalars['String']['input']; -}; - -export type AccessToken = { - __typename?: 'AccessToken'; - accessToken: Scalars['String']['output']; - refreshToken: Scalars['String']['output']; -}; - -export type ConfigurableProjectSettings = { - description?: InputMaybe; - homePage?: InputMaybe; - logo?: InputMaybe; - muiTheme?: InputMaybe; - name?: InputMaybe; - redirectUrl?: InputMaybe; -}; - export type Dataset = { __typename?: 'Dataset'; _id: Scalars['ID']['output']; @@ -65,12 +36,6 @@ export type DatasetProjectPermission = { projectHasAccess: Scalars['Boolean']['output']; }; -export type EmailLoginDto = { - email: Scalars['String']['input']; - password: Scalars['String']['input']; - projectId: Scalars['String']['input']; -}; - export type Entry = { __typename?: 'Entry'; _id: Scalars['String']['output']; @@ -86,46 +51,6 @@ export type Entry = { signedUrlExpiration: Scalars['Float']['output']; }; -export type ForgotDto = { - email: Scalars['String']['input']; - projectId: Scalars['String']['input']; -}; - -export type GoogleLoginDto = { - credential: Scalars['String']['input']; - projectId: Scalars['String']['input']; -}; - -export type InviteModel = { - __typename?: 'InviteModel'; - /** The date and time at which the invitation was created. */ - createdAt: Scalars['DateTime']['output']; - /** The date and time at which the invitation was deleted, if applicable. */ - deletedAt?: Maybe; - /** The email address of the user being invited. */ - email: Scalars['String']['output']; - /** The date and time at which the invitation expires. */ - expiresAt: Scalars['DateTime']['output']; - /** The ID of the invitation. */ - id: Scalars['ID']['output']; - /** The ID of the project to which the invitation belongs. */ - projectId: Scalars['String']['output']; - /** The role that the user being invited will have. */ - role: Scalars['Int']['output']; - /** The status of the invitation. */ - status: InviteStatus; - /** The date and time at which the invitation was last updated. */ - updatedAt: Scalars['DateTime']['output']; -}; - -/** The status of an invite */ -export enum InviteStatus { - Accepted = 'ACCEPTED', - Cancelled = 'CANCELLED', - Expired = 'EXPIRED', - Pending = 'PENDING' -} - /** Represents an entier lexicon */ export type Lexicon = { __typename?: 'Lexicon'; @@ -176,9 +101,7 @@ export type LexiconEntry = { export type Mutation = { __typename?: 'Mutation'; - acceptInvite: InviteModel; assignTag?: Maybe; - cancelInvite: InviteModel; changeDatasetDescription: Scalars['Boolean']['output']; changeDatasetName: Scalars['Boolean']['output']; changeStudyDescription: Study; @@ -186,16 +109,13 @@ export type Mutation = { completeTag: Scalars['Boolean']['output']; completeUploadSession: UploadResult; createDataset: Dataset; - createInvite: InviteModel; createOrganization: Organization; - createProject: ProjectModel; createStudy: Study; createTags: Array; createUploadSession: UploadSession; deleteEntry: Scalars['Boolean']['output']; deleteProject: Scalars['Boolean']['output']; deleteStudy: Scalars['Boolean']['output']; - forgotPassword: Scalars['Boolean']['output']; grantContributor: Scalars['Boolean']['output']; grantOwner: Scalars['Boolean']['output']; grantProjectDatasetAccess: Scalars['Boolean']['output']; @@ -206,25 +126,9 @@ export type Mutation = { /** Remove all entries from a given lexicon */ lexiconClearEntries: Scalars['Boolean']['output']; lexiconCreate: Lexicon; - loginEmail: AccessToken; - loginGoogle: AccessToken; - loginUsername: AccessToken; - refresh: AccessToken; - resendInvite: InviteModel; - resetPassword: Scalars['Boolean']['output']; saveVideoField: VideoField; setEntryEnabled: Scalars['Boolean']['output']; signLabCreateProject: Project; - signup: AccessToken; - updateProject: ProjectModel; - updateProjectAuthMethods: ProjectModel; - updateProjectSettings: ProjectModel; - updateUser: UserModel; -}; - - -export type MutationAcceptInviteArgs = { - input: AcceptInviteModel; }; @@ -233,11 +137,6 @@ export type MutationAssignTagArgs = { }; -export type MutationCancelInviteArgs = { - id: Scalars['ID']['input']; -}; - - export type MutationChangeDatasetDescriptionArgs = { dataset: Scalars['ID']['input']; newDescription: Scalars['String']['input']; @@ -278,22 +177,11 @@ export type MutationCreateDatasetArgs = { }; -export type MutationCreateInviteArgs = { - email: Scalars['String']['input']; - role?: InputMaybe; -}; - - export type MutationCreateOrganizationArgs = { organization: OrganizationCreate; }; -export type MutationCreateProjectArgs = { - project: ProjectCreateInput; -}; - - export type MutationCreateStudyArgs = { study: StudyCreate; }; @@ -325,11 +213,6 @@ export type MutationDeleteStudyArgs = { }; -export type MutationForgotPasswordArgs = { - user: ForgotDto; -}; - - export type MutationGrantContributorArgs = { isContributor: Scalars['Boolean']['input']; study: Scalars['ID']['input']; @@ -385,36 +268,6 @@ export type MutationLexiconCreateArgs = { }; -export type MutationLoginEmailArgs = { - user: EmailLoginDto; -}; - - -export type MutationLoginGoogleArgs = { - user: GoogleLoginDto; -}; - - -export type MutationLoginUsernameArgs = { - user: UsernameLoginDto; -}; - - -export type MutationRefreshArgs = { - refreshToken: Scalars['String']['input']; -}; - - -export type MutationResendInviteArgs = { - id: Scalars['ID']['input']; -}; - - -export type MutationResetPasswordArgs = { - user: ResetDto; -}; - - export type MutationSaveVideoFieldArgs = { field: Scalars['String']['input']; index: Scalars['Int']['input']; @@ -433,35 +286,6 @@ export type MutationSignLabCreateProjectArgs = { project: ProjectCreate; }; - -export type MutationSignupArgs = { - user: UserSignupDto; -}; - - -export type MutationUpdateProjectArgs = { - id: Scalars['String']['input']; - settings: ConfigurableProjectSettings; -}; - - -export type MutationUpdateProjectAuthMethodsArgs = { - id: Scalars['String']['input']; - projectAuthMethods: ProjectAuthMethodsInput; -}; - - -export type MutationUpdateProjectSettingsArgs = { - id: Scalars['String']['input']; - projectSettings: ProjectSettingsInput; -}; - - -export type MutationUpdateUserArgs = { - email: Scalars['String']['input']; - fullname: Scalars['String']['input']; -}; - export type Organization = { __typename?: 'Organization'; _id: Scalars['ID']['output']; @@ -498,52 +322,11 @@ export type Project = { name: Scalars['String']['output']; }; -export type ProjectAuthMethodsInput = { - emailAuth?: InputMaybe; - googleAuth?: InputMaybe; -}; - -export type ProjectAuthMethodsModel = { - __typename?: 'ProjectAuthMethodsModel'; - emailAuth: Scalars['Boolean']['output']; - googleAuth: Scalars['Boolean']['output']; -}; - export type ProjectCreate = { description: Scalars['String']['input']; name: Scalars['String']['input']; }; -export type ProjectCreateInput = { - allowSignup?: InputMaybe; - description?: InputMaybe; - displayProjectName?: InputMaybe; - emailAuth?: InputMaybe; - googleAuth?: InputMaybe; - homePage?: InputMaybe; - logo?: InputMaybe; - muiTheme?: InputMaybe; - name: Scalars['String']['input']; - redirectUrl?: InputMaybe; -}; - -export type ProjectModel = { - __typename?: 'ProjectModel'; - authMethods: ProjectAuthMethodsModel; - createdAt: Scalars['DateTime']['output']; - deletedAt?: Maybe; - description?: Maybe; - homePage?: Maybe; - id: Scalars['ID']['output']; - logo?: Maybe; - muiTheme: Scalars['JSON']['output']; - name: Scalars['String']['output']; - redirectUrl?: Maybe; - settings: ProjectSettingsModel; - updatedAt: Scalars['DateTime']['output']; - users: Array; -}; - export type ProjectPermissionModel = { __typename?: 'ProjectPermissionModel'; editable: Scalars['Boolean']['output']; @@ -551,21 +334,11 @@ export type ProjectPermissionModel = { user: User; }; -export type ProjectSettingsInput = { - allowSignup?: InputMaybe; - displayProjectName?: InputMaybe; -}; - -export type ProjectSettingsModel = { - __typename?: 'ProjectSettingsModel'; - allowSignup: Scalars['Boolean']['output']; - displayProjectName: Scalars['Boolean']['output']; -}; - export type Query = { __typename?: 'Query'; datasetExists: Scalars['Boolean']['output']; entryForDataset: Array; + entryFromID: Entry; exists: Scalars['Boolean']['output']; findStudies: Array; /** Get the presigned URL for where to upload the CSV against */ @@ -575,25 +348,17 @@ export type Query = { getDatasetsByProject: Array; getEntryUploadURL: Scalars['String']['output']; getOrganizations: Array; - getProject: ProjectModel; getProjectPermissions: Array; getProjects: Array; getRoles: Permission; getStudyPermissions: Array; - getUser: UserModel; - invite: InviteModel; - invites: Array; + getTags: Array; isEntryEnabled: Scalars['Boolean']['output']; lexFindAll: Array; lexiconByKey: LexiconEntry; lexiconSearch: Array; - listProjects: Array; - me: UserModel; projectExists: Scalars['Boolean']['output']; - projectUsers: Array; - publicKey: Array; studyExists: Scalars['Boolean']['output']; - users: Array; validateCSV: UploadResult; }; @@ -608,6 +373,11 @@ export type QueryEntryForDatasetArgs = { }; +export type QueryEntryFromIdArgs = { + entry: Scalars['ID']['input']; +}; + + export type QueryExistsArgs = { name: Scalars['String']['input']; }; @@ -640,11 +410,6 @@ export type QueryGetEntryUploadUrlArgs = { }; -export type QueryGetProjectArgs = { - id: Scalars['String']['input']; -}; - - export type QueryGetProjectPermissionsArgs = { project: Scalars['ID']['input']; }; @@ -661,18 +426,8 @@ export type QueryGetStudyPermissionsArgs = { }; -export type QueryGetUserArgs = { - id: Scalars['ID']['input']; -}; - - -export type QueryInviteArgs = { - id: Scalars['ID']['input']; -}; - - -export type QueryInvitesArgs = { - status?: InputMaybe; +export type QueryGetTagsArgs = { + study: Scalars['ID']['input']; }; @@ -699,11 +454,6 @@ export type QueryProjectExistsArgs = { }; -export type QueryProjectUsersArgs = { - projectId: Scalars['String']['input']; -}; - - export type QueryStudyExistsArgs = { name: Scalars['String']['input']; project: Scalars['ID']['input']; @@ -714,13 +464,6 @@ export type QueryValidateCsvArgs = { session: Scalars['ID']['input']; }; -export type ResetDto = { - code: Scalars['String']['input']; - email: Scalars['String']['input']; - password: Scalars['String']['input']; - projectId: Scalars['String']['input']; -}; - export type Study = { __typename?: 'Study'; _id: Scalars['ID']['output']; @@ -807,33 +550,6 @@ export type User = { uid: Scalars['String']['output']; }; -export type UserModel = { - __typename?: 'UserModel'; - createdAt: Scalars['DateTime']['output']; - deletedAt?: Maybe; - email?: Maybe; - fullname?: Maybe; - id: Scalars['ID']['output']; - projectId: Scalars['String']['output']; - role: Scalars['Int']['output']; - updatedAt: Scalars['DateTime']['output']; - username?: Maybe; -}; - -export type UserSignupDto = { - email: Scalars['String']['input']; - fullname: Scalars['String']['input']; - password: Scalars['String']['input']; - projectId: Scalars['String']['input']; - username?: InputMaybe; -}; - -export type UsernameLoginDto = { - password: Scalars['String']['input']; - projectId: Scalars['String']['input']; - username: Scalars['String']['input']; -}; - export type VideoField = { __typename?: 'VideoField'; _id: Scalars['String']['output']; diff --git a/packages/client/src/graphql/lex.graphql b/packages/client/src/graphql/lex.graphql new file mode 100644 index 00000000..d00ce02d --- /dev/null +++ b/packages/client/src/graphql/lex.graphql @@ -0,0 +1,10 @@ +query lexiconByKey($lexicon: String!, $key: String!) { + lexiconByKey(lexicon: $lexicon, key: $key) { + key, + primary, + video, + lexicon, + associates, + fields + } +} diff --git a/packages/client/src/graphql/lex.ts b/packages/client/src/graphql/lex.ts new file mode 100644 index 00000000..451eb1a0 --- /dev/null +++ b/packages/client/src/graphql/lex.ts @@ -0,0 +1,57 @@ +/* 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 LexiconByKeyQueryVariables = Types.Exact<{ + lexicon: Types.Scalars['String']['input']; + key: Types.Scalars['String']['input']; +}>; + + +export type LexiconByKeyQuery = { __typename?: 'Query', lexiconByKey: { __typename?: 'LexiconEntry', key: string, primary: string, video: string, lexicon: string, associates: Array, fields: any } }; + + +export const LexiconByKeyDocument = gql` + query lexiconByKey($lexicon: String!, $key: String!) { + lexiconByKey(lexicon: $lexicon, key: $key) { + key + primary + video + lexicon + associates + fields + } +} + `; + +/** + * __useLexiconByKeyQuery__ + * + * To run a query within a React component, call `useLexiconByKeyQuery` and pass it any options that fit your needs. + * When your component renders, `useLexiconByKeyQuery` 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 } = useLexiconByKeyQuery({ + * variables: { + * lexicon: // value for 'lexicon' + * key: // value for 'key' + * }, + * }); + */ +export function useLexiconByKeyQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(LexiconByKeyDocument, options); + } +export function useLexiconByKeyLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(LexiconByKeyDocument, options); + } +export type LexiconByKeyQueryHookResult = ReturnType; +export type LexiconByKeyLazyQueryHookResult = ReturnType; +export type LexiconByKeyQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/client/src/graphql/tag/tag.graphql b/packages/client/src/graphql/tag/tag.graphql index bcd89b97..d30a7514 100644 --- a/packages/client/src/graphql/tag/tag.graphql +++ b/packages/client/src/graphql/tag/tag.graphql @@ -40,3 +40,23 @@ mutation saveVideoField($tag: ID!, $field: String!, $index: Int!) { uploadURL } } + +query getTags($study: ID!) { + getTags(study: $study) { + _id + entry { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + } + data + complete + } +} diff --git a/packages/client/src/graphql/tag/tag.ts b/packages/client/src/graphql/tag/tag.ts index 58b7f376..d3c4b3b5 100644 --- a/packages/client/src/graphql/tag/tag.ts +++ b/packages/client/src/graphql/tag/tag.ts @@ -54,6 +54,13 @@ export type SaveVideoFieldMutationVariables = Types.Exact<{ export type SaveVideoFieldMutation = { __typename?: 'Mutation', saveVideoField: { __typename?: 'VideoField', _id: string, uploadURL: string } }; +export type GetTagsQueryVariables = Types.Exact<{ + study: Types.Scalars['ID']['input']; +}>; + + +export type GetTagsQuery = { __typename?: 'Query', getTags: Array<{ __typename?: 'Tag', _id: string, data?: any | null, complete: boolean, entry: { __typename?: 'Entry', _id: string, organization: string, entryID: string, contentType: string, dataset: string, creator: string, dateCreated: any, meta?: any | null, signedUrl: string, signedUrlExpiration: number } }> }; + export const CreateTagsDocument = gql` mutation createTags($study: ID!, $entries: [ID!]!) { @@ -268,4 +275,53 @@ export function useSaveVideoFieldMutation(baseOptions?: Apollo.MutationHookOptio } export type SaveVideoFieldMutationHookResult = ReturnType; export type SaveVideoFieldMutationResult = Apollo.MutationResult; -export type SaveVideoFieldMutationOptions = Apollo.BaseMutationOptions; \ No newline at end of file +export type SaveVideoFieldMutationOptions = Apollo.BaseMutationOptions; +export const GetTagsDocument = gql` + query getTags($study: ID!) { + getTags(study: $study) { + _id + entry { + _id + organization + entryID + contentType + dataset + creator + dateCreated + meta + signedUrl + signedUrlExpiration + } + data + complete + } +} + `; + +/** + * __useGetTagsQuery__ + * + * To run a query within a React component, call `useGetTagsQuery` and pass it any options that fit your needs. + * When your component renders, `useGetTagsQuery` 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 } = useGetTagsQuery({ + * variables: { + * study: // value for 'study' + * }, + * }); + */ +export function useGetTagsQuery(baseOptions: Apollo.QueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useQuery(GetTagsDocument, options); + } +export function useGetTagsLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useLazyQuery(GetTagsDocument, options); + } +export type GetTagsQueryHookResult = ReturnType; +export type GetTagsLazyQueryHookResult = ReturnType; +export type GetTagsQueryResult = Apollo.QueryResult; \ No newline at end of file diff --git a/packages/client/src/pages/studies/DownloadTags.tsx b/packages/client/src/pages/studies/DownloadTags.tsx deleted file mode 100644 index c3ff29dd..00000000 --- a/packages/client/src/pages/studies/DownloadTags.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { Button, Container, Typography } from '@mui/material'; -import { useTranslation } from 'react-i18next'; - -export const DownloadTags: React.FC = () => { - const { t } = useTranslation(); - - return ( - - {t('menu.downloadTags')} - - - ); -}; diff --git a/packages/client/src/pages/studies/TagView.tsx b/packages/client/src/pages/studies/TagView.tsx new file mode 100644 index 00000000..09879e1e --- /dev/null +++ b/packages/client/src/pages/studies/TagView.tsx @@ -0,0 +1,16 @@ +import { Container, Typography } from '@mui/material'; +import { useTranslation } from 'react-i18next'; +import { useStudy } from '../../context/Study.context'; +import { TagGridView } from '../../components/tag/view/TagGridView.component'; + +export const TagView: React.FC = () => { + const { t } = useTranslation(); + const { study } = useStudy(); + + return ( + + {t('menu.viewTags')} + {study && } + + ); +}; diff --git a/packages/client/src/pages/tag.stories.tsx b/packages/client/src/pages/tag.stories.tsx deleted file mode 100644 index 687f64fd..00000000 --- a/packages/client/src/pages/tag.stories.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react'; -import { TagPage } from './tag'; -import { ThemeProvider } from '../theme/ThemeProvider'; - -const meta: Meta = { - title: 'Tag', - component: TagPage -}; - -export default meta; -type Story = StoryObj; - -export const Primary: Story = (args: any) => ( - - - -); -Primary.args = {}; diff --git a/packages/client/src/pages/tag.tsx b/packages/client/src/pages/tag.tsx deleted file mode 100644 index ed974e41..00000000 --- a/packages/client/src/pages/tag.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { FC } from 'react'; - -export const TagPage: FC = () => { - return

Hello World

; -}; diff --git a/packages/client/src/types/EntryView.ts b/packages/client/src/types/EntryView.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/client/src/types/TagColumnView.ts b/packages/client/src/types/TagColumnView.ts new file mode 100644 index 00000000..5ea54104 --- /dev/null +++ b/packages/client/src/types/TagColumnView.ts @@ -0,0 +1,20 @@ +import { JsonSchema, TesterContext, UISchemaElement } from '@jsonforms/core'; +import { GridColDef } from '@mui/x-data-grid'; + +export interface TagColumnViewProps { + data: any; + schema: JsonSchema; + uischema: UISchemaElement; +} + +/** + * Represents the view of a tag in a column format. Handles determining if + * the given view is applicable and applying the view to the given data. + */ +export type GetGridColDefs = (uischema: UISchemaElement, schema: JsonSchema, property: string) => GridColDef[]; + +/** + * Test to see if a given field can be transformed into a tag column view. + */ +export type TagViewTest = (uischema: UISchemaElement, schema: JsonSchema, context: TesterContext) => number; +export const NOT_APPLICABLE = -1; diff --git a/packages/server/src/entry/resolvers/entry.resolver.ts b/packages/server/src/entry/resolvers/entry.resolver.ts index 1d43c561..495f303c 100644 --- a/packages/server/src/entry/resolvers/entry.resolver.ts +++ b/packages/server/src/entry/resolvers/entry.resolver.ts @@ -14,13 +14,15 @@ import { OrganizationContext } from '../../organization/organization.context'; import { Organization } from '../../organization/organization.model'; import { EntryPipe } from '../pipes/entry.pipe'; import { OrganizationGuard } from '../../organization/organization.guard'; +import { DatasetService } from '../../dataset/dataset.service'; @UseGuards(JwtAuthGuard, OrganizationGuard) @Resolver(() => Entry) export class EntryResolver { constructor( private readonly entryService: EntryService, - @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer + @Inject(CASBIN_PROVIDER) private readonly enforcer: casbin.Enforcer, + private readonly datasetService: DatasetService ) {} @Query(() => [Entry]) @@ -35,6 +37,22 @@ export class EntryResolver { return this.entryService.findForDataset(dataset); } + @Query(() => Entry) + async entryFromID( + @Args('entry', { type: () => ID }, EntryPipe) entry: Entry, + @TokenContext() user: TokenPayload + ): Promise { + const dataset = await this.datasetService.findById(entry.dataset); + if (!dataset) { + throw new Error('Dataset not found for entry'); + } + if (!(await this.enforcer.enforce(user.user_id, DatasetPermissions.READ, dataset._id))) { + throw new UnauthorizedException('User cannot read entries on this dataset'); + } + + return entry; + } + @ResolveField(() => String) async signedUrl(@Parent() entry: Entry, @TokenContext() user: TokenPayload): Promise { if (!(await this.enforcer.enforce(user.user_id, DatasetPermissions.READ, entry.dataset))) { diff --git a/packages/server/src/tag/resolvers/tag.resolver.ts b/packages/server/src/tag/resolvers/tag.resolver.ts index 3da04202..07a892e9 100644 --- a/packages/server/src/tag/resolvers/tag.resolver.ts +++ b/packages/server/src/tag/resolvers/tag.resolver.ts @@ -85,6 +85,17 @@ export class TagResolver { return this.tagService.isEntryEnabled(study, entry); } + @Query(() => [Tag]) + async getTags( + @Args('study', { type: () => ID }, StudyPipe) study: Study, + @TokenContext() user: TokenPayload + ): Promise { + if (!(await this.enforcer.enforce(user.user_id, TagPermissions.READ, study._id.toString()))) { + throw new UnauthorizedException('User cannot read tags in this study'); + } + return this.tagService.getTags(study); + } + @ResolveField(() => Entry) async entry(@Parent() tag: Tag): Promise { return this.entryPipe.transform(tag.entry); diff --git a/packages/server/src/tag/services/tag.service.ts b/packages/server/src/tag/services/tag.service.ts index 97f02ced..7f1e2a89 100644 --- a/packages/server/src/tag/services/tag.service.ts +++ b/packages/server/src/tag/services/tag.service.ts @@ -144,6 +144,10 @@ export class TagService { return true; } + async getTags(study: Study): Promise { + return this.tagModel.find({ study: study._id }); + } + private async getIncomplete(study: Study, user: string): Promise { return this.tagModel.findOne({ study: study._id, user, complete: false, enabled: true }); }