Skip to content

Commit

Permalink
first cut of support page (#4812)
Browse files Browse the repository at this point in the history
Co-authored-by: Nick Watts <[email protected]>
Co-authored-by: Christina Ahrens Roberts <[email protected]>
  • Loading branch information
3 people authored May 15, 2024
1 parent 71a606f commit 49a44a9
Show file tree
Hide file tree
Showing 12 changed files with 663 additions and 0 deletions.
7 changes: 7 additions & 0 deletions src/libs/ajax/Groups.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import _ from 'lodash/fp';
import { authOpts, fetchSam, jsonBody } from 'src/libs/ajax/ajax-common';
import { SupportSummary } from 'src/support/SupportResourceType';

export type GroupRole = 'admin' | 'member';

Expand Down Expand Up @@ -84,6 +85,12 @@ export const Groups = (signal?: AbortSignal) => ({
const res = await fetchSam(`${resourceRoot}/action/use`, _.merge(authOpts(), { signal }));
return res.json();
},

// we could provide a more specific type here, but we don't use it and don't want to limit future additions
getSupportSummary: async (): Promise<SupportSummary> => {
const res = await fetchSam(`api/admin/v1/groups/${groupName}/supportSummary`, _.merge(authOpts(), { signal }));
return res.json();
},
};
},
});
13 changes: 13 additions & 0 deletions src/libs/ajax/SamResources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { appIdentifier, authOpts, fetchSam, jsonBody } from 'src/libs/ajax/ajax-

type RequesterPaysProject = undefined | string;

export interface FullyQualifiedResourceId {
resourceTypeName: string;
resourceId: string;
}

