import * as React from 'react' import cx from 'classnames' import { createContext, useContext, useId, } from '@wordpress/element' import { BaseControl, TextControl, } from '@wordpress/components' import { chevronUp, chevronDown, plus, } from '@wordpress/icons' import { ButtonGrid, type ButtonGridButtonProps, } from '@ska/components' import { focusElement, } from '@ska/utils' import './style.scss' const noop = () => {} export const RECORD_EDITOR_DUPLICATE_PREFIX = '__ska_duplicate_key' export const RecordEditorRecordContext = createContext<{ index: number key: string value: any duplicate: (index: number) => void }>({ index: -1, key: '', value: '', duplicate: () => null, }) export interface RecordEditorStrings { keyPlaceholder: string valuePlaceholder: string moveUpLabel: string moveDownLabel: string removeLabel: string addLabel: string } export const RECORD_EDITOR_STRINGS = { keyPlaceholder: 'Key…', valuePlaceholder: 'Value…', moveUpLabel: 'Move up', moveDownLabel: 'Move down', removeLabel: 'Remove', addLabel: 'Add', } export const RecordEditorStringsContext = createContext(RECORD_EDITOR_STRINGS) type StringKeyOf = Extract type AnyRecord = Record export interface RecordEditorProps { id?: string label?: string hideLabelFromVision?: boolean help?: string footer?: React.ReactNode className?: string size?: 'large' value: RecordType defaultValue?: RecordType['value'] onChange?: (nextValue: RecordType) => void onPaste?: (e: React.ClipboardEvent) => void lockedKeys?: (StringKeyOf)[] lockedValues?: (StringKeyOf)[] KeyControl?: React.FC>> ValueControl?: React.FC> | false getButtons?: (buttons: ButtonGridButtonProps[], value: RecordType['value'], key: keyof RecordType) => ButtonGridButtonProps[] } export interface CustomControlProps { placeholder?: string value: T onChange: (nextValue: T) => void disabled?: boolean [key: string]: any } const defaultGetButtons = (buttons: ButtonGridButtonProps[]) => buttons const RecordEditor = (props: RecordEditorProps): React.ReactElement => { const { id: providedId, label, hideLabelFromVision = false, help, footer, className, size, value: _value, defaultValue: _defaultValue, onChange = noop, onPaste, lockedKeys = [], lockedValues = [], KeyControl = TextControl as any as React.FC>>, ValueControl = TextControl as any as React.FC>, getButtons = defaultGetButtons, } = props const { keyPlaceholder, valuePlaceholder, moveUpLabel, moveDownLabel, removeLabel, addLabel, } = useContext(RecordEditorStringsContext) const fallbackId = useId().replaceAll('«', '_').replaceAll('»', '_').replaceAll(':', '_') const id = providedId || fallbackId const defaultValue = (_defaultValue || '') as RecordType['value'] const value = Object.keys(_value).length > 0 ? _value : {'': defaultValue} as any as RecordType const keys = Object.keys(value) as (StringKeyOf)[] const updateKey = (key: StringKeyOf, nextKey: string) => { if( nextKey.indexOf(RECORD_EDITOR_DUPLICATE_PREFIX) !== -1 // @ts-ignore && !keys.includes(nextKey.replaceAll(RECORD_EDITOR_DUPLICATE_PREFIX, '')) ) { nextKey = nextKey.replaceAll(RECORD_EDITOR_DUPLICATE_PREFIX, '') } // @ts-ignore if(keys.includes(nextKey as string)) { nextKey = `${RECORD_EDITOR_DUPLICATE_PREFIX}${nextKey}` } onChange( keys.reduce((acc, cur) => { if(cur === key) { acc[nextKey as any as StringKeyOf] = value[cur] } else { acc[cur as any as StringKeyOf] = value[cur] } return acc }, {} as RecordType) ) } const updateValue = (key: StringKeyOf, nextValue: RecordType['value']) => { onChange({...value, [key]: nextValue}) } const addRecord = (index: number, duplicate: boolean = false) => { const { '': _, ...nextValue } = value const currentKeys = Object.keys(nextValue) const emptyKey = '' if(index >= currentKeys.length) { onChange({...nextValue, [emptyKey]: defaultValue} as any as RecordType) return } onChange( currentKeys.reduce((acc, cur, currentIndex) => { // @ts-ignore acc[cur] = nextValue[cur] if(currentIndex === index) { if(duplicate) { // @ts-ignore acc[`${RECORD_EDITOR_DUPLICATE_PREFIX}${cur}`] = nextValue[cur] } else { // @ts-ignore acc[emptyKey] = defaultValue } } return acc }, {} as RecordType) ) } const removeRecord = (key: StringKeyOf) => { const { [key]: _, ...rest } = value onChange({...rest as RecordType}) } const moveRecordUp = (key: StringKeyOf, index: number) => { onChange( keys.filter(k => k !== key).reduce((acc, cur: StringKeyOf, currentIndex) => { if(currentIndex === index - 1) { acc[key] = value[key] } acc[cur] = value[cur] return acc }, {} as RecordType) ) } const moveRecordDown = (key: StringKeyOf, index: number) => { onChange( keys.filter(k => k !== key).reduce((acc, cur: StringKeyOf, currentIndex) => { acc[cur] = value[cur] if(currentIndex === index) { acc[key] = value[key] } return acc }, {} as RecordType) ) } const keyIdCx = `ska-record-${id}` // Unique ID but BaseControl doesn't support wrapper props so it's passed as a class as well. return ( {keys.map((key, index) => { const val = value[key] as RecordType['value'] const isFirst = index === 0 const isLast = index + 1 === keys.length const isOnly = keys.length === 1 const isBlank = key === '' && value[key] === '' const isEmpty = key === '' const hasNextRow = keys[index + 1] !== undefined const isLocked = lockedKeys.includes(key) const buttons = getButtons([ { key: 'move-up', label: moveUpLabel, icon: chevronUp, onClick: () => moveRecordUp(key, index), // disabled: isLocked || isFirst || isAfterLocked, disabled: isFirst, }, { key: 'remove-row', label: removeLabel, icon: plus, iconProps: { style: { transform: 'rotate(45deg)', pointerEvents: 'none', }, }, onClick: () => removeRecord(key), disabled: isLocked || (isOnly && isBlank), }, { key: 'move-down', label: moveDownLabel, icon: chevronDown, onClick: () => moveRecordDown(key, index), disabled: isLast, }, { key: 'add-row', label: addLabel, icon: plus, onClick: () => addRecord(index), }, ], val, key) /** Applied to both key and value inputs. */ const onKeyDown = (e: React.KeyboardEvent) => { if(e.code === 'Enter') { if(!isEmpty) { e.preventDefault() if(!hasNextRow) { addRecord(index) } focusElement(`.${keyIdCx} .ska-record-editor__row[data-index="${index + 1}"] input[data-type="key"]`, 150) } } if(e.code === 'Backspace') { if(isBlank && !isOnly) { e.preventDefault() removeRecord(key) focusElement(`.${keyIdCx} .ska-record-editor__row[data-index="${index - 1}"] input[data-type="value"]`, 150) } } } return (
addRecord(index, true)}}> } autoComplete='off' disabled={isLocked} onChange={nextKey => { updateKey(key, nextKey) }} onPaste={onPaste} onKeyDown={onKeyDown} data-type='key' __nextHasNoMarginBottom __next40pxDefaultSize /> {ValueControl !== false && ( { updateValue(key, nextValue) }} onPaste={onPaste} onKeyDown={onKeyDown} data-type='value' __nextHasNoMarginBottom __next40pxDefaultSize /> )}
) })} {footer && (
{footer}
)}
) } export default RecordEditor