Enhance TagsV2 support in Table component by adding 'Allow multiple selection' option and updating related properties. Adjust styles and refactor TagsRenderer for improved functionality and maintainability.

This commit is contained in:
Nakul Nagargade 2026-04-21 14:04:05 +05:30
parent dbd35e942e
commit 3d30d54b29
8 changed files with 177 additions and 193 deletions

View file

@ -425,7 +425,9 @@ export const PropertiesTabElements = ({
</div>
)}
{['select', 'newMultiSelect', 'datepicker', 'rating'].includes(column.columnType) && <hr className="mx-0 my-2" />}
{['select', 'newMultiSelect', 'datepicker', 'rating', 'tagsV2'].includes(column.columnType) && (
<hr className="mx-0 my-2" />
)}
{column.columnType === 'datepicker' && (
<div className="field" style={{ marginTop: '-24px' }}>
<DatepickerProperties

View file

@ -270,7 +270,7 @@ export const OptionsList = ({
<div className="d-flex custom-gap-7 flex-column">
{column.columnType === 'tagsV2' && (
<>
<div className="field d-flex custom-gap-12 align-items-center align-self-stretch justify-content-between px-3">
<div className="field d-flex custom-gap-12 align-items-center align-self-stretch justify-content-between">
<label className="form-label">Sort tags</label>
<ToggleGroup
onValueChange={(value) => onColumnItemChange(index, 'sortTags', value)}
@ -282,21 +282,6 @@ export const OptionsList = ({
<ToggleGroupItem value="z-a">z-a</ToggleGroupItem>
</ToggleGroup>
</div>
<ProgramaticallyHandleProperties
label="Allow multiple selection"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={onColumnItemChange}
property="allowMultipleSelection"
props={column}
component={component}
paramMeta={{
type: 'toggle',
displayName: 'Allow multiple selection',
}}
paramType="properties"
/>
</>
)}
<ProgramaticallyHandleProperties
@ -314,6 +299,23 @@ export const OptionsList = ({
}}
paramType="properties"
/>
{column.columnType === 'tagsV2' && (
<ProgramaticallyHandleProperties
label="Allow multiple selection"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={onColumnItemChange}
property="allowMultipleSelection"
props={column}
component={component}
paramMeta={{
type: 'toggle',
displayName: 'Allow multiple selection',
}}
paramType="properties"
/>
)}
<ProgramaticallyHandleProperties
label="Dynamic option"
currentState={currentState}

View file

@ -9,7 +9,7 @@ import { isArray } from 'lodash';
const { MenuList } = components;
const COLORS = [
export const COLORS = [
'#40474D33',
'#CE276133',
'#6745E233',
@ -144,9 +144,9 @@ const CustomOption = ({ innerRef, innerProps, children, isSelected, ...props })
);
};
const MultiValueRemove = ({ innerProps }) => <div {...innerProps} />;
export const MultiValueRemove = ({ innerProps }) => <div {...innerProps} />;
const CustomMultiValueContainer = ({ children }) => (
export const CustomMultiValueContainer = ({ children }) => (
<div
style={{
display: 'flex',
@ -158,7 +158,7 @@ const CustomMultiValueContainer = ({ children }) => (
</div>
);
const DropdownIndicator = ({ selectProps }) => {
export const DropdownIndicator = ({ selectProps }) => {
return (
<div
className="cell-icon-display"
@ -188,7 +188,7 @@ const DropdownIndicator = ({ selectProps }) => {
);
};
const getOverlay = (value, containerWidth, darkMode) => {
export const getOverlay = (value, containerWidth, darkMode) => {
if (!isArray(value)) return <div />;
return (

View file

@ -1,27 +1,13 @@
import React, { useCallback, useMemo, useRef, useEffect, useState } from 'react';
import React, { useCallback, useMemo, useRef, useEffect } from 'react';
import Select from '@/_ui/Select';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import { Checkbox } from '@/_ui/CheckBox/CheckBox';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import { isArray } from 'lodash';
import '@/AppBuilder/Widgets/TagsInput/tagsInput.scss';
import TagsInputMenuList from '@/AppBuilder/Widgets/TagsInput/TagsInputMenuList';
import TagsInputOption from '@/AppBuilder/Widgets/TagsInput/TagsInputOption';
import defaultStyles from '@/_ui/Select/styles';
const COLORS = [
'#40474D33',
'#CE276133',
'#6745E233',
'#2576CE33',
'#1A9C6D33',
'#69AF2033',
'#F3571733',
'#EB2E3933',
'#A438C033',
'#405DE633',
'#1E8FA333',
'#34A94733',
'#F1911933',
];
import { DropdownIndicator, CustomMultiValueContainer, MultiValueRemove, getOverlay, COLORS } from './SelectRenderer';
const sortOptions = (opts, sortTags) => {
if (!sortTags || sortTags === 'none') return opts;
@ -31,114 +17,66 @@ const sortOptions = (opts, sortTags) => {
});
};
const CustomOption = ({ innerRef, innerProps, children, isSelected, ...props }) => {
const { label, value, data } = props;
const { optionColors } = props.selectProps;
export const MenuListWithSearch = (props) => {
const { selectProps, optionsLoadingState, darkMode, allOptions, autoAssignColors, handleCreate } = props;
const { inputValue, onMenuInputFocus, onInputChange, inputRef } = selectProps || {};
return (
<div
ref={innerRef}
{...innerProps}
className="option-wrapper d-flex"
style={{ backgroundColor: 'var(--cc-surface1-surface)' }}
className="table-select-custom-menu-list"
style={{
backgroundColor: 'var(--cc-surface1-surface)',
border: '1px solid var(--cc-default-border)',
minWidth: '200px',
}}
>
{props.selectProps.isMulti ? (
<Checkbox label="" isChecked={isSelected} onChange={(e) => e.stopPropagation()} key="" value={children} />
) : (
<div style={{ visibility: isSelected ? 'visible' : 'hidden' }}>
<Checkbox label="" isChecked={isSelected} onChange={(e) => e.stopPropagation()} key="" value={children} />
</div>
)}
<div
className="table-select-menu-pill"
style={{
background: optionColors?.[value] || 'var(--surfaces-surface-03)',
color: data?.labelColor || 'var(--text-primary)',
padding: '2px 6px',
borderRadius: '6px',
fontSize: '12px',
}}
className="table-select-column-type-search-box-wrapper"
style={{ backgroundColor: 'var(--cc-surface1-surface)' }}
>
{label}
{!inputValue && (
<span>
<SolidIcon name="search" width="14" />
</span>
)}
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => onInputChange(e.currentTarget.value, { action: 'input-change' })}
onMouseDown={(e) => {
e.stopPropagation();
e.target.focus();
}}
onFocus={onMenuInputFocus}
placeholder="Search..."
className="table-select-column-type-search-box"
autoCorrect="off"
autoComplete="off"
spellCheck="false"
/>
</div>
<div style={{ borderTop: '1px solid var(--cc-default-border)' }}>
<TagsInputMenuList
{...props}
selectProps={selectProps}
allowNewTags
inputValue={inputValue}
optionsLoadingState={optionsLoadingState}
darkMode={darkMode}
allOptions={allOptions}
onCreateTag={handleCreate}
autoPickChipColor={autoAssignColors}
/>
</div>
</div>
);
};
const MultiValueRemove = ({ innerProps }) => <div {...innerProps} />;
const CustomMultiValueContainer = ({ children }) => (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
}}
>
{children}
</div>
);
const DropdownIndicator = ({ selectProps }) => {
return (
<div
className="cell-icon-display"
style={{ alignSelf: 'center' }}
onMouseDown={(e) => {
const isOpen = selectProps.menuIsOpen;
selectProps.onMenuOpen(!isOpen);
const tdElement = e.currentTarget.closest('td');
if (tdElement) {
const clickEvent = new MouseEvent('click', {
bubbles: true,
cancelable: true,
view: window,
});
tdElement.dispatchEvent(clickEvent);
}
}}
>
<SolidIcon
name={selectProps.menuIsOpen ? 'arrowUpTriangle' : 'arrowDownTriangle'}
width="16"
height="16"
fill="#6A727C"
/>
</div>
);
};
const getOverlay = (value, darkMode) => {
if (!isArray(value)) return <div />;
return (
<div
style={{ maxWidth: '266px' }}
className={`overlay-cell-table overlay-multiselect-table ${darkMode ? 'dark-theme' : ''}`}
>
{value.map((option) => (
<span
key={option.label || option}
style={{
padding: '2px 6px',
background: 'var(--surfaces-surface-03)',
borderRadius: '6px',
color: 'var(--text-primary)',
fontSize: '12px',
}}
>
{option.label || option}
</span>
))}
</div>
);
};
/**
* TagsRenderer creatable tags dropdown for the Table TagsV2 column.
* TagsRenderer tags dropdown for the Table TagsV2 column.
*
* Distinct from SelectRenderer: users can create new tag options at runtime
* Distinct from SelectRenderer: users can create transient tag selections
* via TagsInputMenuList, tags are optionally auto-colored, and the option
* list can be sorted. Borrows only control + valueContainer styles from
* SelectRenderer via _sharedStyles; all other styling is local.
@ -151,7 +89,7 @@ export const TagsRenderer = ({
onChange,
defaultOptionsList = [],
isMulti,
autoAssignColors = true,
autoAssignColors = false,
sortTags = 'none',
placeholder,
disabled,
@ -168,10 +106,7 @@ export const TagsRenderer = ({
isValid = true,
validationError,
}) => {
const [runtimeTags, setRuntimeTags] = useState([]);
const allOptions = useMemo(() => [...options, ...runtimeTags], [options, runtimeTags]);
const sortedOptions = useMemo(() => sortOptions(allOptions, sortTags), [allOptions, sortTags]);
const allOptions = useMemo(() => options || [], [options]);
const optionColors = useMemo(
() =>
@ -188,32 +123,37 @@ export const TagsRenderer = ({
const trimmed = (newLabel || '').trim();
if (!trimmed) return;
const newTag = { label: trimmed, value: trimmed };
setRuntimeTags((prev) => (prev.some((t) => t.value === trimmed) ? prev : [...prev, newTag]));
setIsFocused(false);
if (isMulti) {
const currentValues = isArray(value) ? value.map((v) => (v && typeof v === 'object' ? v.value : v)) : [];
onChange([...currentValues, trimmed]);
const currentValues = isArray(value)
? value.map((currentValue) => {
const rawValue = currentValue && typeof currentValue === 'object' ? currentValue.value : currentValue;
const matchedOption = allOptions.find((option) => option.value === rawValue);
return matchedOption || { label: String(rawValue), value: String(rawValue) };
})
: [];
const existsInSelection = currentValues.some(
(currentValue) => String(currentValue?.value).toLowerCase() === trimmed.toLowerCase()
);
onChange(existsInSelection ? currentValues : [...currentValues, newTag]);
} else {
onChange(trimmed);
onChange(newTag);
}
},
[isMulti, value, onChange]
);
const isValidNewOption = useCallback(
(input) => {
if (!input || !input.trim()) return false;
const trimmed = input.trim().toLowerCase();
return !allOptions.some((o) => o.value?.toLowerCase() === trimmed);
},
[allOptions]
[allOptions, isMulti, value, onChange, setIsFocused]
);
const containerRef = useRef(null);
const searchInputRef = useRef(null);
useEffect(() => {
if (!isMulti && !isNewRow) return;
const handleDocumentClick = (event) => {
if (!containerRef.current?.contains(event.target)) {
const menu = event.target.closest?.('.tags-input-menu-list, .table-select-custom-menu-list');
if (!containerRef.current?.contains(event.target) && !menu) {
setIsFocused?.(false);
}
};
@ -222,19 +162,8 @@ export const TagsRenderer = ({
}, [isMulti, isNewRow, setIsFocused]);
const customComponents = {
MenuList: (props) => (
<TagsInputMenuList
{...props}
allowNewTags
inputValue={props.selectProps?.inputValue}
optionsLoadingState={optionsLoadingState}
darkMode={darkMode}
allOptions={allOptions}
onCreateTag={handleCreate}
autoPickChipColor={autoAssignColors}
/>
),
Option: CustomOption,
MenuList: MenuListWithSearch,
Option: TagsInputOption,
DropdownIndicator: isEditable ? DropdownIndicator : null,
...(isMulti && {
MultiValueRemove,
@ -301,6 +230,33 @@ export const TagsRenderer = ({
boxShadow: 'var(--elevation-300-box-shadow)',
border: '1px solid var(--border-weak)',
}),
option: (provided, state) => {
// Use our controlled focus state instead of react-select's auto-focus
const { selectProps, data } = state;
const options = selectProps?.options || [];
const optionIndex = options.findIndex((opt) => opt.value === data?.value);
const isControlledFocused =
selectProps?.focusedOptionIndex >= 0 && optionIndex === selectProps?.focusedOptionIndex;
// Use color-mix to get 50% of hover color (effectively 4% alpha from 8%)
const hoverBgColor = 'color-mix(in srgb, var(--interactive-overlays-fill-hover) 50%, transparent)';
return {
...provided,
backgroundColor: isControlledFocused ? hoverBgColor : 'var(--surfaces-surface-01)',
color: 'var(--text-primary)',
opacity: state.isDisabled ? 0.3 : 1,
cursor: 'pointer',
padding: '8px 12px',
borderRadius: '6px',
'&:active': {
backgroundColor: 'var(--interactive-overlays-fill-pressed)',
},
'&:hover': {
backgroundColor: hoverBgColor,
},
};
},
}),
[darkMode, isMulti, horizontalAlignment, textColor, optionColors]
);
@ -339,6 +295,8 @@ export const TagsRenderer = ({
};
}, [allOptions, value, isMulti, sortTags]);
const sortedOptions = useMemo(() => sortOptions(allOptions, sortTags), [allOptions, sortTags]);
const handleChange = useCallback(
(newValue) => {
setIsFocused(false);
@ -353,7 +311,7 @@ export const TagsRenderer = ({
const isOverflowing = useCallback(() => {
if (!containerRef.current) return false;
const valueContainer = containerRef.current.querySelector('.react-select__value-container');
const valueContainer = containerRef.current.querySelector('.tags-renderer-select__value-container');
return valueContainer?.clientHeight > containerRef.current?.clientHeight;
}, []);
@ -382,7 +340,7 @@ export const TagsRenderer = ({
>
<Select
options={sortedOptions}
hasSearch
hasSearch={false}
isDisabled={disabled}
className={className}
components={customComponents}
@ -394,17 +352,18 @@ export const TagsRenderer = ({
defaultValue={defaultValue}
placeholder={placeholder}
isMulti={isMulti}
hideSelectedOptions={false}
hideSelectedOptions
isClearable={false}
clearIndicator={false}
darkMode={darkMode}
menuIsOpen={menuIsOpen}
isFocused={isFocused}
optionColors={optionColors}
creatable
onCreateOption={handleCreate}
formatCreateLabel={() => null}
isValidNewOption={isValidNewOption}
optionsLoadingState={optionsLoadingState}
allOptions={allOptions}
onCreateTag={handleCreate}
autoAssignColors={autoAssignColors}
inputRef={searchInputRef}
/>
</div>
{isEditable && !isValid && (

View file

@ -15,14 +15,14 @@ export const TagsV2Column = ({
darkMode,
defaultOptionsList = [],
textColor = '',
allowMultipleSelection = true,
allowMultipleSelection,
sortTags = 'none',
optionsLoadingState = false,
horizontalAlignment = 'left',
isEditable,
column,
isNewRow,
autoAssignColors = true,
autoAssignColors,
id,
}) => {
const [isFocused, setIsFocused] = useState(false);
@ -39,21 +39,44 @@ export const TagsV2Column = ({
});
const { isValid, validationError } = validationData;
const normalizeTagValue = useCallback(
(tagValue) => {
if (tagValue == null || tagValue === '') return null;
if (typeof tagValue === 'object') {
const rawValue = tagValue.value ?? tagValue.label;
if (rawValue == null || rawValue === '') return null;
return {
label: String(tagValue.label ?? rawValue),
value: String(rawValue),
};
}
const matchedOption = options?.find((option) => option.value === tagValue);
return {
label: String(matchedOption?.label ?? tagValue),
value: String(tagValue),
};
},
[options]
);
const handleChange = useCallback(
(newValue) => {
if (allowMultipleSelection) {
const arr = isArray(newValue) ? newValue : [];
onChange(arr.map((v) => (v && typeof v === 'object' ? v.value : v)));
onChange(arr.map(normalizeTagValue).filter(Boolean));
} else {
if (newValue == null || newValue === '') {
onChange([]);
onChange(null);
return;
}
const v = typeof newValue === 'object' ? newValue.value : newValue;
onChange([v]);
onChange(normalizeTagValue(newValue));
}
},
[allowMultipleSelection, onChange]
[allowMultipleSelection, normalizeTagValue, onChange]
);
return (

View file

@ -312,7 +312,7 @@ export default function generateColumnsData({
})) ?? [];
}
const tagsAutoAssignColors = getResolvedValue(column.autoAssignColors) ?? true;
const tagsAutoAssignColors = getResolvedValue(column.autoAssignColors) ?? false;
const sortTags = getResolvedValue(column.sortTags) ?? 'none';
const allowMultipleSelection = getResolvedValue(column.allowMultipleSelection) ?? true;

View file

@ -21,7 +21,11 @@ const TagsInputMenuList = ({
...props
}) => {
const menuId = selectProps?.menuId;
const selectedValues = selectProps?.value || [];
const selectedValues = Array.isArray(selectProps?.value)
? selectProps.value
: selectProps?.value
? [selectProps.value]
: [];
// Check if inputValue already exists in selected tags or all options (case-insensitive)
const trimmedInput = inputValue?.trim()?.toLowerCase();
@ -46,13 +50,7 @@ const TagsInputMenuList = ({
};
return (
<div
id={`tags-input-menu-${menuId}`}
className={cx('tags-input-menu-list', { 'theme-dark dark-theme': darkMode })}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onTouchEnd={(e) => e.stopPropagation()}
>
<div id={`tags-input-menu-${menuId}`} className={cx('tags-input-menu-list', { 'theme-dark dark-theme': darkMode })}>
{!optionsLoadingState ? (
<>
<div className="tags-input-menu-list-body">

View file

@ -58,7 +58,7 @@
min-height: 38px;
>div {
> div:not(.tags-input-option-chip) {
color: var(--text-default) !important;
background-color: transparent !important;
}
@ -66,7 +66,7 @@
&:hover {
background-color: var(--interactive-hover) !important;
>div {
> div:not(.tags-input-option-chip) {
background-color: transparent !important;
}
}