import Button from 'react-bootstrap/Button' import InputGroup from 'react-bootstrap/InputGroup' import BootstrapForm from 'react-bootstrap/Form' 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' import { useToast } from './toast' export function SubmitButton ({ children, variant, value, onClick, disabled, ...props }) { const { isSubmitting, setFieldValue } = useFormikContext() return ( ) } export function CopyInput (props) { const [copied, setCopied] = useState(false) const handleClick = () => { copy(props.placeholder) setCopied(true) setTimeout(() => setCopied(false), 1500) } return ( {copied ? : 'copy'} } {...props} /> ) } export function InputSkeleton ({ label, hint }) { return ( {label && {label}}
.
{hint && {hint} }
) } 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 (
{tab === 'write' ? (
{ 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) }} />
) : (
{meta.value}
)}
) } 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 ( {label && {label}} {children} ) } function InputInner ({ prepend, append, hint, showValid, onChange, onBlur, 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 ( <> {prepend} { 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) } }} onBlur={(e) => { field.onBlur(e) onBlur && onBlur(e) }} isInvalid={invalid} isValid={showValid && meta.initialValue !== meta.value && meta.touched && !meta.error} /> {(clear && field.value) && } {append} {meta.touched && meta.error} {hint && ( {hint} )} ) } 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 ( { setOValue(e.target.value) getSuggestions({ variables: { q: e.target.value.replace(/^[@ ]+|[ ]+$/g, '') } }) }} 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 } }} /> 0}> {suggestions.array.map((v, i) => { setOValue(v.name) setSuggestions(INITIAL_SUGGESTIONS) }} > {v.name} )} ) } export function Input ({ label, groupClassName, ...props }) { return ( ) } export function VariableInput ({ label, groupClassName, name, hint, max, min, readOnlyLen, children, emptyItem = '', ...props }) { return ( {({ form, ...fieldArrayHelpers }) => { const options = form.values[name] return ( <> {options?.map((_, i) => (
{children ? children({ index: i, readOnly: i < readOnlyLen, placeholder: i >= min ? 'optional' : undefined }) : = min ? 'optional' : undefined} />} {options.length - 1 === i && options.length !== max ? fieldArrayHelpers.push(emptyItem)} /> // filler div for col alignment across rows :
} {options.length - 1 === i && <> {hint && {hint}} {form.touched[name] && typeof form.errors[name] === 'string' &&
{form.errors[name]}
} }
))} ) }} ) } 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 ( {hiddenLabel && {label}} { field.onChange(e) handleChange && handleChange(e.target.checked, helpers.setValue) }} />
{label}
{extra &&
{extra}
}
) } const StorageKeyPrefixContext = createContext() export function Form ({ initial, schema, onSubmit, children, initialError, validateImmediately, storageKeyPrefix, validateOnChange = true, ...props }) { const toaster = useToast() useEffect(() => { if (initialError) { toaster.danger(initialError.message || initialError.toString?.()) } }, []) return ( { try { if (onSubmit) { const options = await onSubmit(values, ...args) if (!storageKeyPrefix || options?.keepLocalStorage) return Object.keys(values).forEach(v => { window.localStorage.removeItem(storageKeyPrefix + '-' + v) if (Array.isArray(values[v])) { values[v].forEach( (iv, i) => { Object.keys(iv).forEach(k => { window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}].${k}`) }) window.localStorage.removeItem(`${storageKeyPrefix}-${v}[${i}]`) }) } }) } } catch (err) { console.log(err) toaster.danger(err.message || err.toString?.()) } }} > {children} ) } 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 ( { if (field?.onChange) { field.onChange(e) } if (onChange) { onChange(formik, e) } }} isInvalid={invalid} > {items.map(item => )} {meta.touched && meta.error} ) }