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

Save an activity report - frontend #258

Merged
merged 14 commits into from
Jan 20, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ parameters:
default: "main"
type: string
sandbox_git_branch: # change to feature branch to test deployment
default: "kw-pull-hs-data"
default: "js-saving-activity-report-frontend"
type: string
jobs:
build_and_lint:
Expand Down
10 changes: 5 additions & 5 deletions axe-urls
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
http://localhost:3000,
http://localhost:3000/activity-reports/activity-summary,
http://localhost:3000/activity-reports/topics-resources,
http://localhost:3000/activity-reports/goals-objectives,
http://localhost:3000/activity-reports/next-steps,
http://localhost:3000/activity-reports/review,
http://localhost:3000/activity-reports/new/activity-summary,
http://localhost:3000/activity-reports/new/topics-resources,
http://localhost:3000/activity-reports/new/goals-objectives,
http://localhost:3000/activity-reports/new/next-steps,
http://localhost:3000/activity-reports/new/review,
http://localhost:3000/admin
3 changes: 2 additions & 1 deletion cucumber/features/steps/activityReportSteps.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ const scope = require('../support/scope');

Given('I am on the activity reports page', async () => {
const page = scope.context.currentPage;
const selector = 'a[href$="activity-reports"]';
const selector = 'a[href$="activity-reports/new"]';
await Promise.all([
page.waitForNavigation(),
page.click(selector),
]);
await scope.context.currentPage.waitForSelector('h1');
});

