diff --git a/src/components/src/common/field-selector.tsx b/src/components/src/common/field-selector.tsx index 7f01a55f39..c6b436554c 100644 --- a/src/components/src/common/field-selector.tsx +++ b/src/components/src/common/field-selector.tsx @@ -113,6 +113,7 @@ interface FieldSelectorFactoryProps { suggested?: ReadonlyArray | null; CustomChickletComponent?: ComponentType; size?: string; + reorderItems?: (newOrder: any) => void; } function FieldSelectorFactory( @@ -124,6 +125,7 @@ function FieldSelectorFactory( error: false, fields: [], onSelect: () => {}, + reorderItems: () => {}, placement: 'bottom', value: null, multiSelect: false, @@ -194,6 +196,7 @@ function FieldSelectorFactory( placeholder={this.props.placeholder} placement={this.props.placement} onChange={this.props.onSelect} + reorderItems={this.props.reorderItems} DropDownLineItemRenderComponent={this.fieldListItemSelector(this.props)} DropdownHeaderComponent={this.props.suggested ? SuggestedFieldHeader : null} CustomChickletComponent={this.props.CustomChickletComponent} diff --git a/src/components/src/common/item-selector/chickleted-input.tsx b/src/components/src/common/item-selector/chickleted-input.tsx index 34526ec653..0ecd5f5729 100644 --- a/src/components/src/common/item-selector/chickleted-input.tsx +++ b/src/components/src/common/item-selector/chickleted-input.tsx @@ -18,9 +18,13 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React, {ElementType, MouseEventHandler, ReactNode} from 'react'; +import React, {ElementType, MouseEventHandler, ReactNode, useMemo, useCallback} from 'react'; import styled from 'styled-components'; +import {DndContext, DragOverlay, pointerWithin} from '@dnd-kit/core'; +import {SortableContext, useSortable, arrayMove} from '@dnd-kit/sortable'; +import {restrictToParentElement} from '@dnd-kit/modifiers'; + import Delete from '../icons/delete'; import {FormattedMessage} from '@kepler.gl/localization'; @@ -39,6 +43,7 @@ interface ChickletedInputProps { inputTheme?: string; CustomChickletComponent?: ElementType; className?: string; + reorderItems?: (newOrder: any) => void; } interface ChickletButtonProps { @@ -65,6 +70,7 @@ export const ChickletButton = styled.div` } `; +const DND_MODIFIERS = [restrictToParentElement]; export const ChickletTag = styled.span` margin-right: 10px; text-overflow: ellipsis; @@ -108,6 +114,56 @@ const ChickletedInputContainer = styled.div` overflow: hidden; `; +const ChickletedItem = ({ + item, + removeItem, + displayOption, + CustomChickletComponent, + inputTheme, + disabled, + itemId +}) => { + const {attributes, listeners, setNodeRef, transform, transition, isDragging} = useSortable({ + id: itemId + }); + const chickletProps = useMemo( + () => ({ + inputTheme, + disabled, + name: displayOption(item), + displayOption, + item, + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + remove: e => removeItem(item, e) + }), + [ + item, + removeItem, + displayOption, + CustomChickletComponent, + inputTheme, + disabled, + itemId, + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + ] + ); + return CustomChickletComponent ? ( + + ) : ( + + ); +}; + const ChickletedInput: React.FC = ({ disabled, onClick, @@ -115,39 +171,64 @@ const ChickletedInput: React.FC = ({ selectedItems = [], placeholder = '', removeItem, + reorderItems = d => d, displayOption = d => d, inputTheme, CustomChickletComponent -}) => ( - - {selectedItems.length > 0 ? ( - selectedItems.map((item, i) => { - const chickletProps = { - inputTheme, - disabled, - key: `${displayOption(item)}_${i}`, - name: displayOption(item), - displayOption, - item, - remove: e => removeItem(item, e) - }; - return CustomChickletComponent ? ( - - ) : ( - - ); - }) - ) : ( - - - - )} - -); +}) => { + const selectedItemIds = useMemo(() => selectedItems.map(item => displayOption(item)), [ + displayOption, + selectedItems + ]); + const handleDragEnd = useCallback( + ({active, over}) => { + if (!over) return; + if (active.id !== over.id) { + const oldIndex = selectedItemIds.findIndex(itemId => itemId === active.id); + const newIndex = selectedItemIds.findIndex(itemId => itemId === over.id); + reorderItems(arrayMove(selectedItems, oldIndex, newIndex)); + } + }, + [selectedItems, displayOption, reorderItems] + ); + + return ( + + + + {selectedItems.length > 0 ? ( + selectedItems.map((item, index) => ( + + )) + ) : ( + + + + )} + + + + + ); +}; export default ChickletedInput; diff --git a/src/components/src/common/item-selector/item-selector.tsx b/src/components/src/common/item-selector/item-selector.tsx index 32642c87cf..f290a87e6f 100644 --- a/src/components/src/common/item-selector/item-selector.tsx +++ b/src/components/src/common/item-selector/item-selector.tsx @@ -154,6 +154,7 @@ export type ItemSelectorProps = { CustomChickletComponent?: ComponentType; intl: IntlShape; className?: string; + reorderItems?: (newOrder: any) => void; showDropdownOnMount?: boolean; }; @@ -164,7 +165,8 @@ class ItemSelectorUnmemoized extends Component { closeOnSelect: true, searchable: true, DropDownRenderComponent: DropdownList, - DropDownLineItemRenderComponent: ListItem + DropDownLineItemRenderComponent: ListItem, + reorderItems: undefined }; state = { @@ -342,6 +344,7 @@ class ItemSelectorUnmemoized extends Component { selectedItems={toArray(this.props.selectedItems)} placeholder={this.props.placeholder} removeItem={this._removeItem} + reorderItems={this.props.reorderItems} CustomChickletComponent={this.props.CustomChickletComponent} inputTheme={inputTheme} /> diff --git a/src/components/src/side-panel/interaction-panel/tooltip-config.tsx b/src/components/src/side-panel/interaction-panel/tooltip-config.tsx index 5d10e971c9..8fa748f0ec 100644 --- a/src/components/src/side-panel/interaction-panel/tooltip-config.tsx +++ b/src/components/src/side-panel/interaction-panel/tooltip-config.tsx @@ -18,7 +18,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. -import React from 'react'; +import React, {useCallback} from 'react'; import styled from 'styled-components'; import {injectIntl, IntlShape} from 'react-intl'; import {FormattedMessage} from '@kepler.gl/localization'; @@ -119,27 +119,62 @@ function TooltipConfigFactory( onDisplayFormatChange }: DatasetTooltipConfigProps) => { const dataId = dataset.id; + + const handleClick = useCallback( + () => + onChange({ + ...config, + fieldsToShow: { + ...config.fieldsToShow, + [dataId]: [] + } + }), + [config, dataId, onChange] + ); + + const findSelectedHelper = useCallback((selected, tooltipFields) => { + return selected.map( + f => + tooltipFields.find(tooltipField => tooltipField.name === f.name) || { + name: f.name, + // default initial tooltip is null + format: null + } + ); + }, []); + + const handleSelect = useCallback( + selected => { + const newConfig: DatasetTooltipConfigProps['config'] = { + ...config, + fieldsToShow: { + ...config.fieldsToShow, + [dataId]: findSelectedHelper(selected, config.fieldsToShow[dataId]) + } + }; + onChange(newConfig); + }, + [config, dataId, onChange] + ); + + const handleReorderItems = useCallback( + newOrder => + onChange({ + ...config, + fieldsToShow: { + ...config.fieldsToShow, + [dataId]: newOrder + } + }), + [config, dataId, onChange] + ); return ( {Boolean(config.fieldsToShow[dataId].length) && ( - @@ -148,26 +183,8 @@ function TooltipConfigFactory( { - const newConfig: DatasetTooltipConfigProps['config'] = { - ...config, - fieldsToShow: { - ...config.fieldsToShow, - // @ts-expect-error - [dataId]: selected.map( - f => - config.fieldsToShow[dataId].find( - tooltipField => tooltipField.name === f.name - ) || { - name: f.name, - // default initial tooltip is null - format: null - } - ) - } - }; - onChange(newConfig); - }} + onSelect={handleSelect} + reorderItems={handleReorderItems} closeOnSelect={false} multiSelect inputTheme="secondary" @@ -190,6 +207,15 @@ function TooltipConfigFactory( onDisplayFormatChange, intl }: TooltipConfigProps) => { + const handleChange = useCallback( + (option: string | number | boolean | object | null) => + onChange({ + ...config, + compareType: option as string | null + }), + [config, onChange] + ); + return ( {Object.keys(config.fieldsToShow).map(dataId => @@ -235,14 +261,7 @@ function TooltipConfigFactory( searchable={false} inputTheme={'secondary'} getOptionValue={d => d} - onChange={option => { - const newConfig: TooltipConfigProps['config'] = { - ...config, - // @ts-expect-error - compareType: option - }; - onChange(newConfig); - }} + onChange={handleChange} /> diff --git a/src/components/src/side-panel/interaction-panel/tooltip-config/tooltip-chicklet.tsx b/src/components/src/side-panel/interaction-panel/tooltip-config/tooltip-chicklet.tsx index 5972127a3c..a9e4bba2ee 100644 --- a/src/components/src/side-panel/interaction-panel/tooltip-config/tooltip-chicklet.tsx +++ b/src/components/src/side-panel/interaction-panel/tooltip-config/tooltip-chicklet.tsx @@ -20,8 +20,11 @@ import React, {Component, ComponentType} from 'react'; import styled from 'styled-components'; +import classnames from 'classnames'; +import {DraggableAttributes} from '@dnd-kit/core'; +import {CSS, Transform} from '@dnd-kit/utilities'; import {ChickletButton, ChickletTag} from '../../../common/item-selector/chickleted-input'; -import {Hash, Delete} from '../../../common/icons'; +import {Hash, Delete, VertDots} from '../../../common/icons'; import DropdownList from '../../../common/item-selector/dropdown-list'; import {FormattedMessage} from '@kepler.gl/localization'; import {TimeLabelFormat, TooltipFields} from '@kepler.gl/types'; @@ -34,6 +37,13 @@ interface TooltipChickletProps { item: {name: string}; displayOption: Function; remove: any; + + attributes: DraggableAttributes; + listeners: any; + setNodeRef: (node: HTMLElement | null) => void; + transform: Transform | null; + transition?: string; + isDragging: boolean; } type TooltipConfig = { @@ -80,6 +90,38 @@ const IconDiv = styled.div.attrs({ : props.theme.textColor}; `; +type SortableStyledItemProps = { + transition?: string; + transform?: string; +}; +const SortableStyledItem = styled.div` + transition: ${props => props.transition}; + transform: ${props => props.transform}; + &.sorting { + opacity: 0.3; + pointer-events: none; + } + :hover { + .tooltip-chicklet__drag-handler { + opacity: 1; + } + } +`; + +const StyledDragHandle = styled.div.attrs({ + className: 'tooltip-chicklet__drag-handler' +})` + display: flex; + align-items: center; + z-index: 1000; + opacity: 0; + margin-left: -5px; + :hover { + cursor: move; + color: ${props => props.theme.tooltipVerticalLineColor}; + } +`; + function getFormatTooltip(formatLabels: TimeLabelFormat[], format: string | null) { if (!format) { return null; @@ -102,7 +144,7 @@ function TooltipChickletFactory( state = { show: false }; - private node!: HTMLDivElement; + private node!: HTMLDivElement | null; componentDidMount() { document.addEventListener('mousedown', this.handleClickOutside, false); @@ -113,13 +155,25 @@ function TooltipChickletFactory( } handleClickOutside = (e: any) => { - if (this.node.contains(e.target)) { + if (this.node?.contains(e.target)) { return; } }; render() { - const {disabled, item, displayOption, remove} = this.props; + const { + disabled, + item, + displayOption, + remove, + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging + } = this.props; + const {show} = this.state; const tooltipField = config.fieldsToShow[dataId].find( fieldToShow => fieldToShow.name === item.name @@ -139,73 +193,84 @@ function TooltipChickletFactory( const hashStyle = show ? hashStyles.SHOW : hasFormat ? hashStyles.ACTIVE : null; return ( - (this.node = node)}> - {displayOption(item)} - {formatLabels.length > 1 && ( - - ( - - {hasFormat ? ( - getFormatTooltip(formatLabels, field.displayName) - ) : ( - - )} - - )} - > - - - { + + (this.node = node)}> + + + + {displayOption(item)} + {formatLabels.length > 1 && ( + + ( + + {hasFormat ? ( + getFormatTooltip(formatLabels, field.displayName) + ) : ( + + )} + + )} + > + + + { + e.stopPropagation(); + this.setState({show: Boolean(!show)}); + }} + /> + + + + {show && ( + + label} + onOptionSelected={(result, e) => { e.stopPropagation(); - this.setState({show: Boolean(!show)}); + this.setState({ + show: false + }); + + const displayFormat = getFormatValue(result); + const oldFieldsToShow = config.fieldsToShow[dataId]; + const fieldsToShow = oldFieldsToShow.map(fieldToShow => { + return fieldToShow.name === tooltipField.name + ? { + name: tooltipField.name, + format: displayFormat + } + : fieldToShow; + }); + const newConfig = { + ...config, + fieldsToShow: { + ...config.fieldsToShow, + [dataId]: fieldsToShow + } + }; + onChange(newConfig); + onDisplayFormatChange(dataId, field.name, displayFormat); }} /> - - - - {show && ( - - label} - onOptionSelected={(result, e) => { - e.stopPropagation(); - this.setState({ - show: false - }); - - const displayFormat = getFormatValue(result); - const oldFieldsToShow = config.fieldsToShow[dataId]; - const fieldsToShow = oldFieldsToShow.map(fieldToShow => { - return fieldToShow.name === tooltipField.name - ? { - name: tooltipField.name, - format: displayFormat - } - : fieldToShow; - }); - const newConfig = { - ...config, - fieldsToShow: { - ...config.fieldsToShow, - [dataId]: fieldsToShow - } - }; - onChange(newConfig); - onDisplayFormatChange(dataId, field.name, displayFormat); - }} - /> - - )} - - )} - - + + )} + + )} + + + ); } }