Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
a0c8424
cleanup before mergin main
artemruts Nov 13, 2020
103c8aa
Merge branch 'main' into cloud-emails-redesign-15409
artemruts Nov 13, 2020
5299228
Beta version
artemruts Nov 14, 2020
476fcef
Remove unused imports
artemruts Nov 14, 2020
91c4201
Ready for comments
artemruts Nov 18, 2020
14c0772
Formatting and lint fixes
artemruts Nov 18, 2020
92849c4
Addressing comments - 1
artemruts Nov 19, 2020
1b65ca7
Addressing comments - 2
artemruts Nov 20, 2020
01676bc
Addressing comments - 3
artemruts Nov 20, 2020
dc785a2
Minor tweaks to the select
artemruts Nov 21, 2020
ee20854
Adjust padding
artemruts Nov 21, 2020
09863eb
Simplifying code
artemruts Nov 23, 2020
4330f6a
Allow consumers to override useInputValidation state, use in AddUserE…
tjkandala Nov 23, 2020
391ca02
Remove hook changes and simplify css
artemruts Nov 25, 2020
9f7f08f
Replace custom css with utils
artemruts Nov 25, 2020
3150b9e
Remove unneeded styles
artemruts Nov 25, 2020
f0ef192
fix stale onEmailRemove
tjkandala Nov 27, 2020
8bd340b
fix loader input spinner
tjkandala Nov 27, 2020
02ebdb3
Merge branch 'main' into cloud-emails-redesign-15409
tjkandala Nov 27, 2020
3ed6dda
dedup storybook import
tjkandala Nov 27, 2020
633650b
fix LoaderInput story
tjkandala Nov 30, 2020
edabd7c
Attempt to cast types
artemruts Nov 30, 2020
6b6aa09
Update client/shared/src/util/useInputValidation.test.ts
artemruts Dec 1, 2020
8760033
Addressing feedback
artemruts Dec 1, 2020
4da6551
Merge branch 'cloud-emails-redesign-15409' of https://github.com/sour…
artemruts Dec 1, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion client/branded/src/components/LoaderInput.story.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { storiesOf } from '@storybook/react'
import React from 'react'
import { LoaderInput } from './LoaderInput'
import { BrandedStory } from './BrandedStory'
import webStyles from '../../../web/src/main.scss'

