diff --git a/.circleci/config.yml b/.circleci/config.yml
index a4b3ae6b064..63c6ac677fb 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -299,7 +299,7 @@ jobs:
name: 'Smoke tests'
command: |
bin/smoke_test --remote --no-source-env
- # - notify-slack-smoke-test-status
+ - notify-slack-smoke-test-status
- store-smoketest-results
smoketest-int:
working_directory: ~/identity-idp
@@ -318,7 +318,7 @@ jobs:
name: 'Smoke tests'
command: |
bin/smoke_test --remote --no-source-env
- # - notify-slack-smoke-test-status
+ - notify-slack-smoke-test-status
- store-smoketest-results
smoketest-staging:
working_directory: ~/identity-idp
@@ -337,7 +337,7 @@ jobs:
name: 'Smoke tests'
command: |
bin/smoke_test --remote --no-source-env
- # - notify-slack-smoke-test-status
+ - notify-slack-smoke-test-status
- store-smoketest-results
smoketest-prod:
working_directory: ~/identity-idp
@@ -354,7 +354,7 @@ jobs:
name: 'Smoke tests'
command: |
bin/smoke_test --remote --no-source-env
- # - notify-slack-smoke-test-status
+ - notify-slack-smoke-test-status
- store-smoketest-results
check-pinpoint-config:
executor: ruby_browsers
diff --git a/.erb-lint.yml b/.erb-lint.yml
index aae60fce722..28149675943 100644
--- a/.erb-lint.yml
+++ b/.erb-lint.yml
@@ -5,7 +5,13 @@ linters:
# There's no special reason for these excludes other than the fact that they have existing
# issues that haven't been resolved yet. Fix 'em up!
exclude:
- - '*/app/views/account_reset/*'
+ - '*/app/views/account_reset/cancel/show.html.erb'
+ - '*/app/views/account_reset/confirm_delete_account/show.html.erb'
+ - '*/app/views/account_reset/delete_account/show.html.erb'
+ - '*/app/views/account_reset/pending/cancel.html.erb'
+ - '*/app/views/account_reset/pending/confirm.html.erb'
+ - '*/app/views/account_reset/pending/show.html.erb'
+ - '*/app/views/account_reset/request/show.html.erb'
- '*/app/views/accounts/_auth_apps.html.erb'
- '*/app/views/accounts/_backup_codes.html.erb'
- '*/app/views/accounts/_connected_app.html.erb'
@@ -16,10 +22,10 @@ linters:
- '*/app/views/accounts/_webauthn.html.erb'
- '*/app/views/accounts/actions/_manage_personal_key.html.erb'
- '*/app/views/accounts/show.html.erb'
- - '*/app/views/idv/confirmations/*'
- - '*/app/views/idv/capture_doc/*'
+ - '*/app/views/event_disavowal/new.html.erb'
+ - '*/app/views/idv/capture_doc/document_capture.html.erb'
+ - '*/app/views/idv/confirmations/show.html.erb'
- '*/app/views/idv/doc_auth/_back.html.erb'
- - '*/app/views/idv/doc_auth/_error_messages.html.erb'
- '*/app/views/idv/doc_auth/_ssn_init.html.erb'
- '*/app/views/idv/doc_auth/_ssn_update.html.erb'
- '*/app/views/idv/doc_auth/agreement.html.erb'
@@ -31,32 +37,72 @@ linters:
- '*/app/views/idv/doc_auth/verify_wait.html.erb'
- '*/app/views/idv/phone/new.html.erb'
- '*/app/views/idv/shared/_document_capture.html.erb'
- - '*/app/views/event_disavowal/*'
- - '*/app/views/mfa_confirmation/*'
- - '*/app/views/reactivate_account/*'
- - '*/app/views/session_timeout/*'
- - '*/app/views/shared/*'
- - '*/app/views/two_factor_authentication/*'
- - '*/app/views/user_mailer/*'
- - '*/app/views/users/authorization_confirmation/*'
- - '*/app/views/users/backup_code_setup/*'
- - '*/app/views/users/delete/*'
- - '*/app/views/users/edit_phone/*'
- - '*/app/views/users/email_language/*'
- - '*/app/views/users/emails/*'
- - '*/app/views/users/passwords/*'
- - '*/app/views/users/piv_cac_setup/*'
- - '*/app/views/users/piv_cac_setup_from_sign_in/*'
- - '*/app/views/users/rules_of_use/*'
- - '*/app/views/users/service_provider_inactive/*'
- - '*/app/views/users/service_provider_revoke/*'
+ - '*/app/views/mfa_confirmation/new.html.erb'
+ - '*/app/views/reactivate_account/_modal.html.erb'
+ - '*/app/views/reactivate_account/index.html.erb'
+ - '*/app/views/session_timeout/_expire_session.html.erb'
+ - '*/app/views/session_timeout/_ping.html.erb'
+ - '*/app/views/shared/_alert.html.erb'
+ - '*/app/views/shared/_banner.html.erb'
+ - '*/app/views/shared/_block_link.html.erb'
+ - '*/app/views/shared/_email_languages.html.erb'
+ - '*/app/views/shared/_footer_lite.html.erb'
+ - '*/app/views/shared/_maintenance_window_alert.html.erb'
+ - '*/app/views/shared/_masked_text.html.erb'
+ - '*/app/views/shared/_nav_branded.html.erb'
+ - '*/app/views/shared/_one_time_code_input.html.erb'
+ - '*/app/views/shared/_personal_key.html.erb'
+ - '*/app/views/shared/_spinner_button.html.erb'
+ - '*/app/views/shared/_ssn_field.html.erb'
+ - '*/app/views/shared/_step_indicator.html.erb'
+ - '*/app/views/shared/_step_indicator_step.html.erb'
+ - '*/app/views/shared/_validation_message.html.erb'
+ - '*/app/views/two_factor_authentication/backup_code_verification/show.html.erb'
+ - '*/app/views/two_factor_authentication/options/index.html.erb'
+ - '*/app/views/two_factor_authentication/otp_verification/show.html.erb'
+ - '*/app/views/two_factor_authentication/personal_key_verification/show.html.erb'
+ - '*/app/views/two_factor_authentication/totp_verification/show.html.erb'
+ - '*/app/views/two_factor_authentication/webauthn_verification/show.html.erb'
+ - '*/app/views/user_mailer/account_does_not_exist.html.erb'
+ - '*/app/views/user_mailer/account_reset_granted.html.erb'
+ - '*/app/views/user_mailer/account_verified.html.erb'
+ - '*/app/views/user_mailer/add_email.html.erb'
+ - '*/app/views/user_mailer/add_email_associated_with_another_account.html.erb'
+ - '*/app/views/user_mailer/email_added.html.erb'
+ - '*/app/views/user_mailer/email_confirmation_instructions.html.erb'
+ - '*/app/views/user_mailer/email_deleted.html.erb'
+ - '*/app/views/user_mailer/new_device_sign_in.html.erb'
+ - '*/app/views/user_mailer/password_changed.html.erb'
+ - '*/app/views/user_mailer/personal_key_regenerated.html.erb'
+ - '*/app/views/user_mailer/personal_key_sign_in.html.erb'
+ - '*/app/views/user_mailer/phone_added.html.erb'
+ - '*/app/views/user_mailer/please_reset_password.html.erb'
+ - '*/app/views/user_mailer/reset_password_instructions.html.erb'
+ - '*/app/views/user_mailer/signup_with_your_email.html.erb'
+ - '*/app/views/user_mailer/unconfirmed_email_instructions.html.erb'
+ - '*/app/views/users/authorization_confirmation/show.html.erb'
+ - '*/app/views/users/backup_code_setup/confirm_delete.html.erb'
+ - '*/app/views/users/backup_code_setup/create.html.erb'
+ - '*/app/views/users/backup_code_setup/edit.html.erb'
+ - '*/app/views/users/backup_code_setup/index.html.erb'
+ - '*/app/views/users/delete/show.html.erb'
+ - '*/app/views/users/edit_phone/edit.html.erb'
+ - '*/app/views/users/email_language/show.html.erb'
+ - '*/app/views/users/emails/show.html.erb'
+ - '*/app/views/users/emails/verify.html.erb'
+ - '*/app/views/users/passwords/edit.html.erb'
+ - '*/app/views/users/piv_cac_setup_from_sign_in/prompt.html.erb'
+ - '*/app/views/users/piv_cac_setup_from_sign_in/success.html.erb'
+ - '*/app/views/users/rules_of_use/new.html.erb'
+ - '*/app/views/users/service_provider_inactive/index.html.erb'
+ - '*/app/views/users/service_provider_revoke/show.html.erb'
- '*/app/views/users/shared/_otp_delivery_preference_selection.html.erb'
- '*/app/views/users/shared/_otp_make_default_number.html.erb'
- - '*/app/views/users/totp_setup/*'
- - '*/app/views/users/two_factor_authentication_setup/*'
- - '*/app/views/users/verify_password/*'
- - '*/app/views/users/verify_personal_key/*'
- - '*/app/views/users/webauthn_setup/*'
+ - '*/app/views/users/totp_setup/new.html.erb'
+ - '*/app/views/users/verify_password/new.html.erb'
+ - '*/app/views/users/verify_personal_key/new.html.erb'
+ - '*/app/views/users/webauthn_setup/delete.html.erb'
+ - '*/app/views/users/webauthn_setup/new.html.erb'
rubocop_config:
inherit_from:
- .rubocop.yml
diff --git a/Gemfile.lock b/Gemfile.lock
index 92182433e4e..02cf956fc65 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -180,7 +180,7 @@ GEM
blueprinter (0.25.3)
bootsnap (1.9.1)
msgpack (~> 1.0)
- brakeman (5.1.1)
+ brakeman (5.1.2)
browser (5.3.1)
builder (3.2.4)
bullet (6.1.5)
diff --git a/Makefile b/Makefile
index ff7a5b1cc8c..fede08f598a 100644
--- a/Makefile
+++ b/Makefile
@@ -28,7 +28,7 @@ lint:
@echo "--- rubocop ---"
bundle exec rubocop --parallel
@echo "--- brakeman ---"
- bundle exec brakeman --skip-files app/services/doc_auth_router.rb
+ bundle exec brakeman
@echo "--- zeitwerk check ---"
bin/rails zeitwerk:check
@echo "--- bundler-audit ---"
diff --git a/Procfile b/Procfile
index e8c1f355d93..f5b4d1f46b9 100644
--- a/Procfile
+++ b/Procfile
@@ -1,5 +1,4 @@
web: bundle exec rackup config.ru --port ${PORT:-3000} --host ${HOST:-localhost}
worker: bundle exec good_job start
-job_runs: bundle exec rake job_runs:run
mailcatcher: mailcatcher -f
webpacker: ./bin/webpack-dev-server
diff --git a/app/assets/images/idv/capture-tips-clean.svg b/app/assets/images/idv/capture-tips-clean.svg
new file mode 100644
index 00000000000..60b0eccdbf9
--- /dev/null
+++ b/app/assets/images/idv/capture-tips-clean.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/idv/capture-tips-lighting.svg b/app/assets/images/idv/capture-tips-lighting.svg
new file mode 100644
index 00000000000..aba228f49c2
--- /dev/null
+++ b/app/assets/images/idv/capture-tips-lighting.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/images/idv/capture-tips-surface.svg b/app/assets/images/idv/capture-tips-surface.svg
new file mode 100644
index 00000000000..e752997bb88
--- /dev/null
+++ b/app/assets/images/idv/capture-tips-surface.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/assets/stylesheets/components/_form.scss b/app/assets/stylesheets/components/_form.scss
index 077e2d376df..2745e8a9b48 100644
--- a/app/assets/stylesheets/components/_form.scss
+++ b/app/assets/stylesheets/components/_form.scss
@@ -105,14 +105,20 @@ input::-webkit-inner-spin-button {
}
}
+// Remove/refactor once LGDS v6.3 is released
+
+.usa-error-message,
.usa-success-message {
@include u-padding-y(.5);
- background-image: url(image-path('alert/success.svg'));
- background-position: 0 center;
+ background-position: 0 .5em;
background-repeat: no-repeat;
- background-size: 1rem;
- color: color('success');
- display: block;
+ background-size: units($icon-size);
font-weight: font-weight('bold');
padding-left: 1.5rem;
}
+
+.usa-success-message {
+ background-image: url(image-path('alert/success.svg'));
+ color: color('success');
+ display: block;
+}
diff --git a/app/components/alert_component.html.erb b/app/components/alert_component.html.erb
new file mode 100644
index 00000000000..bdbffddffb8
--- /dev/null
+++ b/app/components/alert_component.html.erb
@@ -0,0 +1,5 @@
+<%= tag.div(**tag_options, class: css_class, role: role) do %>
+
Option 1>, url: 'https://example.com/1', isExternal: true },
+ { text: 'Option 2', url: 'https://example.com/2' },
+ ]}
+ />,
+ );
+
+ const links = /** @type {HTMLAnchorElement[]} */ (getAllByRole('link'));
+
+ expect(links).to.have.lengthOf(2);
+ expect(links[0].textContent).to.equal('Option 1 links.new_window');
+ expect(links[0].href).to.equal('https://example.com/1');
+ expect(links[0].target).to.equal('_blank');
+ expect(links[1].textContent).to.equal('Option 2');
+ expect(links[1].href).to.equal('https://example.com/2');
+ expect(links[1].target).to.be.empty();
+ });
+});
diff --git a/app/javascript/packages/compose-components/README.md b/app/javascript/packages/compose-components/README.md
new file mode 100644
index 00000000000..fec7f76731e
--- /dev/null
+++ b/app/javascript/packages/compose-components/README.md
@@ -0,0 +1,17 @@
+# `@18f/identity-compose-components`
+
+A utility function to compose a set of React components and their props to a single component.
+
+Convenient for flattening a deeply-nested arrangement of context providers, for example.
+
+## Example
+
+```jsx
+const App = composeComponents(
+ [FirstContext.Provider, { value: 1 }],
+ [SecondContext.Provider, { value: 2 }],
+ AppRoot,
+);
+
+render(App, document.getElementById('app-root'));
+```
diff --git a/app/javascript/packages/compose-components/index.js b/app/javascript/packages/compose-components/index.js
new file mode 100644
index 00000000000..d152e389d7f
--- /dev/null
+++ b/app/javascript/packages/compose-components/index.js
@@ -0,0 +1,51 @@
+import { createElement } from 'react';
+
+/** @typedef {import('react').ComponentType} ComponentType @template P */
+
+/**
+ * @typedef {[ComponentType
, P]} NormalizedComponentPair
+ *
+ * @template P
+ */
+
+/**
+ * @typedef {[ComponentType
, P]|[ComponentType
]|ComponentType
} ComponentPair
+ *
+ * @template P
+ */
+
+/**
+ * A utility function to compose a set of React components and their props to a single component.
+ *
+ * Convenient for flattening a deeply-nested arrangement of context providers, for example.
+ *
+ * @example
+ * ```jsx
+ * const App = composeComponents(
+ * [FirstContext.Provider, { value: 1 }],
+ * [SecondContext.Provider, { value: 2 }],
+ * AppRoot,
+ * );
+ *
+ * render(App, document.getElementById('app-root'));
+ * ```
+ *
+ * @param {...ComponentPair<*>} components
+ *
+ * @return {ComponentType<*>}
+ */
+export function composeComponents(...components) {
+ return function ComposedComponent() {
+ /** @type {JSX.Element?} */
+ let element = null;
+ for (let i = components.length - 1; i >= 0; i--) {
+ const componentPair = /** @type {NormalizedComponentPair<*>} */ (Array.isArray(components[i])
+ ? components[i]
+ : [components[i]]);
+ const [ComponentType, props] = componentPair;
+ element = createElement(ComponentType, props, element);
+ }
+
+ return element;
+ };
+}
diff --git a/app/javascript/packages/compose-components/index.spec.jsx b/app/javascript/packages/compose-components/index.spec.jsx
new file mode 100644
index 00000000000..7e1e296e7c0
--- /dev/null
+++ b/app/javascript/packages/compose-components/index.spec.jsx
@@ -0,0 +1,27 @@
+import { createContext, useContext } from 'react';
+import { render } from '@testing-library/react';
+import { composeComponents } from './index.js';
+
+describe('composeComponents', () => {
+ it('composes components', () => {
+ const FirstContext = createContext(null);
+ const SecondContext = createContext(null);
+ const AppRoot = () => (
+ <>
+ {useContext(FirstContext)}
+ {useContext(SecondContext)}
+ >
+ );
+
+ const ComposedComponent = composeComponents(
+ [FirstContext.Provider, { value: 1 }],
+ [SecondContext.Provider, { value: 2 }],
+ [({ children }) => <>{children}3>],
+ AppRoot,
+ );
+
+ const { getByText } = render();
+
+ expect(getByText('123')).to.be.ok();
+ });
+});
diff --git a/app/javascript/packages/compose-components/package.json b/app/javascript/packages/compose-components/package.json
new file mode 100644
index 00000000000..8ec82d11652
--- /dev/null
+++ b/app/javascript/packages/compose-components/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "@18f/identity-compose-components",
+ "private": true,
+ "version": "1.0.0",
+ "dependencies": {
+ "react": "^17.0.2"
+ }
+}
diff --git a/app/javascript/packages/document-capture/components/acuant-capture.jsx b/app/javascript/packages/document-capture/components/acuant-capture.jsx
index 54dddca2561..08561a65d8a 100644
--- a/app/javascript/packages/document-capture/components/acuant-capture.jsx
+++ b/app/javascript/packages/document-capture/components/acuant-capture.jsx
@@ -10,6 +10,7 @@ import {
import { useI18n } from '@18f/identity-react-i18n';
import AnalyticsContext from '../context/analytics';
import AcuantContext from '../context/acuant';
+import FailedCaptureAttemptsContext from '../context/failed-capture-attempts';
import AcuantCaptureCanvas from './acuant-capture-canvas';
import FileInput from './file-input';
import FullScreen from './full-screen';
@@ -102,6 +103,11 @@ import './acuant-capture.scss';
* @prop {string} name Prefix to prepend to user action analytics labels.
*/
+/**
+ * A noop function.
+ */
+const noop = () => {};
+
/**
* Returns true if the given Acuant capture failure was caused by the user declining access to the
* camera, or false otherwise.
@@ -267,6 +273,9 @@ function AcuantCapture(
const { isMobile } = useContext(DeviceContext);
const { t, formatHTML } = useI18n();
const [attempt, incrementAttempt] = useCounter(1);
+ const { onFailedCaptureAttempt, onResetFailedCaptureAttempts } = useContext(
+ FailedCaptureAttemptsContext,
+ );
const hasCapture = !isError && (isReady ? isCameraSupported : isMobile);
useEffect(() => {
// If capture had started before Acuant was ready, stop capture if readiness reveals that no
@@ -292,7 +301,7 @@ function AcuantCapture(
/**
* Returns an analytics payload, decorated with common values.
*
- * @template P
+ * @template {ImageAnalyticsPayload|AcuantImageAnalyticsPayload} P
*
* @param {P} payload
*
@@ -339,15 +348,16 @@ function AcuantCapture(
* @template {(...args: any[]) => any} T
*
* @param {string} source Click source.
+ * @param {{isDrop: boolean}=} metadata Additional payload metadata to log.
*
* @return {(fn: T) => (...args: Parameters) => ReturnType}
*/
- function withLoggedClick(source) {
+ function withLoggedClick(source, metadata = { isDrop: false }) {
return (fn) => (...args) => {
if (!isSuppressingClickLogging.current) {
addPageAction({
label: `IdV: ${name} image clicked`,
- payload: { source },
+ payload: { source, ...metadata },
});
}
@@ -474,6 +484,9 @@ function AcuantCapture(
if (assessment === 'success') {
onChangeAndResetError(data, analyticsPayload);
+ onResetFailedCaptureAttempts();
+ } else {
+ onFailedCaptureAttempt({ isAssessedAsGlare, isAssessedAsBlurry });
}
setIsCapturingEnvironment(false);
@@ -521,6 +534,7 @@ function AcuantCapture(
value={value}
errorMessage={ownErrorMessage ?? errorMessage}
onClick={withLoggedClick('placeholder')(startCaptureOrTriggerUpload)}
+ onDrop={withLoggedClick('placeholder', { isDrop: true })(noop)}
onChange={onUpload}
onError={() => setOwnErrorMessage(null)}
/>
diff --git a/app/javascript/packages/document-capture/components/button-to.jsx b/app/javascript/packages/document-capture/components/button-to.jsx
new file mode 100644
index 00000000000..3f4aab0f12f
--- /dev/null
+++ b/app/javascript/packages/document-capture/components/button-to.jsx
@@ -0,0 +1,44 @@
+import { useContext, useRef } from 'react';
+import { createPortal } from 'react-dom';
+import UploadContext from '../context/upload';
+import Button from './button';
+
+/** @typedef {import('./button').ButtonProps} ButtonProps */
+
+/**
+ * @typedef NativeButtonToProps
+ *
+ * @prop {string} url URL to which the user should navigate.
+ * @prop {string} method Form method button should submit as.
+ */
+
+/**
+ * @typedef {NativeButtonToProps & ButtonProps} ButtonToProps
+ */
+
+/**
+ * Component which renders a button that navigates to the specified URL via form, with method
+ * parameterized as a hidden input and including authenticity token. The form is rendered to the
+ * document root, to avoid conflicts with nested forms.
+ *
+ * @param {ButtonToProps} props
+ */
+function ButtonTo({ url, method, children, ...buttonProps }) {
+ const { csrf } = useContext(UploadContext);
+ const formRef = useRef(/** @type {HTMLFormElement?} */ (null));
+
+ return (
+
+ );
+}
+
+export default ButtonTo;
diff --git a/app/javascript/packages/document-capture/components/callback-on-mount.jsx b/app/javascript/packages/document-capture/components/callback-on-mount.jsx
index fc34ad151aa..c85984e3b57 100644
--- a/app/javascript/packages/document-capture/components/callback-on-mount.jsx
+++ b/app/javascript/packages/document-capture/components/callback-on-mount.jsx
@@ -1,20 +1,25 @@
import { useEffect } from 'react';
+/** @typedef {import('react').ReactNode} ReactNode */
+
/**
* @typedef CallbackOnMountProps
*
* @prop {()=>void} onMount Callback to trigger on mount.
+ * @prop {JSX.Element?=} children Element children.
*/
/**
* @param {CallbackOnMountProps} props Props object.
+ *
+ * @return {JSX.Element?}
*/
-function CallbackOnMount({ onMount }) {
+function CallbackOnMount({ onMount, children = null }) {
useEffect(() => {
onMount();
}, []);
- return null;
+ return children;
}
export default CallbackOnMount;
diff --git a/app/javascript/packages/document-capture/components/capture-advice.jsx b/app/javascript/packages/document-capture/components/capture-advice.jsx
new file mode 100644
index 00000000000..94791815724
--- /dev/null
+++ b/app/javascript/packages/document-capture/components/capture-advice.jsx
@@ -0,0 +1,89 @@
+import { useContext } from 'react';
+import { useI18n } from '@18f/identity-react-i18n';
+import ServiceProviderContext from '../context/service-provider';
+import MarketingSiteContext from '../context/marketing-site';
+import useAsset from '../hooks/use-asset';
+import Warning from './warning';
+
+/** @typedef {import('@18f/identity-components/troubleshooting-options').TroubleshootingOption} TroubleshootingOption */
+
+/**
+ * @typedef CaptureAdviceProps
+ *
+ * @prop {() => void} onTryAgain
+ * @prop {boolean} isAssessedAsGlare
+ * @prop {boolean} isAssessedAsBlurry
+ */
+
+/**
+ * @param {CaptureAdviceProps} props
+ */
+function CaptureAdvice({ onTryAgain, isAssessedAsGlare, isAssessedAsBlurry }) {
+ const { name: spName, getFailureToProofURL } = useContext(ServiceProviderContext);
+ const { documentCaptureTipsURL } = useContext(MarketingSiteContext);
+ const { getAssetPath } = useAsset();
+ const { t } = useI18n();
+
+ return (
+
+
+ {isAssessedAsGlare && t('doc_auth.tips.capture_troubleshooting_glare')}
+ {isAssessedAsBlurry && t('doc_auth.tips.capture_troubleshooting_blurry')}{' '}
+ {t('doc_auth.tips.capture_troubleshooting_lead')}
+
+
+ -
+
+ {t('doc_auth.tips.capture_troubleshooting_surface')}
+
+ -
+
+ {t('doc_auth.tips.capture_troubleshooting_lighting')}
+
+ -
+
+ {t('doc_auth.tips.capture_troubleshooting_clean')}
+
+
+
+ );
+}
+
+export default CaptureAdvice;
diff --git a/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx
new file mode 100644
index 00000000000..ea25445c511
--- /dev/null
+++ b/app/javascript/packages/document-capture/components/capture-troubleshooting.jsx
@@ -0,0 +1,58 @@
+import { useContext, useState } from 'react';
+import FailedCaptureAttemptsContext from '../context/failed-capture-attempts';
+import AnalyticsContext from '../context/analytics';
+import CallbackOnMount from './callback-on-mount';
+import CaptureAdvice from './capture-advice';
+import { FormStepsContext } from './form-steps';
+import useDidUpdateEffect from '../hooks/use-did-update-effect';
+
+/** @typedef {import('react').ReactNode} ReactNode */
+
+/**
+ * @typedef CaptureTroubleshootingProps
+ *
+ * @prop {ReactNode} children
+ */
+
+/**
+ * @param {CaptureTroubleshootingProps} props
+ */
+function CaptureTroubleshooting({ children }) {
+ const { addPageAction } = useContext(AnalyticsContext);
+ const [didShowTroubleshooting, setDidShowTroubleshooting] = useState(false);
+ const { failedCaptureAttempts, maxFailedAttemptsBeforeTips, lastAttemptMetadata } = useContext(
+ FailedCaptureAttemptsContext,
+ );
+ const { onPageTransition } = useContext(FormStepsContext);
+ useDidUpdateEffect(onPageTransition, [didShowTroubleshooting]);
+ const { isAssessedAsGlare, isAssessedAsBlurry } = lastAttemptMetadata;
+
+ function onCaptureTipsShown() {
+ addPageAction({
+ label: 'IdV: Capture troubleshooting shown',
+ payload: lastAttemptMetadata,
+ });
+
+ onPageTransition();
+ }
+
+ function onCaptureTipsDismissed() {
+ addPageAction({ label: 'IdV: Capture troubleshooting dismissed' });
+
+ setDidShowTroubleshooting(true);
+ }
+
+ return failedCaptureAttempts >= maxFailedAttemptsBeforeTips && !didShowTroubleshooting ? (
+
+
+
+ ) : (
+ <>{children}>
+ );
+}
+
+export default CaptureTroubleshooting;
diff --git a/app/javascript/packages/document-capture/components/document-capture.jsx b/app/javascript/packages/document-capture/components/document-capture.jsx
index 26bdf01672a..58c839fcb2f 100644
--- a/app/javascript/packages/document-capture/components/document-capture.jsx
+++ b/app/javascript/packages/document-capture/components/document-capture.jsx
@@ -9,7 +9,6 @@ import ReviewIssuesStep, { reviewIssuesStepValidator } from './review-issues-ste
import ServiceProviderContext from '../context/service-provider';
import Submission from './submission';
import SubmissionStatus from './submission-status';
-import DesktopDocumentDisclosure from './desktop-document-disclosure';
import { RetrySubmissionError } from './submission-complete';
import { BackgroundEncryptedUploadError } from '../higher-order/with-background-encrypted-upload';
import SuspenseErrorBoundary from './suspense-error-boundary';
@@ -94,23 +93,18 @@ function DocumentCapture({ isAsyncForm = false, onStepChange }) {
? [
{
name: 'review',
- title: t('doc_auth.headings.review_issues'),
form: ReviewIssuesStep,
validator: reviewIssuesStepValidator,
- footer: DesktopDocumentDisclosure,
},
]
: /** @type {FormStep[]} */ ([
{
name: 'documents',
- title: t('doc_auth.headings.document_capture'),
form: DocumentsStep,
validator: documentsStepValidator,
- footer: DesktopDocumentDisclosure,
},
serviceProvider.isLivenessRequired && {
name: 'selfie',
- title: t('doc_auth.headings.selfie'),
form: SelfieStep,
validator: selfieStepValidator,
},
diff --git a/app/javascript/packages/document-capture/components/documents-step.jsx b/app/javascript/packages/document-capture/components/documents-step.jsx
index 2b1f5d635fa..45ddb9817c5 100644
--- a/app/javascript/packages/document-capture/components/documents-step.jsx
+++ b/app/javascript/packages/document-capture/components/documents-step.jsx
@@ -1,10 +1,15 @@
import { useContext } from 'react';
import { useI18n } from '@18f/identity-react-i18n';
-import BlockLink from './block-link';
+import { BlockLink } from '@18f/identity-components';
+import { FormStepsContinueButton } from './form-steps';
import DocumentSideAcuantCapture from './document-side-acuant-capture';
import DeviceContext from '../context/device';
import ServiceProviderContext from '../context/service-provider';
import withBackgroundEncryptedUpload from '../higher-order/with-background-encrypted-upload';
+import DesktopDocumentDisclosure from './desktop-document-disclosure';
+import CaptureTroubleshooting from './capture-troubleshooting';
+import PageHeading from './page-heading';
+import StartOverOrCancel from './start-over-or-cancel';
/**
* @typedef {'front'|'back'} DocumentSide
@@ -48,7 +53,8 @@ function DocumentsStep({
const serviceProvider = useContext(ServiceProviderContext);
return (
- <>
+
+ {t('doc_auth.headings.document_capture')}
{isMobile && {t('doc_auth.info.document_capture_intro_acknowledgment')}
}
{t('doc_auth.tips.document_capture_header_text')}
@@ -75,7 +81,10 @@ function DocumentsStep({
onError={onError}
/>
))}
- >
+
+
+
+
);
}
diff --git a/app/javascript/packages/document-capture/components/file-input.jsx b/app/javascript/packages/document-capture/components/file-input.jsx
index 00a13acdd5d..97f7388998a 100644
--- a/app/javascript/packages/document-capture/components/file-input.jsx
+++ b/app/javascript/packages/document-capture/components/file-input.jsx
@@ -14,6 +14,7 @@ import useInstanceId from '../hooks/use-instance-id';
import usePrevious from '../hooks/use-previous';
/** @typedef {import('react').MouseEvent} ReactMouseEvent */
+/** @typedef {import('react').DragEvent} ReactDragEvent */
/** @typedef {import('react').ChangeEvent} ReactChangeEvent */
/** @typedef {import('react').RefAttributes} ReactRefAttributes */
/** @typedef {import('react').ReactNode} ReactNode */
@@ -31,6 +32,7 @@ import usePrevious from '../hooks/use-previous';
* @prop {Blob|string|null|undefined} value Current value.
* @prop {ReactNode=} errorMessage Error to show.
* @prop {(event:ReactMouseEvent)=>void=} onClick Input click handler.
+ * @prop {(event:ReactDragEvent)=>void=} onDrop Input drop handler.
* @prop {(nextValue:File?)=>void=} onChange Input change handler.
* @prop {(message:ReactNode)=>void=} onError Callback to trigger if upload error occurs.
*/
@@ -108,7 +110,8 @@ function FileInput(props, ref) {
capture,
value,
errorMessage,
- onClick = () => {},
+ onClick,
+ onDrop,
onChange = () => {},
onError = () => {},
} = props;
@@ -278,6 +281,7 @@ function FileInput(props, ref) {
onChange={onChangeIfValid}
capture={capture}
onClick={onClick}
+ onDrop={onDrop}
accept={accept ? accept.join() : undefined}
aria-describedby={hint ? hintId : undefined}
/>
diff --git a/app/javascript/packages/document-capture/components/form-steps.jsx b/app/javascript/packages/document-capture/components/form-steps.jsx
index 40d06dbfba4..b9dbf2c9778 100644
--- a/app/javascript/packages/document-capture/components/form-steps.jsx
+++ b/app/javascript/packages/document-capture/components/form-steps.jsx
@@ -1,14 +1,14 @@
-import { useEffect, useRef, useState } from 'react';
+import { useEffect, useRef, useState, createContext, useContext } from 'react';
import { Alert } from '@18f/identity-components';
import { useI18n } from '@18f/identity-react-i18n';
import Button from './button';
-import PageHeading from './page-heading';
import FormErrorMessage, { RequiredValueMissingError } from './form-error-message';
import PromptOnNavigate from './prompt-on-navigate';
import useHistoryParam from '../hooks/use-history-param';
import useForceRender from '../hooks/use-force-render';
import useDidUpdateEffect from '../hooks/use-did-update-effect';
import useIfStillMounted from '../hooks/use-if-still-mounted';
+import './form-steps.scss';
/**
* @typedef FormStepError
@@ -53,9 +53,7 @@ import useIfStillMounted from '../hooks/use-if-still-mounted';
* @typedef FormStep
*
* @prop {string} name Step name, used in history parameter.
- * @prop {string} title Step title, shown as heading.
* @prop {import('react').FC>>} form Step form component.
- * @prop {import('react').FC=} footer Optional step footer component.
* @prop {(object)=>boolean=} validator Optional function to validate values for the step
*/
@@ -78,6 +76,22 @@ import useIfStillMounted from '../hooks/use-if-still-mounted';
* @prop {()=>void=} onStepChange Callback triggered on step change.
*/
+/**
+ * @typedef FormStepsContext
+ *
+ * @prop {boolean} isLastStep Whether the current step is the last step in the flow.
+ * @prop {boolean} canContinueToNextStep Whether the user can proceed to the next step.
+ * @prop {() => void} onPageTransition Callback invoked when content is reset in a page transition.
+ */
+
+export const FormStepsContext = createContext(
+ /** @type {FormStepsContext} */ ({
+ isLastStep: true,
+ canContinueToNextStep: true,
+ onPageTransition: () => {},
+ }),
+);
+
/**
* Returns the index of the step in the array which matches the given name. Returns `-1` if there is
* no step found by that name.
@@ -120,12 +134,9 @@ function FormSteps({
}) {
const [values, setValues] = useState(initialValues);
const [activeErrors, setActiveErrors] = useState(initialActiveErrors);
- const firstAlertRef = useRef(/** @type {?HTMLElement} */ (null));
const formRef = useRef(/** @type {?HTMLFormElement} */ (null));
- const headingRef = useRef(/** @type {?HTMLHeadingElement} */ (null));
const [stepName, setStepName] = useHistoryParam('step', null);
const [stepErrors, setStepErrors] = useState(/** @type {Error[]} */ ([]));
- const { t } = useI18n();
const fields = useRef(/** @type {Record} */ ({}));
const didSubmitWithErrors = useRef(false);
const forceRender = useForceRender();
@@ -141,20 +152,35 @@ function FormSteps({
const stepIndex = Math.max(getStepIndexByName(steps, stepName), 0);
const step = steps[stepIndex];
+ /**
+ * After a change in content, maintain focus by resetting to the beginning of the new content.
+ */
+ function onPageTransition() {
+ const firstElementChild = formRef.current?.firstElementChild;
+ if (firstElementChild instanceof window.HTMLElement) {
+ firstElementChild.classList.add('form-steps__focus-anchor');
+ firstElementChild.setAttribute('tabindex', '-1');
+ firstElementChild.focus();
+ }
+
+ setStepName(stepName);
+ }
+
useEffect(() => {
// Treat explicit initial step the same as step transition, placing focus to header.
- if (autoFocus && headingRef.current) {
- headingRef.current.focus();
+ if (autoFocus) {
+ onPageTransition();
}
}, []);
useEffect(() => {
- if (stepErrors.length && firstAlertRef.current) {
- firstAlertRef.current.focus();
+ if (stepErrors.length) {
+ onPageTransition();
}
}, [stepErrors]);
useDidUpdateEffect(onStepChange, [step]);
+ useDidUpdateEffect(onPageTransition, [step]);
/**
* Returns array of form errors for the current set of values.
@@ -183,7 +209,7 @@ function FormSteps({
const isValidStep = step.validator?.(values) ?? true;
const hasUnresolvedFieldErrors =
activeErrors.length && activeErrors.length > unknownFieldErrors.length;
- const canContinue = isValidStep && !hasUnresolvedFieldErrors;
+ const canContinueToNextStep = isValidStep && !hasUnresolvedFieldErrors;
/**
* Increments state to the next step, or calls onComplete callback if the current step is the last
@@ -218,77 +244,75 @@ function FormSteps({
const { name: nextStepName } = steps[nextStepIndex];
setStepName(nextStepName);
}
-
- headingRef.current?.focus();
}
- const { form: Form, footer: Footer, name, title } = step;
+ const { form: Form, name } = step;
const isLastStep = stepIndex + 1 === steps.length;
return (