diff --git a/CHANGELOG.md b/CHANGELOG.md index 3baefb5d82..612e30cb65 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,7 +18,8 @@ ENHANCEMENTS: * Gitea shared service support app-service standard SKUs ([#2523](https://github.com/microsoft/AzureTRE/pull/2523)) * Keyvault diagnostic settings in base workspace ([#2521](https://github.com/microsoft/AzureTRE/pull/2521)) * Airlock requests contain a field with information about the files that were submitted ([#2504](https://github.com/microsoft/AzureTRE/pull/2504)) -* UI - Operations and notifications stability improvements ([[#2530](https://github.com/microsoft/AzureTRE/pull/2530)]) +* UI - Operations and notifications stability improvements ([[#2530](https://github.com/microsoft/AzureTRE/pull/2530)) +* UI - Initial implemetation of Workspace Airlock Request View ([#2512](https://github.com/microsoft/AzureTRE/pull/2512)) BUG FIXES: diff --git a/ui/app/src/App.scss b/ui/app/src/App.scss index 7562b7a2d4..8955dcc73c 100644 --- a/ui/app/src/App.scss +++ b/ui/app/src/App.scss @@ -24,19 +24,28 @@ code { width: 70%; } -#root {} - -.tre-root {} - .tre-top-nav { box-shadow: 0 1px 2px 0px #033d68; z-index: 100; } +.ms-CommandBar { + background-color: transparent; + padding-left: 0px; + + .ms-Button { + background-color: transparent; + } +} + .tre-notifications-button { position: relative; top: 7px; color: #fff; + + i { + font-size: 20px !important; + } } .tre-notifications-button i { @@ -81,12 +90,20 @@ ul.tre-notifications-steps-list li { font-size:1.2rem; } -.tre-user-menu .ms-Persona-primaryText:hover { - color: #fff; -} +.tre-user-menu { + margin-top: 2px; -.ms-Persona-primaryText { - color: #fff; + .ms-Persona-primaryText:hover { + color: #fff; + } + + .ms-Persona-primaryText { + color: #fff; + } + + .ms-Icon { + margin-top: 3px; + } } .tre-hide-chevron i[data-icon-name=ChevronDown] { @@ -130,14 +147,21 @@ ul.tre-notifications-steps-list li { } .tre-panel { + margin: 10px 15px 10px 10px; + padding: 10px; +} + +.tre-resource-panel { box-shadow: 1px 0px 5px 0px #ccc; margin: 10px 15px 10px 10px; padding: 10px; background-color: #fff; } -.ms-CommandBar { - padding-left: 0; +.tre-table-rows-align-centre { + .ms-DetailsRow-cell { + align-self: baseline; + } } .ms-Pivot { diff --git a/ui/app/src/App.tsx b/ui/app/src/App.tsx index 8eef4c1520..bf7b210af5 100644 --- a/ui/app/src/App.tsx +++ b/ui/app/src/App.tsx @@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react'; import { DefaultPalette, IStackStyles, MessageBar, MessageBarType, Stack } from '@fluentui/react'; import './App.scss'; import { TopNav } from './components/shared/TopNav'; -import { Footer } from './components/shared/Footer'; import { Routes, Route } from 'react-router-dom'; import { RootLayout } from './components/root/RootLayout'; import { WorkspaceProvider } from './components/workspaces/WorkspaceProvider'; @@ -19,6 +18,7 @@ import { ApiEndpoint } from './models/apiEndpoints'; import { CreateUpdateResource } from './components/shared/create-update-resource/CreateUpdateResource'; import { CreateUpdateResourceContext } from './contexts/CreateUpdateResourceContext'; import { CreateFormResource, ResourceType } from './models/resourceType'; +import { Footer } from './components/shared/Footer'; export const App: React.FunctionComponent = () => { const [appRoles, setAppRoles] = useState([] as Array); diff --git a/ui/app/src/components/shared/Footer.tsx b/ui/app/src/components/shared/Footer.tsx index 21811f4cba..5a2d5e5512 100644 --- a/ui/app/src/components/shared/Footer.tsx +++ b/ui/app/src/components/shared/Footer.tsx @@ -8,7 +8,7 @@ import { AnimationClassNames, getTheme, mergeStyles } from '@fluentui/react'; export const Footer: React.FunctionComponent = () => { return (
- Azure TRE + Azure Trusted Research Environment
); }; @@ -22,4 +22,4 @@ const contentClass = mergeStyles([ padding: '0 20px', }, AnimationClassNames.scaleUpIn100, -]); \ No newline at end of file +]); diff --git a/ui/app/src/components/shared/ResourceBody.tsx b/ui/app/src/components/shared/ResourceBody.tsx index c137c23464..5ce70c4629 100644 --- a/ui/app/src/components/shared/ResourceBody.tsx +++ b/ui/app/src/components/shared/ResourceBody.tsx @@ -16,7 +16,7 @@ interface ResourceBodyProps { export const ResourceBody: React.FunctionComponent = (props: ResourceBodyProps) => { return ( - + {
- Azure Trusted Research Environment + + +
Azure Trusted Research Environment
+
diff --git a/ui/app/src/components/shared/airlock/Airlock.tsx b/ui/app/src/components/shared/airlock/Airlock.tsx new file mode 100644 index 0000000000..17d799197e --- /dev/null +++ b/ui/app/src/components/shared/airlock/Airlock.tsx @@ -0,0 +1,229 @@ +import React, { useContext, useEffect, useState } from 'react'; +import { CommandBarButton, DetailsList, getTheme, IColumn, MessageBar, MessageBarType, Persona, PersonaSize, SelectionMode, Spinner, SpinnerSize, Stack } from '@fluentui/react'; +import { HttpMethod, useAuthApiCall } from '../../../hooks/useAuthApiCall'; +import { ApiEndpoint } from '../../../models/apiEndpoints'; +import { WorkspaceContext } from '../../../contexts/WorkspaceContext'; +import { AirlockRequest } from '../../../models/airlock'; +import moment from 'moment'; +import { Route, Routes, useNavigate } from 'react-router-dom'; +import { AirlockViewRequest } from './AirlockViewRequest'; +import { LoadingState } from '../../../models/loadingState'; + +interface AirlockProps { +} + +export const Airlock: React.FunctionComponent = (props: AirlockProps) => { + const [airlockRequests, setAirlockRequests] = useState([] as AirlockRequest[]); + const [requestColumns, setRequestColumns] = useState([] as IColumn[]); + const [loadingState, setLoadingState] = useState(LoadingState.Loading); + const workspaceCtx = useContext(WorkspaceContext); + const apiCall = useAuthApiCall(); + const theme = getTheme(); + const navigate = useNavigate(); + + useEffect(() => { + const getAirlockRequests = async () => { + let requests: AirlockRequest[]; + + try { + if (workspaceCtx.workspace) { + const result = await apiCall( + `${ApiEndpoint.Workspaces}/${workspaceCtx.workspace.id}/${ApiEndpoint.AirlockRequests}`, + HttpMethod.Get, + workspaceCtx.workspaceApplicationIdURI + ); + requests = result.airlockRequests.map((r: { airlockRequest: AirlockRequest }) => r.airlockRequest); + } else { + // TODO: Get all requests across workspaces + requests = []; + } + // Order by updatedWhen for initial view + requests.sort((a, b) => a.updatedWhen < b.updatedWhen ? 1 : -1); + setAirlockRequests(requests); + setLoadingState(LoadingState.Ok); + } catch (error) { + setLoadingState(LoadingState.Error); + } + } + getAirlockRequests(); + }, [apiCall, workspaceCtx.workspace, workspaceCtx.workspaceApplicationIdURI]); + + useEffect(() => { + const reorderColumn = (ev: React.MouseEvent, column: IColumn): void => { + // Reset sorting on other columns and invert selected column if already sorted asc/desc + setRequestColumns(columns => { + const orderedColumns: IColumn[] = columns.slice(); + const selectedColumn: IColumn = orderedColumns.filter(selCol => column.key === selCol.key)[0]; + orderedColumns.forEach((newCol: IColumn) => { + if (newCol === selectedColumn) { + selectedColumn.isSortedDescending = !selectedColumn.isSortedDescending; + selectedColumn.isSorted = true; + } else { + newCol.isSorted = false; + newCol.isSortedDescending = true; + } + }); + return orderedColumns; + }); + + // Re-order airlock requests + setAirlockRequests(requests => { + const key = column.fieldName! as keyof AirlockRequest; + return requests + .slice(0) + .sort((a: AirlockRequest, b: AirlockRequest) => ( + (column.isSortedDescending ? a[key] < b[key] : a[key] > b[key]) ? 1 : -1) + ); + }) + }; + + const columns: IColumn[] = [ + { + key: 'avatar', + name: '', + minWidth: 16, + maxWidth: 16, + isIconOnly: true, + onRender: (request: AirlockRequest) => { + return + } + }, + { + key: 'initiator', + name: 'Initiator', + ariaLabel: 'Creator of the airlock request', + minWidth: 150, + maxWidth: 200, + isResizable: true, + onRender: (request: AirlockRequest) => request.user?.name, + onColumnClick: reorderColumn + }, + { + key: 'type', + name: 'Type', + ariaLabel: 'Whether the request is import or export', + minWidth: 70, + maxWidth: 100, + isResizable: true, + fieldName: 'requestType', + onColumnClick: reorderColumn + }, + { + key: 'status', + name: 'Status', + ariaLabel: 'Status of the request', + minWidth: 70, + isResizable: true, + fieldName: 'status', + onColumnClick: reorderColumn + }, + { + key: 'created', + name: 'Created', + ariaLabel: 'When the request was created', + minWidth: 120, + data: 'number', + isResizable: true, + fieldName: 'createdTime', + onRender: (request: AirlockRequest) => { + return { moment.unix(request.creationTime).format('DD/MM/YYYY') }; + }, + onColumnClick: reorderColumn + }, + { + key: 'updated', + name: 'Updated', + ariaLabel: 'When the request was last updated', + minWidth: 120, + data: 'number', + isResizable: true, + isSorted: true, + fieldName: 'updatedWhen', + onRender: (request: AirlockRequest) => { + return { moment.unix(request.updatedWhen).fromNow() }; + }, + onColumnClick: reorderColumn + } + ]; + setRequestColumns(columns); + }, []); + + let requestsList; + switch (loadingState) { + case LoadingState.Ok: + if (airlockRequests.length > 0) { + requestsList = ( + item.id} + onItemInvoked={(item) => navigate(item.id)} + className="tre-table-rows-align-centre" + /> + ); + } else { + requestsList = ( +
+

No requests found

+ Looks like there are no airlock requests yet. Create a new request to get started. +
+ ) + } + break; + case LoadingState.Error: + requestsList = ( + +

Error fetching airlock requests

+

There was an error fetching the airlock requests. Please see the browser console for details.

+
+ ); break; + default: + requestsList = ( +
+ +
+ ); break; + } + + const updateRequest = (updatedRequest: AirlockRequest) => { + setAirlockRequests(requests => { + const i = requests.findIndex(r => r.id === updatedRequest.id); + const updatedRequests = [...requests]; + updatedRequests[i] = updatedRequest; + return updatedRequests; + }); + }; + + return ( + <> + + + +

Airlock

+ +
+
+
+ +
+ { requestsList } +
+ + + + } /> + + + ); + +}; + diff --git a/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx b/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx new file mode 100644 index 0000000000..3d39073764 --- /dev/null +++ b/ui/app/src/components/shared/airlock/AirlockViewRequest.tsx @@ -0,0 +1,298 @@ +import { DefaultButton, Dialog, DialogFooter, IStackItemStyles, IStackStyles, MessageBar, MessageBarType, Panel, PanelType, Persona, PersonaSize, PrimaryButton, Spinner, SpinnerSize, Stack, TextField, useTheme } from "@fluentui/react"; +import moment from "moment"; +import { useCallback, useContext, useEffect, useMemo, useState } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { WorkspaceContext } from "../../../contexts/WorkspaceContext"; +import { HttpMethod, useAuthApiCall } from "../../../hooks/useAuthApiCall"; +import { AirlockRequest, AirlockRequestStatus } from "../../../models/airlock"; +import { ApiEndpoint } from "../../../models/apiEndpoints"; + +interface AirlockViewRequestProps { + requests: AirlockRequest[]; + updateRequest: (requests: AirlockRequest) => void; +} + +const underlineStackStyles: IStackStyles = { + root: { + borderBottom: '#f2f2f2 solid 1px' + }, +}; + +const stackItemStyles: IStackItemStyles = { + root: { + alignItems: 'center', + display: 'flex', + height: 50, + margin: '0px 5px' + }, +}; + +export const AirlockViewRequest: React.FunctionComponent = (props: AirlockViewRequestProps) => { + const {requestId} = useParams(); + const [request, setRequest] = useState(); + const [filesLink, setFilesLink] = useState(); + const [filesLinkError, setFilesLinkError] = useState(false); + const [hideSubmitDialog, setHideSubmitDialog] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(false); + const [cancelling, setCancelling] = useState(false); + const [cancelError, setCancelError] = useState(false); + const [hideCancelDialog, setHideCancelDialog] = useState(true); + const workspaceCtx = useContext(WorkspaceContext); + const apiCall = useAuthApiCall(); + const navigate = useNavigate(); + const theme = useTheme(); + + const cancelButtonStyles = useMemo(() => ({ + root: { + marginRight: 8, + background: theme.palette.red, + color: theme.palette.white, + borderColor: theme.palette.red + } + }), [theme]); + + useEffect(() => { + // Get the selected request from the router param and find in the requests prop + const req = props.requests.find(r => r.id === requestId) as AirlockRequest; + setRequest(req); + }, [requestId, props.requests]); + + const generateFilesLink = useCallback(async () => { + // Retrieve a link to view/edit the airlock files + if (request && request.workspaceId) { + try { + const linkObject = await apiCall( + `${ApiEndpoint.Workspaces}/${request.workspaceId}/${ApiEndpoint.AirlockRequests}/${request.id}/${ApiEndpoint.AirlockLink}`, + HttpMethod.Get, + workspaceCtx.workspaceApplicationIdURI + ); + setFilesLink(linkObject.containerUrl); + } catch (error) { + setFilesLinkError(true); + } + } + }, [apiCall, request, workspaceCtx.workspaceApplicationIdURI]); + + const dismissPanel = useCallback(() => navigate('../'), [navigate]); + + const submitRequest = useCallback(async () => { + // Submit an airlock request + if (request && request.workspaceId) { + setSubmitting(true); + setSubmitError(false); + try { + const response = await apiCall( + `${ApiEndpoint.Workspaces}/${request.workspaceId}/${ApiEndpoint.AirlockRequests}/${request.id}/${ApiEndpoint.AirlockSubmit}`, + HttpMethod.Post, + workspaceCtx.workspaceApplicationIdURI + ); + props.updateRequest(response.airlockRequest); + setHideSubmitDialog(true); + } catch (error) { + setSubmitError(true); + } + setSubmitting(false); + } + }, [apiCall, request, props, workspaceCtx.workspaceApplicationIdURI]); + + const cancelRequest = useCallback(async () => { + // Cancel an airlock request + if (request && request.workspaceId) { + setCancelling(true); + setCancelError(false); + try { + const response = await apiCall( + `${ApiEndpoint.Workspaces}/${request.workspaceId}/${ApiEndpoint.AirlockRequests}/${request.id}/${ApiEndpoint.AirlockCancel}`, + HttpMethod.Post, + workspaceCtx.workspaceApplicationIdURI + ); + props.updateRequest(response.airlockRequest); + setHideCancelDialog(true); + } catch (error) { + setCancelError(true); + } + setCancelling(false); + } + }, [apiCall, request, props, workspaceCtx.workspaceApplicationIdURI]); + + const renderFooter = useCallback(() => { + let footer = <> + if (request) { + footer = <> + { + request.status === AirlockRequestStatus.Draft &&
+ + This request is currently in draft. Add a file to the request's storage container using the SAS URL and submit when ready. + +
+ } +
+ { + request.status !== AirlockRequestStatus.Cancelled && setHideCancelDialog(false)} styles={cancelButtonStyles}>Cancel Request + } + { + request.status === AirlockRequestStatus.Draft && setHideSubmitDialog(false)}>Submit + } +
+ + } + return footer; + }, [request, cancelButtonStyles]); + + return ( + <> + { + request ? <> + + + Initiator + + + + + + + + + Type + + +

{request.requestType}

+
+
+ + + + Status + + +

{request.status}

+
+
+ + + + Workspace + + +

{workspaceCtx.workspace?.properties?.display_name}

+
+
+ + + + Created + + +

{moment.unix(request.creationTime).format('DD/MM/YYYY')}

+
+
+ + + + Updated + + +

{moment.unix(request.updatedWhen).fromNow()}

+
+
+ + + + Business Justification + + + + +

{request.businessJustification}

+
+
+ + + + Files + + + + + Generate a storage container SAS URL to view/modify the request file(s). + + + + + { + filesLink ? {navigator.clipboard.writeText(filesLink)}} + /> : Generate + } + + + { + filesLinkError && + Error retrieving storage link. Check console. + + } + + + :
+ +
+ } + + + +
+ + ) +} diff --git a/ui/app/src/components/workspaces/WorkspaceHeader.tsx b/ui/app/src/components/workspaces/WorkspaceHeader.tsx index b8c1fb897b..99feba78fb 100644 --- a/ui/app/src/components/workspaces/WorkspaceHeader.tsx +++ b/ui/app/src/components/workspaces/WorkspaceHeader.tsx @@ -1,29 +1,31 @@ -import { getTheme, mergeStyles, Stack } from '@fluentui/react'; +import { getTheme, Icon, mergeStyles, Stack } from '@fluentui/react'; import React, { useContext } from 'react'; import { WorkspaceContext } from '../../contexts/WorkspaceContext'; export const WorkspaceHeader: React.FunctionComponent = () => { const workspaceCtx = useContext(WorkspaceContext); - + return ( <> -

{workspaceCtx.workspace?.properties?.display_name}

+

+ + {workspaceCtx.workspace?.properties?.display_name} +

); }; - const theme = getTheme(); const contentClass = mergeStyles([ { - backgroundColor: theme.palette.themeDark, + backgroundColor: theme.palette.themeDarker, color: theme.palette.white, - lineHeight: '50px', + lineHeight: '15px', padding: '0 20px', boxShadow: '0 1px 8px 0px #ccc' } -]); \ No newline at end of file +]); diff --git a/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx b/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx index 20b1b95bb4..536571d794 100644 --- a/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx +++ b/ui/app/src/components/workspaces/WorkspaceLeftNav.tsx @@ -66,8 +66,13 @@ export const WorkspaceLeftNav: React.FunctionComponent = name: 'Shared Services', key: ApiEndpoint.SharedServices, url: ApiEndpoint.SharedServices, - isExpanded: true, + isExpanded: false, links: sharedServiceLinkArray + }, + { + name: 'Airlock', + key: ApiEndpoint.AirlockRequests, + url: ApiEndpoint.AirlockRequests } ] } diff --git a/ui/app/src/components/workspaces/WorkspaceProvider.tsx b/ui/app/src/components/workspaces/WorkspaceProvider.tsx index 52fe31cb10..bfef562125 100644 --- a/ui/app/src/components/workspaces/WorkspaceProvider.tsx +++ b/ui/app/src/components/workspaces/WorkspaceProvider.tsx @@ -15,6 +15,7 @@ import { Workspace } from '../../models/workspace'; import { SharedService } from '../../models/sharedService'; import { SharedServices } from '../shared/SharedServices'; import { SharedServiceItem } from '../shared/SharedServiceItem'; +import { Airlock } from '../shared/airlock/Airlock'; export const WorkspaceProvider: React.FunctionComponent = () => { const apiCall = useAuthApiCall(); @@ -131,6 +132,9 @@ export const WorkspaceProvider: React.FunctionComponent = () => { } /> + + } />
diff --git a/ui/app/src/models/airlock.ts b/ui/app/src/models/airlock.ts new file mode 100644 index 0000000000..1377b16eb0 --- /dev/null +++ b/ui/app/src/models/airlock.ts @@ -0,0 +1,26 @@ +import { Resource } from "./resource"; + +export interface AirlockRequest extends Resource { + workspaceId: string; + requestType: AirlockRequestType; + files: Array; + businessJustification: string; + errorMessage: null | string; + status: AirlockRequestStatus; + creationTime: number; +} + +export enum AirlockRequestType { + Import = 'import', + Export = 'export' +} + +export enum AirlockRequestStatus { + Draft = 'draft', + InReview = 'in_review', + InProgress = 'in_progress', + Approved = 'approved', + Rejected = 'rejected', + Submitted = 'submitted', + Cancelled = 'cancelled' +} diff --git a/ui/app/src/models/apiEndpoints.ts b/ui/app/src/models/apiEndpoints.ts index 5daee5e327..2d9cb8ffbd 100644 --- a/ui/app/src/models/apiEndpoints.ts +++ b/ui/app/src/models/apiEndpoints.ts @@ -3,6 +3,10 @@ export enum ApiEndpoint { WorkspaceServices = 'workspace-services', UserResources = 'user-resources', SharedServices = 'shared-services', + AirlockRequests = 'requests', + AirlockLink = 'link', + AirlockSubmit = 'submit', + AirlockCancel = 'cancel', WorkspaceTemplates = 'workspace-templates', WorkspaceServiceTemplates = 'workspace-service-templates', UserResourceTemplates = 'user-resource-templates',