Merge pull request #15258 from ToolJet/feat/button-column

Feat : introduce a  button new column type in table widget
This commit is contained in:
Johnson Cherian 2026-03-09 12:34:09 +05:30 committed by GitHub
commit 4e57909da8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1324 additions and 207 deletions

View file

@ -0,0 +1,118 @@
import React, { useMemo } from 'react';
import { SortableTree } from '@/_ui/SortableTree';
import SolidIcon from '@/_ui/Icon/SolidIcons';
import AddNewButton from '@/ToolJetUI/Buttons/AddNewButton/AddNewButton';
import { Button } from '@/components/ui/Button/Button';
const PROPERTY_NAMES = {
isGroup: 'isGroup',
parentId: 'parentId',
};
const ButtonListItem = ({ item, onSelect }) => (
<div
className="page-menu-item"
onClick={(e) => {
e.preventDefault();
onSelect(item.id);
}}
>
<div className="left">
<div className="main-page-icon-wrapper">
<SolidIcon name="cursor" width={16} />
</div>
<span className="page-name">{item.buttonLabel || 'Button'}</span>
</div>
</div>
);
const ButtonListItemGhost = ({ item, darkMode }) => (
<div className={`nav-handler ghost ${darkMode ? 'dark-theme' : ''}`}>
<div className="page-menu-item" style={{ width: '100%' }}>
<div className="left">
<div className="main-page-icon-wrapper">
<SolidIcon name="cursor" width={16} />
</div>
<span className="page-name">{item.buttonLabel || 'Button'}</span>
</div>
</div>
</div>
);
export const ButtonListManager = ({ buttons = [], onAddButton, onReorderButtons, onSelectButton }) => {
const items = useMemo(
() =>
buttons.map((btn) => ({
...btn,
isGroup: false,
parentId: null,
})),
[buttons]
);
const handleReorder = (reorderedTree) => {
const reordered = reorderedTree.map(({ isGroup, parentId, children, ...btn }) => btn);
onReorderButtons(reordered);
};
const renderItem = (item) => <ButtonListItem item={item} onSelect={onSelectButton} />;
const renderGhost = (item, { darkMode } = {}) => <ButtonListItemGhost item={item} darkMode={darkMode} />;
return (
<div className="button-list-manager navigation-inspector" style={{ padding: '0 12px' }}>
<div
className="d-flex align-items-center justify-content-center tj-text-xsm"
style={{ color: 'var(--text-placeholder)', gap: '8px', marginBottom: '8px' }}
>
<span style={{ flex: 1, borderTop: '1px dashed var(--border-weak)' }} />
<span>Buttons</span>
<span style={{ flex: 1, borderTop: '1px dashed var(--border-weak)' }} />
</div>
{items.length === 0 && (
<div className="d-flex justify-content-center align-items-center gap-[8px] flex-column">
<div
style={{
background: 'var(--cc-surface2-surface)',
height: '32px',
width: '32px',
borderRadius: '8px',
padding: '6px',
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
gap: '6px',
}}
>
<Button variant="ghost" size="default" iconOnly={true} isLucid={true} trailingIcon="mouse" />
</div>
<span className="flex tj-header-h8" style={{ color: 'var(--cc-text-default)' }}>
No action button added
</span>
<span className="flex tj-text-xsm text-center" style={{ color: 'var(--cc-text-placeholder)' }}>
Add action buttons to table rows and configure events like you would with any button component
</span>
</div>
)}
{items.length > 0 && (
<SortableTree
items={items}
onReorder={handleReorder}
propertyNames={PROPERTY_NAMES}
renderItem={renderItem}
renderGhost={renderGhost}
collapsible={false}
indicator={true}
indentationWidth={0}
handlerClassName="button-list-handler"
containerElement="div"
/>
)}
<AddNewButton onClick={onAddButton} dataCy="add-new-action-button" className="mt-2 w-100">
Add new action button
</AddNewButton>
</div>
);
};

View file

@ -0,0 +1,135 @@
import React from 'react';
import CodeHinter from '@/AppBuilder/CodeEditor';
import { EventManager } from '@/AppBuilder/RightSideBar/Inspector/EventManager';
import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandleProperties';
import Accordion from '@/_ui/Accordion';
export const ButtonPropertiesTab = ({
button,
column,
index,
darkMode,
currentState,
onButtonPropertyChange,
setColumnPopoverRootCloseBlocker,
component,
props,
columnEventChanged,
handleEventManagerPopoverCallback,
}) => {
if (!button) return null;
const compoundRef = `${column.key || column.name}::${button.id}`;
return (
<>
<div className="field mb-2 px-3">
<label className="form-label">Button label</label>
<CodeHinter
currentState={currentState}
initialValue={button.buttonLabel ?? 'Button'}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
placeholder="Button label"
onChange={(value) => onButtonPropertyChange('buttonLabel', value)}
componentName={`table_column_button_${button.id}_buttonLabel`}
popOverCallback={(showing) => setColumnPopoverRootCloseBlocker('buttonLabel', showing)}
/>
</div>
<div className="field mb-2 px-3">
<label className="form-label">Tooltip</label>
<CodeHinter
currentState={currentState}
initialValue={button.buttonTooltip ?? ''}
theme={darkMode ? 'monokai' : 'default'}
mode="javascript"
lineNumbers={false}
placeholder="Enter tooltip text"
onChange={(value) => onButtonPropertyChange('buttonTooltip', value)}
componentName={`table_column_button_${button.id}_buttonTooltip`}
popOverCallback={(showing) => setColumnPopoverRootCloseBlocker('buttonTooltip', showing)}
/>
</div>
<div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px' }}>
<div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}>
<ProgramaticallyHandleProperties
label="Loading state"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="loadingState"
props={button}
component={component}
paramMeta={{ type: 'toggle', displayName: 'Loading state' }}
paramType="properties"
/>
</div>
</div>
<div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px', marginTop: '-8px' }}>
<div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}>
<ProgramaticallyHandleProperties
label="Visibility"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonVisibility"
props={button}
component={component}
paramMeta={{ type: 'toggle', displayName: 'Visibility' }}
paramType="properties"
/>
</div>
</div>
<div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px', marginTop: '-8px' }}>
<div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}>
<ProgramaticallyHandleProperties
label="Disable action button"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="disableButton"
props={button}
component={component}
paramMeta={{ type: 'toggle', displayName: 'Disable action button' }}
paramType="properties"
/>
</div>
</div>
<div style={{ borderTop: '1px solid var(--border-weak)' }}>
<Accordion
items={[
{
title: 'Events',
isOpen: true,
children: (
<EventManager
sourceId={props?.component?.id}
eventSourceType="table_column"
customEventRefs={{ ref: compoundRef }}
hideEmptyEventsAlert={false}
eventMetaDefinition={{ events: { onClick: { displayName: 'On click' } } }}
currentState={currentState}
dataQueries={props.dataQueries}
components={props.components}
eventsChanged={(events) => columnEventChanged(column, events)}
apps={props.apps}
popOverCallback={(showing) => handleEventManagerPopoverCallback(showing)}
pages={props.pages}
/>
),
},
]}
/>
</div>
</>
);
};

View file

