From 6bc56b8e3df46323a9ba1d63797bb685de0079d9 Mon Sep 17 00:00:00 2001 From: Alfred Rosenthal Date: Fri, 8 Dec 2023 14:38:51 -0800 Subject: [PATCH 1/3] stubbing out more of the UI --- .../components/security/ManageSecurity.tsx | 6 +- .../components/security/SecuritiesDialog.tsx | 31 +++++ .../components/security/SecurityRuleCard.tsx | 24 ++++ .../components/security/SecurityRuleForm.tsx | 128 ++++++++++++++++++ 4 files changed, 188 insertions(+), 1 deletion(-) create mode 100644 app/src/components/security/SecuritiesDialog.tsx create mode 100644 app/src/components/security/SecurityRuleCard.tsx create mode 100644 app/src/components/security/SecurityRuleForm.tsx diff --git a/app/src/components/security/ManageSecurity.tsx b/app/src/components/security/ManageSecurity.tsx index c29ea5a9c..c9e9df28a 100644 --- a/app/src/components/security/ManageSecurity.tsx +++ b/app/src/components/security/ManageSecurity.tsx @@ -4,11 +4,14 @@ import { Button, Menu, MenuItem } from '@mui/material'; import ListItemIcon from '@mui/material/ListItemIcon'; import ListItemText from '@mui/material/ListItemText'; import React, { useState } from 'react'; +import SecuritiesDialog from './SecuritiesDialog'; import UnsecureDialog from './UnsecureDialog'; const ManageSecurity = () => { const [anchorEl, setAnchorEl] = React.useState(null); const [isUnsecureDialogOpen, setIsUnsecuredDialogOpen] = useState(false); + const [isSecuritiesDialogOpen, setIsSecuritiesDialogOpen] = useState(false); + const open = Boolean(anchorEl); const handleClick = (event: React.MouseEvent) => { setAnchorEl(event.currentTarget); @@ -19,6 +22,7 @@ const ManageSecurity = () => { return ( <> + setIsSecuritiesDialogOpen(false)} /> setIsUnsecuredDialogOpen(false)} /> - + setIsSecuritiesDialogOpen(true)}> diff --git a/app/src/components/security/SecuritiesDialog.tsx b/app/src/components/security/SecuritiesDialog.tsx new file mode 100644 index 000000000..007a98c63 --- /dev/null +++ b/app/src/components/security/SecuritiesDialog.tsx @@ -0,0 +1,31 @@ +import EditDialog from 'components/dialog/EditDialog'; +import yup from 'utils/YupSchema'; +import SecurityRuleForm from './SecurityRuleForm'; + +interface ISecuritiesDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const SecuritiesDialog = (props: ISecuritiesDialogProps) => { + const SecurityRuleYupSchema = yup.array(yup.number()); + + return ( + <> + console.log('SAVE SOME SECURITY RULES SON')} + component={{ + element: , + initialValues: [], + validationSchema: SecurityRuleYupSchema + }} + /> + + ); +}; + +export default SecuritiesDialog; diff --git a/app/src/components/security/SecurityRuleCard.tsx b/app/src/components/security/SecurityRuleCard.tsx new file mode 100644 index 000000000..a45f559d1 --- /dev/null +++ b/app/src/components/security/SecurityRuleCard.tsx @@ -0,0 +1,24 @@ +import { Box, Typography } from '@mui/material'; + +interface ISecurityRuleCardProps { + title: string; + subtitle: string; +} +const SecurityRuleCard = (props: ISecurityRuleCardProps) => { + return ( + + + + {props.title} + + + + + {props.subtitle} + + + + ); +}; + +export default SecurityRuleCard; diff --git a/app/src/components/security/SecurityRuleForm.tsx b/app/src/components/security/SecurityRuleForm.tsx new file mode 100644 index 000000000..824a91b0e --- /dev/null +++ b/app/src/components/security/SecurityRuleForm.tsx @@ -0,0 +1,128 @@ +import { mdiMagnify } from '@mdi/js'; +import Icon from '@mdi/react'; +import { Alert, AlertTitle, Typography } from '@mui/material'; +import Autocomplete from '@mui/material/Autocomplete'; +import Box from '@mui/material/Box'; +import Collapse from '@mui/material/Collapse'; +import TextField from '@mui/material/TextField'; +import { useFormikContext } from 'formik'; +import { useState } from 'react'; +import { TransitionGroup } from 'react-transition-group'; +import SecurityRuleCard from './SecurityRuleCard'; + +const SecurityRuleForm = () => { + const { handleSubmit, values, setFieldValue, errors, setErrors } = useFormikContext(); + const [selectedRules, setSelectedRules] = useState([]); + const [searchText, setSearchText] = useState(''); + + return ( +
{}}> + + Manage Team Members + + A minimum of one team member must be assigned the coordinator role. + + {errors?.['participants'] && !selectedRules.length && ( + + + No Rules Selected + At least one team member needs to be added to this project. + + + )} + {errors?.['participants'] && selectedRules.length > 0 && ( + {/* */} + )} + + { + // const searchFilter = createFilterOptions({ ignoreCase: true }); + // const unselectedOptions = options.filter( + // (item) => !selectedUsers.some((existing) => existing.system_user_id === item.system_user_id) + // ); + // return searchFilter(unselectedOptions, state); + // }} + // getOptionLabel={(option) => option.display_name} + inputValue={searchText} + onInputChange={(_, value, reason) => { + if (reason === 'reset') { + setSearchText(''); + } else { + setSearchText(value); + } + }} + onChange={(_, option) => { + if (option) { + } + }} + renderInput={(params) => ( + + + + ) + }} + /> + )} + renderOption={(renderProps, renderOption) => { + return ( + + + + ); + }} + /> + + + + + {selectedRules.map((rule: any, index: number) => { + // const error = rowItemError(index); + return ( + + {/* */} + <> + + ); + })} + + + + +
+ ); +}; + +export default SecurityRuleForm; From a07f86a95300667a999b8beaee6e00a0ee8a399a Mon Sep 17 00:00:00 2001 From: Alfred Rosenthal Date: Fri, 8 Dec 2023 16:24:30 -0800 Subject: [PATCH 2/3] adding basic endpoint for fetching rules --- .../paths/administrative/security/index.ts | 133 ++++++++++++++++++ api/src/repositories/security-repository.ts | 22 +++ api/src/services/security-service.ts | 5 + app/src/hooks/api/useSecurityApi.ts | 21 ++- 4 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 api/src/paths/administrative/security/index.ts diff --git a/api/src/paths/administrative/security/index.ts b/api/src/paths/administrative/security/index.ts new file mode 100644 index 000000000..06df57f6d --- /dev/null +++ b/api/src/paths/administrative/security/index.ts @@ -0,0 +1,133 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { SYSTEM_ROLE } from '../../../constants/roles'; +import { getDBConnection } from '../../../database/db'; +import { authorizeRequestHandler } from '../../../request-handlers/security/authorization'; +import { SecurityService } from '../../../services/security-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/administrative/security'); + +export const GET: Operation = [ + authorizeRequestHandler(() => { + return { + and: [ + { + validSystemRoles: [SYSTEM_ROLE.SYSTEM_ADMIN], + discriminator: 'SystemRole' + } + ] + }; + }), + () => {} +]; + +GET.apiDoc = { + description: 'Get all observations for the survey.', + tags: ['observation'], + security: [ + { + Bearer: [] + } + ], + parameters: [ + { + in: 'path', + name: 'projectId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + }, + { + in: 'path', + name: 'surveyId', + schema: { + type: 'number', + minimum: 1 + }, + required: true + } + ], + responses: { + 200: { + description: 'Security Rules.', + content: { + 'application/json': { + schema: { + type: 'array', + items: { + type: 'object', + properties: { + security_rule_id: { + type: 'number' + }, + name: { + type: 'string' + }, + description: { + type: 'string' + }, + record_effective_date: { + type: 'string' + }, + record_end_date: { + type: 'string' + }, + create_date: { + type: 'string' + }, + create_user: { + type: 'number' + }, + update_date: { + type: 'string' + }, + update_user: { + type: 'number' + }, + revision_count: { + type: 'string' + } + } + } + } + } + } + }, + 400: { + $ref: '#/components/responses/400' + }, + 401: { + $ref: '#/components/responses/401' + }, + 403: { + $ref: '#/components/responses/403' + }, + 500: { + $ref: '#/components/responses/500' + }, + default: { + $ref: '#/components/responses/default' + } + } +}; + +export function getActiveSecurityRules(): RequestHandler { + return async (req, res) => { + const connection = getDBConnection(req['keycloak_token']); + const service = new SecurityService(connection); + + try { + const data = await service.getActiveSecurityRules(); + return res.status(200).json(data); + } catch (error) { + defaultLog.error({ label: 'getActiveSecurityRules', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/security-repository.ts b/api/src/repositories/security-repository.ts index 900ef6f1d..5b69b1a90 100644 --- a/api/src/repositories/security-repository.ts +++ b/api/src/repositories/security-repository.ts @@ -16,6 +16,21 @@ export const PersecutionAndHarmSecurity = z.object({ export type PersecutionAndHarmSecurity = z.infer; +export const SecurityRuleRecord = z.object({ + security_rule_id: z.number(), + name: z.string(), + description: z.string(), + record_effective_date: z.string(), + record_end_date: z.string(), + create_date: z.string(), + create_user: z.number(), + update_date: z.string().nullable(), + update_user: z.number().nullable(), + revision_count: z.number() +}); + +export type SecurityRuleRecord = z.infer; + export const SecurityReason = z.object({ id: z.number(), type_id: z.number() @@ -229,4 +244,11 @@ export class SecurityRepository extends BaseRepository { return results; } + + async getActiveSecurityRules(): Promise { + defaultLog.debug({ label: 'getSecurityRules' }); + const sql = SQL`SELECT * FROM security_rule WHERE record_end_date IS NULL;`; + const response = await this.connection.sql(sql, SecurityRuleRecord); + return response.rows; + } } diff --git a/api/src/services/security-service.ts b/api/src/services/security-service.ts index 11fc4f5c1..44b609fb7 100644 --- a/api/src/services/security-service.ts +++ b/api/src/services/security-service.ts @@ -4,6 +4,7 @@ import { ArtifactPersecution, PersecutionAndHarmSecurity, SecurityRepository, + SecurityRuleRecord, SECURITY_APPLIED_STATUS } from '../repositories/security-repository'; import { getS3SignedURL } from '../utils/file-utils'; @@ -324,4 +325,8 @@ export class SecurityService extends DBService { return isPendingReview; } + + async getActiveSecurityRules(): Promise { + return this.securityRepository.getActiveSecurityRules(); + } } diff --git a/app/src/hooks/api/useSecurityApi.ts b/app/src/hooks/api/useSecurityApi.ts index c8a18c1fc..fab2033f9 100644 --- a/app/src/hooks/api/useSecurityApi.ts +++ b/app/src/hooks/api/useSecurityApi.ts @@ -1,6 +1,19 @@ import { AxiosInstance } from 'axios'; import { IListPersecutionHarmResponse, ISecureDataAccessRequestForm } from 'interfaces/useSecurityApi.interface'; +export interface ISecurityRule { + security_rule_id: number; + name: string; + description: string; + record_effective_date: string; + record_end_date: string; + create_date: string; + create_user: number; + update_date: string; + update_user: number; + revision_count: number; +} + /** * Returns a set of supported api methods for working with security. * @@ -53,10 +66,16 @@ const useSecurityApi = (axios: AxiosInstance) => { return data; }; + const getActiveSecurityRules = async (): Promise => { + const { data } = await axios.get('api/administrative/security'); + return data; + }; + return { sendSecureArtifactAccessRequest, listPersecutionHarmRules, - applySecurityReasonsToArtifacts + applySecurityReasonsToArtifacts, + getActiveSecurityRules }; }; From ace08d23f1820213b7d08eab12eed53e9cb8130c Mon Sep 17 00:00:00 2001 From: Kjartan Date: Fri, 8 Dec 2023 16:56:02 -0800 Subject: [PATCH 3/3] add submission context and review page --- api/src/openapi/root-api-doc.ts | 22 +++ api/src/paths/dataset/{datasetId}/index.ts | 77 ---------- .../paths/{dataset => submission}/intake.ts | 28 ++-- .../submission/{submissionUUID}/index.ts | 126 ++++++++++++++++ api/src/repositories/submission-repository.ts | 59 +++++++- api/src/services/dataset-service.ts | 14 -- api/src/services/submission-service.ts | 43 ++++++ app/src/AppRouter.tsx | 2 +- .../components/layout/header/BaseHeader.tsx | 67 +++++++++ app/src/components/layout/header/Header.tsx | 6 +- app/src/contexts/submissionContext.tsx | 64 ++++++++ .../admin/dashboard/AdminDashboardRouter.tsx | 8 + app/src/features/datasets/DatasetPage.tsx | 15 +- .../submissions/AdminSubmissionPage.tsx | 72 +++++++++ .../components/SubmissionHeader.tsx | 137 ++++++++++++++++++ app/src/hooks/api/useDatasetApi.ts | 14 -- app/src/hooks/api/useSubmissionsApi.ts | 14 +- app/src/interfaces/useDatasetApi.interface.ts | 22 +++ 18 files changed, 654 insertions(+), 136 deletions(-) delete mode 100644 api/src/paths/dataset/{datasetId}/index.ts rename api/src/paths/{dataset => submission}/intake.ts (83%) create mode 100644 api/src/paths/submission/{submissionUUID}/index.ts create mode 100644 app/src/components/layout/header/BaseHeader.tsx create mode 100644 app/src/contexts/submissionContext.tsx create mode 100644 app/src/features/submissions/AdminSubmissionPage.tsx create mode 100644 app/src/features/submissions/components/SubmissionHeader.tsx diff --git a/api/src/openapi/root-api-doc.ts b/api/src/openapi/root-api-doc.ts index 07a7ea875..f4865ef6d 100644 --- a/api/src/openapi/root-api-doc.ts +++ b/api/src/openapi/root-api-doc.ts @@ -168,6 +168,28 @@ export const rootAPIDoc = { } }, additionalProperties: false + }, + feature: { + type: 'object', + required: ['submission_feature_id', 'submission_id', 'feature_type', 'data', 'parent_submission_feature_id'], + properties: { + submission_feature_id: { + type: 'number' + }, + submission_id: { + type: 'number' + }, + feature_type: { + type: 'string' + }, + data: { + type: 'object' + }, + parent_submission_feature_id: { + type: 'number', + nullable: true + } + } } } } diff --git a/api/src/paths/dataset/{datasetId}/index.ts b/api/src/paths/dataset/{datasetId}/index.ts deleted file mode 100644 index 81fe23890..000000000 --- a/api/src/paths/dataset/{datasetId}/index.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { RequestHandler } from 'express'; -import { Operation } from 'express-openapi'; -import { getAPIUserDBConnection, getDBConnection } from '../../../database/db'; -import { defaultErrorResponses } from '../../../openapi/schemas/http-responses'; -import { DatasetService } from '../../../services/dataset-service'; -import { getLogger } from '../../../utils/logger'; - -const defaultLog = getLogger('paths/dataset/{datasetId}'); - -export const GET: Operation = [getDatasetInformation()]; - -GET.apiDoc = { - description: 'retrieves dataset data from the submission table', - tags: ['eml'], - security: [ - { - OptionalBearer: [] - } - ], - parameters: [ - { - description: 'dataset uuid', - in: 'path', - name: 'datasetId', - schema: { - type: 'string', - format: 'uuid' - }, - required: true - } - ], - responses: { - 200: { - description: 'Dataset metadata response object.', - content: { - 'application/json': { - schema: { - type: 'object' - //TODO: add schema - } - } - } - }, - ...defaultErrorResponses - } -}; - -/** - * Retrieves dataset data from the submission table. - * - * @returns {RequestHandler} - */ -export function getDatasetInformation(): RequestHandler { - return async (req, res) => { - const connection = req['keycloak_token'] ? getDBConnection(req['keycloak_token']) : getAPIUserDBConnection(); - - const datasetId = String(req.params.datasetId); - - try { - await connection.open(); - - const datasetService = new DatasetService(connection); - - const result = await datasetService.getDatasetByDatasetUUID(datasetId); - - await connection.commit(); - - res.status(200).json(result); - } catch (error) { - defaultLog.error({ label: 'getMetadataByDatasetId', message: 'error', error }); - await connection.rollback(); - throw error; - } finally { - connection.release(); - } - }; -} diff --git a/api/src/paths/dataset/intake.ts b/api/src/paths/submission/intake.ts similarity index 83% rename from api/src/paths/dataset/intake.ts rename to api/src/paths/submission/intake.ts index dbc6907ad..8a2eb9d1d 100644 --- a/api/src/paths/dataset/intake.ts +++ b/api/src/paths/submission/intake.ts @@ -10,7 +10,11 @@ import { ValidationService } from '../../services/validation-service'; import { getKeycloakSource } from '../../utils/keycloak-utils'; import { getLogger } from '../../utils/logger'; -const defaultLog = getLogger('paths/dataset/intake'); +const defaultLog = getLogger('paths/submission/intake'); + +/* +TODO: UPDATED PATH TO SUBMISSION/INTAKE NEED TO UPDATE SIMS TO USE THIS PATH +*/ export const POST: Operation = [ authorizeRequestHandler(() => { @@ -23,12 +27,12 @@ export const POST: Operation = [ ] }; }), - datasetIntake() + submissionIntake() ]; POST.apiDoc = { - description: 'Submit dataset to BioHub', - tags: ['dataset'], + description: 'Submit submission to BioHub', + tags: ['submission'], security: [ { Bearer: [] @@ -48,7 +52,7 @@ POST.apiDoc = { }, type: { type: 'string', - enum: ['dataset'] + enum: ['submission'] }, properties: { title: 'Dataset properties', @@ -89,7 +93,7 @@ POST.apiDoc = { } }; -export function datasetIntake(): RequestHandler { +export function submissionIntake(): RequestHandler { return async (req, res) => { const sourceSystem = getKeycloakSource(req['keycloak_token']); @@ -99,7 +103,7 @@ export function datasetIntake(): RequestHandler { ]); } - const dataset = { + const submission = { ...req.body, properties: { ...req.body.properties, additionalInformation: req.body.properties.additionalInformation } }; @@ -113,21 +117,21 @@ export function datasetIntake(): RequestHandler { const submissionService = new SubmissionService(connection); const validationService = new ValidationService(connection); - // validate the dataset submission - if (!(await validationService.validateDatasetSubmission(dataset))) { - throw new HTTP400('Invalid dataset submission'); + // validate the submission submission + if (!(await validationService.validateDatasetSubmission(submission))) { + throw new HTTP400('Invalid submission submission'); } // insert the submission record const response = await submissionService.insertSubmissionRecordWithPotentialConflict(id); // insert each submission feature record - await submissionService.insertSubmissionFeatureRecords(response.submission_id, dataset.features); + await submissionService.insertSubmissionFeatureRecords(response.submission_id, submission.features); await connection.commit(); res.status(200).json(response); } catch (error) { - defaultLog.error({ label: 'datasetIntake', message: 'error', error }); + defaultLog.error({ label: 'submissionIntake', message: 'error', error }); await connection.rollback(); throw error; } finally { diff --git a/api/src/paths/submission/{submissionUUID}/index.ts b/api/src/paths/submission/{submissionUUID}/index.ts new file mode 100644 index 000000000..2b93594c2 --- /dev/null +++ b/api/src/paths/submission/{submissionUUID}/index.ts @@ -0,0 +1,126 @@ +import { RequestHandler } from 'express'; +import { Operation } from 'express-openapi'; +import { getAPIUserDBConnection, getDBConnection } from '../../../database/db'; +import { defaultErrorResponses } from '../../../openapi/schemas/http-responses'; +import { SubmissionService } from '../../../services/submission-service'; +import { getLogger } from '../../../utils/logger'; + +const defaultLog = getLogger('paths/submission/{submissionUUID}}'); + +export const GET: Operation = [getSubmissionInformation()]; + +GET.apiDoc = { + description: 'retrieves submission data from the submission table', + tags: ['eml'], + security: [ + { + OptionalBearer: [] + } + ], + parameters: [ + { + description: 'submission uuid', + in: 'path', + name: 'submissionUUID', + schema: { + type: 'string', + format: 'uuid' + }, + required: true + } + ], + responses: { + 200: { + description: 'Dataset metadata response object.', + content: { + 'application/json': { + schema: { + type: 'object', + required: ['submission', 'features'], + properties: { + submission: { + type: 'object', + required: ['submission_id', 'uuid', 'security_review_timestamp'], + properties: { + submission_id: { + type: 'number' + }, + uuid: { + type: 'string', + format: 'uuid' + }, + security_review_timestamp: { + type: 'string', + format: 'date-time', + nullable: true + } + } + }, + features: { + required: ['dataset', 'sampleSites', 'animals', 'observations'], + properties: { + dataset: { + type: 'array', + items: { + $ref: '#/components/schemas/feature' + } + }, + sampleSites: { + type: 'array', + items: { + $ref: '#/components/schemas/feature' + } + }, + animals: { + type: 'array', + items: { + $ref: '#/components/schemas/feature' + } + }, + observations: { + type: 'array', + items: { + $ref: '#/components/schemas/feature' + } + } + } + } + } + } + } + } + }, + ...defaultErrorResponses + } +}; + +/** + * Retrieves submission data from the submission table. + * + * @returns {RequestHandler} + */ +export function getSubmissionInformation(): RequestHandler { + return async (req, res) => { + const connection = req['keycloak_token'] ? getDBConnection(req['keycloak_token']) : getAPIUserDBConnection(); + + const submissionUUID = String(req.params.submissionUUID); + + try { + await connection.open(); + + const submissionService = new SubmissionService(connection); + + const result = await submissionService.getSubmissionAndFeaturesBySubmissionUUID(submissionUUID); + + await connection.commit(); + + res.status(200).json(result); + } catch (error) { + defaultLog.error({ label: 'getSubmissionInformation', message: 'error', error }); + await connection.rollback(); + throw error; + } finally { + connection.release(); + } + }; +} diff --git a/api/src/repositories/submission-repository.ts b/api/src/repositories/submission-repository.ts index 113808829..f512b9009 100644 --- a/api/src/repositories/submission-repository.ts +++ b/api/src/repositories/submission-repository.ts @@ -76,6 +76,22 @@ export interface ISubmissionRecord { revision_count?: string; } +export interface ISubmissionFeatureRecord { + submission_feature_id?: number; + submission_id: number; + feature_type_id: number; + data: any; // TODO: IFeatureSubmission; + feature_type?: string; + parent_submission_feature_id?: number; + record_effective_date?: string; + record_end_date?: string; + create_date?: string; + create_user?: string; + update_date?: string; + update_user?: string; + revision_count?: string; +} + export interface ISubmissionRecordWithSpatial { id: string; source: Record; @@ -1126,11 +1142,11 @@ export class SubmissionRepository extends BaseRepository { */ async getUnreviewedSubmissionsForAdmins(): Promise { const sqlStatement = SQL` - SELECT + SELECT * - FROM + FROM submission - WHERE + WHERE submission.security_review_timestamp is null; `; @@ -1147,15 +1163,46 @@ export class SubmissionRepository extends BaseRepository { */ async getReviewedSubmissionsForAdmins(): Promise { const sqlStatement = SQL` - SELECT + SELECT * - FROM + FROM submission - WHERE + WHERE submission.security_review_timestamp is not null; `; const response = await this.connection.sql(sqlStatement, SubmissionRecord); + return response.rows; + } + + /* + * Fetch a submission from uuid. + * + * @param {number} submissionId + * @return {*} {Promise} + * @memberof SubmissionRepository + */ + async getSubmissionFeaturesBySubmissionId(submissionId: number): Promise { + const sqlStatement = SQL` + SELECT + sf.submission_feature_id, + sf.submission_id, + (SELECT name FROM feature_type WHERE feature_type_id = sf.feature_type_id) AS feature_type, + sf.data, + sf.parent_submission_feature_id + FROM + submission_feature sf + WHERE + submission_id = ${submissionId}; + `; + const response = await this.connection.sql(sqlStatement); + + if (!response.rowCount) { + throw new ApiExecuteSQLError('Failed to get submission feature record', [ + 'SubmissionRepository->getSubmissionFeaturesBySubmissionId', + 'rowCount was null or undefined, expected rowCount != 0' + ]); + } return response.rows; } diff --git a/api/src/services/dataset-service.ts b/api/src/services/dataset-service.ts index d15ccaa13..1aaaccaf0 100644 --- a/api/src/services/dataset-service.ts +++ b/api/src/services/dataset-service.ts @@ -10,18 +10,4 @@ export class DatasetService extends DBService { this.submissionRepository = new SubmissionRepository(connection); } - - /** - * Retrieves dataset data from the submission table. - * - * @param {string} datasetUUID - * @return {*} {Promise} - * @memberof DatasetService - */ - async getDatasetByDatasetUUID(datasetUUID: string): Promise { - const submission = this.submissionRepository.getSubmissionByUUID(datasetUUID); - console.log('submission', submission); - - return submission; - } } diff --git a/api/src/services/submission-service.ts b/api/src/services/submission-service.ts index aacde9dc3..c36bf718b 100644 --- a/api/src/services/submission-service.ts +++ b/api/src/services/submission-service.ts @@ -554,4 +554,47 @@ export class SubmissionService extends DBService { async getReviewedSubmissionsForAdmins(): Promise { return this.submissionRepository.getReviewedSubmissionsForAdmins(); } + + /* + * Retrieves submission data from the submission table. + * + * @param {string} submissionUUID + * @return {*} {Promise} TODO: type + * @memberof DatasetService + */ + async getSubmissionAndFeaturesBySubmissionUUID(submissionUUID: string): Promise { + const submission = await this.submissionRepository.getSubmissionByUUID(submissionUUID); + + if (!submission.submission_id) { + throw new Error(`No submission found for submission ${submissionUUID}`); + } + + const features = await this.submissionRepository.getSubmissionFeaturesBySubmissionId(submission.submission_id); + + const dataset = []; + const sampleSites = []; + const animals = []; + const observations = []; + + for (const feature of features) { + const featureType = feature.feature_type; + + switch (featureType) { + case 'dataset': + dataset.push(feature); + break; + case 'sample_site': + sampleSites.push(feature); + break; + case 'animal': + animals.push(feature); + break; + case 'observation': + observations.push(feature); + break; + } + } + + return { submission, features: { dataset, sampleSites, animals, observations } }; + } } diff --git a/app/src/AppRouter.tsx b/app/src/AppRouter.tsx index 454ea37e0..9ef2071fd 100644 --- a/app/src/AppRouter.tsx +++ b/app/src/AppRouter.tsx @@ -69,7 +69,7 @@ const AppRouter: React.FC = () => { - + { + const { title, subTitle, breadCrumb, buttonJSX } = props; + + return ( + + + {breadCrumb} + + + + {title} + + {subTitle} + + {buttonJSX} + + + + ); +}; + +export default BaseHeader; diff --git a/app/src/components/layout/header/Header.tsx b/app/src/components/layout/header/Header.tsx index 4ba90a7d4..8c4d6119c 100644 --- a/app/src/components/layout/header/Header.tsx +++ b/app/src/components/layout/header/Header.tsx @@ -151,8 +151,10 @@ const Header: React.FC = () => { - - Submission 1 + + Submission test page diff --git a/app/src/contexts/submissionContext.tsx b/app/src/contexts/submissionContext.tsx new file mode 100644 index 000000000..a934b2613 --- /dev/null +++ b/app/src/contexts/submissionContext.tsx @@ -0,0 +1,64 @@ +import { useApi } from 'hooks/useApi'; +import useDataLoader, { DataLoader } from 'hooks/useDataLoader'; +import { IGetSubmissionResponse } from 'interfaces/useDatasetApi.interface'; +import React, { useEffect, useMemo } from 'react'; +import { useParams } from 'react-router'; + +export interface ISubmissionContext { + /** + * The Data Loader used to load submission data + * + * @type {DataLoader<[submissionUUID: string], IGetSubmissionResponse, unknown>} + * @memberof ISubmissionContext + */ + submissionDataLoader: DataLoader<[submissionUUID: string], IGetSubmissionResponse, unknown>; + /** + * The submission UUID + * + * @type {string} + * @memberof ISubmissionContext + */ + submissionUUID: string; +} + +export const SubmissionContext = React.createContext({ + submissionDataLoader: {} as DataLoader<[submissionUUID: string], IGetSubmissionResponse, unknown>, + submissionUUID: '' +}); + +export const SubmissionContextProvider: React.FC = (props) => { + const biohubApi = useApi(); + const submissionDataLoader = useDataLoader(biohubApi.submissions.getSubmission); + + const urlParams: Record = useParams(); + + if (!urlParams['submission_uuid']) { + throw new Error( + "The submission UUID found in SubmissionContextProvider was invalid. Does your current React route provide an 'id' parameter?" + ); + } + + const submissionUUID = urlParams['submission_uuid'] as string; + + submissionDataLoader.load(submissionUUID); + + /** + * Refreshes the current submission object whenever the current submission UUID changes from the currently loaded submission. + */ + useEffect(() => { + if (submissionUUID && submissionUUID !== submissionDataLoader.data?.submission.uuid) { + submissionDataLoader.refresh(submissionUUID); + } + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [submissionUUID]); + + const surveyContext: ISubmissionContext = useMemo(() => { + return { + submissionDataLoader, + submissionUUID + }; + }, [submissionDataLoader, submissionUUID]); + + return {props.children}; +}; diff --git a/app/src/features/admin/dashboard/AdminDashboardRouter.tsx b/app/src/features/admin/dashboard/AdminDashboardRouter.tsx index 56a426845..1c187e5cf 100644 --- a/app/src/features/admin/dashboard/AdminDashboardRouter.tsx +++ b/app/src/features/admin/dashboard/AdminDashboardRouter.tsx @@ -1,3 +1,5 @@ +import { SubmissionContextProvider } from 'contexts/submissionContext'; +import AdminSubmissionPage from 'features/submissions/AdminSubmissionPage'; import { Switch } from 'react-router'; import RouteWithTitle from 'utils/RouteWithTitle'; import { getTitle } from 'utils/Utils'; @@ -14,6 +16,12 @@ const AdminDashboardRouter: React.FC = () => { + + + + + + ); }; diff --git a/app/src/features/datasets/DatasetPage.tsx b/app/src/features/datasets/DatasetPage.tsx index d2d2b605d..aaa5910e9 100644 --- a/app/src/features/datasets/DatasetPage.tsx +++ b/app/src/features/datasets/DatasetPage.tsx @@ -4,9 +4,6 @@ import Container from '@mui/material/Container'; import Paper from '@mui/material/Paper'; import { makeStyles } from '@mui/styles'; import { ActionToolbar } from 'components/toolbar/ActionToolbars'; -import { useApi } from 'hooks/useApi'; -import useDataLoader from 'hooks/useDataLoader'; -import { useParams } from 'react-router'; const useStyles = makeStyles((theme: Theme) => ({ datasetTitleContainer: { @@ -27,14 +24,14 @@ const useStyles = makeStyles((theme: Theme) => ({ const DatasetPage: React.FC = () => { const classes = useStyles(); - const biohubApi = useApi(); - const urlParams = useParams(); + // const biohubApi = useApi(); + // const urlParams = useParams(); // const dialogContext = useContext(DialogContext); // const history = useHistory(); - const datasetId = urlParams['id']; + // const datasetId = urlParams['id']; - const datasetDataLoader = useDataLoader(() => biohubApi.dataset.getDataset(datasetId)); + // const datasetDataLoader = useDataLoader(() => biohubApi.dataset.getDataset(datasetId)); // const templateDataLoader = useDataLoader(() => biohubApi.dataset.getHandleBarsTemplateByDatasetId(datasetId)); // const fileDataLoader = useDataLoader((searchBoundary: Feature, searchType: string[], searchZoom: number) => @@ -64,8 +61,8 @@ const DatasetPage: React.FC = () => { // }; // }); - datasetDataLoader.load(); - console.log('datasetDataLoader.data', datasetDataLoader.data); + // datasetDataLoader.load(); + // console.log('datasetDataLoader.data', datasetDataLoader.data); // // templateDataLoader.load(); // const mapDataLoader = useDataLoader((searchBoundary: Feature, searchType: string[], searchZoom: number) => diff --git a/app/src/features/submissions/AdminSubmissionPage.tsx b/app/src/features/submissions/AdminSubmissionPage.tsx new file mode 100644 index 000000000..24d7b0dd1 --- /dev/null +++ b/app/src/features/submissions/AdminSubmissionPage.tsx @@ -0,0 +1,72 @@ +import { Theme } from '@mui/material'; +import Box from '@mui/material/Box'; +import Container from '@mui/material/Container'; +import Paper from '@mui/material/Paper'; +import { makeStyles } from '@mui/styles'; +import { ActionToolbar } from 'components/toolbar/ActionToolbars'; +import SubmissionHeader from './components/SubmissionHeader'; + +const useStyles = makeStyles((theme: Theme) => ({ + datasetTitleContainer: { + paddingBottom: theme.spacing(5), + background: '#f7f8fa', + '& h1': { + marginTop: '-4px' + } + }, + datasetDetailsLabel: { + borderBottom: '1pt solid #dadada' + }, + datasetDetailsContainer: {}, + datasetMapContainer: { + minHeight: '400px' + } +})); + +const AdminSubmissionPage: React.FC = () => { + const classes = useStyles(); + + return ( + + + + + + + + + {/* */} + + + {/* */} + + + + + {/* */} + + + {/* */} + + + + + ); +}; + +export default AdminSubmissionPage; diff --git a/app/src/features/submissions/components/SubmissionHeader.tsx b/app/src/features/submissions/components/SubmissionHeader.tsx new file mode 100644 index 000000000..b5ef5a8d9 --- /dev/null +++ b/app/src/features/submissions/components/SubmissionHeader.tsx @@ -0,0 +1,137 @@ +import { mdiChevronDown, mdiCog, mdiLock, mdiLockOpen, mdiPencil } from '@mdi/js'; +import Icon from '@mdi/react'; +import Breadcrumbs from '@mui/material/Breadcrumbs'; +import Button from '@mui/material/Button'; +import CircularProgress from '@mui/material/CircularProgress'; +import Link from '@mui/material/Link'; +import ListItemIcon from '@mui/material/ListItemIcon'; +import Menu from '@mui/material/Menu'; +import MenuItem from '@mui/material/MenuItem'; +import Stack from '@mui/material/Stack'; +import Typography from '@mui/material/Typography'; +import BaseHeader from 'components/layout/header/BaseHeader'; +import { SubmissionContext } from 'contexts/submissionContext'; +import React, { useContext, useState } from 'react'; +import { useHistory } from 'react-router'; +import { Link as RouterLink } from 'react-router-dom'; + +/** + * Submission header for a single-submission view. + * + * @return {*} + */ +const SubmissionHeader = () => { + const history = useHistory(); + const submissionContext = useContext(SubmissionContext); + + const [menuAnchorEl, setMenuAnchorEl] = useState(null); + + const submissionUUID = submissionContext.submissionUUID; + const submissionDataLoader = submissionContext.submissionDataLoader; + + const submissionData = submissionDataLoader.data; + console.log('submissionData', submissionData); + + if (!submissionDataLoader.data) { + return ; + } + + const submission = submissionDataLoader.data?.submission; + const features = submissionDataLoader.data?.features; + console.log('features', features); + const dataset = features?.dataset[0]; + console.log('dataset', dataset); + + return ( + <> + + + SUBMISSION DASHBOARD + + + {dataset?.data.name} | {submissionUUID} + + + } + subTitle={ + + + {submission?.security_review_timestamp ? ( + <> + + + SECURED: {submission?.security_review_timestamp} + + + ) : ( + <> + + + PENDING REVIEW + + + )} + + + } + buttonJSX={ + <> + + + + + + + setMenuAnchorEl(null)}> + history.push('edit')}> + + + + MANAGE SECURITY + + + + } + /> + + ); +}; + +export default SubmissionHeader; diff --git a/app/src/hooks/api/useDatasetApi.ts b/app/src/hooks/api/useDatasetApi.ts index af02498fb..762c00db4 100644 --- a/app/src/hooks/api/useDatasetApi.ts +++ b/app/src/hooks/api/useDatasetApi.ts @@ -48,19 +48,6 @@ const useDatasetApi = (axios: AxiosInstance) => { return data; }; - /** - * Fetch dataset data by datasetUUID. - * - * @param {string} datasetUUID - * @return {*} {Promise} - */ - const getDataset = async (datasetUUID: string): Promise => { - const { data } = await axios.get(`api/dataset/${datasetUUID}`); - console.log('data', data); - - return data; - }; - /** * Fetch dataset artifacts by datasetId. * @@ -112,7 +99,6 @@ const useDatasetApi = (axios: AxiosInstance) => { listAllDatasets, getUnreviewedSubmissions, getDatasetEML, - getDataset, getDatasetArtifacts, getArtifactSignedUrl, getHandleBarsTemplateByDatasetId, diff --git a/app/src/hooks/api/useSubmissionsApi.ts b/app/src/hooks/api/useSubmissionsApi.ts index 1b32f6f8d..3a2c44fe2 100644 --- a/app/src/hooks/api/useSubmissionsApi.ts +++ b/app/src/hooks/api/useSubmissionsApi.ts @@ -71,11 +71,23 @@ const useSubmissionsApi = (axios: AxiosInstance) => { return { mockJson: 'mockValue' }; }; + /** + * Fetch submission data by submissionUUID. + * + * @param {string} submissionUUID + * @return {*} {Promise} //TODO: type + */ + const getSubmission = async (submissionUUID: string): Promise => { + const { data } = await axios.get(`api/submission/${submissionUUID}`); + return data; + }; + return { listSubmissions, getSignedUrl, listReviewedSubmissions, - getSubmissionDownloadPackage + getSubmissionDownloadPackage, + getSubmission }; }; diff --git a/app/src/interfaces/useDatasetApi.interface.ts b/app/src/interfaces/useDatasetApi.interface.ts index 945100fdd..16eb4b15f 100644 --- a/app/src/interfaces/useDatasetApi.interface.ts +++ b/app/src/interfaces/useDatasetApi.interface.ts @@ -66,3 +66,25 @@ export interface IUnreviewedSubmission { description: string; create_date: string; } +export interface ISubmission { + submission_id: number; + uuid: string; + security_review_timestamp: string; +} + +export interface IFeature { + submission_feature_id: number; + submission_id: number; + feature_type: string; + data: any; + parent_submission_feature_id: number | null; +} +export interface IGetSubmissionResponse { + submission: ISubmission; + features: { + dataset: IFeature[]; + sampleSites: IFeature[]; + animals: IFeature[]; + observations: IFeature[]; + }; +}