From e11fae18f8c03906019b4a8037ddca8c8abdfe63 Mon Sep 17 00:00:00 2001 From: gatzjames Date: Wed, 29 May 2024 12:13:16 +0200 Subject: [PATCH] re-orderable key value editor --- .../key-value-editor/key-value-editor.tsx | 625 ++++++++++++++---- 1 file changed, 503 insertions(+), 122 deletions(-) diff --git a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx index db89b2e2b04c..6c16e7cbcc6a 100644 --- a/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx +++ b/packages/insomnia/src/ui/components/key-value-editor/key-value-editor.tsx @@ -1,43 +1,141 @@ -import classnames from 'classnames'; -import React, { FC, Fragment } from 'react'; -import styled from 'styled-components'; +import { useResizeObserver } from '@react-aria/utils'; +import React, { FC, Fragment, useCallback, useEffect, useRef, useState } from 'react'; +import { FocusScope } from 'react-aria'; +import { Button, Dialog, DialogTrigger, DropIndicator, GridList, GridListItem, Menu, MenuItem, MenuTrigger, Popover, ToggleButton, Toolbar, useDragAndDrop } from 'react-aria-components'; +import { useListData } from 'react-stately'; -import { generateId } from '../../../common/misc'; +import { describeByteSize, generateId } from '../../../common/misc'; +import { useNunjucksEnabled } from '../../context/nunjucks/nunjucks-enabled-context'; +import { FileInputButton } from '../base/file-input-button'; import { PromptButton } from '../base/prompt-button'; -import { AutocompleteHandler, Pair, Row } from './row'; - -export const Toolbar = styled.div({ - boxSizing: 'content-box', - position: 'sticky', - top: 0, - zIndex: 1, - backgroundColor: 'var(--color-bg)', - display: 'flex', - flexDirection: 'row', - borderBottom: '1px solid var(--hl-md)', - height: 'var(--line-height-sm)', - fontSize: 'var(--font-size-sm)', - '& > button': { - color: 'var(--hl)', - padding: 'var(--padding-xs) var(--padding-xs)', - height: '100%', - }, -}); +import { OneLineEditor, OneLineEditorHandle } from '../codemirror/one-line-editor'; +import { Icon } from '../icon'; +import { showModal } from '../modals'; +import { CodePromptModal } from '../modals/code-prompt-modal'; + +const EditableOneLineEditorModal = ({ + id, + defaultValue, + placeholder, + readOnly, + getAutocompleteConstants, + onChange, +}: { + id: string; + defaultValue: string; + placeholder?: string; + readOnly?: boolean; + getAutocompleteConstants?: () => string[] | PromiseLike; + onChange: (value: string) => void; +}) => { + const [value, setValue] = useState(defaultValue); + const [isOpen, setIsOpen] = useState(false); + const editorRef = useRef(null); + const buttonRef = useRef(null); + + const [buttonDimensions, setButtonDimensions] = useState<{ + width: number; + height: number; + top: number; + left: number; + } | null>(null); + + const onResize = useCallback(() => { + if (buttonRef.current) { + const width = buttonRef.current.offsetWidth; + const height = buttonRef.current.offsetHeight; + const { top, left } = buttonRef.current.getBoundingClientRect(); + + setButtonDimensions({ width, height, top, left }); + } + }, [buttonRef]); + + useResizeObserver({ + ref: buttonRef, + onResize: onResize, + }); + + return ( + { + setIsOpen(isOpen); + if (!isOpen) { + onChange(value); + setValue(value); + } + })} + > + + + + +
{ + editorRef.current?.focusEnd(); + }} + > + +
+
+
+
+
+ ); +}; + +interface Pair { + id?: string; + name: string; + value: string; + description?: string; + fileName?: string; + type?: string; + disabled?: boolean; + multiline?: boolean | string; +} + +type AutocompleteHandler = (pair: Pair) => string[] | PromiseLike; + interface Props { allowFile?: boolean; allowMultiline?: boolean; - className?: string; descriptionPlaceholder?: string; handleGetAutocompleteNameConstants?: AutocompleteHandler; handleGetAutocompleteValueConstants?: AutocompleteHandler; isDisabled?: boolean; namePlaceholder?: string; - onChange: (c: { - name: string; - value: string; - description?: string; - disabled?: boolean; - }[]) => void; + onChange: (pairs: Pair[]) => void; pairs: Pair[]; valuePlaceholder?: string; onBlur?: (e: FocusEvent) => void; @@ -47,7 +145,6 @@ interface Props { export const KeyValueEditor: FC = ({ allowFile, allowMultiline, - className, descriptionPlaceholder, handleGetAutocompleteNameConstants, handleGetAutocompleteValueConstants, @@ -56,113 +153,397 @@ export const KeyValueEditor: FC = ({ onChange, pairs, valuePlaceholder, - onBlur, readOnlyPairs, }) => { - // We should make the pair.id property required and pass them in from the parent - // smelly - const pairsWithIds = pairs.map(pair => ({ ...pair, id: pair.id || generateId('pair') })); - const [showDescription, setShowDescription] = React.useState(false); + const { enabled: nunjucksEnabled } = useNunjucksEnabled(); + const pairsList = useListData({ + initialItems: pairs.map(pair => { + const paidId = pair.id || generateId('pair'); + return { ...pair, id: paidId, key: paidId }; + }), + getKey: item => item.key, + }); + + const items = pairsList.items.length > 0 ? pairsList.items : [{ id: generateId('pair'), key: generateId('pair'), name: '', value: '', description: '', disabled: false }]; + + const readOnlyPairsList = useListData({ + initialItems: readOnlyPairs?.map(pair => { + const paidId = pair.id || generateId('pair'); + return { ...pair, id: paidId, key: paidId }; + }), + getKey: item => item.key, + }); + + const gridListRef = useRef(null); + + // @TODO stable ref or do it on updater functions + useEffect(() => { + onChange(pairsList.items.map(({ key, ...item }) => item)); + }, [pairsList.items]); + + const { dragAndDropHooks } = useDragAndDrop({ + getItems: keys => + [...keys].map(key => { + const pair = pairsList.getItem(key); + return { 'text/plain': `${pair.name}: ${pair.value}` }; + }), + onReorder(e) { + if (e.target.dropPosition === 'before') { + pairsList.moveBefore(e.target.key, e.keys); + } else if (e.target.dropPosition === 'after') { + pairsList.moveAfter(e.target.key, e.keys); + } + }, + renderDropIndicator(target) { + return ( + + ); + }, + }); + + useEffect(() => { + const refElement = gridListRef.current; + console.log({ refElement }); + + if (!refElement) { + return; + } + + const handleKeydown = (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.stopPropagation(); + } + }; + + // refElement.addEventListener('keydown', handleKeydown, true); + + return () => { + refElement.removeEventListener('keydown', handleKeydown, true); + }; + }, []); return ( - - - onChange([])}> - Delete All + Add + + { + pairsList.setSelectedKeys(new Set(pairsList.items.map(item => item.key))); + pairsList.removeSelectedItems(); + }} + className="px-4 py-1 h-full flex items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] text-[--color-font] text-xs hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all" + > + + Delete all - + {({ isSelected }) => ( + <> + + Description + + )} + -
    - {pairs.length === 0 && ( - onChange([...pairs, { - // smelly - id: generateId('pair'), - name: '', - value: '', - description: '', - }])} - pair={{ name: '', value: '' }} - onChange={() => { }} - addPair={() => { }} - /> - )} - {(readOnlyPairs || []).map(pair => ( -
  • -
    -
    - 0 && ( + + {pair => { + const isFileOrMultiline = allowMultiline || allowFile; + const hiddenButtons = isFileOrMultiline ? () : null; + + const isFile = pair.type === 'file'; + const isMultiline = pair.type === 'text' && pair.multiline; + const bytes = isMultiline ? Buffer.from(pair.value, 'utf8').length : 0; + + return ( + + + { }} /> -
    -
    - handleGetAutocompleteValueConstants?.(pair) || []} + onChange={() => { }} /> -
    - - -
    -
  • - ))} - {pairsWithIds.map(pair => ( - onChange(pairsWithIds.map(p => (p.id === pair.id ? pair : p)))} - onDelete={pair => onChange(pairsWithIds.filter(p => p.id !== pair.id))} - handleGetAutocompleteNameConstants={handleGetAutocompleteNameConstants} - handleGetAutocompleteValueConstants={handleGetAutocompleteValueConstants} - allowMultiline={allowMultiline} - allowFile={allowFile} - readOnly={isDisabled} - hideButtons={isDisabled} - pair={pair} - addPair={() => onChange([...pairsWithIds, { - name: '', - value: '', - description: '', - }])} - /> - ))} -
+ {showDescription && ( + { }} + /> + )} + + + + + { + pairsList.update(pair.id, { ...pair, type: 'text', multiline: false }); + }, + }, + { + id: 'multiline-text', + name: 'Multiline text', + textValue: 'Multiline text', + onAction: () => { + pairsList.update(pair.id, { ...pair, type: 'text', multiline: true }); + }, + }, + ]} + className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none" + > + {item => ( + + {item.name} + + )} + + + + { + pairsList.update(pair.id, { ...pair, disabled: !isSelected }); + }} + isSelected={!pair.disabled} + > + + + { + pairsList.remove(pair.id); + }} + > + + + + + ); + }} + + )} + + {pair => { + const isFile = pair.type === 'file'; + const isMultiline = pair.type === 'text' && pair.multiline; + const bytes = isMultiline ? Buffer.from(pair.value, 'utf8').length : 0; + + let valueEditor = ( + handleGetAutocompleteValueConstants?.(pair) || []} + onChange={value => { + pairsList.update(pair.id, { ...pair, value }); + }} + /> + ); + + if (isFile) { + valueEditor = ( + { + pairsList.update(pair.id, { ...pair, fileName }); + }} + /> + ); + } + + if (isMultiline) { + valueEditor = ( + + ); + } + + return ( + + + handleGetAutocompleteNameConstants?.(pair) || []} + onChange={name => { + pairsList.update(pair.id, { ...pair, name }); + }} + /> + {valueEditor} + {showDescription && ( + { + pairsList.update(pair.id, { ...pair, description }); + }} + /> + )} + + + + + { + pairsList.update(pair.id, { ...pair, type: 'text', multiline: false }); + }, + }, + { + id: 'multiline-text', + name: 'Multiline text', + textValue: 'Multiline text', + onAction: () => { + pairsList.update(pair.id, { ...pair, type: 'text', multiline: true }); + }, + }, + ]} + className="border select-none text-sm min-w-max border-solid border-[--hl-sm] shadow-lg bg-[--color-bg] py-2 rounded-md overflow-y-auto max-h-[85vh] focus:outline-none" + > + {item => ( + + {item.name} + + )} + + + + { + pairsList.update(pair.id, { ...pair, disabled: !isSelected }); + }} + isSelected={!pair.disabled} + > + + + { + pairsList.remove(pair.id); + }} + > + + + + + ); + }} +
); };