From 09d8a4deab3d191cb7f713245acd78c822c09d30 Mon Sep 17 00:00:00 2001 From: Rahul Barwal Date: Fri, 21 Mar 2025 15:45:29 +0530 Subject: [PATCH] feat: adds custom sort function feature flag for table widget (#39649) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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" ### :mag: Cypress test results > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: > Commit: b64c2919c5d8eb1b82f6f8e6c76b98a60c2a6e22 > Cypress dashboard. > Tags: `@tag.Table, @tag.Sanity, @tag.Datasource` > Spec: >
Fri, 21 Mar 2025 08:35:48 UTC ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [ ] No ## 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. --- app/client/src/ce/entities/FeatureFlag.ts | 3 + .../TableCustomSortControl.test.tsx | 191 +++++++++ .../TableCustomSortControl.tsx | 364 ++++++++++++++++++ .../src/components/propertyControls/index.ts | 7 +- .../src/widgets/TableWidgetV2/constants.ts | 4 + .../widgets/TableWidgetV2/widget/index.tsx | 10 +- .../widget/propertyConfig/contentConfig.ts | 26 +- 7 files changed, 602 insertions(+), 3 deletions(-) create mode 100644 app/client/src/components/propertyControls/TableCustomSortControl.test.tsx create mode 100644 app/client/src/components/propertyControls/TableCustomSortControl.tsx diff --git a/app/client/src/ce/entities/FeatureFlag.ts b/app/client/src/ce/entities/FeatureFlag.ts index f67c474ef6..13fd0ce62a 100644 --- a/app/client/src/ce/entities/FeatureFlag.ts +++ b/app/client/src/ce/entities/FeatureFlag.ts @@ -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 = { diff --git a/app/client/src/components/propertyControls/TableCustomSortControl.test.tsx b/app/client/src/components/propertyControls/TableCustomSortControl.test.tsx new file mode 100644 index 0000000000..a1ef883e00 --- /dev/null +++ b/app/client/src/components/propertyControls/TableCustomSortControl.test.tsx @@ -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"); + }); +}); diff --git a/app/client/src/components/propertyControls/TableCustomSortControl.tsx b/app/client/src/components/propertyControls/TableCustomSortControl.tsx new file mode 100644 index 0000000000..5c9338407c --- /dev/null +++ b/app/client/src/components/propertyControls/TableCustomSortControl.tsx @@ -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 | 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 ( + + + Access data using{" "} + + {"{{"} + tableData, column, order + {"}}"} + +
+ Original data is available inside{" "} + + __original__ + + + } + size={EditorSize.EXTENDED} + tabBehaviour={TabBehaviour.INDENT} + theme={theme} + /> +
+ ); +} + +class TableCustomSortControl extends BaseControl { + static contextType = CollapseContext; + context!: React.ContextType; + + 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 = + evaluatedProperties.primaryColumns || {}; + const currentRow: Record = {}; + + Object.values(columns).forEach((column) => { + currentRow[column.alias || column.originalId] = undefined; + }); + + // Load default value in evaluated value + if (value && !propertyValue) { + this.onTextChange(value); + } + + return ( + , + column: {} as Record, + order: {} as Record, + }} + 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 | 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; diff --git a/app/client/src/components/propertyControls/index.ts b/app/client/src/components/propertyControls/index.ts index cf85e900e0..3915385606 100644 --- a/app/client/src/components/propertyControls/index.ts +++ b/app/client/src/components/propertyControls/index.ts @@ -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 } = {}; diff --git a/app/client/src/widgets/TableWidgetV2/constants.ts b/app/client/src/widgets/TableWidgetV2/constants.ts index 5bd1786415..345db7c190 100644 --- a/app/client/src/widgets/TableWidgetV2/constants.ts +++ b/app/client/src/widgets/TableWidgetV2/constants.ts @@ -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"]; diff --git a/app/client/src/widgets/TableWidgetV2/widget/index.tsx b/app/client/src/widgets/TableWidgetV2/widget/index.tsx index 5fbd123a83..9cee3b08c0 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/index.tsx +++ b/app/client/src/widgets/TableWidgetV2/widget/index.tsx @@ -1216,6 +1216,7 @@ class TableWidgetV2 extends BaseWidget { const { customIsLoading, customIsLoadingValue, + customSortFunction: customSortFunctionData, delimiter, filteredTableData = [], isVisibleDownload, @@ -1228,7 +1229,14 @@ class TableWidgetV2 extends BaseWidget { } = 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 || diff --git a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts index 700884629b..6bbb90486a 100644 --- a/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts +++ b/app/client/src/widgets/TableWidgetV2/widget/propertyConfig/contentConfig.ts @@ -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: [