From 978fbcf8f1c27d4e1c2a8fd8abfc67eb903ca0ac Mon Sep 17 00:00:00 2001 From: James Gatz Date: Wed, 5 Jun 2024 10:30:44 +0200 Subject: [PATCH] feat(Key-Value Editor): Improve accessibility and make the items re-orderable (#7465) * remove unused components * make read-only editors display nunjucks * graphql toolbar css * re-orderable key value editor * move onChange to event handlers * remove unused test * fix delete all/add updates * clean up logs * remove callbacks --- .../base/__tests__/editable.test.ts | 21 - .../src/ui/components/base/editable.tsx | 111 ---- .../components/codemirror/one-line-editor.tsx | 2 +- .../editors/body/graph-ql-editor.tsx | 5 +- .../key-value-editor/key-value-editor.tsx | 613 ++++++++++++++---- .../src/ui/components/unit-test-editable.tsx | 12 - 6 files changed, 491 insertions(+), 273 deletions(-) delete mode 100644 packages/insomnia/src/ui/components/base/__tests__/editable.test.ts delete mode 100644 packages/insomnia/src/ui/components/base/editable.tsx delete mode 100644 packages/insomnia/src/ui/components/unit-test-editable.tsx diff --git a/packages/insomnia/src/ui/components/base/__tests__/editable.test.ts b/packages/insomnia/src/ui/components/base/__tests__/editable.test.ts deleted file mode 100644 index 49bf51ae9e8..00000000000 --- a/packages/insomnia/src/ui/components/base/__tests__/editable.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; - -import { shouldSave } from '../editable'; - -describe('shouldSave', () => { - it('should save if new and old are not the same', () => { - expect(shouldSave('old', 'new')).toBe(true); - }); - - it('should not save if new and old are the same', () => { - expect(shouldSave('old', 'old')).toBe(false); - }); - - it('should save if new is empty and we are not preventing blank', () => { - expect(shouldSave('old', '', false)).toBe(true); - }); - - it('should not save if new is empty and we are preventing blank', () => { - expect(shouldSave('old', '', true)).toBe(false); - }); -}); diff --git a/packages/insomnia/src/ui/components/base/editable.tsx b/packages/insomnia/src/ui/components/base/editable.tsx deleted file mode 100644 index 11d0b897bc5..00000000000 --- a/packages/insomnia/src/ui/components/base/editable.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import React, { FC, ReactElement, useCallback, useRef, useState } from 'react'; - -import { createKeybindingsHandler } from '../keydown-binder'; -import { HighlightProps } from './highlight'; - -export const shouldSave = (oldValue: string, newValue: string | undefined, preventBlank = false) => { - // Should not save if length = 0 and we want to prevent blank - if (preventBlank && !newValue?.length) { - return false; - } - - // Should not save if old value and new value is the same - if (oldValue === newValue) { - return false; - } - - // Should save - return true; -}; - -interface Props { - blankValue?: string; - className?: string; - fallbackValue?: string; - onEditStart?: () => void; - onSubmit: (value: string) => void; - preventBlank?: boolean; - renderReadView?: (value: string | undefined, props: any) => ReactElement; - singleClick?: boolean; - value: string; -} - -export const Editable: FC = ({ - blankValue, - className, - fallbackValue, - onEditStart, - onSubmit, - preventBlank, - renderReadView, - singleClick, - value, - ...childProps -}) => { - const [editing, setEditing] = useState(false); - const inputRef = useRef(null); - - const handleEditStart = () => { - setEditing(true); - - setTimeout(() => { - inputRef.current?.focus(); - inputRef.current?.select(); - }); - - if (onEditStart) { - onEditStart(); - } - }; - - const onSingleClick = () => singleClick && handleEditStart(); - - const handleEditEnd = useCallback(() => { - if (shouldSave(value, inputRef.current?.value.trim(), preventBlank)) { - // Don't run onSubmit for values that haven't been changed - onSubmit(inputRef.current?.value.trim() || ''); - } - - // This timeout prevents the UI from showing the old value after submit. - // It should give the UI enough time to redraw the new value. - setTimeout(() => setEditing(false), 100); - }, [onSubmit, preventBlank, value]); - - const handleKeyDown = createKeybindingsHandler({ - 'Enter': handleEditEnd, - 'Escape': () => { - if (inputRef.current) { - // Set the input to the original value - inputRef.current.value = value; - - handleEditEnd(); - } - }, - }); - - const initialValue = value || fallbackValue; - if (editing) { - return ( - - ); - } - const readViewProps = { - className: `editable ${className} ${!initialValue && 'empty'}`, - title: singleClick ? 'Click to edit' : 'Double click to edit', - onClick: onSingleClick, - onDoubleClick: handleEditStart, - ...childProps, - }; - return renderReadView ? - renderReadView(initialValue, readViewProps) - : {initialValue || blankValue}; - -}; diff --git a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx index 42f0640ce65..88f5625baad 100644 --- a/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx +++ b/packages/insomnia/src/ui/components/codemirror/one-line-editor.tsx @@ -186,7 +186,7 @@ export const OneLineEditor = forwardRef // Clear history so we can't undo the initial set codeMirror.current?.clearHistory(); // Setup nunjucks listeners - if (!readOnly && handleRender && !settings.nunjucksPowerUserMode) { + if (handleRender && !settings.nunjucksPowerUserMode) { codeMirror.current?.enableNunjucksTags( handleRender, handleGetRenderContext, diff --git a/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx b/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx index d036c60ba48..0565869077e 100644 --- a/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx +++ b/packages/insomnia/src/ui/components/editors/body/graph-ql-editor.tsx @@ -8,7 +8,7 @@ import { DefinitionNode, DocumentNode, GraphQLNonNull, GraphQLSchema, Kind, NonN import { buildClientSchema, getIntrospectionQuery } from 'graphql/utilities'; import { Maybe } from 'graphql-language-service'; import React, { FC, useEffect, useRef, useState } from 'react'; -import { Button } from 'react-aria-components'; +import { Button, Toolbar } from 'react-aria-components'; import ReactDOM from 'react-dom'; import { useLocalStorage } from 'react-use'; @@ -28,7 +28,6 @@ import { CodeEditor, CodeEditorHandle } from '../../codemirror/code-editor'; import { GraphQLExplorer } from '../../graph-ql-explorer/graph-ql-explorer'; import { ActiveReference } from '../../graph-ql-explorer/graph-ql-types'; import { HelpTooltip } from '../../help-tooltip'; -import { Toolbar } from '../../key-value-editor/key-value-editor'; import { useDocBodyKeyboardShortcuts } from '../../keydown-binder'; import { TimeFromNow } from '../../time-from-now'; @@ -470,7 +469,7 @@ export const GraphQLEditor: FC = ({ const canShowSchema = schema && !schemaIsFetching && !schemaFetchError && schemaLastFetchTime > 0; return (
- + 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 { top, left, height, width } = 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(); + }} + > + { + if (e.key === 'Enter') { + e.preventDefault(); + onChange(value); + setIsOpen(false); + } + }} + /> +
+
+
+
+
+ ); +}; + +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 +150,6 @@ interface Props { export const KeyValueEditor: FC = ({ allowFile, allowMultiline, - className, descriptionPlaceholder, handleGetAutocompleteNameConstants, handleGetAutocompleteValueConstants, @@ -56,113 +158,374 @@ 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 pairId = pair.id || generateId('pair'); + return { ...pair, id: pairId }; + }), + getKey: item => item.id, + }); + + const items = pairsList.items.length > 0 ? pairsList.items : [{ id: generateId('pair'), name: '', value: '', description: '', disabled: false }]; + + const readOnlyPairsList = useListData({ + initialItems: readOnlyPairs?.map(pair => { + const pairId = pair.id || generateId('pair'); + return { ...pair, id: pairId }; + }) || [], + getKey: item => item.id, + }); + + function upsertPair(pair: typeof pairsList.items[0]) { + if (pairsList.getItem(pair.id)) { + pairsList.update(pair.id, pair); + onChange(pairsList.items.map(item => (item.id === pair.id ? pair : item))); + } else { + pairsList.append(pair); + onChange(pairsList.items.concat(pair)); + } + + } + + function removePair(id: string) { + if (pairsList.getItem(id)) { + pairsList.remove(id); + onChange(pairsList.items.filter(pair => pair.id !== id)); + } + } + + function removeAllPairs() { + pairsList.setSelectedKeys(new Set(pairsList.items.map(item => item.id))); + pairsList.removeSelectedItems(); + onChange([]); + } + + const { dragAndDropHooks } = useDragAndDrop({ + getItems: keys => + [...keys].map(key => { + const pair = pairsList.getItem(key); + return { 'text/plain': `${pair.id} | ${pair.name}: ${pair.value}` }; + }), + onReorder(e) { + if (e.target.dropPosition === 'before') { + pairsList.moveBefore(e.target.key, e.keys); + + const items = [...pairsList.items]; + for (const key of e.keys) { + const targetItemIndex = items.findIndex(item => item.id === key); + const updatedItems = items.splice(targetItemIndex, 1); + items.splice(targetItemIndex - 1, 0, updatedItems[0]); + } + + onChange(items); + } else if (e.target.dropPosition === 'after') { + pairsList.moveAfter(e.target.key, e.keys); + + const items = [...pairsList.items]; + + for (const key of e.keys) { + const targetItemIndex = items.findIndex(item => item.id === key); + const updatedItems = items.splice(targetItemIndex, 1); + items.splice(targetItemIndex + 1, 0, updatedItems[0]); + } + + onChange(items); + } + }, + renderDropIndicator(target) { + return ( + + ); + }, + }); return ( - - - onChange([])}> - Delete All + Add + + removeAllPairs()} + 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 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={() => { }} />
    - - -
    -
  • - ))} - {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: '', - }])} - /> - ))} -
