From 13d97c50e1eae213f67c6b9b06b16983bcec0903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fred=20Lef=C3=A9v=C3=A8re-Laoide?= <90181748+FredLL-Avaiga@users.noreply.github.com> Date: Fri, 20 Dec 2024 11:49:21 +0100 Subject: [PATCH] table col width (#2358) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * table col width resolves #2286 * Fab's comment Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com> * notSortable => sortable * Fab's comment Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com> --------- Co-authored-by: Fred Lefévère-Laoide Co-authored-by: Fabien Lelaquais <86590727+FabienLelaquais@users.noreply.github.com> --- frontend/taipy-gui/packaging/taipy-gui.d.ts | 2 + .../Taipy/AutoLoadingTable.spec.tsx | 11 + .../src/components/Taipy/AutoLoadingTable.tsx | 301 ++++++++++-------- .../components/Taipy/PaginatedTable.spec.tsx | 16 + .../src/components/Taipy/PaginatedTable.tsx | 35 +- .../src/components/Taipy/tableUtils.tsx | 3 + .../taipy-gui/src/components/Taipy/utils.ts | 8 +- taipy/gui/_renderers/factory.py | 1 + taipy/gui/viselements.json | 8 +- 9 files changed, 245 insertions(+), 140 deletions(-) diff --git a/frontend/taipy-gui/packaging/taipy-gui.d.ts b/frontend/taipy-gui/packaging/taipy-gui.d.ts index bf07e4786b..ab7025678e 100644 --- a/frontend/taipy-gui/packaging/taipy-gui.d.ts +++ b/frontend/taipy-gui/packaging/taipy-gui.d.ts @@ -388,6 +388,8 @@ export interface ColumnDesc { lov?: string[]; /** If true the user can enter any value besides the lov values. */ freeLov?: boolean; + /** If false, the column cannot be sorted */ + sortable?: boolean; } /** * A cell value type. diff --git a/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.spec.tsx b/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.spec.tsx index 36b39da5a6..834496e399 100644 --- a/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.spec.tsx +++ b/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.spec.tsx @@ -98,6 +98,7 @@ const tableValue = { }, }; const tableColumns = JSON.stringify({ Entity: { dfid: "Entity" } }); +const tableWidthColumns = JSON.stringify({ Entity: { dfid: "Entity", width: "100px" }, Country: {dfid: "Country"} }); describe("AutoLoadingTable Component", () => { it("renders", async () => { @@ -132,6 +133,16 @@ describe("AutoLoadingTable Component", () => { const { queryByTestId } = render(); expect(queryByTestId("ArrowDownwardIcon")).toBeNull(); }); + it("hides sort icons when not sortable", async () => { + const { queryByTestId } = render(); + expect(queryByTestId("ArrowDownwardIcon")).toBeNull(); + }); + it("set width if requested", async () => { + const { getByText } = render(); + const header = getByText("Entity").closest("tr"); + expect(header?.firstChild).toHaveStyle({"min-width": "100px"}); + expect(header?.lastChild).toHaveStyle({"width": "100%"}); + }); // keep getting undefined Error from jest, it seems to be linked to the setTimeout that makes the code run after the end of the test :-( // https://github.com/facebook/jest/issues/12262 // Looks like the right way to handle this is to use jest fakeTimers and runAllTimers ... diff --git a/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx b/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx index fabc2cafe7..0b73046d23 100644 --- a/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx +++ b/frontend/taipy-gui/src/components/Taipy/AutoLoadingTable.tsx @@ -11,63 +11,33 @@ * specific language governing permissions and limitations under the License. */ -import React, { useState, useEffect, useCallback, useRef, useMemo, CSSProperties, MouseEvent } from "react"; +import React, { CSSProperties, MouseEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; +import AddIcon from "@mui/icons-material/Add"; +import DataSaverOff from "@mui/icons-material/DataSaverOff"; +import DataSaverOn from "@mui/icons-material/DataSaverOn"; +import Download from "@mui/icons-material/Download"; import Box from "@mui/material/Box"; +import IconButton from "@mui/material/IconButton"; +import Paper from "@mui/material/Paper"; +import Skeleton from "@mui/material/Skeleton"; import MuiTable from "@mui/material/Table"; import TableCell, { TableCellProps } from "@mui/material/TableCell"; import TableContainer from "@mui/material/TableContainer"; import TableHead from "@mui/material/TableHead"; import TableRow from "@mui/material/TableRow"; import TableSortLabel from "@mui/material/TableSortLabel"; -import Paper from "@mui/material/Paper"; +import Tooltip from "@mui/material/Tooltip"; import { visuallyHidden } from "@mui/utils"; import AutoSizer from "react-virtualized-auto-sizer"; import { FixedSizeList, ListOnItemsRenderedProps } from "react-window"; import InfiniteLoader from "react-window-infinite-loader"; -import Skeleton from "@mui/material/Skeleton"; -import IconButton from "@mui/material/IconButton"; -import Tooltip from "@mui/material/Tooltip"; -import AddIcon from "@mui/icons-material/Add"; -import DataSaverOn from "@mui/icons-material/DataSaverOn"; -import DataSaverOff from "@mui/icons-material/DataSaverOff"; -import Download from "@mui/icons-material/Download"; -import { generateHeaderClassName } from "./tableUtils"; import { createRequestInfiniteTableUpdateAction, createSendActionNameAction, FormatConfig, } from "../../context/taipyReducers"; -import { - ColumnDesc, - FilterDesc, - getSortByIndex, - Order, - TaipyTableProps, - baseBoxSx, - paperSx, - tableSx, - RowType, - EditableCell, - OnCellValidation, - RowValue, - EDIT_COL, - OnRowDeletion, - addActionColumn, - headBoxSx, - getClassName, - ROW_CLASS_NAME, - iconInRowSx, - DEFAULT_SIZE, - OnRowSelection, - getRowIndex, - getTooltip, - defaultColumns, - OnRowClick, - DownloadAction, - getFormatFn, - getPageKey, -} from "./tableUtils"; +import { emptyArray } from "../../utils"; import { useClassNames, useDispatch, @@ -78,9 +48,39 @@ import { useModule, } from "../../utils/hooks"; import TableFilter from "./TableFilter"; -import { getSuffixedClassNames, getUpdateVar } from "./utils"; -import { emptyArray } from "../../utils"; +import { + addActionColumn, + baseBoxSx, + ColumnDesc, + DEFAULT_SIZE, + defaultColumns, + DownloadAction, + EDIT_COL, + EditableCell, + FilterDesc, + generateHeaderClassName, + getClassName, + getFormatFn, + getPageKey, + getRowIndex, + getSortByIndex, + getTooltip, + headBoxSx, + iconInRowSx, + OnCellValidation, + OnRowClick, + OnRowDeletion, + OnRowSelection, + Order, + paperSx, + ROW_CLASS_NAME, + RowType, + RowValue, + tableSx, + TaipyTableProps, +} from "./tableUtils"; import { getComponentClassName } from "./TaipyStyle"; +import { getCssSize, getSuffixedClassNames, getUpdateVar } from "./utils"; interface RowData { colsOrder: string[]; @@ -203,6 +203,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => { compare = false, onCompare = "", useCheckbox = false, + sortable = true, } = props; const [rows, setRows] = useState([]); const [compRows, setCompRows] = useState([]); @@ -253,7 +254,7 @@ const AutoLoadingTable = (props: TaipyTableProps) => { useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars); const onSort = useCallback( - (e: React.MouseEvent) => { + (e: MouseEvent) => { const col = e.currentTarget.getAttribute("data-dfid"); if (col) { const isAsc = orderBy === col && order === "asc"; @@ -287,82 +288,107 @@ const AutoLoadingTable = (props: TaipyTableProps) => { e.stopPropagation(); }, []); - const [colsOrder, columns, cellClassNames, tooltips, formats, handleNan, filter, partialEditable] = useMemo(() => { - let hNan = !!props.nanValue; - if (baseColumns) { - try { - let filter = false; - let partialEditable = editable; - const newCols: Record = {}; - Object.entries(baseColumns).forEach(([cId, cDesc]) => { - const nDesc = (newCols[cId] = { ...cDesc }); - if (typeof nDesc.filter != "boolean") { - nDesc.filter = !!props.filter; - } - filter = filter || nDesc.filter; - if (typeof nDesc.notEditable == "boolean") { - nDesc.notEditable = !editable; - } else { - partialEditable = partialEditable || !nDesc.notEditable; - } - if (nDesc.tooltip === undefined) { - nDesc.tooltip = props.tooltip; - } - }); - addActionColumn( - (active && partialEditable && (onAdd || onDelete) ? 1 : 0) + - (active && filter ? 1 : 0) + - (active && downloadable ? 1 : 0), - newCols - ); - const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols)); - const styTt = colsOrder.reduce>>((pv, col) => { - if (newCols[col].className) { - pv.classNames = pv.classNames || {}; - pv.classNames[newCols[col].dfid] = newCols[col].className as string; + const [colsOrder, columns, cellClassNames, tooltips, formats, handleNan, filter, partialEditable, calcWidth] = + useMemo(() => { + let hNan = !!props.nanValue; + if (baseColumns) { + try { + let filter = false; + let partialEditable = editable; + const newCols: Record = {}; + Object.entries(baseColumns).forEach(([cId, cDesc]) => { + const nDesc = (newCols[cId] = { ...cDesc }); + if (typeof nDesc.filter != "boolean") { + nDesc.filter = !!props.filter; + } + filter = filter || nDesc.filter; + if (typeof nDesc.notEditable == "boolean") { + nDesc.notEditable = !editable; + } else { + partialEditable = partialEditable || !nDesc.notEditable; + } + if (nDesc.tooltip === undefined) { + nDesc.tooltip = props.tooltip; + } + if (typeof nDesc.sortable != "boolean") { + nDesc.sortable = sortable; + } + }); + addActionColumn( + (active && partialEditable && (onAdd || onDelete) ? 1 : 0) + + (active && filter ? 1 : 0) + + (active && downloadable ? 1 : 0), + newCols + ); + const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols)); + let nbWidth = 0; + const styTt = colsOrder.reduce>>((pv, col) => { + if (newCols[col].className) { + pv.classNames = pv.classNames || {}; + pv.classNames[newCols[col].dfid] = newCols[col].className as string; + } + hNan = hNan || !!newCols[col].nanValue; + if (newCols[col].tooltip) { + pv.tooltips = pv.tooltips || {}; + pv.tooltips[newCols[col].dfid] = newCols[col].tooltip as string; + } + if (newCols[col].formatFn) { + pv.formats = pv.formats || {}; + pv.formats[newCols[col].dfid] = newCols[col].formatFn; + } + if (newCols[col].width !== undefined) { + const cssWidth = getCssSize(newCols[col].width); + if (cssWidth) { + newCols[col].width = cssWidth; + nbWidth++; + } + } + return pv; + }, {}); + nbWidth = nbWidth ? colsOrder.length - nbWidth : 0; + if (props.rowClassName) { + styTt.classNames = styTt.classNames || {}; + styTt.classNames[ROW_CLASS_NAME] = props.rowClassName; } - hNan = hNan || !!newCols[col].nanValue; - if (newCols[col].tooltip) { - pv.tooltips = pv.tooltips || {}; - pv.tooltips[newCols[col].dfid] = newCols[col].tooltip as string; - } - if (newCols[col].formatFn) { - pv.formats = pv.formats || {}; - pv.formats[newCols[col].dfid] = newCols[col].formatFn; - } - return pv; - }, {}); - if (props.rowClassName) { - styTt.classNames = styTt.classNames || {}; - styTt.classNames[ROW_CLASS_NAME] = props.rowClassName; + return [ + colsOrder, + newCols, + styTt.classNames, + styTt.tooltips, + styTt.formats, + hNan, + filter, + partialEditable, + nbWidth > 0 ? `${100 / nbWidth}%` : undefined, + ]; + } catch (e) { + console.info("ATable.columns: " + ((e as Error).message || e)); } - return [colsOrder, newCols, styTt.classNames, styTt.tooltips, styTt.formats, hNan, filter, partialEditable]; - } catch (e) { - console.info("ATable.columns: " + ((e as Error).message || e)); } - } - return [ - [], - {} as Record, - {} as Record, - {} as Record, - {} as Record, - hNan, - false, - false, - ]; - }, [ - active, - editable, - onAdd, - onDelete, - baseColumns, - props.rowClassName, - props.tooltip, - props.nanValue, - props.filter, - downloadable, - ]); + return [ + [], + {} as Record, + {} as Record, + {} as Record, + {} as Record, + hNan, + false, + false, + "", + ]; + }, [ + active, + editable, + onAdd, + onDelete, + baseColumns, + props.rowClassName, + props.tooltip, + props.nanValue, + props.filter, + downloadable, + sortable, + ]); const boxBodySx = useMemo(() => ({ height: height }), [height]); @@ -389,7 +415,18 @@ const AutoLoadingTable = (props: TaipyTableProps) => { return new Promise((resolve, reject) => { const cols = colsOrder.map((col) => columns[col].dfid).filter((c) => c != EDIT_COL); const afs = appliedFilters.filter((fd) => Object.values(columns).some((cd) => cd.dfid === fd.col)); - const key = getPageKey(columns, "Infinite", cols, orderBy, order, afs, aggregates, cellClassNames, tooltips, formats); + const key = getPageKey( + columns, + "Infinite", + cols, + orderBy, + order, + afs, + aggregates, + cellClassNames, + tooltips, + formats + ); page.current = { key: key, promises: { ...page.current.promises, [startIndex]: { resolve: resolve, reject: reject } }, @@ -594,7 +631,13 @@ const AutoLoadingTable = (props: TaipyTableProps) => { const boxSx = useMemo(() => ({ ...baseBoxSx, width: width }), [width]); return ( - + @@ -605,10 +648,20 @@ const AutoLoadingTable = (props: TaipyTableProps) => { {columns[col].dfid === EDIT_COL ? ( @@ -653,8 +706,8 @@ const AutoLoadingTable = (props: TaipyTableProps) => { direction={orderBy === columns[col].dfid ? order : "asc"} data-dfid={columns[col].dfid} onClick={onSort} - disabled={!active} - hideSortIcon={!active} + disabled={!active || !columns[col].sortable} + hideSortIcon={!active || !columns[col].sortable} > {columns[col].groupBy ? ( diff --git a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx index c2971bfecf..765e038cad 100644 --- a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx +++ b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.spec.tsx @@ -94,6 +94,10 @@ const tableColumns = JSON.stringify({ Entity: { dfid: "Entity" }, "Daily hospital occupancy": { dfid: "Daily hospital occupancy", type: "int64" }, }); +const tableWidthColumns = JSON.stringify({ + Entity: { dfid: "Entity", width: "100px" }, + "Daily hospital occupancy": { dfid: "Daily hospital occupancy", type: "int64" }, +}); const changedValue = { [valueKey]: { data: [ @@ -217,6 +221,18 @@ describe("PaginatedTable Component", () => { ); expect(queryByTestId("ArrowDownwardIcon")).toBeNull(); }); + it("Hides sort icons when not sortable", async () => { + const { queryByTestId } = render( + + ); + expect(queryByTestId("ArrowDownwardIcon")).toBeNull(); + }); + it("set width if requested", async () => { + const { getByText } = render(); + const header = getByText("Entity").closest("tr"); + expect(header?.firstChild).toHaveStyle({"min-width": "100px"}); + expect(header?.lastChild).toHaveStyle({"width": "100%"}); + }); it("dispatch 2 well formed messages at first render", async () => { const dispatch = jest.fn(); const state: TaipyState = INITIAL_STATE; diff --git a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx index c5d28f2ec9..f0acba85e9 100644 --- a/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx +++ b/frontend/taipy-gui/src/components/Taipy/PaginatedTable.tsx @@ -41,7 +41,6 @@ import TableSortLabel from "@mui/material/TableSortLabel"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { visuallyHidden } from "@mui/utils"; -import { generateHeaderClassName } from "./tableUtils"; import { createRequestTableUpdateAction, createSendActionNameAction } from "../../context/taipyReducers"; import { emptyArray } from "../../utils"; @@ -65,6 +64,7 @@ import { EDIT_COL, EditableCell, FilterDesc, + generateHeaderClassName, getClassName, getFormatFn, getPageKey, @@ -87,7 +87,7 @@ import { TaipyPaginatedTableProps, } from "./tableUtils"; import { getComponentClassName } from "./TaipyStyle"; -import { getSuffixedClassNames, getUpdateVar } from "./utils"; +import { getCssSize, getSuffixedClassNames, getUpdateVar } from "./utils"; const loadingStyle: CSSProperties = { width: "100%", height: "3em", textAlign: "right", verticalAlign: "center" }; const skeletonSx = { width: "100%", height: "3em" }; @@ -115,6 +115,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { compare = false, onCompare = "", useCheckbox = false, + sortable = true, } = props; const pageSize = props.pageSize === undefined || props.pageSize < 1 ? 100 : Math.round(props.pageSize); const [value, setValue] = useState>({}); @@ -138,7 +139,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { const hover = useDynamicProperty(props.hoverText, props.defaultHoverText, undefined); const baseColumns = useDynamicJsonProperty(props.columns, props.defaultColumns, defaultColumns); - const [colsOrder, columns, cellClassNames, tooltips, formats, handleNan, filter, partialEditable, nbWidth] = + const [colsOrder, columns, cellClassNames, tooltips, formats, handleNan, filter, partialEditable, calcWidth] = useMemo(() => { let hNan = !!props.nanValue; if (baseColumns) { @@ -160,6 +161,9 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { if (nDesc.tooltip === undefined) { nDesc.tooltip = props.tooltip; } + if (typeof nDesc.sortable != "boolean") { + nDesc.sortable = sortable; + } }); addActionColumn( (active && partialEditable && (onAdd || onDelete) ? 1 : 0) + @@ -169,6 +173,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { ); const colsOrder = Object.keys(newCols).sort(getSortByIndex(newCols)); let nbWidth = 0; + let widthRate = 0; const functions = colsOrder.reduce>>((pv, col) => { if (newCols[col].className) { pv.classNames = pv.classNames || {}; @@ -184,7 +189,14 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { pv.formats[newCols[col].dfid] = newCols[col].formatFn; } if (newCols[col].width !== undefined) { - nbWidth++; + const cssWidth = getCssSize(newCols[col].width); + if (cssWidth) { + newCols[col].width = cssWidth; + nbWidth++; + if (cssWidth.endsWith("%")) { + widthRate += parseInt(cssWidth, 10); + } + } } return pv; }, {}); @@ -202,7 +214,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { hNan, filter, partialEditable, - nbWidth, + nbWidth > 0 ? `${(100 - widthRate) / nbWidth}%` : undefined ]; } catch (e) { console.info("PaginatedTable.columns: ", (e as Error).message || e); @@ -217,7 +229,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { hNan, false, false, - 0, + "" ]; }, [ active, @@ -230,6 +242,7 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { props.nanValue, props.filter, downloadable, + sortable, ]); useDispatchRequestUpdateOnFirstRender(dispatch, id, module, updateVars); @@ -524,9 +537,9 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { sortDirection={orderBy === columns[col].dfid && order} sx={ columns[col].width - ? { minWidth: columns[col].width } - : nbWidth - ? { minWidth: `${100 / nbWidth}%` } + ? { minWidth:columns[col].width } + : calcWidth + ? { width: calcWidth } : undefined } className={col === "EDIT_COL" @@ -576,8 +589,8 @@ const PaginatedTable = (props: TaipyPaginatedTableProps) => { direction={orderBy === columns[col].dfid ? order : "asc"} data-dfid={columns[col].dfid} onClick={onSort} - disabled={!active} - hideSortIcon={!active} + disabled={!active || !columns[col].sortable} + hideSortIcon={!active || !columns[col].sortable} > {columns[col].groupBy ? ( diff --git a/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx b/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx index 3ef9c88453..2b3f295aba 100644 --- a/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx +++ b/frontend/taipy-gui/src/components/Taipy/tableUtils.tsx @@ -102,6 +102,8 @@ export interface ColumnDesc { lov?: string[]; /** If true the user can enter any value besides the lov values. */ freeLov?: boolean; + /** If false, the column cannot be sorted */ + sortable?: boolean; } export const DEFAULT_SIZE = "small"; @@ -156,6 +158,7 @@ export interface TaipyTableProps extends TaipyActiveProps, TaipyMultiSelectProps onCompare?: string; compare?: boolean; useCheckbox?: boolean; + sortable?: boolean; } export const DownloadAction = "__Taipy__download_csv"; diff --git a/frontend/taipy-gui/src/components/Taipy/utils.ts b/frontend/taipy-gui/src/components/Taipy/utils.ts index a714cbbea7..1e82d2d03e 100644 --- a/frontend/taipy-gui/src/components/Taipy/utils.ts +++ b/frontend/taipy-gui/src/components/Taipy/utils.ts @@ -116,14 +116,14 @@ export const noDisplayStyle = { display: "none" }; const RE_ONLY_NUMBERS = /^\d+(\.\d*)?$/; export const getCssSize = (val: string | number) => { if (typeof val === "number") { - return "" + val + "px"; + return `${val}px`; } else { - val = val.trim(); + val = `${val}`.trim(); if (RE_ONLY_NUMBERS.test(val)) { - return val + "px"; + return `${val}px`; } + return val; } - return val; }; /** diff --git a/taipy/gui/_renderers/factory.py b/taipy/gui/_renderers/factory.py index 7a45cdb90f..f6126944c7 100644 --- a/taipy/gui/_renderers/factory.py +++ b/taipy/gui/_renderers/factory.py @@ -585,6 +585,7 @@ class _Factory: ("size",), ("downloadable", PropertyType.boolean), ("use_checkbox", PropertyType.boolean), + ("sortable", PropertyType.boolean, True), ] ) ._set_propagate() diff --git a/taipy/gui/viselements.json b/taipy/gui/viselements.json index 452ec035fb..4cf152bd9d 100644 --- a/taipy/gui/viselements.json +++ b/taipy/gui/viselements.json @@ -814,7 +814,7 @@ { "name": "width[column_name]", "type": "str", - "doc": "The width of the indicated column, in CSS units." + "doc": "The width of the indicated column, in CSS units (% values are not supported)." }, { "name": "selected", @@ -1076,6 +1076,12 @@ "type": "bool", "default_value": "False", "doc": "If True, boolean values are rendered as a simple HTML checkbox." + }, + { + "name": "sortable", + "type": "bool", + "default_value": "True", + "doc": "If False, the table provides no sorting capability. Individual columns can override this global setting, allowing specific columns to be marked as sortable or non-sortable regardless of value of sortable, by setting the sortable property to True or False accordingly, in the dictionary for that column in the columns property value." } ] }