diff --git a/README.md b/README.md index dd4032b..4ec0844 100644 --- a/README.md +++ b/README.md @@ -28,24 +28,87 @@ The integration tests just mock any API interactions, so to make sure the app re ## How it Works -### Component Architecture +### Changing data + +The [src/data](./src/data) directory holds static data the app uses, like names of Government agencies, API urls, and information about each query field. It's possible to change this data without touching any other code. + +Data is stored as json or other javascript data types. + +#### Fields + +Available API Fields are stored in [fields.js](./src/data/fields.js) as a json object of objects, keyed by field attribute. The value of each field is an object with the following keys: + +| Key | Data Type | Default | Required | Usage | +|------|-----------|---------|-------|----------| +| live | boolean | false | Yes | fields are hidden in the app unless live is `true` | +| attribute | string | n/a | Yes | url-friendly, machine-readable name of the field that is used for the url query parameter. Attribute should correspond to the field object's key | +| title | string | n/a | Yes | Display name of the field | +| order | number | n/a | Yes | Available fields are ordered (ascending) by this number. It's possible to use a float or negative number to re-order fields. | +| input | string, one of: `"text"`, `"select"` | n/a | Yes | auto-generates the input field in the app. Type `"select"` requires `input_options` | +| input_options | array of objects (see below) | n/a | Yes when using input type `"select"`; otherwise No | provides the options for a field's select input | +| category | string | n/a | No | The field category, should fields need to be grouped | -Each report is a separate page under `/src/pages`. The page contains the basic configuration for which columns a report will pull in from the API. The setup for these is straightforward: Each column gets a human readable name (`title`), and a function to pluck out the relevant value from the data returned from the API (`accessor`—many of these use the new [optional chaining](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Optional_chaining) syntax, which is just something to be aware of). +#### Field input options -The pages wrap each `` in a ``. This component uses React [Context](https://reactjs.org/docs/context.html) to make the reducer used to manage queries and filters available to child components. +for any field with input type `"select"`, the value of `input_options` must be an array of objects with the following keys: -The `` component performs the main query to the API and renders the data into a table. +| Key | Data Type | Required | Usage | +|-----|-----------|----------|-------| +| label | string | Yes | Display name of the option | +| value | string, number, or boolean | Yes | the value of the option | -`` renders the appropriate filters for each report and, when those filter values change, passes the request back up to the report. It also makes a couple of API calls to populate the agency and scan data filters. +#### Agency / Bureau Data -`` is pretty straightforward, and sends page navigation requests back up to the `` (via the ``). +[agency_bureau_data.js](./src/data/agency_bureau_data.js) is an array of objects with the following keys: + +| Key | Data Type | Required | Usage | +|-----|-----------|----------|-------| +| Agency Name | string | Yes | Display name of agency | +| Bureau Name | string | Yes | Display name of bureau | +| Agency Code | string | No | Code of agency | +| Bureau Code | string | No | Code of bureau | +| Treasury Code | string | No | Code of treasury | +| CGAC Code | string | No | unique code for each agency | + +#### API + +[api.js](./src/data/api.js) holds any info related to the API, like the current endpoint, pathname, or demo key. + +#### Links + +[links.js](./src/data/links.js) is where any other external links can be configured. + +### Component Architecture + +The entry point for the app is [``](./src/components/modules/query-builder.js). + +The QueryBuilder component contains the major modules of the app: +- `` generates the list of API fields able to be filtered by the user +- `` displays the filters the user has selected +- `` contains any user affordances, like copying the API link +- `` contains the instructional text Spotlight UI uses fairly new React features, so here are some links to relevant documentation: - [Basic hooks (`useState`, and `useEffect`)](https://reactjs.org/docs/hooks-reference.html#basic-hooks) -- [`useReducer`](https://reactjs.org/docs/hooks-reference.html#usereducer) - [Context](https://reactjs.org/docs/context.html) +### State Management + +The app uses [redux](https://redux.js.org/) for global state management and [react-redux](https://react-redux.js.org/) to connect components to the global state. + +Currently there's only one Reducer being used in the app, managing the user's field selections. + +The Redux store is created in [src/redux/index.js](./src/redux/index.js) and used in ``. + +Individual Reducers conform to the [ducks pattern](https://github.com/erikras/ducks-modular-redux) and are found under [src/redux/ducks](./src/redux/ducks). + +This app uses a Redux middleware to sync the app url to field selections. This allows a user to share their field selections by sharing the app url. It also allows the user to maintain where they left off on a page reload. Middleware can be found under [src/redux/middleware](./src/redux/middleware). + +If state is relevant to a component only, then it's managed through React's `useState` and `useEffect` hooks. + +Reducers are tested using Jest. + ### Building and deploying Spotlight UI is configured to deploy to Federalist. Builds are validated by CircleCI. diff --git a/cypress/e2e/homepage.test.js b/cypress/e2e/homepage.test.js new file mode 100644 index 0000000..29e7ef1 --- /dev/null +++ b/cypress/e2e/homepage.test.js @@ -0,0 +1,76 @@ +import FIELD_OPTIONS from '../../src/data/fields'; +import { API_DOMAIN } from '../../src/data/api'; + +const testField = Object.values(FIELD_OPTIONS).filter(field => field.live && field.input === 'text')[0]; + +describe('Homepage', () => { + describe('default state', () => { + beforeEach(() => { + cy.visit('http://localhost:8000/'); + }); + it('disables copy url button', () => { + cy.get('button[title="copy url"]').eq(0).should('be.disabled'); + }); + it('shows all live text input filters', () => { + const liveInputCount = Object.values(FIELD_OPTIONS).filter(field => field.live && field.input === 'text').length; + + cy.get('input.usa-input').should('have.length', liveInputCount); + }); + it('shows all live select filters', () => { + const liveSelectCount = Object.values(FIELD_OPTIONS).filter(field => field.live && field.input === 'select').length; + + cy.get('select.usa-select').should('have.length', liveSelectCount); + }); + }); + describe('when user sets a filter', () => { + beforeEach(() => { + cy.visit('http://localhost:8000/'); + cy.get(`input[name="${testField.attribute}"]`).eq(0).type('foo'); + }); + it('displays api url', () => { + cy.get('div#api-url-text').contains(API_DOMAIN); + }); + it('enables copy url button', () => { + cy.get('button[title="copy url"]').eq(0).should('not.be.disabled'); + }); + it('displays removeable filter selection', () => { + cy.get(`button[title="Remove ${testField.title} filter"]`).should('have.length', 1); + }); + it('displays clear all selections button', () => { + cy.get('button[title="clear all selections"]').should('have.length', 1); + }); + }); + describe('when user clicks copy url', () => { + beforeEach(() => { + cy.visit('http://localhost:8000/'); + cy.get(`input[name="${testField.attribute}"]`).eq(0).type('foo'); + cy.get('button[title="copy url"]').eq(0).click(); + }); + it('shows success message', () => { + cy.get('span[role="alert"]').contains('Copied!').should('have.length', 1); + }); + it('removes success message if query changes afterward', () => { + cy.get(`button[title="Remove ${testField.title} filter"]`).eq(0).click(); + cy.get('span[role="alert"]').should('have.length', 0); + }); + }); + describe('when user removes a filter', () => { + beforeEach(() => { + cy.visit('http://localhost:8000/'); + cy.get(`input[name="${testField.attribute}"]`).eq(0).type('foo'); + cy.get(`button[title="Remove ${testField.title} filter"]`).eq(0).click(); + }); + it('removes api url', () => { + cy.get('div#api-url-text').should('have.length', 0); + }); + it('disables copy url button', () => { + cy.get('button[title="copy url"]').eq(0).should('be.disabled'); + }); + it('removes filter selection', () => { + cy.get(`button[title="Remove ${testField.title} filter"]`).should('have.length', 0); + }); + it('removes clear all selections button', () => { + cy.get('button[title="clear all selections"]').should('have.length', 0); + }); + }); +}); diff --git a/cypress/e2e/navigation.test.js b/cypress/e2e/navigation.test.js deleted file mode 100644 index 32a93f2..0000000 --- a/cypress/e2e/navigation.test.js +++ /dev/null @@ -1,44 +0,0 @@ -/* eslint-plugin-disable jest */ -describe('Spotlight', () => { - it('loads the homepage', () => { - cy.visit('http://localhost:8000/'); - cy.findByTestId('site-title'); - }); - - describe('loads each report from the navigation', () => { - const reports = [ - { text: 'Design', path: 'design' }, - { text: 'Security', path: 'security' }, - { text: 'Accessibility', path: 'accessibility' }, - { text: 'Performance', path: 'performance' }, - { text: 'Third-Party Links', path: 'critical-components' }, - ]; - - reports.map(r => { - context(r.text, () => { - it(`loads successfully`, () => { - cy.visit('http://localhost:8000/'); - cy.get('.usa-menu-btn').click(); - cy.get('.usa-accordion__button.usa-nav__link').click(); - cy.get('.usa-nav__submenu').contains(r.text).click(); - cy.url().should('include', r.path); - }); - - it('does not have an error alert', () => { - cy.findByTestId('alert-error').should('not.exist'); - }); - - it('filters domains based on user input', () => { - cy.findByTestId('report-table'); - cy.get('input#domain').type('18f.gsa'); - cy.findByTestId('report-table').find('tr').should('have.length', 1); - }); - - it('shows an informative alert when no results are available', () => { - cy.get('select#agency').select('Broadcasting Board of Governors'); - cy.findByTestId('alert-info'); - }); - }); - }); - }); -}); diff --git a/package-lock.json b/package-lock.json index 21e9a05..8064455 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11682,6 +11682,12 @@ "har-schema": "^2.0.0" } }, + "harmony-reflect": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/harmony-reflect/-/harmony-reflect-1.6.1.tgz", + "integrity": "sha512-WJTeyp0JzGtHcuMsi7rw2VwtkvLa+JyfEKJCFyfcS0+CDkjQ5lHPu7zEhFZP+PDSRrEgXa5Ah0l1MbgbE41XjA==", + "dev": true + }, "has": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/has/-/has-1.0.3.tgz", @@ -12358,6 +12364,15 @@ "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-3.2.0.tgz", "integrity": "sha512-slx8Q6oywCCSfKgPgL0sEsXtPVnSbTLWpyiDcu6msHOyKOLari1TD1qocXVCft80umnkk3/Qqh3lwoFt8T/BPQ==" }, + "identity-obj-proxy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz", + "integrity": "sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=", + "dev": true, + "requires": { + "harmony-reflect": "^1.4.6" + } + }, "ieee754": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", @@ -19005,9 +19020,9 @@ "integrity": "sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ==" }, "query-string": { - "version": "6.12.1", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.12.1.tgz", - "integrity": "sha512-OHj+zzfRMyj3rmo/6G8a5Ifvw3AleL/EbcHMD27YA31Q+cO5lfmQxECkImuNVjcskLcvBRVHNAB3w6udMs1eAA==", + "version": "6.13.6", + "resolved": "https://registry.npmjs.org/query-string/-/query-string-6.13.6.tgz", + "integrity": "sha512-/WWZ7d9na6s2wMEGdVCVgKWE9Rt7nYyNIf7k8xmHXcesPMlEzicWo3lbYwHyA4wBktI2KrXxxZeACLbE84hvSQ==", "requires": { "decode-uri-component": "^0.2.0", "split-on-first": "^1.0.0", @@ -19462,6 +19477,28 @@ "scheduler": "^0.18.0" } }, + "react-redux": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-7.2.2.tgz", + "integrity": "sha512-8+CQ1EvIVFkYL/vu6Olo7JFLWop1qRUeb46sGtIMDCSpgwPQq8fPLpirIB0iTqFe9XYEFPHssdX8/UwN6pAkEA==", + "requires": { + "@babel/runtime": "^7.12.1", + "hoist-non-react-statics": "^3.3.2", + "loose-envify": "^1.4.0", + "prop-types": "^15.7.2", + "react-is": "^16.13.1" + }, + "dependencies": { + "@babel/runtime": { + "version": "7.12.1", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.12.1.tgz", + "integrity": "sha512-J5AIf3vPj3UwXaAzb5j1xM4WAQDX3EMgemF8rjCP3SoW09LfRKAXQKt6CoVYl230P6iWdRcBbnLDDdnqWxZSCA==", + "requires": { + "regenerator-runtime": "^0.13.4" + } + } + } + }, "react-refresh": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.7.2.tgz", @@ -23060,6 +23097,11 @@ "prepend-http": "^2.0.0" } }, + "url-search-params-polyfill": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/url-search-params-polyfill/-/url-search-params-polyfill-8.1.0.tgz", + "integrity": "sha512-MRG3vzXyG20BJ2fox50/9ZRoe+2h3RM7DIudVD2u/GY9MtayO1Dkrna76IUOak+uoUPVWbyR0pHCzxctP/eDYQ==" + }, "url-to-options": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/url-to-options/-/url-to-options-1.0.1.tgz", diff --git a/package.json b/package.json index d30e7bf..60ccf6e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,9 @@ "react": "^16.12.0", "react-dom": "^16.12.0", "react-helmet": "^5.2.1", + "react-redux": "^7.2.2", + "redux": "^4.0.5", + "url-search-params-polyfill": "^8.1.0", "uswds": "^2.7.0", "uuid": "^7.0.3" }, @@ -42,6 +45,7 @@ "eslint-plugin-jest": "^23.10.0", "eslint-plugin-react": "^7.17.0", "husky": "^4.2.5", + "identity-obj-proxy": "^3.0.0", "jest": "^25.5.4", "node-fetch": "^2.6.0", "prettier": "^2.0.5", diff --git a/src/components/__tests__/pagination.spec.js b/src/components/__tests__/pagination.spec.js deleted file mode 100644 index dfc6e9b..0000000 --- a/src/components/__tests__/pagination.spec.js +++ /dev/null @@ -1,48 +0,0 @@ -import React from 'react'; -import { render, fireEvent } from '@testing-library/react'; -import Pagination from '../pagination'; -import '@testing-library/jest-dom'; - -describe('Pagination', () => { - it('renders previous and next page buttons', () => { - const { getByText } = render(); - expect(getByText('Prev')).toBeInTheDocument(); - expect(getByText('Next')).toBeInTheDocument(); - }); - - it('disables "Prev" and "1" buttons on the first page', () => { - const { getByText, getAllByText } = render( - - ); - expect(getByText('Prev').closest('span')).toHaveClass('disabled'); - expect(getAllByText('1')[1].closest('span')).toHaveAttribute( - 'aria-current' - ); - }); - - it.skip('disables "Next" and "5" buttons on the last page', () => { - // FIXME: - // Not sure how to set this now that the current page state lives - // inside the component rather than coming in as a prop 🤔 - - const { getByText } = render(); - const btn5 = getByText('5'); - - fireEvent.click(btn5); - - expect(getByText('Next').closest('button')).toHaveAttribute('disabled'); - expect(btn5.closest('button')).toHaveAttribute('disabled'); - }); - - it('renders links to intermediate pages of records', () => { - const { getAllByText } = render(); - expect(getAllByText('1')[1]).toBeInTheDocument(); - expect(getAllByText('5')[0]).toBeInTheDocument(); - }); - - it('renders links to skip to the beginning & end of the list', () => { - const { getAllByText } = render(); - expect(getAllByText('1')[0].closest('li')).toHaveClass('firstPage'); - expect(getAllByText('5')[1].closest('li')).toHaveClass('lastPage'); - }); -}); diff --git a/src/components/__tests__/report.spec.js b/src/components/__tests__/report.spec.js deleted file mode 100644 index be35e66..0000000 --- a/src/components/__tests__/report.spec.js +++ /dev/null @@ -1,267 +0,0 @@ -import React from 'react'; -import { - render, - act, - fireEvent, - waitFor, - cleanup, -} from '@testing-library/react'; -import '@testing-library/jest-dom'; -import axiosMock from 'axios'; -import Report from '../report'; -import ReportQueryProvider from '../report-query-provider'; -import { API_BASE_URL } from '../../constants'; - -jest.mock('axios'); - -const columns = [ - { title: `Domain`, accessor: obj => obj.domain }, - { title: `Agency`, accessor: obj => obj.agency }, - { title: `Supports HSTS`, accessor: obj => obj.data.HSTS }, - { title: `Supports HTTPS`, accessor: obj => obj.data['HTTPS Live'] }, - { title: `Headers`, accessor: obj => obj.data.endpoints?.https?.headers }, -]; - -const dateUrl = `${API_BASE_URL}lists/dates/`; -const agencyUrl = `${API_BASE_URL}lists/pshtt/agencies`; -const reportUrl = `${API_BASE_URL}scans/pshtt/?page=1`; -const csvUrl = `${API_BASE_URL}scans/pshtt/csv/`; - -const renderReport = () => { - const utils = render( - - - - ); - return utils; -}; - -const mockFn = (url, respObj) => { - switch (url) { - case dateUrl: - return { data: ['2020-04-20', '2020-04-21'] }; - case agencyUrl: - return { data: ['AMTRAK', 'Consumer Financial Protection Bureau'] }; - case reportUrl: - return respObj ? { data: respObj } : ''; - default: { - return { data: { ...respObj, filtered: 'YES!' } }; - } - } -}; - -describe('A ', () => { - afterEach(() => { - cleanup; - jest.clearAllMocks(); - }); - - describe('that loads correctly', () => { - const respObj = { - count: 4914, - results: [ - { - domain: '1.usa.gov', - scantype: 'pshtt', - domaintype: '', - organization: '', - agency: 'General Services Administration', - data: { - HSTS: true, - 'HTTPS Live': true, - endpoints: { - https: { - headers: { Connection: 'keep-alive' }, - }, - }, - }, - }, - ], - }; - - axiosMock.get.mockImplementation(url => mockFn(url, respObj)); - - it('displays a loading indicator', () => { - const utils = renderReport(); - waitFor(() => { - expect(utils.getByTestId('loading-table')).toBeInTheDocument(); - }); - }); - - it('filters data based on user input', async () => { - let utils; - - await act(async () => { - utils = renderReport(); - }); - - await waitFor(() => { - expect(axiosMock.get).toHaveBeenCalledTimes(3); - expect(axiosMock.get).toHaveBeenCalledWith(dateUrl); - expect(axiosMock.get).toHaveBeenCalledWith(agencyUrl); - expect(axiosMock.get).toHaveBeenCalledWith(reportUrl); - }); - - const domainFilter = utils.getByTestId('domain-filter'); - - fireEvent.change(domainFilter, { - target: { value: '18f' }, - }); - - await waitFor(() => { - expect(axiosMock.get).toHaveBeenLastCalledWith( - `${reportUrl}&domain=18f*` - ); - }); - - const filterUrl = `${reportUrl}&domain=18f*&agency="Consumer+Financial+Protection+Bureau"`; - const agencyFilter = utils.getByTestId('agency-filter'); - - // It applies a filter when an agency is selected - fireEvent.change(agencyFilter, { - target: { value: 'Consumer Financial Protection Bureau' }, - }); - - await waitFor(() => { - expect(axiosMock.get).toHaveBeenLastCalledWith(filterUrl); - }); - - // It sets the CSV download link to the filter string - expect( - utils.getByText('Download these results as a CSV').closest('a') - ).toHaveAttribute( - 'href', - `${csvUrl}?domain=18f*&agency="Consumer+Financial+Protection+Bureau"` - ); - - // It removes a filter when the agency is deselected - fireEvent.change(agencyFilter, { - target: { value: ' ' }, - }); - - await waitFor(() => { - expect(axiosMock.get).toHaveBeenLastCalledWith( - expect.stringMatching(/&domain=18f*/) - ); - }); - - //It changes the scan date when the fileter value changes - const scanDateFilter = utils.getByTestId('scan-date-filter'); - - fireEvent.change(scanDateFilter, { - target: { value: '2020-04-20' }, - }); - - await waitFor(() => { - expect(axiosMock.get).toHaveBeenLastCalledWith( - expect.stringMatching(/2020-04-20/) - ); - }); - }); - - it('updates the page when a pagination link is clicked', async () => { - const utils = renderReport(); - - await waitFor(() => { - const pageOneSpan = utils.getByTestId('page-span-1'); - const pageTwoLink = utils.getByTestId('page-2'); - expect(pageOneSpan).toHaveAttribute('aria-current', 'true'); - expect(pageTwoLink).toHaveAttribute('aria-current', 'false'); - - fireEvent.click(pageTwoLink); - - const pageTwoSpan = utils.getByTestId('page-span-2'); - expect(pageTwoSpan).toHaveAttribute('aria-current', 'true'); - }); - }); - - it('renders domains as clickable links', async () => { - const utils = renderReport(); - await waitFor(() => { - expect(utils.getByText('1.usa.gov').closest('a')).toHaveAttribute( - 'href', - 'http://1.usa.gov' - ); - }); - }); - - it('displays a record count', async () => { - const utils = renderReport(); - await waitFor(() => { - expect(utils.getByText('4914 Results')).toBeInTheDocument(); - }); - }); - }); - - describe('when loading records from failed scans', () => { - const invalidObj = { - count: 1, - next: null, - previous: null, - results: [ - { - domain: 'ag.gov', - scantype: 'lighthouse', - domaintype: 'Federal Agency - Executive', - organization: 'USDA', - agency: 'U.S. Department of Agriculture', - data: { - invalid: true, - }, - scan_data_url: - 'https://s3-us-gov-west-1.amazonaws.com/cg-852a6196-0fdb-4a01-a16f-6c24379722cb/lighthouse/ag.gov.json', - lastmodified: '2020-05-21T01:58:20Z', - }, - ], - }; - - beforeEach(async () => { - axiosMock.get.mockImplementation(url => mockFn(url, invalidObj)); - }); - - afterEach(() => { - cleanup; - jest.clearAllMocks(); - }); - - it('indicates the data are invalid', async () => { - const utils = renderReport(); - await waitFor(() => { - expect(utils.getByText(/ag.gov/i).closest('tr')).toHaveClass('invalid'); - }); - }); - }); - - describe('that fails to load data from the API', () => { - beforeEach(async () => { - axiosMock.get.mockImplementation(url => mockFn(url)); - }); - - afterEach(() => { - cleanup; - jest.clearAllMocks(); - }); - - it('loads without crashing', async () => { - const utils = renderReport(); - - await waitFor(() => { - expect(utils.queryByTestId('loading-table')).not.toBeInTheDocument(); - expect(utils.getByTestId('filter-form')).toBeInTheDocument(); - }); - }); - - it('displays an error message', async () => { - const utils = renderReport(); - - await waitFor(() => { - expect(utils.getByTestId('alert-error')).toBeInTheDocument(); - expect(utils.queryByTestId('alert-info')).not.toBeInTheDocument(); - }); - }); - }); -}); diff --git a/src/components/available-field.js b/src/components/available-field.js new file mode 100644 index 0000000..7f1f7cb --- /dev/null +++ b/src/components/available-field.js @@ -0,0 +1,68 @@ +import React from 'react'; // eslint-disable-line +import PropTypes from 'prop-types'; +import * as propTypes from '../prop-types'; +import { connect } from 'react-redux'; +import Checkbox from './uswds/checkbox'; +import TextInput from './uswds/text-input'; +import Dropdown from './uswds/dropdown'; +import { faFilter } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +export const AvailableField = (props) => { + const { attribute, title, input, input_options } = props.field; + const { value } = props; + return ( +
+ { input && +
+ + { input === 'text' && + + } + { input === 'select' && + + } +
+ } +
+ ); +}; + +AvailableField.propTypes = { + field: propTypes.AvailableFieldPropTypes.isRequired, + onFieldChange: PropTypes.func.isRequired, + value: PropTypes.any, +}; + +const mapStateToProps = (state, ownProps) => ({ + value: state.selectedFields[ownProps.field.attribute] ? state.selectedFields[ownProps.field.attribute].value : '', +}); + +const areStatesEqual = (prev, next) => ( + prev.selectedFields === next.selectedFields +); + +export default connect( + mapStateToProps, + null, + null, + { areStatesEqual }, +)(AvailableField); diff --git a/src/components/available-field.test.js b/src/components/available-field.test.js new file mode 100644 index 0000000..5ccb958 --- /dev/null +++ b/src/components/available-field.test.js @@ -0,0 +1,92 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { act } from 'react-dom/test-utils'; +import { AvailableField } from './available-field.js'; +import FIELD_OPTIONS from '../data/fields'; + +describe('', function() { + describe('when field has input type text', function() { + + test('renders a text input', async () => { + render(); + const input = await screen.getByLabelText(/Target Url/i, { + selector: 'input' + }); + expect(input).toBeInTheDocument(); + }); + }); + describe('when field has input type select', function() { + + test('renders a select menu', async () => { + render(); + const input = await screen.getByLabelText(/Final Url is Live/i, { + selector: 'select' + }); + expect(input).toBeInTheDocument(); + }); + }); + describe('when passed a value', function() { + + test('sets input value for text input', async () => { + render(); + const input = await screen.getByLabelText(/Target Url/i, { + selector: 'input' + }); + expect(input.value).toEqual('foo'); + }); + test('sets input value for select input', async () => { + render(); + const input = await screen.getByLabelText(/Final Url is Live/i, { + selector: 'select' + }); + expect(input.value).toEqual('true'); + }); + }); + describe('when input value changes', function() { + test('calls onFieldChange prop for text input', async () => { + const mockFn = jest.fn(); + render(); + const input = await screen.getByLabelText(/Target Url/i, { + selector: 'input' + }); + fireEvent.change(input, { target: { value: 'bar' } }) + + expect(mockFn.mock.calls.length).toBe(1); + }); + test('calls onFieldChange prop for select input', async () => { + const mockFn = jest.fn(); + render(); + const input = await screen.getByLabelText(/Final Url is Live/i, { + selector: 'select' + }); + fireEvent.change(input, { target: { value: 'false' } }) + + expect(mockFn.mock.calls.length).toBe(1); + }); + }); +}); diff --git a/src/components/design-report.js b/src/components/design-report.js deleted file mode 100644 index 6a99994..0000000 --- a/src/components/design-report.js +++ /dev/null @@ -1,49 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import useFetch from '../hooks/useFetch'; - -const Report = ({ reportType }) => { - const columns = [{ title: `Domain Enforces HTTPS` }, { title: `HSTS` }]; - - return ( - <> -

{reportType}

- - - - - - ); -}; - -Report.propTypes = { - reportType: PropTypes.string, -}; - -Report.defaultProps = { - reportType: `Security`, -}; - -export default Report; - -const ReportTable = ({ children }) => ( - {children}
-); - -const ReportTableHead = ({ columns }) => { - return ( - - - {columns.map(c => ( - - {c.title} - - ))} - - - ); -}; - -const ReportTableBody = ({ data }) => { - return ; -}; diff --git a/src/components/footer.js b/src/components/footer.js deleted file mode 100644 index bfd915c..0000000 --- a/src/components/footer.js +++ /dev/null @@ -1,158 +0,0 @@ -import React from 'react'; -import { Link } from 'gatsby'; -import gsaLogo from '../images/gsa-logo-w100.png'; -import { faEnvelope } from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; - -const Footer = () => { - const toggleOrgDetails = e => { - const btn = e.target; - const orgDetails = document.getElementById('org-details'); - orgDetails.hidden = !orgDetails.hidden; - const ariaExpanded = - btn.getAttribute('aria-expanded') == 'true' ? 'false' : 'true'; - btn.setAttribute('aria-expanded', ariaExpanded); - }; - - return ( - - ); -}; - -export default Footer; diff --git a/src/components/header.js b/src/components/header.js deleted file mode 100644 index cd4b146..0000000 --- a/src/components/header.js +++ /dev/null @@ -1,92 +0,0 @@ -import { Link } from 'gatsby'; -import Banner from './uswds/banner'; -import PropTypes from 'prop-types'; -import React from 'react'; -import close from '../../node_modules/uswds/dist/img/close.svg'; - -const Header = ({ siteTitle }) => ( - <> - -
-
-
-
- - -
- -
-
- -); - -Header.propTypes = { - siteTitle: PropTypes.string, -}; - -Header.defaultProps = { - siteTitle: ``, -}; - -export default Header; diff --git a/src/components/layout.js b/src/components/layout.js index 2f3d51a..1dc28fc 100644 --- a/src/components/layout.js +++ b/src/components/layout.js @@ -1,37 +1,12 @@ -/** - * Layout component that queries for data - * with Gatsby's useStaticQuery component - * - * See: https://www.gatsbyjs.org/docs/use-static-query/ - */ - -import React from 'react'; +import React, { Fragment } from 'react'; import PropTypes from 'prop-types'; -import Footer from './footer'; import { useStaticQuery, graphql } from 'gatsby'; -import Header from './header'; - const Layout = ({ children, hero }) => { - const data = useStaticQuery(graphql` - query SiteTitleQuery { - site { - siteMetadata { - title - } - } - } - `); - return ( - <> -
- {hero} -
+
{children}
-
-