SatsAllDay d5f7855adf
Debounce API requests on edit nym by 500ms (#387)
Support an optional debounce prop on Input component

If provided, the debounce is applied to the validation of the containing form,
imperatively invoking form validation after debounce is finalized

Also required introducing the `validateOnChange` prop on `Form` which gets passed to `Formik`, and defaults to true, just as it does in `Formik`.

Imperatively invoking form validation seemed to be the only way to debounce the validation call through formik.
2023-08-09 17:06:22 -05:00

529 lines
18 KiB
JavaScript

import Button from 'react-bootstrap/Button'
import InputGroup from 'react-bootstrap/InputGroup'
import BootstrapForm from 'react-bootstrap/Form'
import Alert from 'react-bootstrap/Alert'
import { Formik, Form as FormikForm, useFormikContext, useField, FieldArray } from 'formik'
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
import copy from 'clipboard-copy'
import Thumb from '../svgs/thumb-up-fill.svg'
import Col from 'react-bootstrap/Col'
import Dropdown from 'react-bootstrap/Dropdown'
import Nav from 'react-bootstrap/Nav'
import Row from 'react-bootstrap/Row'
import Markdown from '../svgs/markdown-line.svg'
import styles from './form.module.css'
import Text from '../components/text'
import AddIcon from '../svgs/add-fill.svg'
import { mdHas } from '../lib/md'
import CloseIcon from '../svgs/close-line.svg'
import { useLazyQuery } from '@apollo/client'
import { USER_SEARCH } from '../fragments/users'
import TextareaAutosize from 'react-textarea-autosize'
export function SubmitButton ({
children, variant, value, onClick, disabled, ...props
}) {
const { isSubmitting, setFieldValue } = useFormikContext()
return (
<Button
variant={variant || 'main'}
type='submit'
disabled={disabled || isSubmitting}
onClick={value
? e => {
setFieldValue('submit', value)
onClick && onClick(e)
}
: onClick}
{...props}
>
{children}
</Button>
)
}
export function CopyInput (props) {
const [copied, setCopied] = useState(false)
const handleClick = () => {
copy(props.placeholder)
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}
return (
<Input
onClick={handleClick}
append={
<Button
className={styles.appendButton}
size={props.size}
onClick={handleClick}
>
{copied ? <Thumb width={18} height={18} /> : 'copy'}
</Button>
}
{...props}
/>
)
}
export function InputSkeleton ({ label, hint }) {
return (
<BootstrapForm.Group>
{label && <BootstrapForm.Label>{label}</BootstrapForm.Label>}
<div className='form-control clouds' style={{ color: 'transparent' }}>.</div>
{hint &&
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>}
</BootstrapForm.Group>
)
}
export function MarkdownInput ({ label, topLevel, groupClassName, onChange, setHasImgLink, onKeyDown, innerRef, ...props }) {
const [tab, setTab] = useState('write')
const [, meta, helpers] = useField(props)
const [selectionRange, setSelectionRange] = useState({ start: 0, end: 0 })
innerRef = innerRef || useRef(null)
props.as ||= TextareaAutosize
props.rows ||= props.minRows || 6
useEffect(() => {
!meta.value && setTab('write')
}, [meta.value])
useEffect(() => {
if (selectionRange.start <= selectionRange.end && innerRef?.current) {
const { start, end } = selectionRange
const input = innerRef.current
input.setSelectionRange(start, end)
}
}, [innerRef, selectionRange.start, selectionRange.end])
return (
<FormGroup label={label} className={groupClassName}>
<div className={`${styles.markdownInput} ${tab === 'write' ? styles.noTopLeftRadius : ''}`}>
<Nav variant='tabs' defaultActiveKey='write' activeKey={tab} onSelect={tab => setTab(tab)}>
<Nav.Item>
<Nav.Link eventKey='write'>write</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey='preview' disabled={!meta.value}>preview</Nav.Link>
</Nav.Item>
<a
className='ms-auto text-muted d-flex align-items-center'
href='https://guides.github.com/features/mastering-markdown/' target='_blank' rel='noreferrer'
>
<Markdown width={18} height={18} />
</a>
</Nav>
{tab === 'write'
? (
<div>
<InputInner
{...props} onChange={(formik, e) => {
if (onChange) onChange(formik, e)
if (setHasImgLink) {
setHasImgLink(mdHas(e.target.value, ['link', 'image']))
}
}}
innerRef={innerRef}
onKeyDown={(e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'k') {
// some browsers use CTRL+K to focus search bar so we have to prevent that behavior
e.preventDefault()
insertMarkdownLinkFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'b') {
// some browsers use CTRL+B to open bookmarks so we have to prevent that behavior
e.preventDefault()
insertMarkdownBoldFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'i') {
// some browsers might use CTRL+I to do something else so prevent that behavior too
e.preventDefault()
insertMarkdownItalicFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
if (e.key === 'Tab' && e.altKey) {
e.preventDefault()
insertMarkdownTabFormatting(innerRef.current, helpers.setValue, setSelectionRange)
}
}
if (onKeyDown) onKeyDown(e)
}}
/>
</div>)
: (
<div className='form-group'>
<div className={`${styles.text} form-control`}>
<Text topLevel={topLevel} noFragments onlyImgProxy={false}>{meta.value}</Text>
</div>
</div>
)}
</div>
</FormGroup>
)
}
function insertMarkdownFormatting (replaceFn, selectFn) {
return function (input, setValue, setSelectionRange) {
const start = input.selectionStart
const end = input.selectionEnd
const val = input.value
const selectedText = val.substring(start, end)
const mdFormatted = replaceFn(selectedText)
const newVal = val.substring(0, start) + mdFormatted + val.substring(end)
setValue(newVal)
// required for undo, see https://stackoverflow.com/a/27028258
document.execCommand('insertText', false, mdFormatted)
// see https://github.com/facebook/react/issues/6483
// for why we don't use `input.setSelectionRange` directly (hint: event order)
setSelectionRange(selectFn ? selectFn(start, end, mdFormatted) : { start: start + mdFormatted.length, end: start + mdFormatted.length })
}
}
const insertMarkdownTabFormatting = insertMarkdownFormatting(
val => `\t${val}`,
(start, end, mdFormatted) => ({ start: start + 1, end: end + 1 }) // move inside tab
)
const insertMarkdownLinkFormatting = insertMarkdownFormatting(
val => `[${val}](url)`,
(start, end, mdFormatted) => (
start === end
? { start: start + 1, end: end + 1 } // move inside brackets
: { start: start + mdFormatted.length - 4, end: start + mdFormatted.length - 1 }) // move to select url part
)
const insertMarkdownBoldFormatting = insertMarkdownFormatting(
val => `**${val}**`,
(start, end, mdFormatted) => ({ start: start + 2, end: end + 2 }) // move inside bold
)
const insertMarkdownItalicFormatting = insertMarkdownFormatting(
val => `_${val}_`,
(start, end, mdFormatted) => ({ start: start + 1, end: end + 1 }) // move inside italic
)
function FormGroup ({ className, label, children }) {
return (
<BootstrapForm.Group className={`form-group ${className}`}>
{label && <BootstrapForm.Label>{label}</BootstrapForm.Label>}
{children}
</BootstrapForm.Group>
)
}
function InputInner ({
prepend, append, hint, showValid, onChange, overrideValue,
innerRef, noForm, clear, onKeyDown, debounce, ...props
}) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
const storageKeyPrefix = useContext(StorageKeyPrefixContext)
const storageKey = storageKeyPrefix ? storageKeyPrefix + '-' + props.name : undefined
useEffect(() => {
if (overrideValue) {
helpers.setValue(overrideValue)
if (storageKey) {
window.localStorage.setItem(storageKey, overrideValue)
}
} else if (storageKey) {
const draft = window.localStorage.getItem(storageKey)
if (draft) {
// for some reason we have to turn off validation to get formik to
// not assume this is invalid
helpers.setValue(draft, false)
}
}
}, [overrideValue])
const invalid = (!formik || formik.submitCount > 0) && meta.touched && meta.error
const debounceRef = useRef(-1)
useEffect(() => {
if (debounceRef.current !== -1) {
clearTimeout(debounceRef.current)
}
if (!noForm && !isNaN(debounce) && debounce > 0) {
debounceRef.current = setTimeout(() => formik.validateForm(), debounce)
}
return () => clearTimeout(debounceRef.current)
}, [noForm, formik, field.value])
return (
<>
<InputGroup hasValidation>
{prepend}
<BootstrapForm.Control
onKeyDown={(e) => {
const metaOrCtrl = e.metaKey || e.ctrlKey
if (metaOrCtrl) {
if (e.key === 'Enter') formik?.submitForm()
}
if (onKeyDown) onKeyDown(e)
}}
ref={innerRef}
{...field} {...props}
onChange={(e) => {
field.onChange(e)
if (storageKey) {
window.localStorage.setItem(storageKey, e.target.value)
}
if (onChange) {
onChange(formik, e)
}
}}
isInvalid={invalid}
isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error}
/>
{(clear && field.value) &&
<Button
variant={null}
onClick={(e) => {
helpers.setValue('')
if (storageKey) {
window.localStorage.removeItem(storageKey)
}
if (onChange) {
onChange(formik, { target: { value: '' } })
}
}}
className={`${styles.clearButton} ${styles.appendButton} ${invalid ? styles.isInvalid : ''}`}
><CloseIcon className='fill-grey' height={20} width={20} />
</Button>}
{append}
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
</InputGroup>
{hint && (
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>
)}
</>
)
}
export function InputUserSuggest ({ label, groupClassName, ...props }) {
const [getSuggestions] = useLazyQuery(USER_SEARCH, {
onCompleted: data => {
setSuggestions({ array: data.searchUsers, index: 0 })
}
})
const INITIAL_SUGGESTIONS = { array: [], index: 0 }
const [suggestions, setSuggestions] = useState(INITIAL_SUGGESTIONS)
const [ovalue, setOValue] = useState()
return (
<FormGroup label={label} className={groupClassName}>
<InputInner
{...props}
autoComplete='off'
onChange={(_, e) => getSuggestions({ variables: { q: e.target.value } })}
overrideValue={ovalue}
onKeyDown={(e) => {
switch (e.code) {
case 'ArrowUp':
e.preventDefault()
setSuggestions(
{
...suggestions,
index: Math.max(suggestions.index - 1, 0)
})
break
case 'ArrowDown':
e.preventDefault()
setSuggestions(
{
...suggestions,
index: Math.min(suggestions.index + 1, suggestions.array.length - 1)
})
break
case 'Enter':
e.preventDefault()
setOValue(suggestions.array[suggestions.index].name)
setSuggestions(INITIAL_SUGGESTIONS)
break
case 'Escape':
e.preventDefault()
setSuggestions(INITIAL_SUGGESTIONS)
break
default:
break
}
}}
/>
<Dropdown show={suggestions.array.length > 0}>
<Dropdown.Menu className={styles.suggestionsMenu}>
{suggestions.array.map((v, i) =>
<Dropdown.Item
key={v.name}
active={suggestions.index === i}
onClick={() => {
setOValue(v.name)
setSuggestions(INITIAL_SUGGESTIONS)
}}
>
{v.name}
</Dropdown.Item>)}
</Dropdown.Menu>
</Dropdown>
</FormGroup>
)
}
export function Input ({ label, groupClassName, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
<InputInner {...props} />
</FormGroup>
)
}
export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, ...props }) {
return (
<FormGroup label={label} className={groupClassName}>
<FieldArray name={name}>
{({ form, ...fieldArrayHelpers }) => {
const options = form.values[name]
return (
<>
{options?.map((_, i) => (
<div key={i}>
<Row className='mb-2'>
<Col>
<InputInner name={`${name}[${i}]`} {...props} readOnly={i < readOnlyLen} placeholder={i >= min ? 'optional' : undefined} />
</Col>
{options.length - 1 === i && options.length !== max
? <Col className='d-flex ps-0' xs='auto'><AddIcon className='fill-grey align-self-center justify-self-center pointer' onClick={() => fieldArrayHelpers.push('')} /></Col>
: null}
</Row>
</div>
))}
</>
)
}}
</FieldArray>
{hint && (
<BootstrapForm.Text>
{hint}
</BootstrapForm.Text>
)}
</FormGroup>
)
}
export function Checkbox ({ children, label, groupClassName, hiddenLabel, extra, handleChange, inline, disabled, ...props }) {
// React treats radios and checkbox inputs differently other input types, select, and textarea.
// Formik does this too! When you specify `type` to useField(), it will
// return the correct bag of props for you
const [field,, helpers] = useField({ ...props, type: 'checkbox' })
return (
<FormGroup className={groupClassName}>
{hiddenLabel && <BootstrapForm.Label className='invisible'>{label}</BootstrapForm.Label>}
<BootstrapForm.Check
id={props.id || props.name}
inline={inline}
>
<BootstrapForm.Check.Input
{...field} {...props} disabled={disabled} type='checkbox' onChange={(e) => {
field.onChange(e)
handleChange && handleChange(e.target.checked, helpers.setValue)
}}
/>
<BootstrapForm.Check.Label className={'d-inline-flex flex-nowrap align-items-center' + (disabled ? ' text-muted' : '')}>
<div className='flex-grow-1'>{label}</div>
{extra &&
<div className={styles.checkboxExtra}>
{extra}
</div>}
</BootstrapForm.Check.Label>
</BootstrapForm.Check>
</FormGroup>
)
}
const StorageKeyPrefixContext = createContext()
export function Form ({
initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props
}) {
const [error, setError] = useState(initialError)
return (
<Formik
initialValues={initial}
validateOnChange={validateOnChange}
validationSchema={schema}
initialTouched={validateImmediately && initial}
validateOnBlur={false}
onSubmit={async (values, ...args) =>
onSubmit && onSubmit(values, ...args).then(() => {
if (!storageKeyPrefix) return
Object.keys(values).forEach(v => {
window.localStorage.removeItem(storageKeyPrefix + '-' + v)
if (Array.isArray(values[v])) {
values[v].forEach(
(_, i) => window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`))
}
}
)
}).catch(e => setError(e.message || e))}
>
<FormikForm {...props} noValidate>
{error && <Alert variant='danger' onClose={() => setError(undefined)} dismissible>{error}</Alert>}
<StorageKeyPrefixContext.Provider value={storageKeyPrefix}>
{children}
</StorageKeyPrefixContext.Provider>
</FormikForm>
</Formik>
)
}
export function Select ({ label, items, groupClassName, onChange, noForm, overrideValue, ...props }) {
const [field, meta, helpers] = noForm ? [{}, {}, {}] : useField(props)
const formik = noForm ? null : useFormikContext()
const invalid = meta.touched && meta.error
useEffect(() => {
if (overrideValue) {
helpers.setValue(overrideValue)
}
}, [overrideValue])
return (
<FormGroup label={label} className={groupClassName}>
<BootstrapForm.Select
{...field} {...props}
onChange={(e) => {
if (field?.onChange) {
field.onChange(e)
}
if (onChange) {
onChange(formik, e)
}
}}
isInvalid={invalid}
>
{items.map(item => <option key={item}>{item}</option>)}
</BootstrapForm.Select>
<BootstrapForm.Control.Feedback type='invalid'>
{meta.touched && meta.error}
</BootstrapForm.Control.Feedback>
</FormGroup>
)
}