feat: adds custom sort function feature flag for table widget (#39649)

## Description
### Custom Sort Function for Table Widget

This PR introduces a new custom sort function feature for the Table
Widget, allowing users to define their own sorting logic for table data.

**Feature Flag Implementation**
- Added a new feature flag `release_table_custom_sort_function_enabled`
to control the availability of this feature
- Set the default value to `false` to allow for controlled rollout

**Table Widget Updates**
- Added `customSortFunction` property to the TableWidgetV2 props
interface
- Updated property configuration to include the custom sort function
control
- Added helper text and placeholder to guide users on proper
implementation
- Implemented visibility condition based on the feature flag and
sortable state

**Control Updates**
- Enhanced `TableCustomSortControl` to support the new custom sorting
logic
- Improved error handling in the sort function implementation
- Updated the binding structure to provide access to original data with
`original_` prefix
- Modified the function signature to use `(tableData, column, order)`
parameters

**Testing**
- Updated test cases to verify the extraction and computation of custom
sort expressions
- Implemented round-trip testing to ensure proper function parsing and
generation
- Added tests for edge cases like empty strings and non-matching formats

This feature enables users to implement complex sorting logic beyond the
default column-based sorting, such as multi-column sorting, custom
collation, or business-specific ordering rules.


Fixes #https://github.com/appsmithorg/appsmith-ee/issues/6503
_or_  
Fixes `Issue URL`
> [!WARNING]  
> _If no issue exists, please create an issue first, and check with the
maintainers if the issue is valid._

## Automation

/ok-to-test tags="@tag.Table, @tag.Sanity, @tag.Datasource"

### 🔍 Cypress test results
<!-- This is an auto-generated comment: Cypress test results  -->
> [!TIP]
> 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉
> Workflow run:
<https://github.com/appsmithorg/appsmith/actions/runs/13986693183>
> Commit: b64c2919c5d8eb1b82f6f8e6c76b98a60c2a6e22
> <a
href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=13986693183&attempt=1"
target="_blank">Cypress dashboard</a>.
> Tags: `@tag.Table, @tag.Sanity, @tag.Datasource`
> Spec:
> <hr>Fri, 21 Mar 2025 08:35:48 UTC
<!-- end of auto-generated comment: Cypress test results  -->


## Communication
Should the DevRel and Marketing teams inform users about this change?
- [ ] Yes
- [ ] No


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary by CodeRabbit

- **New Features**
- Introduced custom table sorting, enabling users to define and apply
their own sorting logic in table displays.
- Added a new control interface for configuring custom sort functions,
offering enhanced flexibility in data presentation.
- Integrated an option to enable or disable the custom sort
functionality based on feature flags.
- Enhanced the table widget to support custom sorting function data,
allowing for dynamic data presentation.
- Added a new property configuration for custom sorting functions within
the table widget.

- **Tests**
- Implemented comprehensive tests to verify custom sorting input
processing and ensure accurate data computation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Rahul Barwal 2025-03-21 15:45:29 +05:30 committed by GitHub
parent db25d123da
commit 09d8a4deab
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 602 additions and 3 deletions

View File

@ -53,6 +53,8 @@ export const FEATURE_FLAG = {
"release_external_saas_plugins_enabled",
release_tablev2_infinitescroll_enabled:
"release_tablev2_infinitescroll_enabled",
release_table_custom_sort_function_enabled:
"release_table_custom_sort_function_enabled",
} as const;
export type FeatureFlag = keyof typeof FEATURE_FLAG;
@ -97,6 +99,7 @@ export const DEFAULT_FEATURE_FLAG_VALUE: FeatureFlags = {
release_ads_entity_item_enabled: false,
release_external_saas_plugins_enabled: false,
release_tablev2_infinitescroll_enabled: false,
release_table_custom_sort_function_enabled: false,
};
export const AB_TESTING_EVENT_KEYS = {

View File

@ -0,0 +1,191 @@
import { EditorTheme } from "components/editorComponents/CodeEditor/EditorConfig";
import TableCustomSortControl from "./TableCustomSortControl";
const requiredParams = {
evaluatedValue: "",
widgetProperties: {
widgetName: "Table1",
primaryColumns: {
id: {
alias: "id",
originalId: "id",
},
name: {
alias: "name",
originalId: "name",
},
},
},
parentPropertyName: "",
parentPropertyValue: "",
additionalDynamicData: {},
label: "Custom Sort",
propertyName: "customSort",
controlType: "TABLE_CUSTOM_SORT",
isBindProperty: true,
isTriggerProperty: false,
onPropertyChange: jest.fn(),
openNextPanel: jest.fn(),
deleteProperties: jest.fn(),
hideEvaluatedValue: false,
placeholderText: "Enter custom sort logic",
};
describe("TableCustomSortControl.getInputComputedValue", () => {
// Create an instance to use for generating test data
const testInstance = new TableCustomSortControl({
...requiredParams,
theme: EditorTheme.LIGHT,
});
it("should extract computation expression correctly from binding string with arrow function", () => {
const sortExpression = "tableData.sort((a, b) => a.id - b.id)";
const bindingString = testInstance.getComputedValue(
`{{${sortExpression}}}`,
"Table1",
);
const result = TableCustomSortControl.getInputComputedValue(bindingString);
expect(result).toBe(`{{${sortExpression}}}`);
});
it("should extract computation expression correctly from binding string with function body", () => {
const sortExpression = `{
return tableData.filter(row => row.original_status === "active");
}`;
const bindingString = testInstance.getComputedValue(
`{{${sortExpression}}}`,
"Table1",
);
const result = TableCustomSortControl.getInputComputedValue(bindingString);
expect(result).toBe(`{{${sortExpression}}}`);
});
it("should return original value when binding string doesn't match expected format", () => {
const nonMatchingString = "{{Table1.tableData[0].id}}";
const result =
TableCustomSortControl.getInputComputedValue(nonMatchingString);
expect(result).toBe(nonMatchingString);
});
it("should handle empty string input", () => {
const emptyString = "";
const result = TableCustomSortControl.getInputComputedValue(emptyString);
expect(result).toBe(emptyString);
});
it("should support round-trip conversion (getComputedValue -> getInputComputedValue)", () => {
// Test with arrow function
const arrowExpression =
"tableData.sort((a, b) => a.name.localeCompare(b.name))";
const arrowBindingString = testInstance.getComputedValue(
arrowExpression,
"Table1",
);
const arrowResult =
TableCustomSortControl.getInputComputedValue(arrowBindingString);
expect(arrowResult).toBe(arrowExpression);
// Test with function body
const bodyExpression = `{
const direction = order === "asc" ? 1 : -1;
return tableData.sort((a, b) => direction * (a[column] - b[column]));
}`;
const bodyBindingString = testInstance.getComputedValue(
`{{${bodyExpression}}}`,
"Table1",
);
const bodyResult =
TableCustomSortControl.getInputComputedValue(bodyBindingString);
// We need to normalize the whitespace for this comparison
const normalizeWhitespace = (str: string) =>
str.replace(/\s+/g, " ").trim();
expect(normalizeWhitespace(bodyResult)).toBe(
normalizeWhitespace(`{{${bodyExpression}}}`),
);
});
});
describe("TableCustomSortControl instance methods", () => {
const testInstance = new TableCustomSortControl({
...requiredParams,
theme: EditorTheme.LIGHT,
});
it("generates correct binding with getComputedValue", () => {
const inputValue = "{{tableData.sort((a, b) => a.id - b.id)}}";
const result = testInstance.getComputedValue(inputValue, "Table1");
// Check that the result contains key parts of our expected binding
expect(result).toContain(
"const originalTableData = Table1.tableData || []",
);
expect(result).toContain(
"const filteredTableData = Table1.filteredTableData || []",
);
expect(result).toContain("const primaryColumnId = Table1.primaryColumnId");
expect(result).toContain(
"const getMergedTableData = (originalData, filteredData, primaryId)",
);
expect(result).toContain(
"const mergedTableData = primaryColumnId ? getMergedTableData(originalTableData, filteredTableData, primaryColumnId) : filteredTableData",
);
expect(result).toContain("tableData.sort((a, b) => a.id - b.id)");
expect(result).toContain(
"if (Array.isArray(sortedTableData) && primaryColumnId)",
);
expect(result).toContain('console.error("Error in table custom sort:", e)');
});
it("handles non-binding values correctly in getComputedValue", () => {
const plainValue = "plain text";
const result = testInstance.getComputedValue(plainValue, "Table1");
expect(result).toBe(plainValue);
});
it("handles empty string in getComputedValue", () => {
const emptyValue = "";
const result = testInstance.getComputedValue(emptyValue, "Table1");
expect(result).toBe(emptyValue);
});
it("correctly handles original data in computed value", () => {
const inputValue =
"{{tableData.map(row => ({...row, age: row.__original__.age}))}}";
const result = testInstance.getComputedValue(inputValue, "Table1");
expect(result).toContain(
"const originalTableData = Table1.tableData || []",
);
expect(result).toContain(
"const filteredTableData = Table1.filteredTableData || []",
);
expect(result).toContain("const primaryColumnId = Table1.primaryColumnId");
expect(result).toContain(
"const getMergedTableData = (originalData, filteredData, primaryId)",
);
expect(result).toContain(
"const mergedTableData = primaryColumnId ? getMergedTableData(originalTableData, filteredTableData, primaryColumnId) : filteredTableData",
);
expect(result).toContain(
"tableData.map(row => ({...row, age: row.__original__.age}))",
);
});
});
describe("TableCustomSortControl.getControlType", () => {
it("returns the correct control type", () => {
expect(TableCustomSortControl.getControlType()).toBe("TABLE_CUSTOM_SORT");
});
});

View File

@ -0,0 +1,364 @@
import React from "react";
import type { ControlProps } from "./BaseControl";
import BaseControl from "./BaseControl";
import { StyledDynamicInput } from "./StyledControls";
import type {
CodeEditorExpected,
EditorProps,
} from "components/editorComponents/CodeEditor";
import {
CodeEditorBorder,
EditorModes,
EditorSize,
EditorTheme,
TabBehaviour,
} from "components/editorComponents/CodeEditor/EditorConfig";
import type { ColumnProperties } from "widgets/TableWidgetV2/component/Constants";
import { isDynamicValue } from "utils/DynamicBindingUtils";
import styled from "styled-components";
import { isString } from "utils/helpers";
import { JSToString, stringToJS } from "./utils";
import type { AdditionalDynamicDataTree } from "utils/autocomplete/customTreeTypeDefCreator";
import LazyCodeEditor from "components/editorComponents/LazyCodeEditor";
import { bindingHintHelper } from "components/editorComponents/CodeEditor/hintHelpers";
import { slashCommandHintHelper } from "components/editorComponents/CodeEditor/commandsHelper";
import { CollapseContext } from "pages/Editor/PropertyPane/PropertySection";
const PromptMessage = styled.span`
line-height: 17px;
> .code-wrapper {
font-family: var(--ads-v2-font-family-code);
display: inline-flex;
align-items: center;
}
`;
const CurlyBraces = styled.span`
color: var(--ads-v2-color-fg);
background-color: var(--ads-v2-color-bg-muted);
border-radius: 2px;
padding: 2px;
margin: 0 2px 0 0;
font-size: 10px;
font-weight: var(--ads-v2-font-weight-bold);
`;
interface InputTextProp {
label: string;
value: string;
onChange: (event: React.ChangeEvent<HTMLTextAreaElement> | string) => void;
evaluatedValue?: unknown;
expected?: CodeEditorExpected;
placeholder?: string;
dataTreePath?: string;
additionalDynamicData: AdditionalDynamicDataTree;
theme: EditorTheme;
height?: string | number;
maxHeight?: string | number;
isEditorHidden?: boolean;
}
function InputText(props: InputTextProp) {
const {
additionalDynamicData,
dataTreePath,
evaluatedValue,
expected,
height,
isEditorHidden,
maxHeight,
onChange,
placeholder,
theme,
value,
} = props;
return (
<StyledDynamicInput>
<LazyCodeEditor
AIAssisted
additionalDynamicData={additionalDynamicData}
border={CodeEditorBorder.ALL_SIDE}
dataTreePath={dataTreePath}
evaluatedValue={evaluatedValue}
expected={expected}
height={height as EditorProps["height"]}
hinting={[bindingHintHelper, slashCommandHintHelper]}
hoverInteraction
input={{
value: value,
onChange: onChange,
}}
isEditorHidden={isEditorHidden}
maxHeight={maxHeight as EditorProps["maxHeight"]}
mode={EditorModes.TEXT_WITH_BINDING}
placeholder={placeholder}
positionCursorInsideBinding
promptMessage={
<PromptMessage>
Access data using{" "}
<span className="code-wrapper">
<CurlyBraces>{"{{"}</CurlyBraces>
tableData, column, order
<CurlyBraces>{"}}"}</CurlyBraces>
</span>
<br />
Original data is available inside{" "}
<span className="code-wrapper">
<CurlyBraces>__original__</CurlyBraces>
</span>
</PromptMessage>
}
size={EditorSize.EXTENDED}
tabBehaviour={TabBehaviour.INDENT}
theme={theme}
/>
</StyledDynamicInput>
);
}
class TableCustomSortControl extends BaseControl<TableCustomSortControlProps> {
static contextType = CollapseContext;
context!: React.ContextType<typeof CollapseContext>;
static getTableCustomSortBinding = (
tableName: string,
stringToEvaluate: string,
) => {
if (!stringToEvaluate) {
return stringToEvaluate;
}
/**
* This is a self-executing function that implements custom table sorting logic.
*
* State Management:
* 1. Initializes with original table data, filtered table data, and primary column ID
* 2. If primary column ID is not set, it returns the filtered table data
* 3. Creates a mapping function to merge original and filtered data
* 4. Processes the data through several states:
* - Initial state: Raw table data and filtered data
* - Merged state: Combines original and filtered data with "original_" prefixed properties
* - Sorted state: Applies user-defined sorting logic from stringToEvaluate
* - Cleaned state: Removes temporary "original_" properties from the result
*
* Error handling is implemented to return filtered data if any step fails.
* Empty dataset handling returns empty results immediately.
* The function maintains data integrity by preserving the original structure.
*/
return `{{(() => {
if(!${tableName}.sortOrder.column || !${tableName}.sortOrder.order) {
return ${tableName}.filteredTableData;
}
const originalTableData = ${tableName}.tableData || [];
const filteredTableData = ${tableName}.filteredTableData || [];
const primaryColumnId = ${tableName}.primaryColumnId;
const getMergedTableData = (originalData, filteredData, primaryId) => {
const originalDataMap = {};
originalData.forEach((row) => {
originalDataMap[row[primaryId]] = row;
});
return filteredData.map(row => {
return {...row, __original__: originalDataMap[row[primaryId]] || {}};
});
};
try {
if(filteredTableData.length === 0) {
return filteredTableData;
}
const mergedTableData = primaryColumnId ? getMergedTableData(originalTableData, filteredTableData, primaryColumnId) : filteredTableData;
const sortedTableData = ((tableData, column, order) => (${stringToEvaluate}))(mergedTableData, ${tableName}.sortOrder.column, ${tableName}.sortOrder.order);
if (Array.isArray(sortedTableData) && primaryColumnId) {
const cleanedData = sortedTableData.map(row => {
if (typeof row !== 'object' || row === null) return row;
const cleanRow = {...row};
delete cleanRow.__original__;
return cleanRow;
});
return cleanedData.length > 0 ? cleanedData : filteredTableData;
}
return filteredTableData;
} catch (e) {
console.error("Error in table custom sort:", e);
return ${tableName}.filteredTableData || [];
}
})()}}`;
};
render() {
const {
controlConfig,
dataTreePath,
defaultValue,
expected,
label,
placeholderText,
propertyValue,
theme,
} = this.props;
// subscribing to context to help re-render component on Property section open or close
const isOpen = this.context;
const value =
propertyValue && isDynamicValue(propertyValue)
? TableCustomSortControl.getInputComputedValue(propertyValue)
: propertyValue
? propertyValue
: defaultValue;
const evaluatedProperties = this.props.widgetProperties;
const columns: Record<string, ColumnProperties> =
evaluatedProperties.primaryColumns || {};
const currentRow: Record<string, unknown> = {};
Object.values(columns).forEach((column) => {
currentRow[column.alias || column.originalId] = undefined;
});
// Load default value in evaluated value
if (value && !propertyValue) {
this.onTextChange(value);
}
return (
<InputText
additionalDynamicData={{
tableData: {} as Record<string, unknown>,
column: {} as Record<string, unknown>,
order: {} as Record<string, unknown>,
}}
dataTreePath={dataTreePath}
expected={expected}
height={controlConfig?.height as EditorProps["height"]}
isEditorHidden={!isOpen}
label={label}
maxHeight={controlConfig?.maxHeight as EditorProps["maxHeight"]}
onChange={this.onTextChange}
placeholder={placeholderText}
theme={theme || EditorTheme.LIGHT}
value={value}
/>
);
}
static getInputComputedValue = (propertyValue: string) => {
// Update the function signature to match the new implementation
const FUNCTION_SIGNATURE = "((tableData, column, order) => {";
const ALTERNATIVE_SIGNATURE = "((tableData, column, order) => (";
if (
!propertyValue.includes(FUNCTION_SIGNATURE) &&
!propertyValue.includes(ALTERNATIVE_SIGNATURE)
) {
return propertyValue;
}
try {
let signatureIndex, computationStart;
if (propertyValue.includes(FUNCTION_SIGNATURE)) {
signatureIndex = propertyValue.indexOf(FUNCTION_SIGNATURE);
computationStart = signatureIndex + FUNCTION_SIGNATURE.length;
// Find the matching closing brace
let braceCount = 1;
let computationEnd = computationStart;
for (let i = computationStart; i < propertyValue.length; i++) {
if (propertyValue[i] === "{") braceCount++;
if (propertyValue[i] === "}") braceCount--;
if (braceCount === 0) {
computationEnd = i;
break;
}
}
if (computationEnd === computationStart) return propertyValue;
// Extract the computation expression
const computationExpression = propertyValue.substring(
computationStart,
computationEnd,
);
return JSToString(computationExpression);
} else {
signatureIndex = propertyValue.indexOf(ALTERNATIVE_SIGNATURE);
computationStart = signatureIndex + ALTERNATIVE_SIGNATURE.length;
const computationEnd = propertyValue.indexOf("))(");
if (computationEnd === -1) return propertyValue;
// Extract the computation expression
const computationExpression = propertyValue.substring(
computationStart,
computationEnd,
);
return JSToString(computationExpression);
}
} catch (e) {
return propertyValue;
}
};
getComputedValue = (value: string, tableName: string) => {
if (
!isDynamicValue(value) &&
!this.props.additionalControlData?.isArrayValue
) {
return value;
}
const stringToEvaluate = stringToJS(value);
if (stringToEvaluate === "") {
return stringToEvaluate;
}
return TableCustomSortControl.getTableCustomSortBinding(
tableName,
stringToEvaluate,
);
};
onTextChange = (event: React.ChangeEvent<HTMLTextAreaElement> | string) => {
let value = "";
if (typeof event !== "string") {
value = event.target?.value;
} else {
value = event;
}
if (isString(value)) {
const output = this.getComputedValue(
value,
this.props.widgetProperties.widgetName,
);
this.updateProperty(this.props.propertyName, output);
} else {
this.updateProperty(this.props.propertyName, value);
}
};
static getControlType() {
return "TABLE_CUSTOM_SORT";
}
}
export interface TableCustomSortControlProps extends ControlProps {
defaultValue?: string;
placeholderText?: string;
controlConfig?: {
height?: string | number;
maxHeight?: string | number;
};
}
export default TableCustomSortControl;

View File

@ -77,6 +77,9 @@ import IconSelectControlV2 from "./IconSelectControlV2";
import PrimaryColumnsControlWDS from "./PrimaryColumnsControlWDS";
import ToolbarButtonListControl from "./ToolbarButtonListControl";
import ArrayControl from "./ArrayControl";
import TableCustomSortControl, {
type TableCustomSortControlProps,
} from "./TableCustomSortControl";
export const PropertyControls = {
InputTextControl,
@ -130,6 +133,7 @@ export const PropertyControls = {
IconSelectControlV2,
PrimaryColumnsControlWDS,
ToolbarButtonListControl,
TableCustomSortControl,
};
export type PropertyControlPropsType =
@ -158,7 +162,8 @@ export type PropertyControlPropsType =
| WrappedCodeEditorControlProps
| ZoneStepperControlProps
| SectionSplitterControlProps
| IconSelectControlV2Props;
| IconSelectControlV2Props
| TableCustomSortControlProps;
export const getPropertyControlTypes = (): { [key: string]: string } => {
const _types: { [key: string]: string } = {};

View File

@ -67,6 +67,7 @@ export interface TableWidgetProps
onRowSelected?: string;
onSearchTextChanged: string;
onSort: string;
customSortFunction?: string;
selectedRowIndex?: number;
selectedRowIndices: number[];
serverSidePaginationEnabled?: boolean;
@ -247,3 +248,6 @@ export const ALLOW_TABLE_WIDGET_SERVER_SIDE_FILTERING =
export const INFINITE_SCROLL_ENABLED =
FEATURE_FLAG["release_tablev2_infinitescroll_enabled"];
export const CUSTOM_SORT_FUNCTION_ENABLED =
FEATURE_FLAG["release_table_custom_sort_function_enabled"];

View File

@ -1216,6 +1216,7 @@ class TableWidgetV2 extends BaseWidget<TableWidgetProps, WidgetState> {
const {
customIsLoading,
customIsLoadingValue,
customSortFunction: customSortFunctionData,
delimiter,
filteredTableData = [],
isVisibleDownload,
@ -1228,7 +1229,14 @@ class TableWidgetV2 extends BaseWidget<TableWidgetProps, WidgetState> {
} = this.props;
const tableColumns = this.getTableColumns() || emptyArr;
const transformedData = this.transformData(filteredTableData, tableColumns);
let data = filteredTableData;
if (customSortFunctionData && Array.isArray(customSortFunctionData)) {
data = customSortFunctionData;
}
const transformedData = this.transformData(data, tableColumns);
const isVisibleHeaderOptions =
isVisibleDownload ||
isVisibleFilters ||

View File

@ -7,7 +7,10 @@ import { ValidationTypes } from "constants/WidgetValidation";
import { EvaluationSubstitutionType } from "ee/entities/DataTree/types";
import { AutocompleteDataType } from "utils/autocomplete/AutocompleteDataType";
import type { TableWidgetProps } from "widgets/TableWidgetV2/constants";
import { ALLOW_TABLE_WIDGET_SERVER_SIDE_FILTERING } from "../../constants";
import {
ALLOW_TABLE_WIDGET_SERVER_SIDE_FILTERING,
CUSTOM_SORT_FUNCTION_ENABLED,
} from "../../constants";
import { InlineEditingSaveOptions } from "widgets/TableWidgetV2/constants";
import { composePropertyUpdateHook } from "widgets/WidgetUtils";
import {
@ -409,9 +412,30 @@ export default [
hidden: (props: TableWidgetProps) => !props.isSortable,
dependencies: ["isSortable"],
},
{
helperText:
"Client side only, custom sort function data(overrides default sorting)",
helpText:
"Function should expect three arguments: tableData, columnId, and order. Return the sorted tableData.",
propertyName: "customSortFunction",
label: "Custom sort function data",
controlType: "TABLE_CUSTOM_SORT",
placeholderText:
"{{(tableData, columnId, order) => { /* Return sorted table data */ }}}",
controlConfig: {
maxHeight: "400px",
height: "100px",
},
isTriggerProperty: false,
hidden: (props: TableWidgetProps) =>
!props.isSortable ||
!Widget.getFeatureFlag(CUSTOM_SORT_FUNCTION_ENABLED),
dependencies: ["isSortable"],
},
],
expandedByDefault: false,
},
{
sectionName: "Adding a row",
children: [