mirror of
https://github.com/Kong/insomnia.git
synced 2026-05-19 06:12:37 -04:00
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
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<HighlightProps>;
|
||||
singleClick?: boolean;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const Editable: FC<Props> = ({
|
||||
blankValue,
|
||||
className,
|
||||
fallbackValue,
|
||||
onEditStart,
|
||||
onSubmit,
|
||||
preventBlank,
|
||||
renderReadView,
|
||||
singleClick,
|
||||
value,
|
||||
...childProps
|
||||
}) => {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<input
|
||||
{...childProps}
|
||||
className={`editable ${className || ''}`}
|
||||
type="text"
|
||||
ref={inputRef}
|
||||
defaultValue={initialValue}
|
||||
onBlur={handleEditEnd}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
);
|
||||
}
|
||||
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)
|
||||
: <span {...readViewProps}>{initialValue || blankValue}</span>;
|
||||
|
||||
};
|
||||
@@ -186,7 +186,7 @@ export const OneLineEditor = forwardRef<OneLineEditorHandle, OneLineEditorProps>
|
||||
// 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,
|
||||
|
||||
@@ -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<Props> = ({
|
||||
const canShowSchema = schema && !schemaIsFetching && !schemaFetchError && schemaLastFetchTime > 0;
|
||||
return (
|
||||
<div className="graphql-editor">
|
||||
<Toolbar>
|
||||
<Toolbar className="content-box sticky top-0 z-10 bg-[var(--color-bg)] flex flex-row border-b border-[var(--hl-md)] h-[var(--line-height-sm)] text-[var(--font-size-sm)]">
|
||||
<Dropdown
|
||||
aria-label='Operations Dropdown'
|
||||
isDisabled={!state.operations.length}
|
||||
|
||||
@@ -1,43 +1,146 @@
|
||||
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, 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';
|
||||
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<string[]>;
|
||||
onChange: (value: string) => void;
|
||||
}) => {
|
||||
const [value, setValue] = useState(defaultValue);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const editorRef = useRef<OneLineEditorHandle>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(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 (
|
||||
<DialogTrigger
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(isOpen => {
|
||||
setIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
onChange(value);
|
||||
setValue(value);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Button ref={buttonRef} className={`relative px-2 hover:bg-[--hl-sm] aria-pressed:bg-[--hl-md] focus:bg-[--hl-md] ${isOpen ? 'opacity-0' : ''}`}>
|
||||
<OneLineEditor
|
||||
id={id}
|
||||
key={(isOpen ? 'open' : 'closed') + value}
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
readOnly
|
||||
getAutocompleteConstants={getAutocompleteConstants}
|
||||
onChange={() => { }}
|
||||
/>
|
||||
<span className='absolute top-0 left-0 w-full h-full' />
|
||||
</Button>
|
||||
<Popover
|
||||
offset={0}
|
||||
placement='start top'
|
||||
style={{
|
||||
'--trigger-width': buttonDimensions?.width ? `${buttonDimensions.width}px` : '0',
|
||||
'--trigger-height': buttonDimensions?.height ? `${buttonDimensions.height}px` : '0',
|
||||
'--trigger-top': buttonDimensions?.top ? `${buttonDimensions.top}px` : '0',
|
||||
'--trigger-left': buttonDimensions?.left ? `${buttonDimensions.left}px` : '0',
|
||||
} as React.CSSProperties}
|
||||
className="transform text-[--color-font] px-2 !z-10 w-[--trigger-width] h-[--trigger-height] top-[--trigger-top] left-[--trigger-left] flex relative overflow-y-auto focus:outline-none"
|
||||
>
|
||||
<Dialog className='w-full outline-none'>
|
||||
<FocusScope autoFocus>
|
||||
<div
|
||||
className='w-full h-full'
|
||||
onFocus={() => {
|
||||
editorRef.current?.focusEnd();
|
||||
}}
|
||||
>
|
||||
<OneLineEditor
|
||||
id={id}
|
||||
ref={editorRef}
|
||||
placeholder={placeholder}
|
||||
defaultValue={value}
|
||||
readOnly={readOnly}
|
||||
getAutocompleteConstants={getAutocompleteConstants}
|
||||
onChange={setValue}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onChange(value);
|
||||
setIsOpen(false);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</FocusScope>
|
||||
</Dialog>
|
||||
</Popover>
|
||||
</DialogTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
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<string[]>;
|
||||
|
||||
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%',
|
||||
},
|
||||
});
|
||||
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<Props> = ({
|
||||
allowFile,
|
||||
allowMultiline,
|
||||
className,
|
||||
descriptionPlaceholder,
|
||||
handleGetAutocompleteNameConstants,
|
||||
handleGetAutocompleteValueConstants,
|
||||
@@ -56,113 +158,374 @@ export const KeyValueEditor: FC<Props> = ({
|
||||
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 (
|
||||
<DropIndicator
|
||||
target={target}
|
||||
className="data-[drop-target]:outline-[--color-surprise] z-10 outline-1 outline"
|
||||
/>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<Toolbar>
|
||||
<button
|
||||
className="btn btn--compact"
|
||||
onClick={() =>
|
||||
onChange([
|
||||
...pairs,
|
||||
{
|
||||
// smelly
|
||||
id: generateId('pair'),
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
},
|
||||
])
|
||||
}
|
||||
<Toolbar className="content-box sticky top-0 z-10 bg-[var(--color-bg)] flex flex-shrink-0 border-b border-[var(--hl-md)] h-[var(--line-height-sm)] text-[var(--font-size-sm)]">
|
||||
<Button
|
||||
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"
|
||||
onPress={() => {
|
||||
const id = generateId('pair');
|
||||
upsertPair({ id, name: '', value: '', description: '', disabled: false });
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
<PromptButton className="btn btn--compact" onClick={() => onChange([])}>
|
||||
Delete All
|
||||
<Icon icon="plus" /> Add
|
||||
</Button>
|
||||
<PromptButton
|
||||
disabled={pairsList.items.length === 0}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<Icon icon="trash-can" />
|
||||
<span>Delete all</span>
|
||||
</PromptButton>
|
||||
<button
|
||||
className="btn btn--compact"
|
||||
onClick={() => setShowDescription(!showDescription)}
|
||||
<ToggleButton
|
||||
className="px-4 py-1 h-full flex items-center justify-center gap-2 text-[--color-font] text-xs hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all"
|
||||
onChange={setShowDescription}
|
||||
isSelected={showDescription}
|
||||
>
|
||||
Toggle Description
|
||||
</button>
|
||||
{({ isSelected }) => (
|
||||
<>
|
||||
<Icon className={isSelected ? 'text-[--color-success]' : ''} icon={isSelected ? 'toggle-on' : 'toggle-off'} />
|
||||
<span>Description</span>
|
||||
</>
|
||||
)}
|
||||
</ToggleButton>
|
||||
</Toolbar>
|
||||
<ul className={classnames('key-value-editor', 'wide', className)}>
|
||||
{pairs.length === 0 && (
|
||||
<Row
|
||||
key='empty-row'
|
||||
showDescription={showDescription}
|
||||
descriptionPlaceholder={descriptionPlaceholder}
|
||||
hideButtons
|
||||
readOnly
|
||||
onBlur={onBlur}
|
||||
onClick={() => onChange([...pairs, {
|
||||
// smelly
|
||||
id: generateId('pair'),
|
||||
name: '',
|
||||
value: '',
|
||||
description: '',
|
||||
}])}
|
||||
pair={{ name: '', value: '' }}
|
||||
onChange={() => { }}
|
||||
addPair={() => { }}
|
||||
/>
|
||||
)}
|
||||
{(readOnlyPairs || []).map(pair => (
|
||||
<li key={pair.id} className="key-value-editor__row-wrapper">
|
||||
<div className="key-value-editor__row">
|
||||
<div className="form-control form-control--underlined form-control--wide">
|
||||
<input
|
||||
style={{ width: '100%' }}
|
||||
defaultValue={pair.name}
|
||||
readOnly
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control form-control--underlined form-control--wide">
|
||||
<input
|
||||
style={{ width: '100%' }}
|
||||
{readOnlyPairsList.items.length > 0 && (
|
||||
<GridList
|
||||
aria-label='Key-value pairs readonly'
|
||||
selectionMode='none'
|
||||
dependencies={[showDescription, nunjucksEnabled]}
|
||||
className="flex pt-1 flex-col w-full overflow-y-auto flex-1 relative"
|
||||
items={readOnlyPairsList.items}
|
||||
>
|
||||
{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 = (
|
||||
<div className="relative h-full w-full flex flex-1 px-2">
|
||||
<OneLineEditor
|
||||
id={'key-value-editor__value' + pair.id}
|
||||
placeholder={valuePlaceholder || 'Value'}
|
||||
defaultValue={pair.value}
|
||||
readOnly
|
||||
getAutocompleteConstants={() => handleGetAutocompleteValueConstants?.(pair) || []}
|
||||
onChange={() => { }}
|
||||
/>
|
||||
</div>
|
||||
<button><i className="fa fa-empty" /></button>
|
||||
<button><i className="fa fa-empty" /></button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
{pairsWithIds.map(pair => (
|
||||
<Row
|
||||
key={pair.id}
|
||||
showDescription={showDescription}
|
||||
namePlaceholder={namePlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
descriptionPlaceholder={descriptionPlaceholder}
|
||||
onBlur={onBlur}
|
||||
onChange={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: '',
|
||||
}])}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</Fragment >
|
||||
);
|
||||
|
||||
if (isFile) {
|
||||
valueEditor = (
|
||||
<FileInputButton
|
||||
showFileName
|
||||
showFileIcon
|
||||
disabled
|
||||
className="px-2 py-1 w-full flex flex-1 items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm overflow-hidden"
|
||||
path={pair.fileName || ''}
|
||||
onChange={() => { }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMultiline) {
|
||||
valueEditor = (
|
||||
<Button
|
||||
isDisabled
|
||||
className="px-2 py-1 w-full flex flex-1 items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm overflow-hidden"
|
||||
>
|
||||
<i className="fa fa-pencil-square-o space-right" />
|
||||
{bytes > 0 ? describeByteSize(bytes, true) : 'Click to Edit'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<GridListItem className="flex outline-none bg-[--color-bg] flex-shrink-0 h-[--line-height-sm] items-center gap-2 px-2 data-[dragging]:opacity-50">
|
||||
<Button slot="drag" className="cursor-grab invisible p-2 w-5 flex focus-visible:bg-[--hl-sm] justify-center items-center flex-shrink-0">
|
||||
<Icon icon="grip-vertical" className='w-2 text-[--hl]' />
|
||||
</Button>
|
||||
<div className="relative h-full w-full flex flex-1 px-2">
|
||||
<OneLineEditor
|
||||
id={'key-value-editor__name' + pair.id}
|
||||
placeholder={namePlaceholder || 'Name'}
|
||||
defaultValue={pair.name}
|
||||
readOnly
|
||||
onChange={() => { }}
|
||||
/>
|
||||
</div>
|
||||
{valueEditor}
|
||||
{showDescription && (
|
||||
<div className="relative h-full w-full flex flex-1 px-2">
|
||||
<OneLineEditor
|
||||
id={'key-value-editor__description' + pair.id}
|
||||
placeholder={descriptionPlaceholder || 'Description'}
|
||||
defaultValue={pair.description || ''}
|
||||
readOnly
|
||||
onChange={() => { }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-shrink-0 items-center gap-2 w-[5.75rem]" />
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
)}
|
||||
<GridList
|
||||
aria-label='Key-value pairs'
|
||||
selectionMode='none'
|
||||
disabledBehavior='all'
|
||||
dependencies={[showDescription, nunjucksEnabled]}
|
||||
className="flex pt-1 flex-col w-full overflow-y-auto flex-1 relative"
|
||||
dragAndDropHooks={dragAndDropHooks}
|
||||
items={items}
|
||||
>
|
||||
{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 = (
|
||||
<EditableOneLineEditorModal
|
||||
id={'key-value-editor__value' + pair.id}
|
||||
placeholder={valuePlaceholder || 'Value'}
|
||||
defaultValue={pair.value}
|
||||
readOnly={isDisabled}
|
||||
getAutocompleteConstants={() => handleGetAutocompleteValueConstants?.(pair) || []}
|
||||
onChange={value => upsertPair({ ...pair, value })}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isFile) {
|
||||
valueEditor = (
|
||||
<FileInputButton
|
||||
showFileName
|
||||
showFileIcon
|
||||
disabled={isDisabled}
|
||||
className="px-2 py-1 w-full fle flex-shrink-0 flex-1 items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm overflow-hidden"
|
||||
path={pair.fileName || ''}
|
||||
onChange={fileName => upsertPair({ ...pair, fileName })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (isMultiline) {
|
||||
valueEditor = (
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
className="px-2 py-1 w-full flex flex-1 items-center justify-center gap-2 aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm overflow-hidden"
|
||||
onPress={() => showModal(CodePromptModal, {
|
||||
submitName: 'Done',
|
||||
title: `Edit ${pair.name}`,
|
||||
defaultValue: pair.value,
|
||||
enableRender: nunjucksEnabled,
|
||||
mode: pair.multiline && typeof pair.multiline === 'string' ? pair.multiline : 'text/plain',
|
||||
onChange: (value: string) => upsertPair({ ...pair, value }),
|
||||
onModeChange: (mode: string) => upsertPair({ ...pair, multiline: mode }),
|
||||
})}
|
||||
>
|
||||
<i className="fa fa-pencil-square-o space-right" />
|
||||
{bytes > 0 ? describeByteSize(bytes, true) : 'Click to Edit'}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
let selectedValueType = 'text';
|
||||
|
||||
if (isFile) {
|
||||
selectedValueType = 'file';
|
||||
} else if (isMultiline) {
|
||||
selectedValueType = 'multiline-text';
|
||||
}
|
||||
|
||||
return (
|
||||
<GridListItem id={pair.id} className={`grid relative outline-none bg-[--color-bg] flex-shrink-0 h-[--line-height-sm] gap-2 px-2 data-[dragging]:opacity-50 ${showDescription ? '[grid-template-columns:max-content_1fr_1fr_1fr_max-content]' : '[grid-template-columns:max-content_1fr_1fr_max-content]'}`}>
|
||||
<Button slot="drag" className="cursor-grab p-2 w-5 flex focus-visible:bg-[--hl-sm] justify-center items-center flex-shrink-0">
|
||||
<Icon icon="grip-vertical" className='w-2 text-[--hl]' />
|
||||
</Button>
|
||||
<EditableOneLineEditorModal
|
||||
id={'key-value-editor__name' + pair.id}
|
||||
placeholder={namePlaceholder || 'Name'}
|
||||
defaultValue={pair.name}
|
||||
readOnly={isDisabled}
|
||||
getAutocompleteConstants={() => handleGetAutocompleteNameConstants?.(pair) || []}
|
||||
onChange={name => upsertPair({ ...pair, name })}
|
||||
/>
|
||||
{valueEditor}
|
||||
{showDescription && (
|
||||
<EditableOneLineEditorModal
|
||||
id={'key-value-editor__description' + pair.id}
|
||||
placeholder={descriptionPlaceholder || 'Description'}
|
||||
defaultValue={pair.description || ''}
|
||||
readOnly={isDisabled}
|
||||
onChange={description => upsertPair({ ...pair, description })}
|
||||
/>
|
||||
)}
|
||||
<Toolbar className="flex items-center gap-1">
|
||||
<MenuTrigger>
|
||||
<Button
|
||||
aria-label="Text mode"
|
||||
className="flex items-center justify-center h-7 aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
|
||||
>
|
||||
<Icon icon="caret-down" />
|
||||
</Button>
|
||||
<Popover className="min-w-max">
|
||||
<Menu
|
||||
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"
|
||||
aria-label="Create a new request"
|
||||
selectionMode="single"
|
||||
selectedKeys={[selectedValueType]}
|
||||
items={[
|
||||
{
|
||||
id: 'text',
|
||||
name: 'Text',
|
||||
textValue: 'Text',
|
||||
onAction: () => 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 => (
|
||||
<MenuItem
|
||||
key={item.id}
|
||||
id={item.id}
|
||||
onAction={item.onAction}
|
||||
className="flex gap-2 px-[--padding-md] aria-selected:font-bold items-center text-[--color-font] h-[--line-height-xs] w-full text-md whitespace-nowrap bg-transparent hover:bg-[--hl-sm] disabled:cursor-not-allowed focus:bg-[--hl-xs] focus:outline-none transition-colors"
|
||||
aria-label={item.name}
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
</MenuItem>
|
||||
)}
|
||||
</Menu>
|
||||
</Popover>
|
||||
</MenuTrigger>
|
||||
<ToggleButton
|
||||
className="flex items-center justify-center h-7 aspect-square rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
|
||||
onChange={isSelected => upsertPair({ ...pair, disabled: !isSelected })}
|
||||
isSelected={!pair.disabled}
|
||||
>
|
||||
<Icon icon={pair.disabled ? 'square' : 'check-square'} />
|
||||
</ToggleButton>
|
||||
<PromptButton
|
||||
disabled={pairsList.items.length === 0}
|
||||
className="flex items-center disabled:opacity-50 justify-center h-7 aspect-square aria-pressed:bg-[--hl-sm] rounded-sm text-[--color-font] hover:bg-[--hl-xs] focus:ring-inset ring-1 ring-transparent focus:ring-[--hl-md] transition-all text-sm"
|
||||
confirmMessage=''
|
||||
doneMessage=''
|
||||
onClick={() => removePair(pair.id)}
|
||||
>
|
||||
<Icon icon="trash-can" />
|
||||
</PromptButton>
|
||||
</Toolbar>
|
||||
</GridListItem>
|
||||
);
|
||||
}}
|
||||
</GridList>
|
||||
</Fragment>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<Props> = ({ onSubmit, value }) => {
|
||||
return <Editable singleClick onSubmit={onSubmit} value={value} />;
|
||||
};
|
||||
Reference in New Issue
Block a user