diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/PropertiesTabElements.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/PropertiesTabElements.jsx index aa878ff627..6bb8810c43 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/PropertiesTabElements.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/PropertiesTabElements.jsx @@ -425,7 +425,9 @@ export const PropertiesTabElements = ({ )} - {['select', 'newMultiSelect', 'datepicker', 'rating'].includes(column.columnType) &&
} + {['select', 'newMultiSelect', 'datepicker', 'rating', 'tagsV2'].includes(column.columnType) && ( +
+ )} {column.columnType === 'datepicker' && (
{column.columnType === 'tagsV2' && ( <> -
+
onColumnItemChange(index, 'sortTags', value)} @@ -282,21 +282,6 @@ export const OptionsList = ({ z-a
- )} + {column.columnType === 'tagsV2' && ( + + )}
; +export const MultiValueRemove = ({ innerProps }) =>
; -const CustomMultiValueContainer = ({ children }) => ( +export const CustomMultiValueContainer = ({ children }) => (
(
); -const DropdownIndicator = ({ selectProps }) => { +export const DropdownIndicator = ({ selectProps }) => { return (
{ ); }; -const getOverlay = (value, containerWidth, darkMode) => { +export const getOverlay = (value, containerWidth, darkMode) => { if (!isArray(value)) return
; return ( diff --git a/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx b/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx index 8d5c9da199..d58d2d9d34 100644 --- a/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx +++ b/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx @@ -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 (
- {props.selectProps.isMulti ? ( - e.stopPropagation()} key="" value={children} /> - ) : ( -
- e.stopPropagation()} key="" value={children} /> -
- )}
- {label} + {!inputValue && ( + + + + )} + 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" + /> +
+
+
); }; -const MultiValueRemove = ({ innerProps }) =>
; - -const CustomMultiValueContainer = ({ children }) => ( -
- {children} -
-); - -const DropdownIndicator = ({ selectProps }) => { - return ( -
{ - 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); - } - }} - > - -
- ); -}; - -const getOverlay = (value, darkMode) => { - if (!isArray(value)) return
; - - return ( -
- {value.map((option) => ( - - {option.label || option} - - ))} -
- ); -}; - /** - * 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) => ( - - ), - 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 = ({ >