Skip to content

Commit

Permalink
emanifest backend logic separation and Floating Acion Buttons (#663)
Browse files Browse the repository at this point in the history
* Rename class 'ManifestService' to 'EManifest'. This class will now be used to encapsulate intereactions with e-Manifest (the RCRAInfo module)

we're extracting the e-Manifest parts of the manifest service to help keep the separation of responsiblities clear

* add a faker library provider for manifest status and use in manifest_factory

* create_manifest service module functionality and utilize in CreateManifestView

* use different endpoints depending on whether the user is saving a manifest to RCRAInfo or just saving a manifest to haztrak

* refactor various components to use RTK query hooks instead of dispatching async thunks or custom hook API calls. In effect, we are centralizing our API layer into the redux store

* add badge to show handler search component is searching though the RCRAInfo web services

* fix warning from redux selectors made with createSelector that used output selectors that returned input values. The warning stated that likely would result in memoization errors.

See the redux documentation https://redux.js.org/usage/deriving-data-selectors\#writing-memoized-selectors-with-reselect

Add new selectHaztrakSiteEpaIds redux selector

* Refactor rcrainfo search badge, which notifies the user when HazTrak is using the RCRAInfo site search services, into separate component. The badge also shows a warning when a user's organization admin has not set up their RCRAInfo API ID and Keys in RCRAInfo.

* refactor DOT ID number select component to use RTK query's useLazyQuery for dot ID number selection. Also, the component fetched and pre-populate DOT ID numbers with options upon mounting

* New UI component, FloatingActionBtn a reusable floating action button, aimed at implementing material design's FAB

* Add floating Action buttons to Manifest, the ManifestActionBtns shows various actions depending on the manifest state

* Minor UI adjustments and ManifestActionBtns test suite

Adjust ManifestForm test suite to accommodate multiple save buttons (one floating action button, one regular)
Minor changes to make HtSpinner for flexible and use correct profile selector in Profile Feature
  • Loading branch information
dpgraham4401 authored Dec 11, 2023
1 parent 35cad0f commit acfc60d
Show file tree
Hide file tree
Showing 52 changed files with 1,232 additions and 780 deletions.
3 changes: 2 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getHaztrakProfile,
getHaztrakUser,
getRcraProfile,
selectHaztrakProfile,
selectRcraProfile,
selectUserName,
useAppDispatch,
Expand All @@ -19,7 +20,7 @@ import { HtSpinner } from 'components/UI';

function App(): ReactElement {
const userName = useAppSelector(selectUserName);
const profile = useAppSelector(selectRcraProfile);
const profile = useAppSelector(selectHaztrakProfile);
const dispatch = useAppDispatch();

useEffect(() => {
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/Layout/Root.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { ErrorBoundary } from 'components/Error';
import { HtSpinner } from 'components/UI';
import React, { createContext, Dispatch, SetStateAction, Suspense, useState } from 'react';
import { Container } from 'react-bootstrap';
import { Outlet } from 'react-router-dom';
import { PrivateRoute } from './PrivateRoute';
import { Sidebar } from './Sidebar/Sidebar';
import { TopNav } from './TopNav/TopNav';
import { HtSpinner } from 'components/UI';

export interface NavContextProps {
showSidebar: boolean;
Expand All @@ -29,7 +29,7 @@ export function Root() {
<Suspense
fallback={
<Container fluid className="d-flex justify-content-center vh-100">
<HtSpinner size="7x" />
<HtSpinner size="6x" className="my-auto" />
</Container>
}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import '@testing-library/jest-dom';
import { ManifestActionBtns } from 'components/Manifest/ActionBtns/ManifestActionBtns';
import React from 'react';
import { cleanup, renderWithProviders, screen } from 'test-utils';
import { afterEach, describe, expect, test } from 'vitest';

afterEach(() => {
cleanup();
});

describe('ManifestActionBtns', () => {
test.each([
['Scheduled', true],
['Scheduled', false],
['NotAssigned', false],
['NotAssigned', true],
])('renders a save button when editing and {status: "%s", signAble: %s}', (status, signAble) => {
renderWithProviders(
// @ts-ignore - Status is expected to be a ManifestStatus, but we're passing a string
<ManifestActionBtns readOnly={false} manifestStatus={status} signAble={signAble} />
);
expect(screen.queryByRole('button', { name: /Save/i })).toBeInTheDocument();
});
test.each([[true, false]])(
'renders a edit button when {readOnly: %s, signAble: %s}',
(readOnly, signAble) => {
renderWithProviders(
// @ts-ignore - Status is expected to be a ManifestStatus, but we're passing a string
<ManifestActionBtns readOnly={readOnly} manifestStatus={'Scheduled'} signAble={signAble} />
);
expect(screen.queryByRole('button', { name: /Edit/i })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Sign/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /Save/i })).not.toBeInTheDocument();
}
);
test.each([[true, true]])(
'renders a sign button when {readOnly: %s, signAble: %s}',
(readOnly, signAble) => {
renderWithProviders(
// @ts-ignore - Status is expected to be a ManifestStatus, but we're passing a string
<ManifestActionBtns readOnly={readOnly} manifestStatus={'Scheduled'} signAble={signAble} />
);
expect(screen.queryByRole('button', { name: /Sign/i })).toBeInTheDocument();
}
);
test.each([
[true, true],
[true, false],
[false, false],
[false, true],
])(
'never renders a sign button when draft status with {readOnly: %s, signAble: %s}',
(readOnly, signAble) => {
renderWithProviders(
// @ts-ignore - Status is expected to be a ManifestStatus, but we're passing a string
<ManifestActionBtns
readOnly={readOnly}
manifestStatus={'NotAssigned'}
signAble={signAble}
/>
);
expect(screen.queryByRole('button', { name: /Sign/i })).not.toBeInTheDocument();
}
);
});
54 changes: 54 additions & 0 deletions client/src/components/Manifest/ActionBtns/ManifestActionBtns.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { faFloppyDisk, faPen, faPenToSquare } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { ManifestStatus } from 'components/Manifest/manifestSchema';
import { FloatingActionBtn } from 'components/UI';
import React from 'react';

interface ManifestActionBtnsProps {
manifestStatus?: ManifestStatus;
readOnly?: boolean;
signAble?: boolean;
}

export function ManifestActionBtns({
manifestStatus,
readOnly,
signAble,
}: ManifestActionBtnsProps) {
let variant: string | undefined = undefined;
let text: string | undefined = undefined;
let icon: IconProp | undefined = undefined;
let type: 'button' | 'submit' | 'reset' | undefined = undefined;
let name: string | undefined = undefined;
if (!readOnly || manifestStatus === 'NotAssigned') {
variant = 'success';
icon = faFloppyDisk;
text = 'Save';
type = 'submit';
name = 'saveFAB';
} else if (signAble) {
variant = 'primary';
icon = faPen;
text = 'Sign';
name = 'signFAB';
type = 'button';
} else if (readOnly) {
variant = 'primary';
icon = faPenToSquare;
text = 'Edit';
name = 'editFAB';
type = 'button';
} else {
return <></>;
}

return (
<FloatingActionBtn name={name} variant={variant} type={type} position="bottom-left" extended>
<span className="h5 me-3">{text}</span>
<span className="h5">
<FontAwesomeIcon icon={icon} />
</span>
</FloatingActionBtn>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -119,11 +119,16 @@ export function AdditionalInfoForm({ readOnly }: AdditionalFormProps) {
})}
</Row>
</Col>
<div>
<div className="d-flex justify-content-end ">
{readOnly ? (
<></>
) : (
<Button onClick={() => append({ description: '', label: '' })}>Add Reference</Button>
<Button
variant="outline-secondary"
onClick={() => append({ description: '', label: '' })}
>
Add Reference
</Button>
)}
</div>
</>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import React from 'react';
import '@testing-library/jest-dom';
import { HandlerSearchForm } from './HandlerSearchForm';
import { cleanup, renderWithProviders, screen } from 'test-utils';
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'vitest';
import { createMockRcrainfoSite } from 'test-utils/fixtures';
import { setupServer } from 'msw/node';
import { http, HttpResponse } from 'msw';
import { API_BASE_URL } from 'test-utils/mock/handlers';
import userEvent from '@testing-library/user-event';

const mockRcraSite1Id = 'VATEST111111111';
const mockRcraSite2Id = 'VATEST222222222';
const mockRcrainfoSite1Id = 'VATEST333333333';
const mockRcrainfoSite2Id = 'VATEST444444444';

const mockRcrainfoSite1 = createMockRcrainfoSite({ epaSiteId: mockRcrainfoSite1Id });
const mockRcrainfoSite2 = createMockRcrainfoSite({ epaSiteId: mockRcrainfoSite2Id });
const mockRcraSite1 = createMockRcrainfoSite({ epaSiteId: mockRcraSite1Id });
const mockRcraSite2 = createMockRcrainfoSite({ epaSiteId: mockRcraSite2Id });
export const testURL = [
http.get(`${API_BASE_URL}/api/site/search`, (info) => {
return HttpResponse.json([mockRcraSite1, mockRcraSite2], { status: 200 });
}),
http.get(`${API_BASE_URL}/api/rcra/handler/search`, (info) => {
return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 });
}),
http.post(`${API_BASE_URL}/api/rcra/handler/search`, (info) => {
return HttpResponse.json([mockRcrainfoSite1, mockRcrainfoSite2], { status: 200 });
}),
];

const server = setupServer(...testURL);
afterEach(() => {
cleanup();
});
beforeAll(() => server.listen());
afterAll(() => server.close()); // Disable API mocking after the tests are done.

describe('HandlerSearchForm', () => {
test('renders with basic information inputs', () => {
renderWithProviders(
<HandlerSearchForm handleClose={() => undefined} handlerType="generator" />
);
expect(screen.getByText(/EPA ID/i)).toBeInTheDocument();
});
test('retrieves rcra sites from haztrak and RCRAInfo', async () => {
renderWithProviders(
<HandlerSearchForm handleClose={() => undefined} handlerType="generator" />,
{
preloadedState: {
profile: {
user: 'testuser1',
org: {
name: 'my org',
rcrainfoIntegrated: true,
id: '1234',
},
},
},
}
);
const epaId = screen.getByRole('combobox');
await userEvent.type(epaId, 'VATEST');
expect(await screen.findByText(new RegExp(mockRcraSite1Id, 'i'))).toBeInTheDocument();
expect(await screen.findByText(new RegExp(mockRcraSite2Id, 'i'))).toBeInTheDocument();
expect(await screen.findByText(new RegExp(mockRcrainfoSite1Id, 'i'))).toBeInTheDocument();
expect(await screen.findByText(new RegExp(mockRcrainfoSite2Id, 'i'))).toBeInTheDocument();
});
test('retrieves rcra sites from haztrak if org not rcrainfo integrated', async () => {
renderWithProviders(
<HandlerSearchForm handleClose={() => undefined} handlerType="generator" />,
{
preloadedState: {
profile: {
user: 'testuser1',
org: {
name: 'my org',
rcrainfoIntegrated: false,
id: '1234',
},
},
},
}
);
const epaId = screen.getByRole('combobox');
await userEvent.type(epaId, 'VATEST');
expect(await screen.findByText(new RegExp(mockRcraSite1Id, 'i'))).toBeInTheDocument();
expect(await screen.findByText(new RegExp(mockRcraSite2Id, 'i'))).toBeInTheDocument();
expect(screen.queryByText(new RegExp(mockRcrainfoSite1Id, 'i'))).not.toBeInTheDocument();
expect(screen.queryByText(new RegExp(mockRcrainfoSite2Id, 'i'))).not.toBeInTheDocument();
});
});
Loading

0 comments on commit acfc60d

Please sign in to comment.