export const SamResources = (signal: AbortSignal) => ({
leave: (samResourceType, samResourceId): Promise<void> =>
fetchSam(
Expand All @@ -28,4 +33,12 @@ export const SamResources = (signal: AbortSignal) => ({
): Promise<string> => {
return Ajax(signal).SamResources.getRequesterPaysSignedUrl(`gs://${bucket}/${object}`, requesterPaysProject);
},

getResourcePolicies: async (fqResourceId: FullyQualifiedResourceId): Promise<object> => {
const res = await fetchSam(
`api/admin/v1/resources/${fqResourceId.resourceTypeName}/${fqResourceId.resourceId}/policies`,
_.mergeAll([authOpts(), appIdentifier])
);
return res.json();
},
});
2 changes: 2 additions & 0 deletions src/libs/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import * as DataExplorer from 'src/pages/library/datasets/DataExplorer';
import * as Showcase from 'src/pages/library/Showcase';
import * as NotFound from 'src/pages/NotFound';
import * as Profile from 'src/pages/ProfilePage';
import * as Support from 'src/pages/SupportPage';
import * as UploadData from 'src/pages/UploadDataPage';
import * as WorkflowsList from 'src/pages/workflows/List';
import * as WorkflowDetails from 'src/pages/workflows/workflow/WorkflowDetails';
Expand Down Expand Up @@ -85,6 +86,7 @@ const routes = _.flatten([
WorkspaceFiles.navPaths,
WorkflowsApp.navPaths,
SignOutPage.navPaths,
Support.navPaths,
NotFound.navPaths, // must be last
]);

Expand Down
47 changes: 47 additions & 0 deletions src/pages/SupportPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import React from 'react';
import FooterWrapper from 'src/components/FooterWrapper';
import { PageBox, PageBoxVariants } from 'src/components/PageBox';
import TopBar from 'src/components/TopBar';
import * as Nav from 'src/libs/nav';
import * as Style from 'src/libs/style';
import { SupportResourceList } from 'src/support/SupportResourceList';

interface SupportPageProps {
queryParams: {
resourceType: string | undefined;
resourceId: string | undefined;
};
}

const SupportPage = (props: SupportPageProps) => {
const selectedType = props.queryParams.resourceType;
const resourceId = props.queryParams.resourceId;
const breadcrumbs = `Support > ${selectedType}`;

return (
<FooterWrapper>
<TopBar title='Support' href={Nav.getLink('support')}>
{resourceId && (
<div style={Style.breadcrumb.breadcrumb}>
<div style={Style.noWrapEllipsis}>{breadcrumbs}</div>
<div style={Style.breadcrumb.textUnderBreadcrumb}>{resourceId}</div>
</div>
)}
</TopBar>
<PageBox role='main' variant={PageBoxVariants.light}>
<h2 style={{ ...Style.elements.sectionHeader, textTransform: 'uppercase' }}>Support</h2>
<p>Select resource type.</p>
<SupportResourceList queryParams={{ resourceType: selectedType, resourceId }} />
</PageBox>
</FooterWrapper>
);
};

export const navPaths = [
{
name: 'support',
path: '/support',
component: SupportPage,
title: 'Support',
},
];
57 changes: 57 additions & 0 deletions src/support/LookupSummaryAndPolicies.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { ButtonPrimary } from '@terra-ui-packages/components';
import React, { Fragment, useState } from 'react';
import { TextInput } from 'src/components/input';
import colors from 'src/libs/colors';
import * as Nav from 'src/libs/nav';
import { ResourcePolicies } from 'src/support/ResourcePolicies';
import { ResourceTypeSummaryProps } from 'src/support/SupportResourceType';
import { SupportSummary } from 'src/support/SupportSummary';

export const LookupSummaryAndPolicies = (props: ResourceTypeSummaryProps) => {
const { query } = Nav.useRoute();
const [resourceId, setResourceId] = useState<string>(props.fqResourceId.resourceId);

function submit() {
Nav.updateSearch({ ...query, resourceId: resourceId || undefined });
}

// event hook to clear the resourceId when resourceType changes
React.useEffect(() => {
setResourceId('');
}, [props.fqResourceId.resourceTypeName]);

return (
<>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '1rem' }}>
<div
style={{
color: colors.dark(),
fontSize: 18,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
marginLeft: '1rem',
}}
>
{props.displayName}
</div>
<TextInput
style={{ marginRight: '1rem', marginLeft: '1rem' }}
placeholder={`Enter ${props.displayName} ID`}
onChange={(newResourceId) => {
setResourceId(newResourceId);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
submit();
}
}}
value={resourceId}
/>
<ButtonPrimary onClick={() => submit()}>Load</ButtonPrimary>
</div>
{!!props.loadSupportSummaryFn && <SupportSummary {...props} />}
<ResourcePolicies {...props} />
</>
);
};
118 changes: 118 additions & 0 deletions src/support/ResourcePolicies.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { asMockedFn } from '@terra-ui-packages/test-utils';
import { act, render, screen } from '@testing-library/react';
import React from 'react';
import { Ajax } from 'src/libs/ajax';
import { FullyQualifiedResourceId } from 'src/libs/ajax/SamResources';
import { reportError } from 'src/libs/error';
import { ResourcePolicies } from 'src/support/ResourcePolicies';
import { ResourceTypeSummaryProps } from 'src/support/SupportResourceType';
import { v4 as uuidv4 } from 'uuid';

jest.mock('src/libs/ajax');
type ErrorExports = typeof import('src/libs/error');
jest.mock(
'src/libs/error',
(): ErrorExports => ({
...jest.requireActual('src/libs/error'),
reportError: jest.fn(),
})
);

