From 2caa4c297eb364530e724ae39af63acf41c8422c Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 17 Oct 2023 11:07:16 -0500 Subject: [PATCH 1/6] Revert "Remove LoadingIndicator React component (#879)" This reverts commit 69b99850b8303eb4136bb3e5e7896734af16c67c. --- .../write-react-owners-to-csv.js.html | 3 +- .../src/write-react-owners-to-csv.js | 3 +- packages/react-components/index.js | 2 + packages/react-components/package.json | 2 +- .../LoadingIndicator/LoadingIndicator.jsx | 81 +++++++++++++++++++ .../LoadingIndicator/LoadingIndicator.mdx | 29 +++++++ .../LoadingIndicator.unit.spec.jsx | 68 ++++++++++++++++ packages/react-components/src/index.js | 2 + 8 files changed, 187 insertions(+), 3 deletions(-) create mode 100644 packages/react-components/src/components/LoadingIndicator/LoadingIndicator.jsx create mode 100644 packages/react-components/src/components/LoadingIndicator/LoadingIndicator.mdx create mode 100644 packages/react-components/src/components/LoadingIndicator/LoadingIndicator.unit.spec.jsx diff --git a/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html b/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html index 11c93513b..ad96289e8 100644 --- a/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html +++ b/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html @@ -332,10 +332,11 @@

All files write-react-owners-to-csv.js