When('I select {string}', async (inputLabel) => {
Expand Down
4 changes: 2 additions & 2 deletions cucumber/features/steps/homePageSteps.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Given('I am logged in', async () => {

const loginLinkSelector = 'a[href$="api/login"]';
// const homeLinkSelector = 'a[href$="/"]';
const activityReportsSelector = 'a[href$="activity-reports"]';
const activityReportsSelector = 'a[href$="activity-reports/new"]';

await page.goto(scope.uri);
await page.waitForSelector('em'); // Page title
Expand Down Expand Up @@ -52,7 +52,7 @@ Then('I see {string} message', async (string) => {

Then('I see {string} link', async (string) => {
const page = scope.context.currentPage;
const selector = 'a[href$="activity-reports"]';
const selector = 'a[href$="activity-reports/new"]';

await page.waitForSelector(selector);
const value = await page.$eval(selector, (el) => el.textContent);
Expand Down
11 changes: 8 additions & 3 deletions docs/openapi/paths/activity-reports/activity-recipients.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@ get:
type: object
properties:
grants:
type: array
items:
$ref: '../../index.yaml#/components/schemas/activityRecipient'
type: object
properties:
name:
type: string
grants:
type: array
items:
$ref: '../../index.yaml#/components/schemas/activityRecipient'
nonGrantees:
type: array
items:
Expand Down
2 changes: 2 additions & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"@fortawesome/react-fontawesome": "^0.1.11",
"@testing-library/jest-dom": "^4.2.4",
"@trussworks/react-uswds": "^1.9.1",
"@use-it/interval": "^1.0.0",
"http-proxy-middleware": "^1.0.5",
"lodash": "^4.17.20",
"moment": "^2.29.1",
Expand All @@ -32,6 +33,7 @@
"react-stickynode": "^3.0.4",
"react-with-direction": "^1.3.1",
"url-join": "^4.0.1",
"use-deep-compare-effect": "^1.6.1",
"uswds": "^2.9.0"
},
"engines": {
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ function App() {
)}
/>
<Route
path="/activity-reports/:currentPage?"
path="/activity-reports/:activityReportId/:currentPage?"
render={({ match }) => (
<ActivityReport match={match} />
)}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/components/DatePicker.js
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ DateInput.propTypes = {
};

DateInput.defaultProps = {
minDate: undefined,
maxDate: undefined,
minDate: '',
maxDate: '',
disabled: false,
openUp: false,
required: true,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/Header.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ function Header({ authenticated, admin }) {
<NavLink exact to="/">
Home
</NavLink>,
<NavLink to="/activity-reports">
<NavLink to="/activity-reports/new">
Activity Reports
</NavLink>,
];
Expand Down
106 changes: 86 additions & 20 deletions frontend/src/components/MultiSelect.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,25 @@
/*
This multiselect component uses react-select. React select expects options and selected
values to be in a specific format, arrays of `{ label: x, value: y }` items. Sometimes
we want to just push in and pull out simple arrays of strings instead of these objects.
Dealing with arrays of strings is easier than arrays of objects. The `simple` prop being
true makes this component look for values that are arrays of strings (other primitives may
work, haven't tried though). In simple mode we must convert the selected array of strings to
the object react-select expects before passing the value to react-select (Right below the
render). When an "onChange" event happens we have to convert from the react-select object back
to an array of strings (<Select>'s onChange). If you need this component display something other
than the value you must use `simple=false` in order to have items show up properly in the review
accordions.

If simple is false this component expects the selected value to be an array of objects (say
we want to multiselect database models, where the label will be human readable and value a
database id). In this case the `labelProperty` and `valueProperty` props are used to convert
the selected value to and from the react-select format.

Options are always passed in the way react-select expects, this component passes options straight
through to react-select. If the selected value is not in the options prop the multiselect box will
display an empty tag.
*/
import React from 'react';
import PropTypes from 'prop-types';
import Select, { components } from 'react-select';
Expand All @@ -22,6 +44,15 @@ const styles = {
outline,
};
},
groupHeading: (provided) => ({
...provided,
fontWeight: 'bold',
fontFamily: 'SourceSansPro',
textTransform: 'capitalize',
fontSize: '14px',
color: '#21272d',
lineHeight: '22px',
}),
control: (provided, state) => ({
...provided,
borderColor: '#565c65',
Expand All @@ -42,35 +73,56 @@ const styles = {
};

function MultiSelect({
label, name, options, disabled, control, required,
label, name, options, disabled, control, required, labelProperty, valueProperty, simple,
}) {
const findLabel = (value) => {
const opt = options.find((o) => o.value === value);
if (!opt) {
return value;
/*
* @param {Array<string> || Array<object>} - value array. Either an array of strings or array
* of objects
* @returns {Array<{ label: string, value: string }>} - values array in format required by
* react-select
*/
const getValues = (value) => {
if (simple) {
return value.map((v) => ({
value: v, label: v,
}));
}
return value.map((item) => ({ label: item[labelProperty], value: item[valueProperty] }));
};

/*
* @param {*} - event. Contains values in the react-select format, an array of
* `{ label: x, value: y }` objects
* @param {func} - controllerOnChange. On change function from react-hook-form, to be called
* the values in the format to be used outside of this component (not the react-select format)
*/
const onChange = (event, controllerOnChange) => {
if (simple) {
controllerOnChange(event.map((v) => v.value));
} else {
controllerOnChange(
event.map((item) => ({ [labelProperty]: item.label, [valueProperty]: item.value })),
);
}
return opt.label;
};

return (
<Label>
{label}
<Controller
render={({ onChange, value }) => {
let values = value;
if (value) {
values = value.map((v) => ({
value: v, label: findLabel(v),
}));
}
render={({ onChange: controllerOnChange, value }) => {
const values = value ? getValues(value) : value;
return (
<Select
className="margin-top-1"
id={name}
value={values}
onChange={(e) => {
const newValue = e ? e.map((v) => v.value) : null;
onChange(newValue);
onChange={(event) => {
if (event) {
onChange(event, controllerOnChange);
} else {
controllerOnChange(event);
}
}}
styles={styles}
components={{ DropdownIndicator }}
Expand All @@ -92,15 +144,26 @@ function MultiSelect({
);
}

const value = PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]);

MultiSelect.propTypes = {
label: PropTypes.string.isRequired,
name: PropTypes.string.isRequired,
labelProperty: PropTypes.string,
valueProperty: PropTypes.string,
simple: PropTypes.bool,
options: PropTypes.arrayOf(
PropTypes.shape({
value: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
]).isRequired,
value,
options: PropTypes.arrayOf(
PropTypes.shape({
label: PropTypes.string.isRequired,
value: value.isRequired,
}),
),
label: PropTypes.string.isRequired,
}),
).isRequired,
Expand All @@ -113,6 +176,9 @@ MultiSelect.propTypes = {
MultiSelect.defaultProps = {
disabled: false,
required: true,
simple: true,
labelProperty: 'label',
valueProperty: 'value',
};

export default MultiSelect;
38 changes: 19 additions & 19 deletions frontend/src/components/Navigator/__tests__/index.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import '@testing-library/jest-dom';
import React from 'react';
import { MemoryRouter } from 'react-router';
import userEvent from '@testing-library/user-event';
import {
render, screen, waitFor, within,
Expand All @@ -14,6 +13,7 @@ const pages = [
position: 1,
path: 'first',
label: 'first page',
review: false,
render: (hookForm) => (
<input
type="radio"
Expand All @@ -27,6 +27,7 @@ const pages = [
position: 2,
path: 'second',
label: 'second page',
review: false,
render: (hookForm) => (
<input
type="radio"
Expand All @@ -38,8 +39,8 @@ const pages = [
},
{
position: 3,
path: 'review',
label: 'review page',
path: 'review',
review: true,
render: (allComplete, formData, submitted, onSubmit) => (
<div>
Expand All @@ -53,25 +54,24 @@ describe('Navigator', () => {
// eslint-disable-next-line arrow-body-style
const renderNavigator = (currentPage = 'first', onSubmit = () => {}, updatePage = () => {}) => {
render(
<MemoryRouter>
<Navigator
submitted={false}
initialPageState={{ 1: NOT_STARTED, 2: NOT_STARTED }}
defaultValues={{ first: '', second: '' }}
pages={pages}
updatePage={updatePage}
currentPage={currentPage}
onFormSubmit={onSubmit}
/>
</MemoryRouter>,
<Navigator
submitted={false}
initialData={{ pageState: { 1: NOT_STARTED, 2: NOT_STARTED } }}
defaultValues={{ first: '', second: '' }}
pages={pages}
updatePage={updatePage}
currentPage={currentPage}
onFormSubmit={onSubmit}
onSave={() => {}}
/>,
);
};

it('sets dirty forms as "in progress"', async () => {
renderNavigator();
const firstInput = screen.getByTestId('first');
userEvent.click(firstInput);
const first = await screen.findByRole('link', { name: 'first page' });
const first = await screen.findByRole('button', { name: 'first page' });
await waitFor(() => expect(within(first).getByText('In progress')).toBeVisible());
});

Expand All @@ -89,10 +89,10 @@ describe('Navigator', () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
});

it('changes navigator state to complete when "continuing"', async () => {
renderNavigator();
userEvent.click(screen.getByRole('button', { name: 'Continue' }));
const first = await screen.findByRole('link', { name: 'first page' });
await waitFor(() => expect(within(first).getByText('Complete')).toBeVisible());
it('calls updatePage on navigation', async () => {
const updatePage = jest.fn();
renderNavigator('second', () => {}, updatePage);
userEvent.click(screen.getByRole('button', { name: 'first page' }));
await waitFor(() => expect(updatePage).toHaveBeenCalledWith(1));
});
});
Loading