@ -0,0 +1,182 @@
import React, { useState } from 'react';
import ToggleGroup from '@/ToolJetUI/SwitchGroup/ToggleGroup';
import ToggleGroupItem from '@/ToolJetUI/SwitchGroup/ToggleGroupItem';
import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandleProperties';
import { Icon as IconPicker } from '@/AppBuilder/CodeBuilder/Elements/Icon';
import AlignLeftinspector from '@/_ui/Icon/solidIcons/AlignLeftinspector';
import AlignRightinspector from '@/_ui/Icon/solidIcons/AlignRightinspector';
export const ButtonStylesTab = ({
button,
index,
darkMode,
currentState,
onButtonPropertyChange,
onButtonPropertiesChange,
component,
}) => {
const [buttonType, setButtonType] = useState(button?.buttonType || 'solid');
const isSolid = buttonType === 'solid';
// Label colors considered "default" that should be swapped on mode change
const DEFAULT_LABEL = [
'#FFFFFF',
'#ffffff',
'var(--cc-surface1-surface)',
'var(--text-on-solid)',
'var(--cc-primary-text)',
];
const handleTypeChange = (value) => {
setButtonType(value);
// Batch buttonType + label color change in a single update to avoid stale closure
const updates = { buttonType: value };
if (!button?.buttonLabelColor || DEFAULT_LABEL.includes(button.buttonLabelColor)) {
updates.buttonLabelColor = value === 'outline' ? 'var(--cc-primary-text)' : '#FFFFFF';
}
onButtonPropertiesChange(updates);
};
if (!button) return null;
return (
<div className="d-flex flex-column custom-gap-16">
{/* Button type - Solid/Outline */}
<div className="field d-flex align-items-center justify-content-between px-3">
<label className="tj-text-xsm color-slate12">Button type</label>
<ToggleGroup onValueChange={handleTypeChange} defaultValue={buttonType}>
<ToggleGroupItem value="solid">Solid</ToggleGroupItem>
<ToggleGroupItem value="outline">Outline</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Background color - only shown in Solid mode (matches Button widget behavior) */}
{isSolid && (
<div className="field px-3">
<ProgramaticallyHandleProperties
label="Background"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonBackgroundColor"
props={button}
component={component}
paramMeta={{ type: 'colorSwatches', displayName: 'Background' }}
paramType="properties"
/>
</div>
)}
{/* Label color */}
<div className="field px-3">
<ProgramaticallyHandleProperties
label="Label color"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonLabelColor"
props={button}
component={component}
paramMeta={{ type: 'colorSwatches', displayName: 'Label color' }}
paramType="properties"
/>
</div>
{/* Border color */}
<div className="field px-3">
<ProgramaticallyHandleProperties
label="Border color"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonBorderColor"
props={button}
component={component}
paramMeta={{ type: 'colorSwatches', displayName: 'Border color' }}
paramType="properties"
/>
</div>
{/* Loader color */}
<div className="field px-3">
<ProgramaticallyHandleProperties
label="Loader color"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonLoaderColor"
props={button}
component={component}
paramMeta={{ type: 'colorSwatches', displayName: 'Loader color' }}
paramType="properties"
/>
</div>
{/* Icon - picker with visibility toggle */}
<div className="field d-flex align-items-center justify-content-between px-3">
<label className="tj-text-xsm color-slate12">Icon</label>
<IconPicker
value={button?.buttonIconName || 'IconHome2'}
onChange={(value) => onButtonPropertyChange('buttonIconName', value)}
onVisibilityChange={(value) => onButtonPropertyChange('buttonIconVisibility', value)}
styleDefinition={{ iconVisibility: { value: button?.buttonIconVisibility ?? false } }}
component={component}
isVisibilityEnabled={true}
/>
</div>
{/* Icon color */}
<div className="field px-3">
<ProgramaticallyHandleProperties
label="Icon color"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonIconColor"
props={button}
component={component}
paramMeta={{ type: 'colorSwatches', displayName: '', showLabel: false }}
paramType="properties"
/>
</div>
{/* Icon alignment */}
<div className="field d-flex align-items-center justify-content-between px-3">
<label className="tj-text-xsm color-slate12"></label>
<ToggleGroup
onValueChange={(_value) => onButtonPropertyChange('buttonIconAlignment', _value)}
defaultValue={button?.buttonIconAlignment || 'left'}
>
<ToggleGroupItem value="left">
<AlignLeftinspector width={14} fill="#C1C8CD" />
</ToggleGroupItem>
<ToggleGroupItem value="right">
<AlignRightinspector width={14} fill="#C1C8CD" />
</ToggleGroupItem>
</ToggleGroup>
</div>
{/* Border radius */}
<div className="field px-3">
<ProgramaticallyHandleProperties
label="Border radius"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={(_, prop, val) => onButtonPropertyChange(prop, val)}
property="buttonBorderRadius"
props={button}
component={component}
paramMeta={{ type: 'numberInput', displayName: 'Border radius' }}
paramType="properties"
/>
</div>
</div>
);
};

View file