describe('ResourcePolicies', () => {
function setGetResourcePoliciesMock(getResourcePolicies: jest.Mock<Promise<Awaited<object>>, []>) {
asMockedFn(Ajax).mockImplementation(() => {
return {
SamResources: { getResourcePolicies } as Partial<ReturnType<typeof Ajax>['SamResources']>,
} as ReturnType<typeof Ajax>;
});
}

it('calls Ajax().SamResources.getResourcePolicies and displays the result', async () => {
// Arrange
const testValue = uuidv4();
const getResourcePolicies = jest.fn(() => Promise.resolve({ policy: testValue }));
setGetResourcePoliciesMock(getResourcePolicies);
const fqResourceId: FullyQualifiedResourceId = { resourceId: 'resource-id', resourceTypeName: 'resource-type' };
const props: ResourceTypeSummaryProps = {
displayName: 'display-name',
fqResourceId,
loadSupportSummaryFn: undefined,
};

// Act
await act(async () => {
render(<ResourcePolicies {...props} />);
});

// Assert
expect(getResourcePolicies).toHaveBeenCalledWith(fqResourceId);
expect(screen.getByText('Sam Policies')).toBeInTheDocument();
expect(screen.getByText(new RegExp(testValue, 'i'))).toBeInTheDocument();
});

it('displays an error message when getResourcePolicies throws an error', async () => {
// Arrange
const errorMessage = 'test error message';
const getResourcePolicies = jest.fn(() => Promise.reject(new Error(errorMessage)));
setGetResourcePoliciesMock(getResourcePolicies);
const fqResourceId: FullyQualifiedResourceId = { resourceId: 'resource-id', resourceTypeName: 'resource-type' };
const props: ResourceTypeSummaryProps = {
displayName: 'display-name',
fqResourceId,
loadSupportSummaryFn: undefined,
};

// Act
await act(async () => {
render(<ResourcePolicies {...props} />);
});

// Assert
expect(getResourcePolicies).toHaveBeenCalledWith(fqResourceId);
expect(reportError).toHaveBeenCalled();
});

it('displays an error message when getResourcePolicies returns an empty array', async () => {
// Arrange
const getResourcePolicies = jest.fn(() => Promise.resolve([]));
setGetResourcePoliciesMock(getResourcePolicies);
const fqResourceId: FullyQualifiedResourceId = { resourceId: 'resource-id', resourceTypeName: 'resource-type' };
const props: ResourceTypeSummaryProps = {
displayName: 'display-name',
fqResourceId,
loadSupportSummaryFn: undefined,
};

// Act
await act(async () => {
render(<ResourcePolicies {...props} />);
});

// Assert
expect(getResourcePolicies).toHaveBeenCalledWith(fqResourceId);
expect(screen.getByText('No policies found')).toBeInTheDocument();
});

it('displays an error message when getResourcePolicies throws 403', async () => {
// Arrange
const getResourcePolicies = jest.fn(() => Promise.reject(new Response('', { status: 403 })));
setGetResourcePoliciesMock(getResourcePolicies);
const fqResourceId: FullyQualifiedResourceId = { resourceId: 'resource-id', resourceTypeName: 'resource-type' };
const props: ResourceTypeSummaryProps = {
displayName: 'display-name',
fqResourceId,
loadSupportSummaryFn: undefined,
};

// Act
await act(async () => {
render(<ResourcePolicies {...props} />);
});

// Assert
expect(getResourcePolicies).toHaveBeenCalledWith(fqResourceId);
expect(
screen.getByText('You do not have permission to view display-name policies or are not on VPN')
).toBeInTheDocument();
});
});
66 changes: 66 additions & 0 deletions src/support/ResourcePolicies.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import ReactJson from '@microlink/react-json-view';
import React, { useEffect, useState } from 'react';
import { Ajax } from 'src/libs/ajax';
import colors from 'src/libs/colors';
import { reportError } from 'src/libs/error';
import { ResourceTypeSummaryProps } from 'src/support/SupportResourceType';

export const ResourcePolicies = (props: ResourceTypeSummaryProps) => {
const [resourcePolicies, setResourcePolicies] = useState<object>();
const [errorMessage, setErrorMessage] = useState<string>('');

function clear() {
setErrorMessage('');
setResourcePolicies(undefined);
}

useEffect(() => {
const loadResourcePolicies = async () => {
clear();
if (props.fqResourceId.resourceId) {
try {
const policies = await Ajax().SamResources.getResourcePolicies(props.fqResourceId);
Array.isArray(policies) && policies.length === 0
? setErrorMessage('No policies found')
: setResourcePolicies(policies);
} catch (e) {
if (e instanceof Response && (e.status === 404 || e.status === 403)) {
setErrorMessage(`You do not have permission to view ${props.displayName} policies or are not on VPN`);
} else {
await reportError('Error loading resource policies', e);
}
}
}
};

loadResourcePolicies();
}, [props.fqResourceId, props.displayName]);

return (
<>
{!!errorMessage && <div style={{ color: colors.danger(), marginLeft: '1rem' }}>{errorMessage}</div>}
{!!resourcePolicies && (
<>
<div
style={{
color: colors.dark(),
fontSize: 18,
fontWeight: 600,
display: 'flex',
alignItems: 'center',
marginLeft: '1rem',
marginTop: '1rem',
}}
>
Sam Policies
</div>
<ReactJson
src={resourcePolicies}
name={false}
style={{ marginLeft: '1rem', border: '1px solid black', padding: '1rem' }}
/>
</>
)}
</>
);
};
Loading

0 comments on commit 49a44a9

Please sign in to comment.