import { KeyboardEvent, ReactNode, useEffect, useRef, useState, } from 'react'; import styled from 'styled-components'; import { UseMutationResult } from 'react-query'; import { spacing, Stack } from '../../spacing'; import { Input, InputProps } from '../inputv2/inputv2'; import { Loader } from '../loader/Loader.component'; import { HelperText } from '../text/Text.component'; import { useToast } from '../toast/ToastProvider'; import { Tooltip } from '../tooltip/Tooltip.component'; type InlineInputForm = { value: string }; type CheckResult = | { hasError: true; message: string } | { hasError: false }; type ConfirmationModalRenderArgs = { isOpen: boolean; pendingValue: string; currentValue: string; onConfirm: () => void; onCancel: () => void; isLoading: boolean; error: unknown; }; export type InlineInputProps = { defaultValue?: string; changeMutation: UseMutationResult; check?: (value: string) => CheckResult; confirmationModal?: (args: ConfirmationModalRenderArgs) => ReactNode; editTooltip?: string; loadingTooltip?: string; helperTextPlacement?: 'bottom' | 'right'; } & Omit; const NameTrigger = styled.span` display: inline-flex; align-items: center; gap: ${spacing.r4}; box-sizing: border-box; height: ${spacing.r32}; padding: 0 ${spacing.r8}; border-radius: ${spacing.r16}; border: 1px solid ${(props) => props.theme.border}; background: transparent; color: ${(props) => props.theme.textPrimary}; font-family: 'Lato'; font-size: 1rem; white-space: nowrap; cursor: pointer; transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease; &:focus-visible { outline: none; border-color: ${(props) => props.theme.infoPrimary}; } &[aria-disabled='true'] { opacity: 0.6; cursor: not-allowed; } &:not([aria-disabled='true']):hover { transition-delay: 150ms; border-color: ${(props) => props.theme.infoPrimary}; background: ${(props) => props.theme.highlight}; } `; const InlineLoaderWrapper = styled.span` display: inline-flex; align-items: center; svg { width: 1em; height: 1em; } `; const FloatingErrorWrapper = styled.span` position: relative; display: inline-flex; `; const FloatingErrorContainer = styled.div` position: absolute; top: 100%; left: 0; margin: 0; padding: 0 ${spacing.r8}; white-space: nowrap; z-index: 1; `; export const InlineInput = ({ defaultValue = '', changeMutation, check, confirmationModal, editTooltip = 'Edit', loadingTooltip = 'Saving…', helperTextPlacement = 'right', id, ...inputProps }: InlineInputProps) => { const [isEditing, setIsEditing] = useState(false); const [pendingValue, setPendingValue] = useState(defaultValue); const [isModalOpen, setIsModalOpen] = useState(false); const inputRef = useRef(null); const { showToast } = useToast(); const isLoading = changeMutation.isLoading; const validation = check ? check(pendingValue) : ({ hasError: false } as const); const validationError = validation.hasError ? validation.message : undefined; useEffect(() => { if (isEditing) { inputRef.current?.focus(); inputRef.current?.select(); } }, [isEditing]); const closeAndReset = () => { setIsEditing(false); setPendingValue(defaultValue); }; const commit = (value: string) => { changeMutation.mutate( { value }, { onSuccess: () => { setIsModalOpen(false); setIsEditing(false); }, onError: () => { showToast({ open: true, status: 'error', message: 'An error occurred while updating the value', }); }, }, ); }; const submit = () => { if (validationError) { return; } const trimmed = pendingValue.trim(); if (trimmed === defaultValue.trim()) { closeAndReset(); return; } if (confirmationModal) { setIsEditing(false); setIsModalOpen(true); return; } commit(trimmed); }; const handleKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter') { event.preventDefault(); submit(); } else if (event.key === 'Escape') { event.preventDefault(); closeAndReset(); } }; const handleEditStart = () => { if (isLoading || isModalOpen) { return; } setPendingValue(defaultValue); setIsEditing(true); }; const handleTriggerKeyDown = (event: KeyboardEvent) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); handleEditStart(); } }; const modalNode = confirmationModal && confirmationModal({ isOpen: isModalOpen, pendingValue: pendingValue.trim(), currentValue: defaultValue, onConfirm: () => commit(pendingValue.trim()), onCancel: () => { setIsModalOpen(false); }, isLoading, error: changeMutation.error, }); if (isEditing) { const inputNode = ( setPendingValue(e.target.value)} onKeyDown={handleKeyDown} onBlur={() => { if (!isLoading) submit(); }} /> ); const editingContent = helperTextPlacement === 'bottom' ? ( {inputNode} {validationError && ( {validationError} )} ) : ( {inputNode} {validationError && ( {validationError} )} ); return ( <> {editingContent} {modalNode} ); } return ( <> {isModalOpen ? pendingValue.trim() : defaultValue} {isLoading && ( )} {modalNode} ); };