@ -3,6 +3,8 @@ import Popover from 'react-bootstrap/Popover';
import { StylesTabElements } from './StylesTabElements'; import { StylesTabElements } from './StylesTabElements';
import { PropertiesTabElements } from './PropertiesTabElements'; import { PropertiesTabElements } from './PropertiesTabElements';
import { TableColumnContext } from './TableColumnContext'; import { TableColumnContext } from './TableColumnContext';
import { Button } from '@/components/ui/Button/Button';
import { useButtonManager } from '../hooks/useButtonManager';
export const ColumnPopoverContent = ({ export const ColumnPopoverContent = ({
column, column,
@ -16,8 +18,11 @@ export const ColumnPopoverContent = ({
props, props,
columnEventChanged, columnEventChanged,
handleEventManagerPopoverCallback, handleEventManagerPopoverCallback,
onDuplicateColumn,
onDeleteColumn,
}) => { }) => {
const [activeTab, setActiveTab] = useState('propertiesTab'); const [activeTab, setActiveTab] = useState('propertiesTab');
const [selectedButtonId, setSelectedButtonId] = useState(null);
const [isGoingBelowScreen, setIsGoingBelowScreen] = useState(false); const [isGoingBelowScreen, setIsGoingBelowScreen] = useState(false);
const popoverRef = useRef(null); const popoverRef = useRef(null);
@ -68,10 +73,7 @@ export const ColumnPopoverContent = ({
} }
}; };
// Check position after a short delay to ensure popover is rendered
const timeoutId = setTimeout(checkPopoverPosition, 100); const timeoutId = setTimeout(checkPopoverPosition, 100);
// Also check on window resize
window.addEventListener('resize', checkPopoverPosition); window.addEventListener('resize', checkPopoverPosition);
return () => { return () => {
@ -80,6 +82,29 @@ export const ColumnPopoverContent = ({
}; };
}, [index]); }, [index]);
const isButtonColumn = column.columnType === 'button';
const isButtonDetailView = isButtonColumn && selectedButtonId !== null;
const buttonManager = useButtonManager({ column, index, onColumnItemChange });
const { removeButton, duplicateButton } = buttonManager;
const handleDelete = () => {
if (isButtonDetailView) {
removeButton(selectedButtonId);
setSelectedButtonId(null);
} else {
onDeleteColumn?.();
}
};
const handleDuplicate = () => {
if (isButtonDetailView) {
duplicateButton(selectedButtonId);
} else {
onDuplicateColumn?.();
}
};
{ {
/* TableColumnContext provides the table's component ID to all nested CodeHinters, /* TableColumnContext provides the table's component ID to all nested CodeHinters,
enabling rowData/cellValue autocomplete hints in column editors (properties, styles, etc.). enabling rowData/cellValue autocomplete hints in column editors (properties, styles, etc.).
@ -87,7 +112,48 @@ export const ColumnPopoverContent = ({
} }
return ( return (
<TableColumnContext.Provider value={component?.id}> <TableColumnContext.Provider value={component?.id}>
<Popover.Header> <Popover.Header style={{ padding: '8px 16px 0 16px' }}>
<div className="d-flex align-items-center justify-content-between">
<div className="d-flex custom-gap-6 flex-row items-center justify-start">
{isButtonDetailView && (
<Button
variant="ghost"
size="medium"
iconOnly={true}
isLucid={true}
trailingIcon="arrow-left"
onClick={() => {
setSelectedButtonId(null);
setActiveTab('propertiesTab');
}}
/>
)}
{!isButtonDetailView && <span className="tj-text tj-header-h8 d-flex align-items-center">Edit Column</span>}
{isButtonDetailView && <span className="tj-text tj-header-h8 d-flex align-items-center">Edit Button</span>}
</div>
<div className="d-flex flex-row">
<Button
variant="ghost"
size="medium"
iconOnly={true}
isLucid={true}
trailingIcon="copy"
onClick={handleDuplicate}
title={isButtonDetailView ? 'Duplicate button' : 'Duplicate column'}
/>
<Button
variant="ghost"
size="medium"
iconOnly={true}
isLucid={true}
trailingIcon="trash"
onClick={handleDelete}
title={isButtonDetailView ? 'Delete button' : 'Delete column'}
/>
</div>
</div>
<div className="d-flex custom-gap-4 align-self-stretch tj-text tj-text-xsm font-weight-500 text-secondary cursor-pointer"> <div className="d-flex custom-gap-4 align-self-stretch tj-text tj-text-xsm font-weight-500 text-secondary cursor-pointer">
<div <div
className={`${activeTab === 'propertiesTab' && 'active-column-tab'} column-header-tab`} className={`${activeTab === 'propertiesTab' && 'active-column-tab'} column-header-tab`}
@ -125,6 +191,9 @@ export const ColumnPopoverContent = ({
columnEventChanged={columnEventChanged} columnEventChanged={columnEventChanged}
timeZoneOptions={timeZoneOptions} timeZoneOptions={timeZoneOptions}
handleEventManagerPopoverCallback={handleEventManagerPopoverCallback} handleEventManagerPopoverCallback={handleEventManagerPopoverCallback}
selectedButtonId={selectedButtonId}
setSelectedButtonId={setSelectedButtonId}
buttonManager={buttonManager}
/> />
) : ( ) : (
<StylesTabElements <StylesTabElements
@ -135,6 +204,8 @@ export const ColumnPopoverContent = ({
onColumnItemChange={onColumnItemChange} onColumnItemChange={onColumnItemChange}
getPopoverFieldSource={getPopoverFieldSource} getPopoverFieldSource={getPopoverFieldSource}
component={component} component={component}
selectedButtonId={selectedButtonId}
buttonManager={buttonManager}
/> />
)} )}
</Popover.Body> </Popover.Body>

View file

@ -20,6 +20,8 @@ import Check from '@/_ui/Icon/solidIcons/Check';
import Icon from '@/_ui/Icon/solidIcons/index'; import Icon from '@/_ui/Icon/solidIcons/index';
import RatingIconToggle from './RatingColumn/RatingIconToggle'; import RatingIconToggle from './RatingColumn/RatingIconToggle';
import RatingColumnProperties from './RatingColumn/RatingColumnProperties'; import RatingColumnProperties from './RatingColumn/RatingColumnProperties';
import { ButtonListManager } from './ButtonListManager';
import { ButtonPropertiesTab } from './ButtonPropertiesTab';
const CustomOption = (props) => { const CustomOption = (props) => {
const ColumnIcon = getColumnIcon(props.data.value); const ColumnIcon = getColumnIcon(props.data.value);
@ -74,8 +76,12 @@ export const PropertiesTabElements = ({
columnEventChanged, columnEventChanged,
timeZoneOptions, timeZoneOptions,
handleEventManagerPopoverCallback, handleEventManagerPopoverCallback,
selectedButtonId,
setSelectedButtonId,
buttonManager,
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { addButton, removeButton, updateButtonProperty, reorderButtons, getButton } = buttonManager;
const customStylesForSelect = { const customStylesForSelect = {
...defaultStyles(darkMode, '100%'), ...defaultStyles(darkMode, '100%'),
@ -83,102 +89,109 @@ export const PropertiesTabElements = ({
return ( return (
<> <>
{column.columnType && <DeprecatedColumnTypeMsg columnType={column.columnType} darkMode={darkMode} />} {!selectedButtonId && (
<div className="field px-3" data-cy={`dropdown-column-type`} onClick={(e) => e.stopPropagation()}> <>
<label data-cy={`label-column-type`} className="form-label"> {column.columnType && <DeprecatedColumnTypeMsg columnType={column.columnType} darkMode={darkMode} />}
{t('widget.Table.columnType', 'Column type')} <div className="field px-3" data-cy={`dropdown-column-type`} onClick={(e) => e.stopPropagation()}>
</label> <label data-cy={`label-column-type`} className="form-label">
{t('widget.Table.columnType', 'Column type')}
</label>
<CustomSelect <CustomSelect
options={[ options={[
{ label: 'String', value: 'string' }, { label: 'String', value: 'string' },
{ label: 'Number', value: 'number' }, { label: 'Number', value: 'number' },
{ label: 'Text', value: 'text' }, { label: 'Text', value: 'text' },
{ label: 'Date Picker', value: 'datepicker' }, { label: 'Date Picker', value: 'datepicker' },
{ label: 'Select', value: 'select' }, { label: 'Select', value: 'select' },
{ label: 'MultiSelect', value: 'newMultiSelect' }, { label: 'MultiSelect', value: 'newMultiSelect' },
{ label: 'Boolean', value: 'boolean' }, { label: 'Boolean', value: 'boolean' },
{ label: 'Image', value: 'image' }, { label: 'Image', value: 'image' },
{ label: 'Link', value: 'link' }, { label: 'Link', value: 'link' },
{ label: 'JSON', value: 'json' }, { label: 'JSON', value: 'json' },
{ label: 'Markdown', value: 'markdown' }, { label: 'Markdown', value: 'markdown' },
{ label: 'HTML', value: 'html' }, { label: 'HTML', value: 'html' },
{ label: 'Rating', value: 'rating' }, { label: 'Rating', value: 'rating' },
// Following column types are deprecated { label: 'Button', value: 'button' },
{ label: 'Default', value: 'default' }, // Following column types are deprecated
{ label: 'Dropdown', value: 'dropdown' }, { label: 'Default', value: 'default' },
{ label: 'Multiselect', value: 'multiselect' }, { label: 'Dropdown', value: 'dropdown' },
{ label: 'Toggle switch', value: 'toggle' }, { label: 'Multiselect', value: 'multiselect' },
{ label: 'Radio', value: 'radio' }, { label: 'Toggle switch', value: 'toggle' },
{ label: 'Badge', value: 'badge' }, { label: 'Radio', value: 'radio' },
{ label: 'Multiple badges', value: 'badges' }, { label: 'Badge', value: 'badge' },
{ label: 'Tags', value: 'tags' }, { label: 'Multiple badges', value: 'badges' },
]} { label: 'Tags', value: 'tags' },
components={{ ]}
DropdownIndicator, components={{
Option: CustomOption, DropdownIndicator,
SingleValue: CustomValueContainer, Option: CustomOption,
}} SingleValue: CustomValueContainer,
onChange={(value) => { }}
onColumnItemChange(index, 'columnType', value); onChange={(value) => {
}} onColumnItemChange(index, 'columnType', value);
value={column.columnType} }}
useCustomStyles={true} value={column.columnType}
styles={customStylesForSelect} useCustomStyles={true}
className={`column-type-table-inspector`} styles={customStylesForSelect}
/> className={`column-type-table-inspector`}
</div> />
<div className="field px-3" data-cy={`input-and-label-column-name`}> </div>
<label data-cy={`label-column-name`} className="form-label"> <div className="field px-3" data-cy={`input-and-label-column-name`}>
{t('widget.Table.columnName', 'Column name')} <label data-cy={`label-column-name`} className="form-label">
</label> {t('widget.Table.columnName', 'Column name')}
<CodeHinter </label>
currentState={currentState} <CodeHinter
initialValue={column.name} currentState={currentState}
theme={darkMode ? 'monokai' : 'default'} initialValue={column.name}
mode="javascript" theme={darkMode ? 'monokai' : 'default'}
lineNumbers={false} mode="javascript"
placeholder={column.name} lineNumbers={false}
onChange={(value) => onColumnItemChange(index, 'name', value)} placeholder={column.name}
componentName={getPopoverFieldSource(column.columnType, 'name')} onChange={(value) => onColumnItemChange(index, 'name', value)}
popOverCallback={(showing) => { componentName={getPopoverFieldSource(column.columnType, 'name')}
setColumnPopoverRootCloseBlocker('name', showing); popOverCallback={(showing) => {
}} setColumnPopoverRootCloseBlocker('name', showing);
/> }}
</div> />
<div data-cy={`input-and-label-key`} className="field px-3"> </div>
<label className="form-label">{t('widget.Table.key', 'Key')}</label> <div data-cy={`input-and-label-key`} className="field px-3">
<CodeHinter <label className="form-label">{t('widget.Table.key', 'Key')}</label>
currentState={currentState} <CodeHinter
initialValue={column.key} currentState={currentState}
theme={darkMode ? 'monokai' : 'default'} initialValue={column.key}
mode="javascript" theme={darkMode ? 'monokai' : 'default'}
lineNumbers={false} mode="javascript"
placeholder={column.name} lineNumbers={false}
onChange={(value) => onColumnItemChange(index, 'key', value)} placeholder={column.name}
componentName={getPopoverFieldSource(column.columnType, 'key')} onChange={(value) => onColumnItemChange(index, 'key', value)}
popOverCallback={(showing) => { componentName={getPopoverFieldSource(column.columnType, 'key')}
setColumnPopoverRootCloseBlocker('tableKey', showing); popOverCallback={(showing) => {
}} setColumnPopoverRootCloseBlocker('tableKey', showing);
/> }}
</div> />
<div data-cy={`transformation-field`} className="field px-3"> </div>
<label className="form-label">{t('widget.Table.transformationField', 'Transformation')}</label> </>
<CodeHinter )}
currentState={currentState} {column.columnType !== 'button' && (
initialValue={column?.transformation ?? '{{cellValue}}'} <div data-cy={`transformation-field`} className="field px-3">
theme={darkMode ? 'monokai' : 'default'} <label className="form-label">{t('widget.Table.transformationField', 'Transformation')}</label>
mode="javascript" <CodeHinter
lineNumbers={false} currentState={currentState}
placeholder={column.name} initialValue={column?.transformation ?? '{{cellValue}}'}
onChange={(value) => onColumnItemChange(index, 'transformation', value)} theme={darkMode ? 'monokai' : 'default'}
componentName={getPopoverFieldSource(column.columnType, 'transformation')} mode="javascript"
popOverCallback={(showing) => { lineNumbers={false}
setColumnPopoverRootCloseBlocker('transformation', showing); placeholder={column.name}
}} onChange={(value) => onColumnItemChange(index, 'transformation', value)}
enablePreview={false} componentName={getPopoverFieldSource(column.columnType, 'transformation')}
/> popOverCallback={(showing) => {
</div> setColumnPopoverRootCloseBlocker('transformation', showing);
}}
enablePreview={false}
/>
</div>
)}
{column.columnType === 'rating' && ( {column.columnType === 'rating' && (
<div data-cy={`rating-type-field`} className="field px-3 d-flex justify-content-between"> <div data-cy={`rating-type-field`} className="field px-3 d-flex justify-content-between">
<label className="form-label d-flex align-items-center">{'Icon'}</label> <label className="form-label d-flex align-items-center">{'Icon'}</label>
@ -193,6 +206,7 @@ export const PropertiesTabElements = ({
<EventManager <EventManager
sourceId={props?.component?.id} sourceId={props?.component?.id}
eventSourceType="table_column" eventSourceType="table_column"
customEventRefs={{ ref: column.name }}
hideEmptyEventsAlert={true} hideEmptyEventsAlert={true}
eventMetaDefinition={{ events: { onChange: { displayName: 'On change' } } }} eventMetaDefinition={{ events: { onChange: { displayName: 'On change' } } }}
currentState={currentState} currentState={currentState}
@ -207,6 +221,47 @@ export const PropertiesTabElements = ({
/> />
</div> </div>
)} )}
{column.columnType === 'button' && !selectedButtonId && (
<>
<div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px' }}>
<div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}>
<ProgramaticallyHandleProperties
label="Visibility"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={onColumnItemChange}
property="columnVisibility"
props={column}
component={component}
paramMeta={{ type: 'toggle', displayName: 'Visibility' }}
paramType="properties"
/>
</div>
</div>
<ButtonListManager
buttons={column.buttons || []}
onAddButton={addButton}
onReorderButtons={reorderButtons}
onSelectButton={setSelectedButtonId}
/>
</>
)}
{column.columnType === 'button' && selectedButtonId && (
<ButtonPropertiesTab
button={getButton(selectedButtonId)}
column={column}
index={index}
darkMode={darkMode}
currentState={currentState}
onButtonPropertyChange={(property, value) => updateButtonProperty(selectedButtonId, property, value)}
setColumnPopoverRootCloseBlocker={setColumnPopoverRootCloseBlocker}
component={component}
props={props}
columnEventChanged={columnEventChanged}
handleEventManagerPopoverCallback={handleEventManagerPopoverCallback}
/>
)}
{(column.columnType === 'dropdown' || {(column.columnType === 'dropdown' ||
column.columnType === 'multiselect' || column.columnType === 'multiselect' ||
column.columnType === 'badge' || column.columnType === 'badge' ||
@ -302,7 +357,7 @@ export const PropertiesTabElements = ({
/> />
</div> </div>
)} )}
{!['image', 'link'].includes(column.columnType) && ( {!['image', 'link', 'button'].includes(column.columnType) && (
<div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px' }}> <div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px' }}>
<div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}> <div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}>
<ProgramaticallyHandleProperties <ProgramaticallyHandleProperties
@ -350,22 +405,24 @@ export const PropertiesTabElements = ({
</div> </div>
</div> </div>
)} )}
<div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px', marginTop: '-8px' }}> {column.columnType !== 'button' && (
<div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}> <div className="border mx-3 column-popover-card-ui" style={{ borderRadius: '6px', marginTop: '-8px' }}>
<ProgramaticallyHandleProperties <div style={{ background: 'var(--surfaces-surface-02)', padding: '8px 12px' }}>
label="Visibility" <ProgramaticallyHandleProperties
currentState={currentState} label="Visibility"
index={index} currentState={currentState}
darkMode={darkMode} index={index}
callbackFunction={onColumnItemChange} darkMode={darkMode}
property="columnVisibility" callbackFunction={onColumnItemChange}
props={column} property="columnVisibility"
component={component} props={column}
paramMeta={{ type: 'toggle', displayName: 'Visibility' }} component={component}
paramType="properties" paramMeta={{ type: 'toggle', displayName: 'Visibility' }}
/> paramType="properties"
/>
</div>
</div> </div>
</div> )}
{['select', 'newMultiSelect', 'datepicker', 'rating'].includes(column.columnType) && <hr className="mx-0 my-2" />} {['select', 'newMultiSelect', 'datepicker', 'rating'].includes(column.columnType) && <hr className="mx-0 my-2" />}
{column.columnType === 'datepicker' && ( {column.columnType === 'datepicker' && (

View file

@ -9,6 +9,7 @@ import AlignCenter from '@/_ui/Icon/solidIcons/AlignCenter';
import AlignRight from '@/_ui/Icon/solidIcons/AlignRight'; import AlignRight from '@/_ui/Icon/solidIcons/AlignRight';
import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandleProperties'; import { ProgramaticallyHandleProperties } from '../ProgramaticallyHandleProperties';
import { Select } from '@/AppBuilder/CodeBuilder/Elements/Select'; import { Select } from '@/AppBuilder/CodeBuilder/Elements/Select';
import { ButtonStylesTab } from './ButtonStylesTab';
export const StylesTabElements = ({ export const StylesTabElements = ({
column, column,
@ -18,32 +19,37 @@ export const StylesTabElements = ({
onColumnItemChange, onColumnItemChange,
getPopoverFieldSource, getPopoverFieldSource,
component, component,
selectedButtonId,
buttonManager,
}) => { }) => {
const { updateButtonProperty, updateButtonProperties, getButton } = buttonManager;
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<> <>
<div className="field d-flex custom-gap-12 align-items-center align-self-stretch justify-content-between px-3"> {column.columnType !== 'button' && (
<label className="d-flex align-items-center" style={{ flex: '1 1 0' }}> <div className="field d-flex custom-gap-12 align-items-center align-self-stretch justify-content-between px-3">
{column.columnType !== 'boolean' && column.columnType !== 'image' && column.columnType !== 'rating' <label className="d-flex align-items-center" style={{ flex: '1 1 0' }}>
? t('widget.Table.textAlignment', 'Text Alignment') {column.columnType !== 'boolean' && column.columnType !== 'image' && column.columnType !== 'rating'
: 'Alignment'} ? t('widget.Table.textAlignment', 'Text Alignment')
</label> : 'Alignment'}
<ToggleGroup </label>
onValueChange={(_value) => onColumnItemChange(index, 'horizontalAlignment', _value)} <ToggleGroup
defaultValue={column?.horizontalAlignment || 'left'} onValueChange={(_value) => onColumnItemChange(index, 'horizontalAlignment', _value)}
style={{ width: '58%' }} defaultValue={column?.horizontalAlignment || 'left'}
> style={{ width: '58%' }}
<ToggleGroupItem value="left"> >
<AlignLeft width={14} /> <ToggleGroupItem value="left">
</ToggleGroupItem> <AlignLeft width={14} />
<ToggleGroupItem value="center"> </ToggleGroupItem>
<AlignCenter width={14} /> <ToggleGroupItem value="center">
</ToggleGroupItem> <AlignCenter width={14} />
<ToggleGroupItem value="right"> </ToggleGroupItem>
<AlignRight width={14} /> <ToggleGroupItem value="right">
</ToggleGroupItem> <AlignRight width={14} />
</ToggleGroup> </ToggleGroupItem>
</div> </ToggleGroup>
</div>
)}
{column.columnType === 'toggle' && ( {column.columnType === 'toggle' && (
<div> <div>
<div className="field px-3"> <div className="field px-3">
@ -253,6 +259,37 @@ export const StylesTabElements = ({
</div> </div>
</div> </div>
)} )}
{column.columnType === 'button' && !selectedButtonId && (
<div className="d-flex flex-column custom-gap-16">
<div className="field px-3" data-cy={`input-and-label-cell-background-color`}>
<ProgramaticallyHandleProperties
label="Cell color"
currentState={currentState}
index={index}
darkMode={darkMode}
callbackFunction={onColumnItemChange}
property="cellBackgroundColor"
props={column}
component={component}
paramMeta={{ type: 'colorSwatches', displayName: 'Cell color' }}
paramType="properties"
/>
</div>
</div>
)}
{column.columnType === 'button' && selectedButtonId && (
<ButtonStylesTab
button={getButton(selectedButtonId)}
index={index}
darkMode={darkMode}
currentState={currentState}
onButtonPropertyChange={(property, value) => updateButtonProperty(selectedButtonId, property, value)}
onButtonPropertiesChange={(updates) => updateButtonProperties(selectedButtonId, updates)}
component={component}
/>
)}
</> </>
); );
}; };

View file

@ -11,62 +11,10 @@ export const ProgramaticallyHandleProperties = ({
paramMeta, paramMeta,
}) => { }) => {
const getValueBasedOnProperty = (property, props) => { const getValueBasedOnProperty = (property, props) => {
switch (property) { if (property === 'makeDefaultOption') {
case 'isEditable': return props?.[index]?.makeDefaultOption;
return props.isEditable;
case 'disableActionButton':
return props.disableActionButton;
case 'columnVisibility':
return props.columnVisibility;
case 'fieldVisibility':
return props.fieldVisibility;
case 'linkTarget':
return props.linkTarget;
case 'isAllColumnsEditable':
return props?.isAllColumnsEditable;
case 'isAllFieldsEditable':
return props?.isAllFieldsEditable;
case 'underlineColor':
return props.underlineColor;
case 'linkColor':
return props.linkColor;
case 'useDynamicOptions':
return props?.useDynamicOptions;
case 'autoAssignColors':
return props?.autoAssignColors;
case 'makeDefaultOption':
return props?.[index]?.makeDefaultOption;
case 'textColor':
return props?.textColor;
case 'cellBackgroundColor':
return props?.cellBackgroundColor;
case 'optionsLoadingState':
return props?.optionsLoadingState;
case 'isTimeChecked':
return props?.isTimeChecked;
case 'isTwentyFourHrFormatEnabled':
return props?.isTwentyFourHrFormatEnabled;
case 'parseInUnixTimestamp':
return props?.parseInUnixTimestamp;
case 'isDateSelectionEnabled':
return props?.isDateSelectionEnabled;
case 'jsonIndentation':
return props?.jsonIndentation;
case 'labelColor':
return props?.labelColor;
case 'optionColor':
return props?.optionColor;
case 'allowHalfStar':
return props?.allowHalfStar;
case 'selectedBgColorStars':
return props?.selectedBgColorStars;
case 'selectedBgColorHearts':
return props?.selectedBgColorHearts;
case 'unselectedBgColor':
return props?.unselectedBgColor;
default:
return;
} }
return props?.[property];
}; };
const getInitialValue = (property, definitionObj) => { const getInitialValue = (property, definitionObj) => {
@ -84,7 +32,7 @@ export const ProgramaticallyHandleProperties = ({
return value || '{{true}}'; return value || '{{true}}';
} }
if (property === 'cellBackgroundColor') { if (property === 'cellBackgroundColor') {
return definitionObj?.value ?? ''; return definitionObj?.value || 'var(--cc-surface1-surface)';
} }
if (property === 'textColor') { if (property === 'textColor') {
return definitionObj?.value ?? '#11181C'; return definitionObj?.value ?? '#11181C';
@ -109,6 +57,33 @@ export const ProgramaticallyHandleProperties = ({
if (property === 'jsonIndentation') { if (property === 'jsonIndentation') {
return definitionObj?.value ?? `{{true}}`; return definitionObj?.value ?? `{{true}}`;
} }
if (property === 'buttonVisibility') {
return definitionObj?.value ?? '{{true}}';
}
if (property === 'disableButton') {
return definitionObj?.value ?? '{{false}}';
}
if (property === 'loadingState') {
return definitionObj?.value ?? '{{false}}';
}
if (property === 'buttonBackgroundColor') {
return definitionObj?.value ?? 'var(--cc-primary-brand)';
}
if (property === 'buttonLabelColor') {
return definitionObj?.value ?? 'var(--cc-surface1-surface)';
}
if (property === 'buttonIconColor') {
return definitionObj?.value ?? 'var(--cc-surface1-surface)';
}
if (property === 'buttonLoaderColor') {
return definitionObj?.value ?? 'var(--cc-surface1-surface)';
}
if (property === 'buttonBorderColor') {
return definitionObj?.value ?? 'var(--cc-weak-border)';
}
if (property === 'buttonBorderRadius') {
return definitionObj?.value ?? '6';
}
return definitionObj?.value ?? `{{false}}`; return definitionObj?.value ?? `{{false}}`;
}; };

View file

@ -16,6 +16,8 @@ import NoListItem from './NoListItem';
import { ProgramaticallyHandleProperties } from './ProgramaticallyHandleProperties'; import { ProgramaticallyHandleProperties } from './ProgramaticallyHandleProperties';
import { ColumnPopoverContent } from './ColumnManager/ColumnPopover'; import { ColumnPopoverContent } from './ColumnManager/ColumnPopover';
import { checkIfTableColumnDeprecated } from './ColumnManager/DeprecatedColumnTypeMsg'; import { checkIfTableColumnDeprecated } from './ColumnManager/DeprecatedColumnTypeMsg';
import { ToolTip } from '@/_components/ToolTip';
import Icon from '@/_ui/Icon/solidIcons/index';
import { ColorSwatches } from '@/modules/Appbuilder/components'; import { ColorSwatches } from '@/modules/Appbuilder/components';
import { getColumnIcon } from './utils'; import { getColumnIcon } from './utils';
import { getSafeRenderableValue } from '@/AppBuilder/Widgets/utils'; import { getSafeRenderableValue } from '@/AppBuilder/Widgets/utils';
@ -45,6 +47,7 @@ const getColumnTypeDisplayText = (columnType) => {
json: 'JSON', json: 'JSON',
markdown: 'Markdown', markdown: 'Markdown',
html: 'HTML', html: 'HTML',
button: 'Button',
}; };
return displayMap[columnType] ?? capitalize(columnType ?? ''); return displayMap[columnType] ?? capitalize(columnType ?? '');
}; };
@ -153,6 +156,8 @@ export const Table = (props) => {
props={props} props={props}
columnEventChanged={handleColumnEventChange} columnEventChanged={handleColumnEventChange}
handleEventManagerPopoverCallback={handleEventManagerPopoverCallback} handleEventManagerPopoverCallback={handleEventManagerPopoverCallback}
onDuplicateColumn={() => duplicateColumn(index)}
onDeleteColumn={() => removeColumn(index, `${column.name}-${index}`)}
/> />
</Popover> </Popover>
), ),
@ -166,6 +171,8 @@ export const Table = (props) => {
props, props,
handleColumnEventChange, handleColumnEventChange,
handleEventManagerPopoverCallback, handleEventManagerPopoverCallback,
duplicateColumn,
removeColumn,
] ]
); );
@ -488,6 +495,7 @@ export const Table = (props) => {
<OverlayTrigger <OverlayTrigger
trigger="click" trigger="click"
placement="left" placement="left"
flip={true}
rootClose={isRootCloseEnabled} rootClose={isRootCloseEnabled}
overlay={renderColumnPopover(item, index)} overlay={renderColumnPopover(item, index)}
onToggle={(show) => handleToggleColumnPopover(index, show)} onToggle={(show) => handleToggleColumnPopover(index, show)}
@ -554,9 +562,27 @@ export const Table = (props) => {
</div> </div>
), ),
}, },
// Action buttons section // Action buttons section (deprecated replaced by button column type)
{ {
title: 'Action buttons', title: (
<div className="d-flex flex-row align-items-center" style={{ gap: '6px' }}>
<span>Action buttons</span>
<ToolTip
message={
<div style={{ padding: '8px 4px', textAlign: 'left', width: '185px' }}>
These Action buttons are deprecated and will be removed in a future update. Use the new Button column
instead by adding a new column and selecting type as a button.
</div>
}
show={true}
placement="bottom"
>
<span>
<Icon name={'warning'} height={14} width={14} fill="#DB4324" />
</span>
</ToolTip>
</div>
),
children: ( children: (
<div className="field"> <div className="field">
<div className="row g-2"> <div className="row g-2">

View file

@ -0,0 +1,18 @@
import React from 'react';
const ButtonTypeIcon = ({ fill = '#ACB2B9', width = '16', className = '', viewBox = '0 0 16 16', style, height }) => (
<svg
className={className}
width={width}
height={height}
viewBox={viewBox}
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={style}
>
<rect x="1.5" y="4.5" width="13" height="7" rx="2" stroke={fill} strokeWidth="1.2" />
<path d="M5.5 8H10.5" stroke={fill} strokeWidth="1.2" strokeLinecap="round" />
</svg>
);
export default ButtonTypeIcon;

View file

@ -14,3 +14,4 @@ export { default as BadgeTypeIcon } from './BadgeTypeIcon';
export { default as TagsTypeIcon } from './TagsTypeIcon'; export { default as TagsTypeIcon } from './TagsTypeIcon';
export { default as RadioTypeIcon } from './RadioTypeIcon'; export { default as RadioTypeIcon } from './RadioTypeIcon';
export { default as RatingTypeIcon } from './RadioTypeIcon'; export { default as RatingTypeIcon } from './RadioTypeIcon';
export { default as ButtonTypeIcon } from './ButtonTypeIcon';

View file

@ -0,0 +1,72 @@
import { v4 as uuidv4 } from 'uuid';
import useStore from '@/AppBuilder/_stores/store';
export const DEFAULT_BUTTON = {
buttonLabel: 'Button',
buttonTooltip: '',
disableButton: false,
loadingState: false,
buttonVisibility: true,
buttonType: 'solid',
buttonBackgroundColor: 'var(--cc-primary-brand)',
buttonLabelColor: '#FFFFFF',
buttonBorderColor: 'var(--cc-primary-brand)',
buttonBorderRadius: '6',
buttonLoaderColor: 'var(--cc-surface1-surface)',
buttonIconName: 'IconHome2',
buttonIconVisibility: false,
buttonIconColor: 'var(--cc-default-icon)',
buttonIconAlignment: 'left',
};
export const useButtonManager = ({ column, index, onColumnItemChange }) => {
const addButton = () => {
const newButton = { ...DEFAULT_BUTTON, id: uuidv4() };
const updatedButtons = [...(column.buttons || []), newButton];
onColumnItemChange(index, 'buttons', updatedButtons);
return newButton.id;
};
const removeButton = (buttonId) => {
const updatedButtons = (column.buttons || []).filter((b) => b.id !== buttonId);
onColumnItemChange(index, 'buttons', updatedButtons);
// Clean up events for this button
const columnKey = column.key || column.name;
const ref = `${columnKey}::${buttonId}`;
const { getModuleEvents, deleteAppVersionEventHandler } = useStore.getState().eventsSlice;
const events = getModuleEvents('canvas').filter((e) => e.target === 'table_column' && e.event?.ref === ref);
Promise.all(events.map((e) => deleteAppVersionEventHandler(e.id))).catch((err) => {
console.error('[useButtonManager] Failed to delete event handlers for button', buttonId, err);
});
};
const updateButtonProperty = (buttonId, property, value) => {
const updatedButtons = (column.buttons || []).map((b) => (b.id === buttonId ? { ...b, [property]: value } : b));
onColumnItemChange(index, 'buttons', updatedButtons);
};
const updateButtonProperties = (buttonId, updates) => {
const updatedButtons = (column.buttons || []).map((b) => (b.id === buttonId ? { ...b, ...updates } : b));
onColumnItemChange(index, 'buttons', updatedButtons);
};
const reorderButtons = (reorderedButtons) => {
onColumnItemChange(index, 'buttons', reorderedButtons);
};
const duplicateButton = (buttonId) => {
const button = (column.buttons || []).find((b) => b.id === buttonId);
if (!button) return null;
const newButton = { ...button, id: uuidv4() };
const updatedButtons = [...(column.buttons || []), newButton];
onColumnItemChange(index, 'buttons', updatedButtons);
return newButton.id;
};
const getButton = (buttonId) => {
return (column.buttons || []).find((b) => b.id === buttonId);
};
return { addButton, removeButton, duplicateButton, updateButtonProperty, updateButtonProperties, reorderButtons, getButton };
};

View file

@ -43,6 +43,16 @@ export const useColumnManager = ({ component, paramUpdated, currentState }) => {
}; };
} }
// Handle button column initialization — starts with empty buttons array
if (property === 'columnType' && value === 'button') {
modifiedColumn = {
...modifiedColumn,
columnVisibility: true,
horizontalAlignment: 'left',
buttons: [],
};
}
return modifiedColumn; return modifiedColumn;
}, []); }, []);
@ -62,6 +72,16 @@ export const useColumnManager = ({ component, paramUpdated, currentState }) => {
if (ref) { if (ref) {
await deleteEvents({ ref }, 'table_column'); await deleteEvents({ ref }, 'table_column');
} }
// Clean up events for all buttons in removed button columns
for (const col of removedColumns) {
if (col.columnType === 'button' && col.buttons) {
const columnKey = col.key || col.name;
for (const btn of col.buttons) {
await deleteEvents({ ref: `${columnKey}::${btn.id}` }, 'table_column');
}
}
}
}, },
[component, paramUpdated, deleteEvents] [component, paramUpdated, deleteEvents]
); );
@ -75,6 +95,9 @@ export const useColumnManager = ({ component, paramUpdated, currentState }) => {
typeProp: 'columnType', typeProp: 'columnType',
nonEditableTypes: ['link', 'image'], nonEditableTypes: ['link', 'image'],
namePrefix: 'new_column', namePrefix: 'new_column',
defaultItemProps: {
includeKey: true,
},
onPropertyChange: handlePropertyChange, onPropertyChange: handlePropertyChange,
onRemove: handleRemove, onRemove: handleRemove,
}, },

View file

@ -15,6 +15,7 @@ import {
TagsTypeIcon, TagsTypeIcon,
RadioTypeIcon, RadioTypeIcon,
RatingTypeIcon, RatingTypeIcon,
ButtonTypeIcon,
} from './_assets'; } from './_assets';
export const getColumnIcon = (columnType) => { export const getColumnIcon = (columnType) => {
@ -57,6 +58,8 @@ export const getColumnIcon = (columnType) => {
return TagsTypeIcon; return TagsTypeIcon;
case 'rating': case 'rating':
return RatingTypeIcon; return RatingTypeIcon;
case 'button':
return ButtonTypeIcon;
default: default:
return null; return null;
} }

View file

@ -0,0 +1,127 @@
import React from 'react';
import { Button } from '@/components/ui/Button/Button';
import TablerIcon from '@/_ui/Icon/TablerIcon';
import OverlayTrigger from 'react-bootstrap/OverlayTrigger';
import Tooltip from 'react-bootstrap/Tooltip';
export const ButtonColumn = ({
buttonLabel,
buttonType,
disableButton,
loadingState,
backgroundColor,
labelColor,
iconName,
iconVisibility,
iconColor,
iconAlignment,
loaderColor,
borderColor,
borderRadius,
tooltip,
onClick,
}) => {
const handleClick = (e) => {
e.stopPropagation();
if (onClick) onClick();
};
const variant = buttonType === 'outline' ? 'outline' : 'primary';
const isOutline = variant === 'outline';
// Compute colors based on solid/outline mode, matching Button widget behavior.
// When colors are at defaults, adapt them to the current mode.
// When user has customized, keep the custom value.
const DEFAULT_LABEL_COLORS = ['#FFFFFF', '#ffffff', 'var(--cc-surface1-surface)'];
const DEFAULT_BG_COLORS = ['#4368E3', '#4368e3', 'var(--cc-primary-brand)'];
const DEFAULT_BORDER_COLORS = [...DEFAULT_BG_COLORS, 'var(--cc-weak-border)'];
const DEFAULT_ICON_COLORS = [
'var(--cc-default-icon)',
'var(--cc-default-icon)',
'var(--cc-surface1-surface)',
'#FFFFFF',
'#ffffff',
];
const DEFAULT_LOADER_COLORS = ['#FFFFFF', '#ffffff', 'var(--cc-surface1-surface)'];
const isDefaultLabel = !labelColor || DEFAULT_LABEL_COLORS.includes(labelColor);
const isDefaultIcon = !iconColor || DEFAULT_ICON_COLORS.includes(iconColor);
const isDefaultBg = !backgroundColor || DEFAULT_BG_COLORS.includes(backgroundColor);
const isDefaultBorder = !borderColor || DEFAULT_BORDER_COLORS.includes(borderColor);
const isDefaultLoader = !loaderColor || DEFAULT_LOADER_COLORS.includes(loaderColor);
const computedBgColor = isDefaultBg
? isOutline
? 'transparent'
: 'var(--cc-primary-brand)'
: isOutline
? 'transparent'
: backgroundColor;
const computedLabelColor = isDefaultLabel
? isOutline
? 'var(--cc-primary-text)'
: 'var(--text-on-solid)'
: labelColor;
const computedIconColor = isDefaultIcon ? (isOutline ? 'var(--cc-default-icon)' : 'var(--icon-on-solid)') : iconColor;
const computedBorderColor = isDefaultBorder ? (isOutline ? 'var(--borders-strong)' : undefined) : borderColor;
const computedLoaderColor = isDefaultLoader ? (isOutline ? 'var(--cc-primary-brand)' : '#FFFFFF') : loaderColor;
let iconElement = null;
if (iconName && iconVisibility) {
iconElement = <TablerIcon iconName={iconName} size={14} stroke={1.5} style={{ color: computedIconColor }} />;
}
const buttonStyle = {
padding: '4px 10px',
borderRadius: borderRadius ? `${borderRadius}px` : '6px',
fontSize: '12px',
fontWeight: 500,
lineHeight: '20px',
gap: '6px',
height: '28px',
backgroundColor: computedBgColor,
color: computedLabelColor,
};
if (computedBorderColor) {
buttonStyle.borderColor = computedBorderColor;
buttonStyle.borderStyle = 'solid';
buttonStyle.borderWidth = '1px';
}
if (disableButton) {
buttonStyle.opacity = '50%';
}
const buttonElement = (
<Button
variant={variant}
size="default"
isLoading={!!loadingState}
disabled={!!disableButton}
fill={computedLoaderColor || undefined}
style={buttonStyle}
onClick={handleClick}
>
{iconAlignment === 'left' && iconElement}
{buttonLabel || 'Button'}
{iconAlignment === 'right' && iconElement}
</Button>
);
const hasTooltip = tooltip && tooltip.toString().trim();
if (hasTooltip) {
return (
<OverlayTrigger placement="auto" delay={{ show: 500, hide: 0 }} overlay={<Tooltip>{tooltip}</Tooltip>}>
<div style={{ display: 'flex' }}>{buttonElement}</div>
</OverlayTrigger>
);
}
return buttonElement;
};
export default ButtonColumn;

View file

@ -0,0 +1,65 @@
import React from 'react';
import useStore from '@/AppBuilder/_stores/store';
import useTableStore from '../../../_stores/tableStore';
import { shallow } from 'zustand/shallow';
import { ButtonColumn } from './ButtonColumnAdapter';
export const ButtonColumnGroup = ({ id, buttons = [], cellBackgroundColor, cellValue, rowData, onClick }) => {
const getResolvedValue = useStore((state) => state.getResolvedValue);
const getTableColumnEvents = useTableStore((state) => state.getTableColumnEvents, shallow);
return (
<div
className="h-100 d-flex align-items-center"
style={{
gap: '6px',
}}
>
{buttons.map((button) => {
const context = { cellValue, rowData };
const resolvedVisibility = getResolvedValue(button.buttonVisibility, context);
if (resolvedVisibility === false) return null;
const resolvedLabel = getResolvedValue(button.buttonLabel, context) || 'Button';
const resolvedType = getResolvedValue(button.buttonType, context) || 'solid';
const resolvedDisable = getResolvedValue(button.disableButton, context);
const resolvedLoading = getResolvedValue(button.loadingState, context);
const resolvedBgColor = getResolvedValue(button.buttonBackgroundColor, context);
const resolvedLabelColor = getResolvedValue(button.buttonLabelColor, context);
const resolvedIconName = getResolvedValue(button.buttonIconName, context);
const resolvedIconVisibility = getResolvedValue(button.buttonIconVisibility, context);
const resolvedIconColor = getResolvedValue(button.buttonIconColor, context);
const resolvedIconAlignment = getResolvedValue(button.buttonIconAlignment, context) || 'left';
const resolvedLoaderColor = getResolvedValue(button.buttonLoaderColor, context);
const resolvedBorderColor = getResolvedValue(button.buttonBorderColor, context);
const resolvedBorderRadius = getResolvedValue(button.buttonBorderRadius, context);
const resolvedTooltip = getResolvedValue(button.buttonTooltip, context);
return (
<ButtonColumn
key={button.id}
buttonLabel={resolvedLabel}
buttonType={resolvedType}
disableButton={resolvedDisable}
loadingState={resolvedLoading}
backgroundColor={resolvedBgColor}
labelColor={resolvedLabelColor}
iconName={resolvedIconName}
iconVisibility={resolvedIconVisibility}
iconColor={resolvedIconColor}
iconAlignment={resolvedIconAlignment}
loaderColor={resolvedLoaderColor}
borderColor={resolvedBorderColor}
borderRadius={resolvedBorderRadius}
tooltip={resolvedTooltip}
onClick={() => {
if (onClick) onClick(button.id, getTableColumnEvents(id));
}}
/>
);
})}
</div>
);
};
export default ButtonColumnGroup;

View file

@ -10,6 +10,7 @@ export { CustomSelectColumn } from './adapters/SelectColumnAdapter'; // Select &
export { JsonColumn } from './adapters/JsonColumnAdapter'; export { JsonColumn } from './adapters/JsonColumnAdapter';
export { MarkdownColumn } from './adapters/MarkdownColumnAdapter'; export { MarkdownColumn } from './adapters/MarkdownColumnAdapter';
export { HTMLColumn } from './adapters/HtmlColumnAdapter'; export { HTMLColumn } from './adapters/HtmlColumnAdapter';
export { ButtonColumnGroup } from './adapters/ButtonColumnGroupAdapter';
// Deprecated columns not moved to shared renderers // Deprecated columns not moved to shared renderers
export { ToggleColumn } from './Toggle'; export { ToggleColumn } from './Toggle';

View file

@ -57,15 +57,18 @@ export const TableRow = ({
data-cy={`${generateCypressDataCy(componentName)}-row-${virtualRow.index}`} data-cy={`${generateCypressDataCy(componentName)}-row-${virtualRow.index}`}
> >
{row.getVisibleCells().map((cell) => { {row.getVisibleCells().map((cell) => {
const isButtonColumn = cell.column.columnDef?.meta?.columnType === 'button';
const cellStyles = { const cellStyles = {
backgroundColor: getResolvedValue(cell.column.columnDef?.meta?.cellBackgroundColor ?? 'inherit', { backgroundColor: getResolvedValue(cell.column.columnDef?.meta?.cellBackgroundColor ?? 'inherit', {
rowData: row.original, rowData: row.original,
cellValue: cell.getValue(), cellValue: cell.getValue(),
}), }),
justifyContent: determineJustifyContentValue(cell.column.columnDef?.meta?.horizontalAlignment), justifyContent: isButtonColumn
? undefined
: determineJustifyContentValue(cell.column.columnDef?.meta?.horizontalAlignment),
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
textAlign: cell.column.columnDef?.meta?.horizontalAlignment, textAlign: isButtonColumn ? undefined : cell.column.columnDef?.meta?.horizontalAlignment,
width: cell.column.getSize(), width: cell.column.getSize(),
}; };
@ -79,10 +82,15 @@ export const TableRow = ({
return ( return (
<td <td
key={cell.id} key={cell.id}
data-cy={`${generateCypressDataCy(componentName)}-${generateCypressDataCy(typeof cell.column.columnDef?.header === 'string' ? cell.column.columnDef?.header : cell.column.id)}-row-${virtualRow.index}`} data-cy={`${generateCypressDataCy(componentName)}-${generateCypressDataCy(
typeof cell.column.columnDef?.header === 'string' ? cell.column.columnDef?.header : cell.column.id
)}-row-${virtualRow.index}`}
style={cellStyles} style={cellStyles}
className={cx('table-cell td', { className={cx('table-cell td', {
'has-actions': cell.column.id === 'rightActions' || cell.column.id === 'leftActions', 'has-actions':
cell.column.id === 'rightActions' ||
cell.column.id === 'leftActions' ||
cell.column.columnDef?.meta?.columnType === 'button',
'has-left-actions': cell.column.id === 'leftActions', 'has-left-actions': cell.column.id === 'leftActions',
'has-right-actions': cell.column.id === 'rightActions', 'has-right-actions': cell.column.id === 'rightActions',
'table-text-align-center': cell.column.columnDef?.meta?.horizontalAlignment === 'center', 'table-text-align-center': cell.column.columnDef?.meta?.horizontalAlignment === 'center',
@ -130,8 +138,9 @@ export const TableRow = ({
}} }}
> >
<div <div
className={`td-container ${cell.column.columnDef?.meta?.columnType === 'image' && 'jet-table-image-column h-100' className={`td-container ${
} ${cell.column.columnDef?.meta?.columnType !== 'image' && `w-100 h-100`}`} cell.column.columnDef?.meta?.columnType === 'image' && 'jet-table-image-column h-100'
} ${cell.column.columnDef?.meta?.columnType !== 'image' && `w-100 h-100`}`}
> >
{flexRender(cell.column?.columnDef?.cell, cell.getContext())} {flexRender(cell.column?.columnDef?.cell, cell.getContext())}
</div> </div>

View file

@ -15,6 +15,7 @@ import {
JsonColumn, JsonColumn,
MarkdownColumn, MarkdownColumn,
HTMLColumn, HTMLColumn,
ButtonColumnGroup,
// Deprecated columns // Deprecated columns
TagsColumn, TagsColumn,
RadioColumn, RadioColumn,
@ -23,8 +24,57 @@ import {
RatingColumn, RatingColumn,
} from '../_components/DataTypes'; } from '../_components/DataTypes';
import useTableStore from '../_stores/tableStore'; import useTableStore from '../_stores/tableStore';
import { normalizeButtonEvent } from './normalizeButtonEvent';
import SelectSearch from 'react-select-search'; import SelectSearch from 'react-select-search';
// Module-level singleton for text measurement (avoids creating canvas on every call)
let _measureCanvas = null;
const getMeasureContext = () => {
if (!_measureCanvas) {
_measureCanvas = document.createElement('canvas');
}
const ctx = _measureCanvas.getContext('2d');
ctx.font = '500 12px "IBM Plex Sans"';
return ctx;
};
// Calculate width needed for button column based on button labels/icons
const calculateButtonColumnWidth = (buttons, getResolvedValue) => {
if (!buttons || buttons.length === 0) return 90;
const context = getMeasureContext();
let totalWidth = 0;
const cellPadding = 24; // .has-actions: padding 0 12px (12px each side)
const buttonGap = 6; // ButtonColumnGroup: gap 6px
let visibleCount = 0;
buttons.forEach((button) => {
// Only skip when explicitly false (matching ButtonColumnGroupAdapter behavior)
const isVisible = getResolvedValue(button.buttonVisibility);
if (isVisible === false) return;
const label = getResolvedValue(button.buttonLabel) || 'Button';
const textWidth = context.measureText(label).width;
// Button style: padding 4px 10px = 20px horizontal, border 1px each side = 2px (conservative upper bound)
const buttonPadding = 20;
const buttonBorder = 2;
// Icon: 14px icon + 6px gap (button internal gap) when visible
const iconVisible = getResolvedValue(button.buttonIconVisibility);
const iconWidth = iconVisible ? 20 : 0;
totalWidth += textWidth + buttonPadding + buttonBorder + iconWidth;
visibleCount++;
});
// Add gaps between buttons
if (visibleCount > 1) totalWidth += buttonGap * (visibleCount - 1);
totalWidth += cellPadding;
return Math.max(90, Math.ceil(totalWidth));
};
export default function generateColumnsData({ export default function generateColumnsData({
columnProperties, columnProperties,
columnSizes, columnSizes,
@ -413,12 +463,63 @@ export default function generateColumnsData({
); );
} }
case 'button': {
if (columnForAddNewRow) return <span />;
const columnKey = column?.key || column?.name;
const buttons = column.buttons || [];
return (
<ButtonColumnGroup
id={id}
buttons={buttons}
cellBackgroundColor={getResolvedValue(column.cellBackgroundColor, { cellValue, rowData })}
cellValue={cellValue}
rowData={rowData}
onClick={(buttonId, tableColumnEvents) => {
const buttonEvents = tableColumnEvents.filter(
(event) => event?.event?.ref === `${columnKey}::${buttonId}`
);
// Dynamic mode: merge inline events from button data
const useDynamicColumn =
useTableStore.getState().components?.[id]?.columnDetails?.useDynamicColumn ?? false;
if (useDynamicColumn) {
const button = buttons.find((b) => b.id === buttonId);
const inlineEvents = (button?.events || [])
.map((evt) => {
const normalized = normalizeButtonEvent(evt, buttonId);
if (!normalized) return null;
return { event: { ...normalized, ref: `${columnKey}::${buttonId}` } };
})
.filter(Boolean);
buttonEvents.push(...inlineEvents);
}
fireEvent('OnTableButtonColumnClicked', {
column,
buttonId,
tableColumnEvents: buttonEvents,
});
}}
/>
);
}
default: default:
return cellValue || ''; return cellValue || '';
} }
}, },
}; };
// Disable sorting, filtering, and resizing for button columns; auto-size to content
if (columnType === 'button') {
columnDef.enableSorting = false;
columnDef.enableColumnFilter = false;
columnDef.enableResizing = false;
const buttons = column.buttons || [];
columnDef.size = calculateButtonColumnWidth(buttons, getResolvedValue);
}
// Add sorting configuration for specific column types // Add sorting configuration for specific column types
if (columnType === 'number') { if (columnType === 'number') {
columnDef.sortingFn = (rowA, rowB, columnId) => { columnDef.sortingFn = (rowA, rowB, columnId) => {

View file

@ -0,0 +1,64 @@
import { ActionTypes } from '@/AppBuilder/RightSideBar/Inspector/ActionTypes';
// Derives the label-to-id mapping from the canonical ActionTypes array so it stays
// in sync automatically when actions are added or changed.
// Supports both space-separated ("switch page") and collapsed ("switchpage") formats.
const ACTION_LABEL_TO_ID = ActionTypes.reduce((map, action) => {
const lowerName = action.name.toLowerCase();
map[lowerName] = action.id;
// Also add a collapsed (no-spaces) variant for convenience
const collapsed = lowerName.replace(/\s+/g, '');
if (collapsed !== lowerName) {
map[collapsed] = action.id;
}
return map;
}, {});
// Maps user-friendly event labels to internal eventId values
const EVENT_LABEL_TO_ID = {
'on click': 'onClick',
'on change': 'onChange',
'on focus': 'onFocus',
'on blur': 'onBlur',
'on hover': 'onHover',
};
/**
* Normalizes a user-friendly button event object into the internal format expected by executeAction.
*
* User format:
* { event: "On click", action: "Show Alert", message: "Hello!", alertType: "success" }
*
* Internal format:
* { eventId: "onClick", actionId: "show-alert", message: "Hello!", alertType: "success" }
*
* If the event already uses internal keys (actionId), it is passed through unchanged.
*/
export function normalizeButtonEvent(evt, buttonId) {
// Already in internal format — pass through
if (evt.actionId) return evt;
const { event: eventLabel, action: actionLabel, ...rest } = evt;
// Map action label → actionId
const actionId = actionLabel ? ACTION_LABEL_TO_ID[actionLabel.toLowerCase()] : undefined;
if (actionLabel && !actionId) {
const available = Object.keys(ACTION_LABEL_TO_ID)
.map((k) => `"${k}"`)
.join(', ');
console.warn(`[Table] Unknown action "${actionLabel}" in button "${buttonId}". Available actions: ${available}`);
return null;
}
// Map event label → eventId
const eventId = eventLabel ? EVENT_LABEL_TO_ID[eventLabel.toLowerCase()] : 'onClick';
if (eventLabel && !eventId) {
const available = Object.keys(EVENT_LABEL_TO_ID)
.map((k) => `"${k}"`)
.join(', ');
console.warn(`[Table] Unknown event "${eventLabel}" in button "${buttonId}". Available events: ${available}`);
return null;
}
return { ...rest, eventId, actionId };
}

View file

@ -326,6 +326,20 @@ export const createEventsSlice = (set, get) => ({
} }
} }
if (eventName === 'OnTableButtonColumnClicked') {
const { column, tableColumnEvents } = options;
if (column && tableColumnEvents) {
for (const event of tableColumnEvents) {
if (event?.event?.actionId) {
await get().eventsSlice.executeAction(event.event, mode, customVariables, moduleId);
}
}
} else {
console.log('No action is associated with this event');
}
}
if (eventName === 'onCalendarEventSelect') { if (eventName === 'onCalendarEventSelect') {
const { id, calendarEvent } = options; const { id, calendarEvent } = options;
setExposedValue(id, 'selectedEvent', calendarEvent); setExposedValue(id, 'selectedEvent', calendarEvent);
@ -943,12 +957,23 @@ export const createEventsSlice = (set, get) => ({
} }
case 'switch-page': { case 'switch-page': {
try { try {
const { pageId } = event; let { pageId } = event;
const { pageHandle } = event;
// Resolve pageHandle → pageId if pageId not provided
if (!pageId && pageHandle) {
const pages = get().modules[moduleId].pages;
pageId = pages.find((p) => p.handle === pageHandle.toLowerCase())?.id;
if (!pageId) {
throw new Error(`Invalid page handle: "${pageHandle}"`);
}
}
if (!pageId) { if (!pageId) {
throw new Error('No page ID provided'); throw new Error('Either pageId or pageHandle must be provided');
} }
const { switchPage } = get(); const { switchPage } = get();
const page = get().modules[moduleId].pages.find((page) => page.id === event.pageId); const page = get().modules[moduleId].pages.find((page) => page.id === pageId);
const queryParams = event.queryParams || []; const queryParams = event.queryParams || [];
if (page.restricted && mode !== 'edit') { if (page.restricted && mode !== 'edit') {
toast.error('Access to this page is restricted. Contact admin to know more.'); toast.error('Access to this page is restricted. Contact admin to know more.');

View file

@ -1207,6 +1207,7 @@
z-index: 999 !important; z-index: 999 !important;
min-width: 280px; min-width: 280px;
max-height: 85vh !important; max-height: 85vh !important;
background-color: var(--cc-surface1-surface);
.popover-body { .popover-body {
display: flex; display: flex;
@ -1214,8 +1215,10 @@
padding: 16px 0px 16px !important; padding: 16px 0px 16px !important;
gap: 16px !important; gap: 16px !important;
align-self: stretch; align-self: stretch;
background-color: var(--slate1) !important; // background-color: var(--slate1) !important;
border-radius: 0; border-radius: 0;
overflow-y: auto;
max-height: calc(85vh - 40px);
.optional-properties-when-editable-true { .optional-properties-when-editable-true {
// background-color: var(--slate3); // background-color: var(--slate3);
@ -1258,8 +1261,12 @@
} }
.popover-header { .popover-header {
padding: 4px 0 0 0 !important; padding: 8px 16px 0px 16px !important;
background-color: var(--slate3); display: flex;
flex-direction: column;
gap: 6px;
background-color: var(--surfaces-surface-01);
border-bottom: 1px solid var(--borders-weak-disabled) !important;
.active-column-tab { .active-column-tab {
border-bottom: 1px solid var(--indigo9); border-bottom: 1px solid var(--indigo9);