const componentsToKeep = [ 'ExpandingGroup', 'IconSearch', + 'LoadingIndicator', 'TextInput', ];   -const hasMigrationScript = []; +const hasMigrationScript = ['LoadingIndicator'];   function cleanPath(pathToClean) { const cwd = process.cwd(); diff --git a/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js b/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js index bf2bae4f4..f7d68d189 100644 --- a/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js +++ b/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js @@ -9,11 +9,12 @@ const componentsToKeep = [ 'Breadcrumbs', 'ExpandingGroup', 'IconSearch', + 'LoadingIndicator', 'Modal', 'TextInput', ]; -const hasMigrationScript = ['AlertBox']; +const hasMigrationScript = ['AlertBox', 'LoadingIndicator']; function cleanPath(pathToClean) { const cwd = process.cwd(); diff --git a/packages/react-components/index.js b/packages/react-components/index.js index 56d43127a..a3074f667 100644 --- a/packages/react-components/index.js +++ b/packages/react-components/index.js @@ -2,6 +2,7 @@ import Breadcrumbs from './Breadcrumbs'; import ExpandingGroup from './ExpandingGroup'; import IconBase from './IconBase'; import IconSearch from './IconSearch'; +import LoadingIndicator from './LoadingIndicator'; import Modal from './Modal'; import TextInput from './TextInput'; @@ -12,6 +13,7 @@ export { ExpandingGroup, IconBase, IconSearch, + LoadingIndicator, Modal, TextInput, }; diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 672391016..096b98c67 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@department-of-veterans-affairs/react-components", - "version": "21.0.0", + "version": "20.0.1", "description": "VA.gov component library in React", "keywords": [ "react", diff --git a/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.jsx b/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.jsx new file mode 100644 index 000000000..eac66a0d2 --- /dev/null +++ b/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.jsx @@ -0,0 +1,81 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import dispatchAnalyticsEvent from '../../helpers/analytics'; + +/** + * **Note:** This component is deprecated in favor of the `` Web Component + */ +export default class LoadingIndicator extends React.Component { + constructor(props) { + super(props); + + // This state variable is used as a constant to get a diff + // between initial mount and unmount + this.state = { loadingStartTime: Date.now() }; + } + + componentDidMount() { + if (this.props.setFocus && this.spinnerDiv) { + this.spinnerDiv.focus(); + } + } + + componentWillUnmount() { + if (this.props.enableAnalytics) { + dispatchAnalyticsEvent({ + componentName: 'LoadingIndicator', + action: 'displayed', + details: { + displayTime: Date.now() - this.state.loadingStartTime, + message: this.props.message, + }, + }); + } + } + + render() { + const { message } = this.props; + const { label } = this.props; + + return ( +
+
{ + this.spinnerDiv = div; + }} + className="loading-indicator" + role="progressbar" + aria-label={label} + aria-valuetext={message} + tabIndex="0" + /> + {message} +
+ ); + } +} + +LoadingIndicator.propTypes = { + /** + * The message visible on screen when loading + */ + message: PropTypes.string.isRequired, + /** + * Set to true if the loading indicator should capture focus + */ + setFocus: PropTypes.bool, + /** + * An aXe label + */ + label: PropTypes.string, + /** + * Analytics tracking function(s) will be called. Form components + * are disabled by default due to PII/PHI concerns. + */ + enableAnalytics: PropTypes.bool, +}; + +LoadingIndicator.defaultProps = { + setFocus: false, + label: 'Loading', +}; diff --git a/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.mdx b/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.mdx new file mode 100644 index 000000000..742ec1d6e --- /dev/null +++ b/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.mdx @@ -0,0 +1,29 @@ +--- +title: LoadingIndicator +name: LoadingIndicator +tags: indicator, component +--- + +import LoadingIndicator from './LoadingIndicator' + +### Code: + +```javascript +import LoadingIndicator from '@department-of-veterans-affairs/component-library/LoadingIndicator' + +
+ +
+``` + +### Rendered Component + +
+ +
diff --git a/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.unit.spec.jsx b/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.unit.spec.jsx new file mode 100644 index 000000000..242b53973 --- /dev/null +++ b/packages/react-components/src/components/LoadingIndicator/LoadingIndicator.unit.spec.jsx @@ -0,0 +1,68 @@ +import { expect } from 'chai'; +import React from 'react'; +import sinon from 'sinon'; +import { + axeCheck, + mountToDiv, + testAnalytics, +} from '../../helpers/test-helpers'; + +import LoadingIndicator from './LoadingIndicator.jsx'; + +describe('', () => { + it('should not focus if setFocus is not set', () => { + const component = ( + + ); + const mountedComponent = mountToDiv(component, 'loadingContainer'); + + expect(document.activeElement.classList.contains('loading-indicator')).to.be + .false; + mountedComponent.unmount(); + }); + + it('should focus if setFocus is set', () => { + const component = ( + + ); + const mountedComponent = mountToDiv(component, 'loadingContainer'); + + expect(document.activeElement.classList.contains('loading-indicator')).to.be + .true; + mountedComponent.unmount(); + }); + + it('should pass aXe check', () => + axeCheck()); + + describe('analytics event', function () { + it('should be triggered when component is unmounted', () => { + const component = ( + + ); + + const mountedComponent = mountToDiv(component, 'loadingContainer'); + const spy = testAnalytics(mountedComponent, () => { + mountedComponent.unmount(); + }); + + expect( + spy.calledWith( + sinon.match.has('detail', { + componentName: 'LoadingIndicator', + action: 'displayed', + details: { + displayTime: sinon.match.number, + message: 'Loading', + }, + version: sinon.match.string, + }), + ), + ).to.be.true; + }); + }); +}); diff --git a/packages/react-components/src/index.js b/packages/react-components/src/index.js index 7ea2bdbc8..cfa2887cd 100644 --- a/packages/react-components/src/index.js +++ b/packages/react-components/src/index.js @@ -2,6 +2,7 @@ import Breadcrumbs from './components/Breadcrumbs/Breadcrumbs'; import ExpandingGroup from './components/ExpandingGroup/ExpandingGroup'; import IconBase from './components/IconBase/IconBase'; import IconSearch from './components/IconSearch/IconSearch'; +import LoadingIndicator from './components/LoadingIndicator/LoadingIndicator'; import Modal from './components/Modal/Modal'; import TextInput from './components/TextInput/TextInput'; @@ -10,6 +11,7 @@ export { ExpandingGroup, IconBase, IconSearch, + LoadingIndicator, Modal, TextInput, }; From 526f0784eb81462c4b9d73eca0de07c236ea8733 Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 17 Oct 2023 11:08:44 -0500 Subject: [PATCH 2/6] Revert "OMBInfo: remove React component (#854)" This reverts commit aa1ad59aa109a87bee83fe31d68159aded4db407. # Conflicts: # packages/react-components/package.json --- .../src/write-react-owners-to-csv.js | 1 + packages/react-components/index.js | 2 + .../src/components/OMBInfo/OMBInfo.jsx | 143 ++++++++++++++++++ .../src/components/OMBInfo/OMBInfo.mdx | 37 +++++ .../components/OMBInfo/OMBInfo.unit.spec.jsx | 121 +++++++++++++++ packages/react-components/src/index.js | 2 + 6 files changed, 306 insertions(+) create mode 100644 packages/react-components/src/components/OMBInfo/OMBInfo.jsx create mode 100644 packages/react-components/src/components/OMBInfo/OMBInfo.mdx create mode 100644 packages/react-components/src/components/OMBInfo/OMBInfo.unit.spec.jsx diff --git a/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js b/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js index f7d68d189..e8c8a8d6b 100644 --- a/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js +++ b/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js @@ -11,6 +11,7 @@ const componentsToKeep = [ 'IconSearch', 'LoadingIndicator', 'Modal', + 'OMBInfo', 'TextInput', ]; diff --git a/packages/react-components/index.js b/packages/react-components/index.js index a3074f667..d80103986 100644 --- a/packages/react-components/index.js +++ b/packages/react-components/index.js @@ -4,6 +4,7 @@ import IconBase from './IconBase'; import IconSearch from './IconSearch'; import LoadingIndicator from './LoadingIndicator'; import Modal from './Modal'; +import OMBInfo from './OMBInfo'; import TextInput from './TextInput'; import './i18n-setup'; @@ -15,5 +16,6 @@ export { IconSearch, LoadingIndicator, Modal, + OMBInfo, TextInput, }; diff --git a/packages/react-components/src/components/OMBInfo/OMBInfo.jsx b/packages/react-components/src/components/OMBInfo/OMBInfo.jsx new file mode 100644 index 000000000..14b51943b --- /dev/null +++ b/packages/react-components/src/components/OMBInfo/OMBInfo.jsx @@ -0,0 +1,143 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from '../Modal/Modal'; + +class OMBInfo extends React.Component { + constructor(props) { + super(props); + + this.state = { modalOpen: false }; + this.id = 'omb-modal'; + } + + openModal = () => { + this.setState({ modalOpen: true }); + }; + + closeModal = () => { + this.setState({ modalOpen: false }); + }; + + modalContents = (minutes, benefitType) => ( +
+

Privacy Act Statement

+ {minutes && ( +

+ Respondent Burden: We need this information to + determine your eligibility for {benefitType} (38 U.S.C. 3471). Title + 38, United States Code, allows us to ask for this information. We + estimate that you will need an average of {minutes} minutes to review + the instructions, find the information, and complete this form. The VA + cannot conduct or sponsor a collection of information unless a valid + OMB (Office of Management and Budget) control number is displayed. You + are not required to respond to a collection of information if this + number is not displayed. Valid OMB control numbers can be located on + the OMB Internet Page at www.reginfo.gov/public/do/PRAMain. If + desired, you can call 1-800-827-1000 to get + information on where to send comments or suggestions about this form. +

+ )} +

+ Privacy Act Notice: The VA will not disclose + information collected on this form to any source other than what has + been authorized under the Privacy Act of 1974 or title 38, Code of + Federal Regulations, section 1.576 for routine uses (e.g., the VA sends + educational forms or letters with a veteran’s identifying information to + the Veteran’s school or training establishment to (1) assist the veteran + in the completion of claims forms or (2) for the VA to obtain further + information as may be necessary from the school for VA to properly + process the Veteran’s education claim or to monitor his or her progress + during training) as identified in the VA system of records, + 58VA21/22/28, Compensation, Pension, Education, and Vocational + Rehabilitation and Employment Records - VA, and published in the Federal + Register. Your obligation to respond is required to obtain or retain + {benefitType}. Giving us your SSN account information is voluntary. + Refusal to provide your SSN by itself will not result in the denial of + benefits. The VA will not deny an individual benefits for refusing to + provide his or her SSN unless the disclosure of the SSN is required by a + Federal Statute of law enacted before January 1, 1975, and still in + effect. The requested information is considered relevant and necessary + to determine the maximum benefits under the law. While you do not have + to respond, VA cannot process your claim for education assistance unless + the information is furnished as required by existing law (38 U.S.C. + 3471). The responses you submit are considered confidential (38 U.S.C. + 5701). Any information provided by applicants, recipients, and others + may be subject to verification through computer matching programs with + other agencies. +

+
+ ); + + render() { + const { resBurden, benefitType, ombNumber, expDate } = this.props; + + return ( +
+ {resBurden && ( +
+ Respondent burden: {resBurden} minutes +
+ )} + {ombNumber && ( +
+ OMB Control #: {ombNumber} +
+ )} +
+ Expiration date: {expDate} +
+
+ +
+ +
+ ); + } +} + +OMBInfo.propTypes = { + /** + * Displays the Respondent Burden section in the Privacy Act Statement modal + * and how many minutes the form is expected to take. + */ + resBurden: PropTypes.number, + + /** + * The name of the benefit displayed in the Respondent Burden section + * of the Privacy Act Statement. + */ + benefitType: PropTypes.string, + + /** + * OMB control number / form number + */ + ombNumber: PropTypes.string, + + /** + * Form expiration date. + */ + expDate: PropTypes.string.isRequired, + + /** + * Child elements (content) of modal when displayed + */ + children: PropTypes.node, +}; + +OMBInfo.defaultProps = { + benefitType: 'benefits', +}; + +export default OMBInfo; diff --git a/packages/react-components/src/components/OMBInfo/OMBInfo.mdx b/packages/react-components/src/components/OMBInfo/OMBInfo.mdx new file mode 100644 index 000000000..7cfd92074 --- /dev/null +++ b/packages/react-components/src/components/OMBInfo/OMBInfo.mdx @@ -0,0 +1,37 @@ +--- +title: OMBInfo +name: OMBInfo +--- + +import OMBInfo from './OMBInfo' + +### Code: + +```javascript +import OMBInfo from '@department-of-veterans-affairs/component-library/OMBInfo' + + +``` + +### Available Props + +| Name | Type | Description | Required| +|-|-|-|:-:| +|resBurden| String or Number | Displays the Respondent Burden section in the Privacy Act Statement modal and how many minutes the form is expected to take. | No| +|ombNumber | String | The form's OMB (Office of Management and Budget) control number | No | +| expDate | Date as a string | This should be a formated date, in the format of `MMMM DD YYYY`| Yes | +|children | JSX | Any children nodes will should up in the Privacy Statement Act model | No| + +### Rendered Component + +
+ +
diff --git a/packages/react-components/src/components/OMBInfo/OMBInfo.unit.spec.jsx b/packages/react-components/src/components/OMBInfo/OMBInfo.unit.spec.jsx new file mode 100644 index 000000000..3016de7dc --- /dev/null +++ b/packages/react-components/src/components/OMBInfo/OMBInfo.unit.spec.jsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { expect } from 'chai'; +import { axeCheck } from '../../helpers/test-helpers'; + +import OMBInfo from './OMBInfo.jsx'; + +describe('', () => { + it('should render all data', () => { + const tree = shallow( + , + ); + expect(tree.text()).to.contain('Expiration date'); + expect(tree.text()).to.contain('OMB Number'); + expect(tree.text()).to.contain('Respondent burden'); + tree.unmount(); + }); + it('should render just privacy', () => { + const tree = shallow(); + expect(tree.text()).to.contain('Privacy Act Statement'); + tree.unmount(); + }); + it('should pass aXe check with modal closed', () => + axeCheck( + , + )); + it('should pass aXe check with modal open', () => + axeCheck( + , + [], + { modalOpen: true }, + )); + it('should render resBurden', () => { + const tree = shallow( + , + ); + expect(tree.text()).to.contain('Respondent burden'); + tree.unmount(); + }); + it('should not render resBurden', () => { + const tree = shallow( + , + ); + expect(tree.text()).to.not.contain('Respondent burden'); + tree.unmount(); + }); + it('should render omb number', () => { + const tree = shallow(); + expect(tree.text()).to.contain('OMB Number'); + tree.unmount(); + }); + it('should not render omb number', () => { + const tree = shallow(); + expect(tree.text()).to.not.contain('OMB Number'); + tree.unmount(); + }); + it('modal should have response burden', () => { + const tree = shallow(); + const instance = tree.instance(); + const modelContent = shallow( + instance.modalContents(instance.props.resBurden), + ); + expect(modelContent.text()).to.contain('Privacy Act Statement'); + expect(modelContent.text()).to.contain('Respondent Burden'); + modelContent.unmount(); + tree.unmount(); + }); + it('modal should not have response burden', () => { + const tree = shallow(); + const instance = tree.instance(); + const modelContent = shallow( + instance.modalContents(instance.props.resBurden), + ); + expect(modelContent.text()).to.contain('Privacy Act Statement'); + expect(modelContent.text()).to.not.contain('Respondent Burden'); + modelContent.unmount(); + tree.unmount(); + }); + it('modal should have default response burden feature name when omitted', () => { + const tree = shallow(); + const instance = tree.instance(); + const modelContent = shallow( + instance.modalContents( + instance.props.resBurden, + instance.props.benefitType, + ), + ); + expect(modelContent.text()).to.contain('benefits (38 U.S.C. 3471)'); + modelContent.unmount(); + tree.unmount(); + }); + it('modal should have response burden feature name when given', () => { + const tree = shallow(); + const instance = tree.instance(); + const modelContent = shallow( + instance.modalContents( + instance.props.resBurden, + instance.props.benefitType, + ), + ); + expect(modelContent.text()).to.contain('TEST (38 U.S.C. 3471)'); + modelContent.unmount(); + tree.unmount(); + }); +}); diff --git a/packages/react-components/src/index.js b/packages/react-components/src/index.js index cfa2887cd..27c995c46 100644 --- a/packages/react-components/src/index.js +++ b/packages/react-components/src/index.js @@ -4,6 +4,7 @@ import IconBase from './components/IconBase/IconBase'; import IconSearch from './components/IconSearch/IconSearch'; import LoadingIndicator from './components/LoadingIndicator/LoadingIndicator'; import Modal from './components/Modal/Modal'; +import OMBInfo from './components/OMBInfo/OMBInfo'; import TextInput from './components/TextInput/TextInput'; export { @@ -13,5 +14,6 @@ export { IconSearch, LoadingIndicator, Modal, + OMBInfo, TextInput, }; From 90c827e7909688f4407f0b40e51951e4810f72a5 Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 17 Oct 2023 11:11:05 -0500 Subject: [PATCH 3/6] Revert "Remove Checkbox React component (#882)" This reverts commit 76883c174b3cf50ca8313cd4026efa35f63fbe14. # Conflicts: # packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html # packages/react-components/package.json --- .../write-react-owners-to-csv.js.html | 4 +- .../src/write-react-owners-to-csv.js | 1 + packages/react-components/index.js | 2 + .../src/components/Checkbox/Checkbox.jsx | 170 +++++++++++++++ .../src/components/Checkbox/Checkbox.mdx | 73 +++++++ .../Checkbox/Checkbox.unit.spec.jsx | 204 ++++++++++++++++++ packages/react-components/src/index.js | 2 + packages/storybook/stories/additional-docs.js | 4 + 8 files changed, 459 insertions(+), 1 deletion(-) create mode 100644 packages/react-components/src/components/Checkbox/Checkbox.jsx create mode 100644 packages/react-components/src/components/Checkbox/Checkbox.mdx create mode 100644 packages/react-components/src/components/Checkbox/Checkbox.unit.spec.jsx diff --git a/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html b/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html index ad96289e8..59ad019fd 100644 --- a/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html +++ b/packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html @@ -330,13 +330,15 @@

All files write-react-owners-to-csv.js

const today = require('./today');   const componentsToKeep = [ + 'AlertBox', + 'Checkbox', 'ExpandingGroup', 'IconSearch', 'LoadingIndicator', 'TextInput', ];   -const hasMigrationScript = ['LoadingIndicator']; +const hasMigrationScript = ['AlertBox', 'LoadingIndicator'];   function cleanPath(pathToClean) { const cwd = process.cwd(); diff --git a/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js b/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js index e8c8a8d6b..de4cea1fe 100644 --- a/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js +++ b/packages/design-system-dashboard-cli/src/write-react-owners-to-csv.js @@ -7,6 +7,7 @@ const today = require('./today'); const componentsToKeep = [ 'AlertBox', 'Breadcrumbs', + 'Checkbox', 'ExpandingGroup', 'IconSearch', 'LoadingIndicator', diff --git a/packages/react-components/index.js b/packages/react-components/index.js index d80103986..6bdffdb5d 100644 --- a/packages/react-components/index.js +++ b/packages/react-components/index.js @@ -1,4 +1,5 @@ import Breadcrumbs from './Breadcrumbs'; +import Checkbox from './Checkbox'; import ExpandingGroup from './ExpandingGroup'; import IconBase from './IconBase'; import IconSearch from './IconSearch'; @@ -11,6 +12,7 @@ import './i18n-setup'; export { Breadcrumbs, + Checkbox, ExpandingGroup, IconBase, IconSearch, diff --git a/packages/react-components/src/components/Checkbox/Checkbox.jsx b/packages/react-components/src/components/Checkbox/Checkbox.jsx new file mode 100644 index 000000000..ba5835816 --- /dev/null +++ b/packages/react-components/src/components/Checkbox/Checkbox.jsx @@ -0,0 +1,170 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { uniqueId } from '../../helpers/utilities'; + +import dispatchAnalyticsEvent from '../../helpers/analytics'; + +class Checkbox extends React.Component { + constructor() { + super(); + this.handleChange = this.handleChange.bind(this); + } + + UNSAFE_componentWillMount() { + this.inputId = uniqueId('errorable-checkbox-'); + } + + handleChange(domEvent) { + const isChecked = domEvent.target.checked; + + if (isChecked && this.props.enableAnalytics) { + dispatchAnalyticsEvent({ + componentName: 'Checkbox', + action: 'change', + details: { + label: this.props.label, + labelAboveCheckbox: this.props.labelAboveCheckbox, + required: this.props.required, + }, + }); + } + + this.props.onValueChange(isChecked); + } + + render() { + // TODO: extract error logic into a utility function + // Calculate error state. + let errorSpan = ''; + let errorSpanId = undefined; + if (this.props.errorMessage) { + errorSpanId = `${this.inputId}-error-message`; + errorSpan = ( + + Error {this.props.errorMessage} + + ); + } + + // Calculate required. + let requiredSpan = undefined; + if (this.props.required) { + requiredSpan = (*Required); + } + + let className = `form-checkbox${ + this.props.errorMessage ? ' usa-input-error' : '' + }`; + if (this.props.className !== undefined) { + className = `${className} ${this.props.className}`; + } + + return ( +
+ {this.props.labelAboveCheckbox && ( + + {this.props.labelAboveCheckbox} + + )} + {errorSpan} + + +
+ ); + } +} + +Checkbox.propTypes = { + /** + * If the checkbox is checked or not + */ + checked: PropTypes.bool, + /** + * Optionally adds one or more CSS classes to the NAV element + */ + className: PropTypes.string, + /** + * Error message for the modal + */ + errorMessage: PropTypes.string, + /** + * Name for the modal + */ + name: PropTypes.string, + /** + * Label [string or object] for the checkbox. Either this or ariaLabelledBy is required. + */ + /* eslint-disable consistent-return */ + label: (props, propName, componentName) => { + const validTypes = ['string', 'object']; + + if (!props.label && !props.ariaLabelledBy) { + return new Error( + `Either ${propName} or ariaLabelledBy property is required in ${componentName}, but both are missing.`, + ); + } + + if (props.label && !validTypes.includes(typeof props.label)) { + return new Error( + `${componentName}’s label property type is invalid -- should be one of + these types: ${validTypes.join(', ')}.`, + ); + } + }, + /* eslint-enable consistent-return */ + /** + * Descriptive text to sit above the checkbox and label + */ + labelAboveCheckbox: PropTypes.string, + /** + * aria-labelledby attribute [string] (external-heading ID). Either this or label is required. + */ + /* eslint-disable consistent-return */ + ariaLabelledBy: (props, propName, componentName) => { + if (!props.label && !props.ariaLabelledBy) { + return new Error( + `Either ${propName} or label property is required in ${componentName}, but both are missing.`, + ); + } + + if (props.ariaLabelledBy && typeof props.ariaLabelledBy !== 'string') { + return new Error( + `${componentName}’s ariaLabelledBy property type is invalid -- should be + string.`, + ); + } + }, + /* eslint-enable consistent-return */ + /** + * Handler for when the checkbox is changed + */ + onValueChange: PropTypes.func.isRequired, + /** + * If the checkbox is required or not + */ + required: PropTypes.bool, + /** + * Analytics tracking function(s) will be called. Form components + * are disabled by default due to PII/PHI concerns. + */ + enableAnalytics: PropTypes.bool, +}; + +export default Checkbox; diff --git a/packages/react-components/src/components/Checkbox/Checkbox.mdx b/packages/react-components/src/components/Checkbox/Checkbox.mdx new file mode 100644 index 000000000..88aebda23 --- /dev/null +++ b/packages/react-components/src/components/Checkbox/Checkbox.mdx @@ -0,0 +1,73 @@ +--- +title: Checkbox +name: Checkbox +tags: checkbox, component +--- + +import Checkbox from './Checkbox' + +### Code: +```javascript +import Checkbox from '@department-of-veterans-affairs/component-library/Checkbox' + +
+ value} + id='default' + errorMessage='' + required={true} + title='Checkbox' + /> + value} + id='default' + errorMessage='Error message' + required={true} + title='Checkbox' + /> +