-
+ ); + + if (isFile) { + valueEditor = ( + { }} + /> + ); + } + + if (isMultiline) { + valueEditor = ( + + ); + } + + return ( + + +
+ { }} + /> +
+ {valueEditor} + {showDescription && ( +
+ { }} + /> +
+ )} +
+ + ); + }} + + )} + + {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 => upsertPair({ ...pair, value })} + /> + ); + + if (isFile) { + valueEditor = ( + upsertPair({ ...pair, fileName })} + /> + ); + } + + if (isMultiline) { + valueEditor = ( + + ); + } + + let selectedValueType = 'text'; + + if (isFile) { + selectedValueType = 'file'; + } else if (isMultiline) { + selectedValueType = 'multiline-text'; + } + + return ( + + + handleGetAutocompleteNameConstants?.(pair) || []} + onChange={name => upsertPair({ ...pair, name })} + /> + {valueEditor} + {showDescription && ( + upsertPair({ ...pair, description })} + /> + )} + + + + + upsertPair({ ...pair, type: 'text', multiline: false }), + }, + ...allowMultiline ? [ + { + id: 'multiline-text', + name: 'Multiline text', + textValue: 'Multiline text', + onAction: () => upsertPair({ ...pair, type: 'text', multiline: true }), + }, + ] : [], + ...allowFile ? [ + { + id: 'file', + name: 'File', + textValue: 'File', + onAction: () => upsertPair({ ...pair, type: 'file' }), + }, + ] : [], + ]} + > + {item => ( + + {item.name} + + )} + + + + upsertPair({ ...pair, disabled: !isSelected })} + isSelected={!pair.disabled} + > + + + removePair(pair.id)} + > + + + + + ); + }} + + ); }; diff --git a/packages/insomnia/src/ui/components/unit-test-editable.tsx b/packages/insomnia/src/ui/components/unit-test-editable.tsx deleted file mode 100644 index 7141c86b77a..00000000000 --- a/packages/insomnia/src/ui/components/unit-test-editable.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React, { FunctionComponent } from 'react'; - -import { Editable } from './base/editable'; - -interface Props { - onSubmit: (value?: string) => void; - value: string; -} - -export const UnitTestEditable: FunctionComponent = ({ onSubmit, value }) => { - return ; -};