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 c712157bb6..aa878ff627 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/PropertiesTabElements.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/PropertiesTabElements.jsx @@ -105,6 +105,7 @@ export const PropertiesTabElements = ({ { label: 'Date Picker', value: 'datepicker' }, { label: 'Select', value: 'select' }, { label: 'MultiSelect', value: 'newMultiSelect' }, + { label: 'Tags', value: 'tagsV2' }, { label: 'Boolean', value: 'boolean' }, { label: 'Image', value: 'image' }, { label: 'Link', value: 'link' }, @@ -449,7 +450,7 @@ export const PropertiesTabElements = ({ component={component} /> )} - {['select', 'newMultiSelect'].includes(column.columnType) && ( + {['select', 'newMultiSelect', 'tagsV2'].includes(column.columnType) && ( diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ValidationProperties.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ValidationProperties.jsx index d8f62bb0b4..b3ba0d11fb 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ValidationProperties.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/ColumnManager/ValidationProperties.jsx @@ -97,6 +97,7 @@ export const ValidationProperties = ({ case 'dropdown': case 'select': case 'newMultiSelect': + case 'tagsV2': return [ { property: 'customRule', diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx index bd6fbb9936..d77a237669 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/SelectOptionsList/OptionsList.jsx @@ -10,6 +10,8 @@ import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandlePropert import { resolveReferences } from '@/_helpers/utils'; import { Button as ButtonComponent } from '@/components/ui/Button/Button'; import { unset } from 'lodash'; +import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup'; +import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem'; export const OptionsList = ({ column, @@ -263,9 +265,40 @@ export const OptionsList = ({ }; items.push({ - title: 'Options', + title: column.columnType === 'tagsV2' ? 'Tags' : 'Options', children: (
+ {column.columnType === 'tagsV2' && ( + <> +
+ + onColumnItemChange(index, 'sortTags', value)} + defaultValue={column?.sortTags || 'none'} + style={{ width: '58%' }} + > + None + a-z + z-a + +
+ + + )} - Add new option + {column.columnType === 'tagsV2' ? 'Add new tag' : 'Add new option'}
diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx index 7184547a33..9554e092ec 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/Table.jsx @@ -44,6 +44,7 @@ const getColumnTypeDisplayText = (columnType) => { boolean: 'Boolean', select: 'Select', newMultiSelect: 'Multiselect', + tagsV2: 'Tags', json: 'JSON', markdown: 'Markdown', html: 'HTML', diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsV2TypeIcon.jsx b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsV2TypeIcon.jsx new file mode 100644 index 0000000000..86ce95dd24 --- /dev/null +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/TagsV2TypeIcon.jsx @@ -0,0 +1,29 @@ +import React from 'react'; + +const TagsV2TypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => ( + + + + + +); + +export default TagsV2TypeIcon; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js index 9ebe274952..449022e83c 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/_assets/index.js @@ -12,6 +12,7 @@ export { default as MarkdownTypeIcon } from './MarkdownTypeIcon'; export { default as HTMLTypeIcon } from './HTMLTypeIcon'; export { default as BadgeTypeIcon } from './BadgeTypeIcon'; export { default as TagsTypeIcon } from './TagsTypeIcon'; +export { default as TagsV2TypeIcon } from './TagsV2TypeIcon'; export { default as RadioTypeIcon } from './RadioTypeIcon'; export { default as RatingTypeIcon } from './RadioTypeIcon'; export { default as ButtonTypeIcon } from './ButtonTypeIcon'; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/hooks/useColumnManager.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/hooks/useColumnManager.js index 6e08a007d6..a1b6f569d3 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/hooks/useColumnManager.js +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/hooks/useColumnManager.js @@ -22,7 +22,7 @@ export const useColumnManager = ({ component, paramUpdated, currentState }) => { let modifiedColumn = { ...column }; // Handle select/multiselect default options - if (property === 'columnType' && (value === 'select' || value === 'newMultiSelect')) { + if (property === 'columnType' && (value === 'select' || value === 'newMultiSelect' || value === 'tagsV2')) { if (modifiedColumn.options?.length > 0) { modifiedColumn.options = modifiedColumn.options.map((opt) => { const { makeDefaultOption, ...rest } = opt; diff --git a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js index 32fb329002..f042086878 100644 --- a/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js +++ b/frontend/src/AppBuilder/RightSideBar/Inspector/Components/Table/utils.js @@ -13,6 +13,7 @@ import { StringTypeIcon, BadgeTypeIcon, TagsTypeIcon, + TagsV2TypeIcon, RadioTypeIcon, RatingTypeIcon, ButtonTypeIcon, @@ -56,6 +57,8 @@ export const getColumnIcon = (columnType) => { return BadgeTypeIcon; case 'tags': return TagsTypeIcon; + case 'tagsV2': + return TagsV2TypeIcon; case 'rating': return RatingTypeIcon; case 'button': diff --git a/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx b/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx new file mode 100644 index 0000000000..8d5c9da199 --- /dev/null +++ b/frontend/src/AppBuilder/Shared/DataTypes/renderers/TagsRenderer.jsx @@ -0,0 +1,425 @@ +import React, { useCallback, useMemo, useRef, useEffect, useState } 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 TagsInputMenuList from '@/AppBuilder/Widgets/TagsInput/TagsInputMenuList'; +import defaultStyles from '@/_ui/Select/styles'; + +const COLORS = [ + '#40474D33', + '#CE276133', + '#6745E233', + '#2576CE33', + '#1A9C6D33', + '#69AF2033', + '#F3571733', + '#EB2E3933', + '#A438C033', + '#405DE633', + '#1E8FA333', + '#34A94733', + '#F1911933', +]; + +const sortOptions = (opts, sortTags) => { + if (!sortTags || sortTags === 'none') return opts; + return [...opts].sort((a, b) => { + const cmp = (a.label || '').localeCompare(b.label || ''); + return sortTags === 'a-z' ? cmp : -cmp; + }); +}; + +const CustomOption = ({ innerRef, innerProps, children, isSelected, ...props }) => { + const { label, value, data } = props; + const { optionColors } = props.selectProps; + + return ( +
+ {props.selectProps.isMulti ? ( + e.stopPropagation()} key="" value={children} /> + ) : ( +
+ e.stopPropagation()} key="" value={children} /> +
+ )} +
+ {label} +
+
+ ); +}; + +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. + * + * Distinct from SelectRenderer: users can create new tag options at runtime + * 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. + * + * Validation is owned by the adapter; this component is pure UI. + */ +export const TagsRenderer = ({ + options, + value, + onChange, + defaultOptionsList = [], + isMulti, + autoAssignColors = true, + sortTags = 'none', + placeholder, + disabled, + className, + darkMode, + textColor = '', + horizontalAlignment = 'left', + optionsLoadingState = false, + isEditable, + isNewRow, + isFocused, + setIsFocused, + menuIsOpen, + isValid = true, + validationError, +}) => { + const [runtimeTags, setRuntimeTags] = useState([]); + + const allOptions = useMemo(() => [...options, ...runtimeTags], [options, runtimeTags]); + const sortedOptions = useMemo(() => sortOptions(allOptions, sortTags), [allOptions, sortTags]); + + const optionColors = useMemo( + () => + allOptions.reduce((acc, option, index) => { + acc[option.value] = + option.optionColor || (autoAssignColors ? COLORS[index % COLORS.length] : 'var(--surfaces-surface-03)'); + return acc; + }, {}), + [allOptions, autoAssignColors] + ); + + const handleCreate = useCallback( + (newLabel) => { + const trimmed = (newLabel || '').trim(); + if (!trimmed) return; + const newTag = { label: trimmed, value: trimmed }; + setRuntimeTags((prev) => (prev.some((t) => t.value === trimmed) ? prev : [...prev, newTag])); + if (isMulti) { + const currentValues = isArray(value) ? value.map((v) => (v && typeof v === 'object' ? v.value : v)) : []; + onChange([...currentValues, trimmed]); + } else { + onChange(trimmed); + } + }, + [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] + ); + + const containerRef = useRef(null); + + useEffect(() => { + if (!isMulti && !isNewRow) return; + const handleDocumentClick = (event) => { + if (!containerRef.current?.contains(event.target)) { + setIsFocused?.(false); + } + }; + document.addEventListener('mousedown', handleDocumentClick); + return () => document.removeEventListener('mousedown', handleDocumentClick); + }, [isMulti, isNewRow, setIsFocused]); + + const customComponents = { + MenuList: (props) => ( + + ), + Option: CustomOption, + DropdownIndicator: isEditable ? DropdownIndicator : null, + ...(isMulti && { + MultiValueRemove, + MultiValueContainer: CustomMultiValueContainer, + }), + }; + + const customStyles = useMemo( + () => ({ + ...defaultStyles(darkMode, '100%'), + valueContainer: (provided) => ({ + ...provided, + ...(isMulti && { + marginBottom: '0', + display: 'flex', + flexWrap: 'no-wrap', + overflow: 'hidden', + flexDirection: 'row', + }), + justifyContent: horizontalAlignment, + }), + ...(isMulti && { + multiValue: (provided) => ({ + ...provided, + display: 'inline-block', + marginRight: '4px', + }), + multiValueLabel: (provided, state) => { + const option = state.data; + return { + ...provided, + padding: '2px 6px', + background: optionColors?.[option.value] || 'var(--surfaces-surface-03)', + borderRadius: '6px', + color: option?.labelColor || textColor || 'var(--text-primary)', + fontSize: '12px', + }; + }, + }), + singleValue: (provided, state) => { + const option = state.data; + return { + ...provided, + padding: '2px 6px', + background: optionColors?.[option.value] || 'var(--surfaces-surface-03)', + borderRadius: '6px', + color: option?.labelColor || textColor || 'var(--text-primary)', + fontSize: '12px', + }; + }, + menuList: (provided) => ({ + ...provided, + display: 'flex', + flexDirection: 'column', + overflowY: 'auto', + backgroundColor: 'var(--surfaces-surface-01)', + padding: '4px', + }), + menu: (provided) => ({ + ...provided, + padding: '0', + marginTop: '5px', + borderRadius: '8px', + boxShadow: 'var(--elevation-300-box-shadow)', + border: '1px solid var(--border-weak)', + }), + }), + [darkMode, isMulti, horizontalAlignment, textColor, optionColors] + ); + + const defaultValue = useMemo( + () => + defaultOptionsList.length >= 1 + ? isMulti + ? defaultOptionsList + : defaultOptionsList.slice(-1)[0] + : isMulti + ? [] + : {}, + [isMulti, defaultOptionsList] + ); + + const selectedValue = useMemo(() => { + if (!value) return null; + if (isMulti && value?.length) { + if (!isArray(value)) return []; + const resolved = value.map((val) => { + const v = val && typeof val === 'object' ? val.value : val; + const match = allOptions?.find((option) => option.value === v); + if (match) return match; + return { label: String(v), value: String(v) }; + }); + return sortOptions(resolved.filter(Boolean), sortTags); + } + const v = isArray(value) ? value[0] : value; + if (!v) return null; + const match = allOptions?.find((option) => option.value === (v && typeof v === 'object' ? v.value : v)); + if (match) return match; + return { + label: String(v && typeof v === 'object' ? v.value : v), + value: String(v && typeof v === 'object' ? v.value : v), + }; + }, [allOptions, value, isMulti, sortTags]); + + const handleChange = useCallback( + (newValue) => { + setIsFocused(false); + if (!isMulti && newValue === selectedValue?.value) { + onChange(''); + } else { + onChange(newValue); + } + }, + [isMulti, selectedValue, onChange, setIsFocused] + ); + + const isOverflowing = useCallback(() => { + if (!containerRef.current) return false; + const valueContainer = containerRef.current.querySelector('.react-select__value-container'); + return valueContainer?.clientHeight > containerRef.current?.clientHeight; + }, []); + + return ( + + ) + } + trigger={isMulti && !isFocused && isOverflowing() && ['hover', 'focus']} + rootClose={true} + > + <> +
{ + if (isNewRow && isEditable) { + setIsFocused((prev) => !prev); + } + }} + > +