External heading [label-substitute]

+ value} + id='default' + errorMessage='' + required={true} + title='Checkbox' + /> +
+``` + +### Rendered Component +
+ value} + id='default' + errorMessage='' + required={true} + title='Checkbox' + /> + value} + id='default' + errorMessage='Error message' + required={true} + title='Checkbox' + /> +

External heading [label-substitute]

+ value} + id='default' + errorMessage='' + required={true} + title='Checkbox' + /> +
diff --git a/packages/react-components/src/components/Checkbox/Checkbox.unit.spec.jsx b/packages/react-components/src/components/Checkbox/Checkbox.unit.spec.jsx new file mode 100644 index 000000000..44911ea46 --- /dev/null +++ b/packages/react-components/src/components/Checkbox/Checkbox.unit.spec.jsx @@ -0,0 +1,204 @@ +import React from 'react'; +import { expect } from 'chai'; +import { mount, shallow } from 'enzyme'; +import { axeCheck } from '../../helpers/test-helpers'; +import Checkbox from './Checkbox.jsx'; +import sinon from 'sinon'; +import { testAnalytics } from '../../helpers/test-helpers'; + +describe('', () => { + it('should render without the labelAboveCheckbox', () => { + const tree = shallow( {}} />); + expect(tree.text()).to.contain('test'); + tree.unmount(); + }); + + it('should render with the labelAboveCheckbox', () => { + const tree = shallow( + {}} + />, + ); + expect(tree.text()).to.contain('test'); + expect(tree.text()).to.contain('this is a checkbox'); + tree.unmount(); + }); + + it('should pass aXe check', () => + axeCheck( {}} />)); + it('ensure checked changes propagate', () => { + const handleChangeSpy = sinon.spy(Checkbox.prototype, 'handleChange'); + const tree = shallow( {}} />); + const event = { target: { checked: true } }; + + const checkBox = () => tree.find('[type="checkbox"]'); + checkBox().simulate('change', event); + expect(handleChangeSpy.calledOnce).to.be.true; + tree.unmount(); + }); + it('no error styles when errorMessage undefined', () => { + const tree = shallow( + {}} />, + ); + + // No error classes. + expect(tree.children('.usa-input-error')).to.have.lengthOf(0); + expect(tree.children('.usa-input-error-label')).to.have.lengthOf(0); + expect(tree.children('.usa-input-error-message')).to.have.lengthOf(0); + + // Ensure no unnecessary class names on label w/o error.. + const labels = tree.children('label'); + expect(labels).to.have.lengthOf(1); + expect(labels.prop('className')).to.be.equal( + undefined, + 'Unnecessary class names on label without error', + ); + + // No error means no aria-describedby to not confuse screen readers. + const inputs = tree.find('input'); + expect(inputs).to.have.lengthOf(1); + expect(inputs.prop('aria-describedby')).to.be.equal( + undefined, + 'Unnecessary aria-describedby', + ); + tree.unmount(); + }); + + it('has error styles when errorMessage is set', () => { + const tree = shallow( + {}} + />, + ); + + // Ensure all error classes set. + expect(tree.find('.usa-input-error')).to.have.lengthOf(1); + + const labels = tree.find('.usa-input-error-label'); + expect(labels).to.have.lengthOf(1); + + const errorMessages = tree.find('.usa-input-error-message'); + expect(errorMessages).to.have.lengthOf(1); + expect(errorMessages.text()).to.equal('Error error message'); + + // No error means no aria-describedby to not confuse screen readers. + const inputs = tree.find('input'); + expect(inputs).to.have.lengthOf(1); + expect(inputs.prop('aria-describedby')).to.not.be.equal(undefined); + expect(inputs.prop('aria-describedby')).to.equal(errorMessages.prop('id')); + tree.unmount(); + }); + + it('required=false does not have required asterisk', () => { + const tree = shallow( + {}} />, + ); + + expect(tree.find('label').text()).to.equal('my label'); + tree.unmount(); + }); + + it('required=true has required asterisk', () => { + const tree = shallow( + {}} />, + ); + + const label = tree.find('label'); + expect(label.text()).to.equal('my label(*Required)'); + tree.unmount(); + }); + + it('label attribute propagates', () => { + const tree = shallow( + {}} />, + ); + + // Ensure label text is correct. + const labels = tree.find('label'); + expect(labels).to.have.lengthOf(1); + expect(labels.text()).to.equal('my label'); + + // Ensure label htmlFor is attached to input id. + const inputs = tree.find('input'); + expect(inputs).to.have.lengthOf(1); + expect(inputs.prop('id')).to.not.be.equal(undefined); + expect(inputs.prop('id')).to.equal(labels.prop('htmlFor')); + tree.unmount(); + }); + + it('adds aria-labelledby attribute', () => { + const tree = shallow( + {}} />, + ); + + // Ensure label text is empty string. + const labels = tree.find('label'); + expect(labels).to.have.lengthOf(1); + expect(labels.text()).to.equal(''); + + // Ensure label aria-labelledby is attached to input id. + const inputs = tree.find('input'); + expect(inputs).to.have.lengthOf(1); + expect(inputs.prop('aria-labelledby')).to.equal('headingId'); + tree.unmount(); + }); + + describe('analytics event', function () { + it('should NOT be triggered when enableAnalytics is not true', () => { + const wrapper = shallow( + {}} + />, + ); + + const spy = testAnalytics(wrapper, () => { + const event = { target: { checked: true } }; + wrapper.find('[type="checkbox"]').simulate('change', event); + }); + + expect(spy.called).to.be.false; + + wrapper.unmount(); + }); + + it('should be triggered when Checkbox is checked', () => { + const wrapper = mount( + {}} + enableAnalytics + required={false} + />, + ); + + const spy = testAnalytics(wrapper, wrapper => { + const event = { target: { checked: true } }; + wrapper.find('[type="checkbox"]').simulate('change', event); + }); + + expect( + spy.calledWith( + sinon.match.has('detail', { + componentName: 'Checkbox', + action: 'change', + details: { + label: 'test', + labelAboveCheckbox: 'this is a checkbox', + required: false, + }, + version: sinon.match.string, + }), + ), + ).to.be.true; + + wrapper.unmount(); + }); + }); +}); diff --git a/packages/react-components/src/index.js b/packages/react-components/src/index.js index 27c995c46..0e06de581 100644 --- a/packages/react-components/src/index.js +++ b/packages/react-components/src/index.js @@ -1,4 +1,5 @@ import Breadcrumbs from './components/Breadcrumbs/Breadcrumbs'; +import Checkbox from './components/Checkbox/Checkbox'; import ExpandingGroup from './components/ExpandingGroup/ExpandingGroup'; import IconBase from './components/IconBase/IconBase'; import IconSearch from './components/IconSearch/IconSearch'; @@ -9,6 +10,7 @@ import TextInput from './components/TextInput/TextInput'; export { Breadcrumbs, + Checkbox, ExpandingGroup, IconBase, IconSearch, diff --git a/packages/storybook/stories/additional-docs.js b/packages/storybook/stories/additional-docs.js index d76444325..1da789d92 100644 --- a/packages/storybook/stories/additional-docs.js +++ b/packages/storybook/stories/additional-docs.js @@ -16,6 +16,10 @@ export const additionalDocs = { maturityCategory: DONT_USE, maturityLevel: DEPRECATED, }, + 'Checkbox - React': { + maturityCategory: DONT_USE, + maturityLevel: DEPRECATED, + }, // MDX 'Divider': { guidanceHref: 'divider', From d79989f6d9cb1091880fd7714218c270150cccf4 Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 17 Oct 2023 11:11:54 -0500 Subject: [PATCH 4/6] Revert "AlertBox and MaintenanceBanner: remove deprecated components (#902)" This reverts commit 1dc8b7e46d83187cff922ffce78aa4eefe3a56c4. # Conflicts: # packages/design-system-dashboard-cli/coverage/lcov-report/write-react-owners-to-csv.js.html --- packages/react-components/index.js | 5 + .../src/components/AlertBox/AlertBox.jsx | 203 ++++++++++++++++ .../src/components/AlertBox/AlertBox.mdx | 102 ++++++++ .../AlertBox/AlertBox.unit.spec.jsx | 221 ++++++++++++++++++ .../MaintenanceBanner/MaintenanceBanner.jsx | 185 +++++++++++++++ .../MaintenanceBanner/MaintenanceBanner.mdx | 51 ++++ .../MaintenanceBanner.unit.spec.jsx | 64 +++++ packages/react-components/src/index.js | 5 + 8 files changed, 836 insertions(+) create mode 100644 packages/react-components/src/components/AlertBox/AlertBox.jsx create mode 100644 packages/react-components/src/components/AlertBox/AlertBox.mdx create mode 100644 packages/react-components/src/components/AlertBox/AlertBox.unit.spec.jsx create mode 100644 packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.jsx create mode 100644 packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.mdx create mode 100644 packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.unit.spec.jsx diff --git a/packages/react-components/index.js b/packages/react-components/index.js index 6bdffdb5d..c8167ca30 100644 --- a/packages/react-components/index.js +++ b/packages/react-components/index.js @@ -1,9 +1,11 @@ +import AlertBox, { ALERT_TYPE } from './AlertBox'; import Breadcrumbs from './Breadcrumbs'; import Checkbox from './Checkbox'; import ExpandingGroup from './ExpandingGroup'; import IconBase from './IconBase'; import IconSearch from './IconSearch'; import LoadingIndicator from './LoadingIndicator'; +import MaintenanceBanner from './MaintenanceBanner'; import Modal from './Modal'; import OMBInfo from './OMBInfo'; import TextInput from './TextInput'; @@ -11,12 +13,15 @@ import TextInput from './TextInput'; import './i18n-setup'; export { + AlertBox, + ALERT_TYPE, Breadcrumbs, Checkbox, ExpandingGroup, IconBase, IconSearch, LoadingIndicator, + MaintenanceBanner, Modal, OMBInfo, TextInput, diff --git a/packages/react-components/src/components/AlertBox/AlertBox.jsx b/packages/react-components/src/components/AlertBox/AlertBox.jsx new file mode 100644 index 000000000..b8a91e8e3 --- /dev/null +++ b/packages/react-components/src/components/AlertBox/AlertBox.jsx @@ -0,0 +1,203 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import classNames from 'classnames'; +import dispatchAnalyticsEvent from '../../helpers/analytics'; + +// Enum used to set the AlertBox's `status` prop +export const ALERT_TYPE = Object.freeze({ + INFO: 'info', // Blue border, black circled 'i' + ERROR: 'error', // Red border, red circled exclamation + SUCCESS: 'success', // Green border, green checkmark + WARNING: 'warning', // Yellow border, black triangle exclamation + CONTINUE: 'continue', // Green border, green lock +}); + +/** + * This component is no longer supported, please use `` instead. + */ +class AlertBox extends Component { + constructor(props) { + super(props); + this.handleAlertBodyClick = this.handleAlertBodyClick.bind(this); + } + + componentDidMount() { + this.scrollToAlert(); + } + + componentWillUnmount() { + clearTimeout(this.scrollToAlertTimeout); + } + + shouldComponentUpdate(nextProps) { + const visibilityChanged = this.props.isVisible !== nextProps.isVisible; + const contentChanged = this.props.content !== nextProps.content; + const statusChanged = this.props.status !== nextProps.status; + return visibilityChanged || contentChanged || statusChanged; + } + + componentDidUpdate() { + this.scrollToAlert(); + } + + scrollToAlert = () => { + if (!this._ref || !this._ref.scrollIntoView) { + return; + } + + // Without using the setTimeout, React has not added the element + // to the DOM when it calls scrollIntoView() + if (this.props.isVisible && this.props.scrollOnShow) { + clearTimeout(this.scrollToAlertTimeout); + this.scrollToAlertTimeout = setTimeout(() => { + this._ref.scrollIntoView({ + block: this.props.scrollPosition, + behavior: 'smooth', + }); + }, 0); + } + }; + + handleAlertBodyClick(e) { + if (!this.props.disableAnalytics) { + // If it's a link being clicked, dispatch an analytics event + if (e.target?.tagName === 'A') { + dispatchAnalyticsEvent({ + componentName: 'AlertBox', + action: 'linkClick', + details: { + clickLabel: e.target.innerText, + headline: this.props.headline, + status: this.props.status, + backgroundOnly: this.props.backgroundOnly, + closeable: !!this.props.onCloseAlert, + }, + }); + } + } + } + + render() { + if (!this.props.isVisible) return
; + + const alertClass = classNames( + 'usa-alert', + `usa-alert-${this.props.status}`, + { 'background-color-only': this.props.backgroundOnly }, + this.props.className, + ); + + const closeButton = this.props.onCloseAlert && ( + + ); + + const alertHeading = this.props.headline; + const alertText = this.props.content || this.props.children; + const H = `h${this.props.level}`; + + return ( +
{ + this._ref = ref; + }} + > +
+ {alertHeading && {alertHeading}} + {alertText &&
{alertText}
} +
+ {closeButton} +
+ ); + } +} + +/* eslint-disable consistent-return */ +AlertBox.propTypes = { + /** + * Determines the color and icon of the alert box. + */ + status: PropTypes.oneOf(Object.values(ALERT_TYPE)).isRequired, + + /** + * Show or hide the alert. Useful for alerts triggered by app interaction. + */ + isVisible: PropTypes.bool, + /** + * Child elements (content) + */ + children: PropTypes.node, + /** + * Body content of the alert, which can also be passed via children. + */ + content: PropTypes.node, + + /** + * Optional headline. + */ + headline: PropTypes.node, + + /** + * Optional Close button aria-label. + */ + closeBtnAriaLabel: PropTypes.string, + + /** + * Close event handler if the alert can be dismissed or closed. + */ + onCloseAlert: PropTypes.func, + + /** + * If true, page scrolls to alert when it is shown. + */ + scrollOnShow: PropTypes.bool, + + /** + * Defaults to 'start' but customizable. + */ + scrollPosition: PropTypes.string, + + /** + * Optional class name to add to the alert box. + */ + className: PropTypes.string, + + /** + * If true, renders an AlertBox with only a background color, without an + * accented left edge or an icon + */ + backgroundOnly: PropTypes.bool, + + /** + * The header level to use with the headline prop, must be a number 1-6 + */ + level(props, propName) { + const level = parseInt(props[propName], 10); + if (Number.isNaN(level) || level < 1 || level > 6) { + return new Error( + `Invalid prop: AlertBox level must be a number from 1-6, was passed ${props[propName]}`, + ); + } + }, + /** + * Analytics tracking function(s) will not be called + */ + disableAnalytics: PropTypes.bool, +}; +/* eslint-enable consistent-return */ + +AlertBox.defaultProps = { + scrollPosition: 'start', + isVisible: true, + backgroundOnly: false, + closeBtnAriaLabel: 'Close notification', + level: 3, +}; + +export default AlertBox; diff --git a/packages/react-components/src/components/AlertBox/AlertBox.mdx b/packages/react-components/src/components/AlertBox/AlertBox.mdx new file mode 100644 index 000000000..2f2cb1263 --- /dev/null +++ b/packages/react-components/src/components/AlertBox/AlertBox.mdx @@ -0,0 +1,102 @@ +--- +title: AlertBox +name: AlertBox +tags: informational, warning, error, success, continue, dismissable +--- + +import AlertBox, { ALERT_TYPE } from './AlertBox' + + +### Code: +```javascript +import AlertBox, { ALERT_TYPE } from '@department-of-veterans-affairs/component-library/AlertBox' + +
+ + + + + + {}}/> + + Content without heading.

} + status={ALERT_TYPE.INFO}/> + +
+``` + +### Rendered Component +
+
+ + + + + + {}}/> + + Content without heading.

} + status={ALERT_TYPE.INFO}/> + +
+
diff --git a/packages/react-components/src/components/AlertBox/AlertBox.unit.spec.jsx b/packages/react-components/src/components/AlertBox/AlertBox.unit.spec.jsx new file mode 100644 index 000000000..030ca79b4 --- /dev/null +++ b/packages/react-components/src/components/AlertBox/AlertBox.unit.spec.jsx @@ -0,0 +1,221 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { axeCheck } from '../../helpers/test-helpers'; +import { expect } from 'chai'; +import sinon from 'sinon'; + +import AlertBox from './AlertBox.jsx'; + +// Placeholder for required "content" element +const Content =

; +const Headline = 'Headline'; +const CloseBtnAriaLabelOptional = 'Close notification optional'; +function closeAlert() { + // +} + +describe('', () => { + it('should be an empty div if invisible', () => { + const wrapper = shallow( + , + ); + expect(wrapper.html()).to.equal('

'); + wrapper.unmount(); + }); + + it('should have the expected classnames', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find('.usa-alert').hasClass('usa-alert-info')).to.equal( + true, + ); + expect( + wrapper.find('.usa-alert').hasClass('background-color-only'), + ).to.equal(false); + wrapper.unmount(); + }); + + it('should have have `background-color-only` class added when `backgroundOnly` is `true`', () => { + const wrapper = shallow( + , + ); + expect( + wrapper.find('.usa-alert').hasClass('background-color-only'), + ).to.equal(true); + wrapper.unmount(); + }); + + it('should apply classes set via `className`', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find('.usa-alert').hasClass('foo')).to.equal(true); + wrapper.unmount(); + }); + + it('should use level prop for headline element', () => { + const wrapper = shallow( + , + ); + expect(wrapper.find('.usa-alert-heading').is('h4')).to.equal(true); + wrapper.unmount(); + }); + + it('should pass aXe check when visible', () => + axeCheck()); + + it('should pass aXe check when not visible', () => + axeCheck()); + + it('should pass aXe check without a headline', () => + axeCheck()); + + it('should pass aXe check with a headline', () => + axeCheck( + , + )); + + it('should pass aXe check when it has a close button', () => + axeCheck( + , + )); + + it('should pass aXe check when it has a close button with optional aria-label', () => + axeCheck( + , + )); + + it('should pass aXe check when `backgroundOnly` is `true`', () => + axeCheck( + , + )); + + describe('analytics event', function () { + let wrapper; + + const HeadlineWithLink = ( + <> + Headline with a link + + ); + + beforeEach(() => { + wrapper = mount( + +
+ This is content + + with a link + + . +
+
, + ); + }); + + afterEach(() => { + wrapper.unmount(); + }); + + it('should be triggered when link in AlertBox content is clicked', () => { + const handleAnalyticsEvent = sinon.spy(); + + global.document.body.addEventListener( + 'component-library-analytics', + handleAnalyticsEvent, + ); + + // Click link in content + const testLink = wrapper.find('.usa-alert-text a'); + testLink.simulate('click'); + + global.document.body.removeEventListener( + 'component-library-analytics', + handleAnalyticsEvent, + ); + + expect( + handleAnalyticsEvent.calledWith( + sinon.match.has('detail', { + componentName: 'AlertBox', + action: 'linkClick', + details: { + clickLabel: 'with a link', + headline: HeadlineWithLink, + status: 'info', + backgroundOnly: true, + closeable: false, + }, + version: sinon.match.string, + }), + ), + ).to.be.true; + }); + + it('should be triggered when link in AlertBox headline is clicked', () => { + const handleAnalyticsEvent = sinon.spy(); + + global.document.body.addEventListener( + 'component-library-analytics', + handleAnalyticsEvent, + ); + + // Click link in headline + const testLink = wrapper.find('.usa-alert-heading a'); + testLink.simulate('click'); + + global.document.body.removeEventListener( + 'component-library-analytics', + handleAnalyticsEvent, + ); + + expect( + handleAnalyticsEvent.calledWith( + sinon.match.has('detail', { + componentName: 'AlertBox', + action: 'linkClick', + details: { + clickLabel: 'with a link', + headline: HeadlineWithLink, + status: 'info', + backgroundOnly: true, + closeable: false, + }, + version: sinon.match.string, + }), + ), + ).to.be.true; + }); + }); +}); diff --git a/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.jsx b/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.jsx new file mode 100644 index 000000000..856dd04e1 --- /dev/null +++ b/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.jsx @@ -0,0 +1,185 @@ +// Node modules. +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +// Relative imports. +import AlertBox from '../AlertBox/AlertBox'; +import { + formatDate, + isDateAfter, + isDateBefore, + isDateSameDay, +} from '../../helpers/format-date'; + +export const MAINTENANCE_BANNER = 'MAINTENANCE_BANNER'; + +// @WARNING: This is currently only used once in vets-website. +/** + * Display a maintenance banner for a given time window. + */ +export class MaintenanceBanner extends Component { + static propTypes = { + /** + * The content of the banner for downtime. + */ + content: PropTypes.string.isRequired, + /** + * A Date object used when downtime expires. + */ + expiresAt: PropTypes.instanceOf(Date).isRequired, + /** + * A unique ID that will be used for conditionally rendering the banner based on if the user has dismissed it already. + */ + id: PropTypes.string.isRequired, + /** + * Usually this is just window.localStorage + */ + localStorage: PropTypes.shape({ + getItem: PropTypes.func.isRequired, + setItem: PropTypes.func.isRequired, + }), + /** + * A Date object used when downtime starts. + */ + startsAt: PropTypes.instanceOf(Date).isRequired, + /** + * The title of the banner for downtime. + */ + title: PropTypes.string.isRequired, + /** + * The content of the banner for pre-downtime. + */ + warnContent: PropTypes.string, + /** + * A Date object used when pre-downtime starts. + */ + warnStartsAt: PropTypes.instanceOf(Date), + /** + * The title of the banner for pre-downtime. + */ + warnTitle: PropTypes.string, + }; + + constructor(props) { + super(props); + this.state = { + dismissed: + props.localStorage && + props.localStorage.getItem(MAINTENANCE_BANNER) === this.props.id, + }; + } + + derivePostContent = () => { + const { startsAt, expiresAt } = this.props; + + if (isDateSameDay(startsAt, expiresAt)) { + return ( + <> +

+ Date: {formatDate(startsAt, 'dateFull')} +

+

+ Start/End time: {formatDate(startsAt, 'timeShort')}{' '} + to {formatDate(expiresAt, 'timeShort')} ET +

+ + ); + } + + return ( + <> +

+ Start: {formatDate(startsAt)} ET +

+

+ End: {formatDate(expiresAt)} ET +

+ + ); + }; + + onCloseAlert = () => { + if (this.props.localStorage) { + this.props.localStorage.setItem(MAINTENANCE_BANNER, this.props.id); + } + this.setState({ dismissed: true }); + }; + + render() { + const { derivePostContent, onCloseAlert } = this; + const { dismissed } = this.state; + const { + content, + expiresAt, + id, + startsAt, + title, + warnContent, + warnStartsAt, + warnTitle, + } = this.props; + + // Derive dates. + const now = new Date(); + const postContent = derivePostContent(); + + // Escape early if the banner is dismissed. + if (dismissed) { + return null; + } + + // Escape early if it's before when it should show. + if (isDateBefore(now, warnStartsAt)) { + return null; + } + + // Escape early if it's after when it should show. + if (isDateAfter(now, expiresAt)) { + return null; + } + + // Show pre-downtime. + if (isDateBefore(now, startsAt)) { + return ( +
+ +

{warnContent}

+ {postContent} + + } + headline={warnTitle} + onCloseAlert={onCloseAlert} + status="warning" + /> +
+ ); + } + + // Show downtime. + return ( +
+ +

{content}

+ {postContent} + + } + headline={title} + onCloseAlert={onCloseAlert} + status="error" + /> +
+ ); + } +} + +export default MaintenanceBanner; diff --git a/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.mdx b/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.mdx new file mode 100644 index 000000000..cceb7bc48 --- /dev/null +++ b/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.mdx @@ -0,0 +1,51 @@ +--- +title: MaintenanceBanner +name: MaintenanceBanner +--- + +import MaintenanceBanner from './MaintenanceBanner'; + +### Code: + +```javascript +import MaintenanceBanner from '@department-of-veterans-affairs/component-library/MaintenanceBanner'; + +// Derive startsAt, expiresAt and warnStartsAt +const startsAt = new Date(); +const expiresAt = new Date(); +const warnStartsAt = new Date(); +expiresAt.setHours(expiresAt.getHours() + 24); +warnStartsAt.setHours(warnStartsAt.getHours() - 12); + +; +``` + +### Rendered Component + +const startsAt = new Date(); +const expiresAt = new Date(); +const warnStartsAt = new Date(); +expiresAt.setHours(expiresAt.getHours() + 24); +warnStartsAt.setHours(warnStartsAt.getHours() - 12); + +
+ +
diff --git a/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.unit.spec.jsx b/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.unit.spec.jsx new file mode 100644 index 000000000..359973e4f --- /dev/null +++ b/packages/react-components/src/components/MaintenanceBanner/MaintenanceBanner.unit.spec.jsx @@ -0,0 +1,64 @@ +// Node modules. +import React from 'react'; +import { mount } from 'enzyme'; +import { expect } from 'chai'; + +// Relative imports. +import MaintenanceBanner from './MaintenanceBanner.jsx'; +import { formatDate } from '../../helpers/format-date.js'; + +const deriveDefaultProps = (startsAt = new Date()) => { + const expiresAt = new Date(startsAt); + const warnStartsAt = new Date(startsAt); + expiresAt.setHours(expiresAt.getHours() + 2); + warnStartsAt.setHours(warnStartsAt.getHours() - 12); + + const formattedStartsAt = formatDate(startsAt); + const formattedExpiresAt = formatDate(expiresAt); + + return { + id: '1', + startsAt, + expiresAt, + title: 'DS Logon is down for maintenance.', + content: + 'DS Logon is down for maintenance. Please use ID.me or MyHealtheVet to sign in or use online tools.', + warnStartsAt, + warnTitle: 'DS Logon will be down for maintenance', + warnContent: `DS Logon will be unavailable from ${formattedStartsAt} to ${formattedExpiresAt} Please use ID.me or MyHealtheVet to sign in or use online tools during this time.`, + }; +}; + +describe('', () => { + it("Escapes early if it's before when it should show.", () => { + const date = new Date(); + date.setHours(date.getHours() + 13); + const wrapper = mount(); + expect(wrapper.html()).to.equal(null); + wrapper.unmount(); + }); + + it('Shows pre-downtime.', () => { + const date = new Date(); + date.setHours(date.getHours() + 2); + const wrapper = mount(); + expect(wrapper.html()).to.not.equal(null); + expect(wrapper.html()).to.include('vads-u-border-color--warning-message'); + wrapper.unmount(); + }); + + it('Shows downtime.', () => { + const wrapper = mount(); + expect(wrapper.html()).to.not.equal(null); + expect(wrapper.html()).to.include('vads-u-border-color--secondary'); + wrapper.unmount(); + }); + + it("Escapes early if it's after when it should show.", () => { + const date = new Date(); + date.setHours(date.getHours() - 3); + const wrapper = mount(); + expect(wrapper.html()).to.equal(null); + wrapper.unmount(); + }); +}); diff --git a/packages/react-components/src/index.js b/packages/react-components/src/index.js index 0e06de581..0e305cb1a 100644 --- a/packages/react-components/src/index.js +++ b/packages/react-components/src/index.js @@ -1,20 +1,25 @@ +import AlertBox, { ALERT_TYPE } from './components/AlertBox/AlertBox'; import Breadcrumbs from './components/Breadcrumbs/Breadcrumbs'; import Checkbox from './components/Checkbox/Checkbox'; import ExpandingGroup from './components/ExpandingGroup/ExpandingGroup'; import IconBase from './components/IconBase/IconBase'; import IconSearch from './components/IconSearch/IconSearch'; import LoadingIndicator from './components/LoadingIndicator/LoadingIndicator'; +import MaintenanceBanner from './components/MaintenanceBanner/MaintenanceBanner'; import Modal from './components/Modal/Modal'; import OMBInfo from './components/OMBInfo/OMBInfo'; import TextInput from './components/TextInput/TextInput'; export { + AlertBox, + ALERT_TYPE, Breadcrumbs, Checkbox, ExpandingGroup, IconBase, IconSearch, LoadingIndicator, + MaintenanceBanner, Modal, OMBInfo, TextInput, From c702fda1974745ea493b13b32b5417c9fa8ee27a Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 17 Oct 2023 11:25:36 -0500 Subject: [PATCH 5/6] Remove components from Storybook --- packages/storybook/stories/additional-docs.js | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/storybook/stories/additional-docs.js b/packages/storybook/stories/additional-docs.js index 1da789d92..d97bea15b 100644 --- a/packages/storybook/stories/additional-docs.js +++ b/packages/storybook/stories/additional-docs.js @@ -16,10 +16,6 @@ export const additionalDocs = { maturityCategory: DONT_USE, maturityLevel: DEPRECATED, }, - 'Checkbox - React': { - maturityCategory: DONT_USE, - maturityLevel: DEPRECATED, - }, // MDX 'Divider': { guidanceHref: 'divider', @@ -50,22 +46,10 @@ export const additionalDocs = { maturityCategory: USE, maturityLevel: DEPLOYED, }, - 'Banner - Maintenance': { - maturityCategory: USE, - maturityLevel: DEPLOYED, - guidanceHref: 'banner/maintenance', - guidanceName: 'Banner - maintenance', - }, 'Modal - React': { maturityCategory: DONT_USE, maturityLevel: DEPRECATED, }, - 'OMB info - React': { - guidanceHref: 'omb-info', - guidanceName: 'OMB info', - maturityCategory: DONT_USE, - maturityLevel: DEPRECATED, - }, 'Privacy agreement - React': { maturityCategory: CAUTION, maturityLevel: AVAILABLE, From 9862df3b2e388d73eb422109834e6a5f6a889c6b Mon Sep 17 00:00:00 2001 From: Jami Gibbs Date: Tue, 17 Oct 2023 11:25:50 -0500 Subject: [PATCH 6/6] update versions --- packages/core/package.json | 2 +- packages/react-components/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 59c9f9d6b..bb6ab8493 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,7 +1,7 @@ { "name": "@department-of-veterans-affairs/component-library", "description": "VA.gov component library. Includes React and web components.", - "version": "25.0.0", + "version": "26.0.0", "license": "MIT", "scripts": { "build": "webpack" diff --git a/packages/react-components/package.json b/packages/react-components/package.json index 096b98c67..038c53e45 100644 --- a/packages/react-components/package.json +++ b/packages/react-components/package.json @@ -1,6 +1,6 @@ { "name": "@department-of-veterans-affairs/react-components", - "version": "20.0.1", + "version": "22.0.0", "description": "VA.gov component library in React", "keywords": [ "react",