-
Notifications
You must be signed in to change notification settings - Fork 166
LG-6314: Add Start Over and Cancel links to password confirmation #6350
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6c7beeb
88100cc
0f3f511
52f1f14
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,67 @@ | ||
| import sinon from 'sinon'; | ||
| import { render } from '@testing-library/react'; | ||
| import userEvent from '@testing-library/user-event'; | ||
| import ButtonTo from './button-to'; | ||
|
|
||
| describe('ButtonTo', () => { | ||
| beforeEach(() => { | ||
| const csrf = document.createElement('meta'); | ||
| csrf.name = 'csrf-token'; | ||
| csrf.content = 'token-value'; | ||
| document.body.appendChild(csrf); | ||
| }); | ||
|
|
||
| it('renders props passed through to Button', () => { | ||
| const { getByRole } = render( | ||
| <ButtonTo url="" method="" isUnstyled> | ||
| Click me | ||
| </ButtonTo>, | ||
| ); | ||
|
|
||
| const button = getByRole('button', { name: 'Click me' }) as HTMLButtonElement; | ||
|
|
||
| expect(button.type).to.equal('button'); | ||
| expect(button.classList.contains('usa-button')).to.be.true(); | ||
| expect(button.classList.contains('usa-button--unstyled')).to.be.true(); | ||
| }); | ||
|
|
||
| it('creates a form in the body outside the root container', () => { | ||
| const { container, getByRole } = render( | ||
| <ButtonTo url="/" method="delete" isUnstyled> | ||
| Click me | ||
| </ButtonTo>, | ||
| ); | ||
|
|
||
| const form = document.querySelector('form')!; | ||
| expect(form).to.be.ok(); | ||
| expect(container.contains(form)).to.be.false(); | ||
| return Promise.all([ | ||
| new Promise<void>((resolve) => { | ||
| form.addEventListener('submit', (event) => { | ||
| event.preventDefault(); | ||
| expect(Object.fromEntries(new window.FormData(form))).to.deep.equal({ | ||
| _method: 'delete', | ||
| authenticity_token: 'token-value', | ||
| }); | ||
| resolve(); | ||
| }); | ||
| }), | ||
| userEvent.click(getByRole('button')), | ||
| ]); | ||
| }); | ||
|
|
||
| it('submits to form on click', async () => { | ||
| const { getByRole } = render( | ||
| <ButtonTo url="" method="" isUnstyled> | ||
| Click me | ||
| </ButtonTo>, | ||
| ); | ||
|
|
||
| const form = document.querySelector('form')!; | ||
| sinon.stub(form, 'submit'); | ||
|
|
||
| await userEvent.click(getByRole('button')); | ||
|
|
||
| expect(form.submit).to.have.been.calledOnce(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| import { useRef } from 'react'; | ||
| import { createPortal } from 'react-dom'; | ||
| import Button from './button'; | ||
| import type { ButtonProps } from './button'; | ||
|
|
||
| interface ButtonToProps extends ButtonProps { | ||
| /** | ||
| * URL to which the user should navigate. | ||
| */ | ||
| url: string; | ||
|
|
||
| /** | ||
| * Form method button should submit as. | ||
| */ | ||
| method: string; | ||
| } | ||
|
|
||
| /** | ||
| * 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. | ||
| */ | ||
| function ButtonTo({ url, method, children, ...buttonProps }: ButtonToProps) { | ||
| const formRef = useRef<HTMLFormElement>(null); | ||
| const csrfRef = useRef<HTMLInputElement>(null); | ||
|
|
||
| function submitForm() { | ||
| const csrf = document.querySelector<HTMLMetaElement>('meta[name="csrf-token"]')?.content; | ||
| if (csrf && csrfRef.current) { | ||
| csrfRef.current.value = csrf; | ||
| } | ||
| formRef.current?.submit(); | ||
| } | ||
|
|
||
| return ( | ||
| <Button {...buttonProps} onClick={submitForm}> | ||
| {children} | ||
| {createPortal( | ||
| <form ref={formRef} method="post" action={url}> | ||
| <input type="hidden" name="_method" value={method} /> | ||
| <input ref={csrfRef} type="hidden" name="authenticity_token" /> | ||
| </form>, | ||
| document.body, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. do we need some sort of key to reference these at the document body? so that two ButtonTo's on the same page don't overwrite each other's forms?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
If there were two The markup would look like... <body>
<div id="app-root">
<button></button>
<button></button>
</div>
<form></form>
<form></form>
</body> |
||
| )} | ||
| </Button> | ||
| ); | ||
| } | ||
|
|
||
| export default ButtonTo; | ||
This file was deleted.
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { render } from '@testing-library/react'; | ||
| import { Provider as UploadContextProvider } from '../context/upload'; | ||
| import StartOverOrCancel from './start-over-or-cancel'; | ||
|
|
||
| describe('StartOverOrCancel', () => { | ||
| it('omits start over link when in hybrid flow', () => { | ||
| const { getByText } = render( | ||
| <UploadContextProvider | ||
| flowPath="hybrid" | ||
| endpoint="" | ||
| csrf="" | ||
| method="POST" | ||
| backgroundUploadURLs={{}} | ||
| > | ||
| <StartOverOrCancel /> | ||
| </UploadContextProvider>, | ||
| ); | ||
|
|
||
| expect(() => getByText('doc_auth.buttons.start_over')).to.throw(); | ||
| expect(getByText('links.cancel')).to.be.ok(); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import { useContext } from 'react'; | ||
| import { StartOverOrCancel as FlowStartOverOrCancel } from '@18f/identity-verify-flow'; | ||
| import UploadContext from '../context/upload'; | ||
|
|
||
| function StartOverOrCancel() { | ||
| const { flowPath } = useContext(UploadContext); | ||
|
|
||
| return <FlowStartOverOrCancel canStartOver={flowPath !== 'hybrid'} />; | ||
| } | ||
|
|
||
| export default StartOverOrCancel; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,8 +9,6 @@ const UploadContext = createContext({ | |
| backgroundUploadURLs: /** @type {Record<string,string>} */ ({}), | ||
| backgroundUploadEncryptKey: /** @type {CryptoKey=} */ (undefined), | ||
| flowPath: /** @type {FlowPath} */ ('standard'), | ||
| startOverURL: /** @type {string} */ (''), | ||
| cancelURL: /** @type {string} */ (''), | ||
|
Comment on lines
-12
to
-13
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. awesome that we can clean up the existing flow by using this too |
||
| csrf: /** @type {string?} */ (null), | ||
| }); | ||
|
|
||
|
|
@@ -76,8 +74,6 @@ UploadContext.displayName = 'UploadContext'; | |
| * @prop {string?} csrf CSRF token to send as parameter to upload implementation. | ||
| * @prop {Record<string,any>=} formData Extra form data to merge into the payload before uploading | ||
| * @prop {FlowPath} flowPath The user's session flow path, one of "standard" or "hybrid". | ||
| * @prop {string} startOverURL URL to application DELETE path for session restart. | ||
| * @prop {string} cancelURL URL to application path for session cancel. | ||
| * @prop {ReactNode} children Child elements. | ||
| */ | ||
|
|
||
|
|
@@ -96,8 +92,6 @@ function UploadContextProvider({ | |
| csrf, | ||
| formData, | ||
| flowPath, | ||
| startOverURL, | ||
| cancelURL, | ||
| children, | ||
| }) { | ||
| const uploadWithCSRF = (payload) => | ||
|
|
@@ -117,8 +111,6 @@ function UploadContextProvider({ | |
| backgroundUploadEncryptKey, | ||
| isMockClient, | ||
| flowPath, | ||
| startOverURL, | ||
| cancelURL, | ||
| csrf, | ||
| }), | ||
| [ | ||
|
|
@@ -129,8 +121,6 @@ function UploadContextProvider({ | |
| backgroundUploadEncryptKey, | ||
| isMockClient, | ||
| flowPath, | ||
| startOverURL, | ||
| cancelURL, | ||
| csrf, | ||
| ], | ||
| ); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,25 +2,13 @@ | |
| * Given a URL or a string fragment of search parameters and an object of parameters, returns a | ||
| * new URL or search parameters with the parameters added. | ||
| * | ||
| * @param urlOrParams Original URL or search parameters. | ||
| * @param url Original URL. | ||
| * @param params Search parameters to add. | ||
| * | ||
| * @return Modified URL or search parameters. | ||
| * @return Modified URL. | ||
| */ | ||
| export function addSearchParams(urlOrParams: string, params: Record<string, any>): string { | ||
| let parsedURLOrParams: URL | URLSearchParams; | ||
| let searchParams: URLSearchParams; | ||
|
|
||
| try { | ||
| parsedURLOrParams = new URL(urlOrParams); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. is there a reason we moved away from the
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
I went through a few iterations of this. The main problem with the URL class is that this function is meant to be quite flexible with its input values (absolute URLs, paths, querystring-only), and it's hard to map each of those to a corresponding return value of the same form using That being said, I couldn't actually find any existing usage of the querystring parameter input, so we could adjust this to assume that both the input and output would be a full URL.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also, to be more specific about why it was changed here: We had been initializing
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Simplifying supports simplifies the implementation! 0f3f511 |
||
| searchParams = parsedURLOrParams.searchParams; | ||
| } catch { | ||
| parsedURLOrParams = new URLSearchParams(urlOrParams); | ||
| searchParams = parsedURLOrParams; | ||
| } | ||
|
|
||
| Object.entries(params).forEach(([key, value]) => searchParams.set(key, value)); | ||
|
|
||
| const result = parsedURLOrParams.toString(); | ||
| return parsedURLOrParams instanceof URLSearchParams ? `?${result}` : result; | ||
| export function addSearchParams(url: string, params: Record<string, any>): string { | ||
| const parsedURL = new URL(url, window.location.href); | ||
| Object.entries(params).forEach(([key, value]) => parsedURL.searchParams.set(key, value)); | ||
| return parsedURL.toString(); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import { createContext } from 'react'; | ||
|
|
||
| const FlowContext = createContext({ | ||
| /** | ||
| * URL to path for session restart. | ||
| */ | ||
| startOverURL: '', | ||
|
|
||
| /** | ||
| * URL to path for session cancel. | ||
| */ | ||
| cancelURL: '', | ||
|
|
||
| /** | ||
| * Current step name. | ||
| */ | ||
| currentStep: '', | ||
| }); | ||
|
|
||
| FlowContext.displayName = 'FlowContext'; | ||
|
|
||
| export default FlowContext; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
would we ever need to add params here that could be posted? or is that the kind of thing we'd add if/when we need it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Possibly? I liken this to Rails'
button_to, which (TIL) supports aparamshash for just that purpose. But I heavily subscribe to the school of YAGNI, so I'm not in any hurry to add it.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
YAGNI 👍