Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Airlock UI: view requests (workspace-level) #2512

Merged
merged 16 commits into from
Sep 1, 2022
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
46 changes: 35 additions & 11 deletions ui/app/src/App.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion ui/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<string>);
Expand Down
4 changes: 2 additions & 2 deletions ui/app/src/components/shared/Footer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { AnimationClassNames, getTheme, mergeStyles } from '@fluentui/react';
export const Footer: React.FunctionComponent = () => {
return (
<div className={contentClass}>
Azure TRE
Azure Trusted Research Environment
</div>
);
};
Expand All @@ -22,4 +22,4 @@ const contentClass = mergeStyles([
padding: '0 20px',
},
AnimationClassNames.scaleUpIn100,
]);
]);
2 changes: 1 addition & 1 deletion ui/app/src/components/shared/ResourceBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ interface ResourceBodyProps {
export const ResourceBody: React.FunctionComponent<ResourceBodyProps> = (props: ResourceBodyProps) => {

return (
<Pivot aria-label="Resource Menu" className='tre-panel'>
<Pivot aria-label="Resource Menu" className='tre-resource-panel'>
<PivotItem
headerText="Overview"
headerButtonProps={{
Expand Down
7 changes: 5 additions & 2 deletions ui/app/src/components/shared/TopNav.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React from 'react';
import { getTheme, mergeStyles, Stack } from '@fluentui/react';
import { getTheme, Icon, mergeStyles, Stack } from '@fluentui/react';
import { Link } from 'react-router-dom';
import { UserMenu } from './UserMenu';
import { NotificationPanel } from './notifications/NotificationPanel';
Expand All @@ -10,7 +10,10 @@ export const TopNav: React.FunctionComponent = () => {
<div className={contentClass}>
<Stack horizontal>
<Stack.Item grow={100}>
<Link to='/' className='tre-home-link'>Azure Trusted Research Environment</Link>
<Link to='/' className='tre-home-link'>
<Icon iconName="TestBeakerSolid" style={{ marginLeft: '10px', marginRight: '10px', verticalAlign: 'middle' }} />
<h5 style={{display: 'inline'}}>Azure Trusted Research Environment</h5>
</Link>
</Stack.Item>
<Stack.Item>
<NotificationPanel />
Expand Down
229 changes: 229 additions & 0 deletions ui/app/src/components/shared/airlock/Airlock.tsx
Original file line number Diff line number Diff line change
@@ -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<AirlockProps> = (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<HTMLElement>, 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 <Persona size={ PersonaSize.size24 } text={ request.user?.name } />
}
},
{
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 <span>{ moment.unix(request.creationTime).format('DD/MM/YYYY') }</span>;
},
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 <span>{ moment.unix(request.updatedWhen).fromNow() }</span>;
},
onColumnClick: reorderColumn
}
];
setRequestColumns(columns);
}, []);

let requestsList;
switch (loadingState) {
damoodamoo marked this conversation as resolved.
Show resolved Hide resolved
case LoadingState.Ok:
if (airlockRequests.length > 0) {
requestsList = (
<DetailsList
items={airlockRequests}
columns={requestColumns}
selectionMode={SelectionMode.none}
getKey={(item) => item.id}
onItemInvoked={(item) => navigate(item.id)}
className="tre-table-rows-align-centre"
/>
);
} else {
requestsList = (
<div style={{textAlign: 'center', padding: '50px'}}>
<h4>No requests found</h4>
<small>Looks like there are no airlock requests yet. Create a new request to get started.</small>
</div>
)
}
break;
case LoadingState.Error:
requestsList = (
<MessageBar
messageBarType={MessageBarType.error}
isMultiline={true}
>
<h3>Error fetching airlock requests</h3>
<p>There was an error fetching the airlock requests. Please see the browser console for details.</p>
</MessageBar>
); break;
default:
requestsList = (
<div style={{ padding: '50px' }}>
<Spinner label="Loading airlock requests" ariaLive="assertive" labelPosition="top" size={SpinnerSize.large} />
damoodamoo marked this conversation as resolved.
Show resolved Hide resolved
</div>
); 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 (
<>
<Stack className="tre-panel">
<Stack.Item>
<Stack horizontal horizontalAlign="space-between">
<h1 style={{marginBottom: '0px'}}>Airlock</h1>
<CommandBarButton
iconProps={{ iconName: 'add' }}
text="New request"
style={{ background: 'none', color: theme.palette.themePrimary }}
/>
</Stack>
</Stack.Item>
</Stack>

<div className="tre-resource-panel" style={{padding: '0px'}}>
{ requestsList }
</div>

<Routes>
<Route path=":requestId" element={
<AirlockViewRequest requests={airlockRequests} updateRequest={updateRequest}/>
} />
</Routes>
</>
);

};

Loading