const { add } = storiesOf('branded/LoaderInput', module).addDecorator(story => (
<div className="container mt-3" style={{ width: 800 }}>
Expand All @@ -11,7 +12,7 @@ const { add } = storiesOf('branded/LoaderInput', module).addDecorator(story => (
))

add('Interactive', () => (
<BrandedStory>
<BrandedStory styles={webStyles}>
{() => (
<LoaderInput loading={boolean('loading', true)}>
<input type="text" placeholder="Loader input" className="form-control" />
Expand Down
1 change: 1 addition & 0 deletions client/branded/src/global-styles/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ $hr-margin-y: 0.25rem;
@import './forms';
@import './highlight';
@import './web-content';
@import '~@sourcegraph/react-loading-spinner/lib/LoadingSpinner.css';

* {
box-sizing: border-box;
Expand Down
1 change: 0 additions & 1 deletion client/browser/src/branded.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,3 @@
@import './browser-extension/after-install-page/AfterInstallPageContent';
@import './browser-extension/options-menu/OptionsPage';
@import '../../branded/src/components/LoaderInput.scss';
@import '~@sourcegraph/react-loading-spinner/lib/LoadingSpinner.css';
39 changes: 39 additions & 0 deletions client/shared/src/util/useInputValidation.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's a false-positive with act calls in @testing-library/react-hooks

import { renderHook, act } from '@testing-library/react-hooks'
import { min, noop } from 'lodash'
import { Observable, of, Subject, Subscription } from 'rxjs'
import { delay } from 'rxjs/operators'
import * as sinon from 'sinon'
import {
useInputValidation,
createValidationPipeline,
InputValidationState,
ValidationOptions,
Expand Down Expand Up @@ -311,4 +314,40 @@ describe('useInputValidation()', () => {

expect(executeUserInputScript(inputs)).toStrictEqual(expectedStates)
})

it('works with the state override', () => {
const { result } = renderHook(() =>
useInputValidation({
synchronousValidators: [isDotCo],
})
)

act(() => {
const [, nextEmailFieldChange, emailInputReference] = result.current
const inputElement = createEmailInputElement()
emailInputReference.current = (inputElement as unknown) as HTMLInputElement

inputElement.changeValue('test-string')
nextEmailFieldChange({
target: emailInputReference.current,
preventDefault: noop,
} as React.ChangeEvent<HTMLInputElement>)
})

expect(result.current[0]).toStrictEqual({ value: 'test-string', kind: 'LOADING' })

act(() => {
const overrideEmailState = result.current[3]
overrideEmailState({ value: 'test@sg.co', validate: false })
})

expect(result.current[0]).toStrictEqual({ value: 'test@sg.co', kind: 'NOT_VALIDATED' })

act(() => {
const overrideEmailState = result.current[3]
overrideEmailState({ value: '' })
})

expect(result.current[0]).toStrictEqual({ value: '', kind: 'NOT_VALIDATED' })
})
})
48 changes: 40 additions & 8 deletions client/shared/src/util/useInputValidation.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { compact, head } from 'lodash'
import { useMemo, useState, useRef } from 'react'
import { compact, head, noop } from 'lodash'
import { useMemo, useState, useRef, useCallback } from 'react'
import { concat, EMPTY, Observable, of, zip } from 'rxjs'
import { catchError, map, switchMap, tap, debounceTime } from 'rxjs/operators'
import { useEventObservable } from './useObservable'
Expand Down Expand Up @@ -36,22 +36,32 @@ export type InputValidationState = { value: string } & (
| { kind: 'INVALID'; reason: string }
)

/**
* Options for overriding input state programmatically. If `overrideState` is called without
* these options, the input will be cleared and not validated.
*/
interface OverrideOptions {
/** The value to set input state to */
value: string

/** Whether to validate the new value */
validate?: boolean
}

/**
* React hook to manage validation of a single form input field.
* `useInputValidation` helps with coodinating the constraint validation API
* `useInputValidation` helps with coordinating the constraint validation API
* and custom synchronous and asynchronous validators.
*
* @param options Config object that declares sync + async validators
* @param initialValue
*
* @returns
*/
export function useInputValidation(
options: ValidationOptions
): [
InputValidationState,
(change: React.ChangeEvent<HTMLInputElement>) => void,
React.MutableRefObject<HTMLInputElement | null>
React.MutableRefObject<HTMLInputElement | null>,
(overrideOptions: OverrideOptions) => void
] {
const inputReference = useRef<HTMLInputElement>(null)

Expand All @@ -66,7 +76,29 @@ export function useInputValidation(

const [nextInputChangeEvent] = useEventObservable(validationPipeline)

return [inputState, nextInputChangeEvent, inputReference]
// TODO(tj): Move control of state to consumer
const overrideState = useCallback(
(overrideOptions: OverrideOptions) => {
// clear custom validity
inputReference.current?.setCustomValidity('')

// clear React state
setInputState({
kind: overrideOptions?.validate ? 'LOADING' : 'NOT_VALIDATED',
value: overrideOptions?.value ?? '',
})

if (overrideOptions?.validate) {
nextInputChangeEvent({
preventDefault: noop,
target: { value: overrideOptions.value },
})
}
},
[nextInputChangeEvent]
)

return [inputState, nextInputChangeEvent, inputReference, overrideState]
}

/**
Expand Down
2 changes: 1 addition & 1 deletion client/web/src/SourcegraphWebApp.scss
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ body,
@import '../../branded/src/components/tooltip/Tooltip';
@import './components/CtaBanner.scss';
@import './user/settings/UserSettingsArea';
@import './user/settings/emails/UserSettingsEmailsPage.scss';
@import './site-admin/SiteAdminAlert';
@import './site/DockerForMacAlert';
@import './user/UserAvatar';
Expand All @@ -142,7 +143,6 @@ body,
@import '../../branded/src/components/panel/views/FileLocations';
@import '../../branded/src/components/panel/views/HierarchicalLocationsView';
@import '../../branded/src/components/LoaderInput';
@import '~@sourcegraph/react-loading-spinner/lib/LoadingSpinner.css';

@import '../../shared/src/index';

Expand Down
193 changes: 99 additions & 94 deletions client/web/src/user/settings/emails/AddUserEmailForm.tsx
Original file line number Diff line number Diff line change
@@ -1,119 +1,124 @@
import * as React from 'react'
import { merge, Observable, of, Subject, Subscription } from 'rxjs'
import { catchError, map, switchMap, tap } from 'rxjs/operators'
import { gql } from '../../../../../shared/src/graphql/graphql'
import * as GQL from '../../../../../shared/src/graphql/schema'
import { createAggregateError, ErrorLike } from '../../../../../shared/src/util/errors'
import { mutateGraphQL } from '../../../backend/graphql'
import { Form } from '../../../../../branded/src/components/Form'
import React, { FunctionComponent, useMemo, useState } from 'react'
import classNames from 'classnames'
import * as H from 'history'

import { AddUserEmailResult, AddUserEmailVariables } from '../../../graphql-operations'
import { gql, dataOrThrowErrors } from '../../../../../shared/src/graphql/graphql'
import { requestGraphQL } from '../../../backend/graphql'
import { asError, isErrorLike, ErrorLike } from '../../../../../shared/src/util/errors'
import { useInputValidation, deriveInputClassName } from '../../../../../shared/src/util/useInputValidation'

import { eventLogger } from '../../../tracking/eventLogger'
import { ErrorAlert } from '../../../components/alerts'
import * as H from 'history'
import { LoaderButton } from '../../../components/LoaderButton'
import { LoaderInput } from '../../../../../branded/src/components/LoaderInput'

interface Props {
/** The GraphQL ID of the user with whom the new emails are associated. */
user: GQL.ID

/** Called after successfully adding an email to the user. */
user: string
onDidAdd: () => void
history: H.History

className?: string
history: H.History
}

interface State {
email: string
error?: ErrorLike | null
}
type Status = undefined | 'loading' | ErrorLike

export class AddUserEmailForm extends React.PureComponent<Props, State> {
public state: State = { email: '', error: null }
export const AddUserEmailForm: FunctionComponent<Props> = ({ user, className, onDidAdd, history }) => {
const [statusOrError, setStatusOrError] = useState<Status>()

private submits = new Subject<React.FormEvent<HTMLFormElement>>()
private subscriptions = new Subscription()
const [emailState, nextEmailFieldChange, emailInputReference, overrideEmailState] = useInputValidation(
useMemo(
() => ({
synchronousValidators: [],
asynchronousValidators: [],
}),
[]
)
)

public componentDidMount(): void {
this.subscriptions.add(
this.submits
.pipe(
tap(event => event.preventDefault()),
switchMap(() =>
merge(
of<Pick<State, 'error'>>({ error: undefined }),
this.addUserEmail(this.state.email).pipe(
tap(() => this.props.onDidAdd()),
map(() => ({ error: null, email: '' })),
catchError(error => [{ error, email: this.state.email }])
)
)
)
)
.subscribe(
stateUpdate => this.setState(stateUpdate),
error => console.error(error)
const onSubmit: React.FormEventHandler<HTMLFormElement> = async event => {
event.preventDefault()

if (emailState.kind === 'VALID') {
setStatusOrError('loading')

try {
dataOrThrowErrors(
await requestGraphQL<AddUserEmailResult, AddUserEmailVariables>(
gql`
mutation AddUserEmail($user: ID!, $email: String!) {
addUserEmail(user: $user, email: $email) {
alwaysNil
}
}
`,
{ user, email: emailState.value }
).toPromise()
)
)
}

public componentWillUnmount(): void {
this.subscriptions.unsubscribe()
eventLogger.log('NewUserEmailAddressAdded')
overrideEmailState({ value: '' })
setStatusOrError(undefined)

if (onDidAdd) {
onDidAdd()
}
} catch (error) {
setStatusOrError(asError(error))
}
}
}

public render(): JSX.Element | null {
const loading = this.state.error === undefined
return (
<div className={`add-user-email-form ${this.props.className || ''}`}>
<h3>Add email address</h3>
<Form className="form-inline" onSubmit={this.onSubmit}>
<label className="sr-only" htmlFor="AddUserEmailForm-email">
Email address
</label>
return (
<div className={`add-user-email-form ${className || ''}`}>
<label
htmlFor="AddUserEmailForm-email"
className={classNames('align-self-start', {
'text-danger font-weight-bold': emailState.kind === 'INVALID',
})}
>
Email address
</label>
{/* eslint-disable-next-line react/forbid-elements */}
<form className="form-inline" onSubmit={onSubmit} noValidate={true}>
<LoaderInput
className={(deriveInputClassName(emailState), 'mr-sm-2')}
loading={emailState.kind === 'LOADING'}
>
<input
id="AddUserEmailForm-email"
type="email"
name="email"
className="form-control mr-sm-2 test-user-email-add-input"
id="AddUserEmailForm-email"
onChange={this.onChange}
className={classNames(
'form-control test-user-email-add-input',
deriveInputClassName(emailState)
)}
onChange={nextEmailFieldChange}
size={32}
value={this.state.email}
value={emailState.value}
ref={emailInputReference}
required={true}
autoComplete="email"
autoCorrect="off"
spellCheck={false}
autoCapitalize="off"
readOnly={loading}
placeholder="Email"
/>{' '}
<button type="submit" className="btn btn-primary" disabled={loading}>
{loading ? 'Adding...' : 'Add'}
</button>
</Form>
{this.state.error && (
<ErrorAlert className="mt-2" error={this.state.error} history={this.props.history} />
spellCheck={false}
readOnly={false}
/>
</LoaderInput>{' '}
<LoaderButton
loading={statusOrError === 'loading'}
label="Add"
type="submit"
disabled={statusOrError === 'loading' || emailState.kind !== 'VALID'}
className="btn btn-primary"
/>
{emailState.kind === 'INVALID' && (
<small className="invalid-feedback" role="alert">
{emailState.reason}
</small>
)}
</div>
)
}

private onChange: React.ChangeEventHandler<HTMLInputElement> = event =>
this.setState({ email: event.currentTarget.value })
private onSubmit: React.FormEventHandler<HTMLFormElement> = event => this.submits.next(event)

private addUserEmail = (email: string): Observable<void> =>
mutateGraphQL(
gql`
mutation AddUserEmail($user: ID!, $email: String!) {
addUserEmail(user: $user, email: $email) {
alwaysNil
}
}
`,
{ user: this.props.user, email }
).pipe(
map(({ data, errors }) => {
if (!data || (errors && errors.length > 0)) {
throw createAggregateError(errors)
}
eventLogger.log('NewUserEmailAddressAdded')
})
)
</form>
{isErrorLike(statusOrError) && <ErrorAlert className="mt-2" error={statusOrError} history={history} />}
</div>
)
}
Loading