Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,18 @@ const buildQuery: BuildQuery<TableChartFormData> = (

if (queryMode === QueryMode.Aggregate) {
metrics = metrics || [];
// override orderby with timeseries metric when in aggregation mode
if (sortByMetric) {
orderby = [[sortByMetric, !orderDesc]];
} else if (metrics?.length > 0) {
// default to ordering by first metric in descending order
// when no "sort by" metric is set (regardless if "SORT DESC" is set to true)
orderby = [[metrics[0], false]];
// Fall back to a metric-based default sort only when no explicit orderby
// was supplied (e.g. a column sort from the "View as table" results pane).
// An explicit orderby from form data takes precedence.
if (orderby.length === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test for explicit orderby

The new guard if (orderby.length === 0) correctly prevents overriding explicit orderby from form data. However, no test covers this scenario — existing tests use basicFormData without explicit orderby. Add a test with orderby: [['state', true]] alongside sortByMetric to verify the explicit orderby is preserved.

Code Review Run #0a5875


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

// override orderby with timeseries metric when in aggregation mode
if (sortByMetric) {
orderby = [[sortByMetric, !orderDesc]];
} else if (metrics?.length > 0) {
// default to ordering by first metric in descending order
// when no "sort by" metric is set (regardless if "SORT DESC" is set to true)
orderby = [[metrics[0], false]];
}
}
// add postprocessing for percent metrics only when in aggregation mode
if (percentMetrics && percentMetrics.length > 0) {
Expand Down
17 changes: 16 additions & 1 deletion superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,18 @@ export default function TableChart<D extends DataRecord = DataRecord>(
}
}}
role="columnheader button"
// Expose sort state to assistive technology. Only
// sortable headers advertise aria-sort; the value tracks the
// ascending/descending/none cycle rendered by <SortIcon>.
aria-sort={
col.canSort
? col.isSorted
? col.isSortedDesc
? 'descending'
: 'ascending'
: 'none'
: undefined
}
onClick={onClick}
data-column-name={col.id}
{...(allowRearrangeColumns && {
Expand Down Expand Up @@ -1524,10 +1536,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const modifiedOwnState = {
...serverPaginationData,
sortBy,
// Changing the sort re-queries the full dataset, so the
// previous page offset is meaningless — return to the first page.
currentPage: 0,
};
updateTableOwnState(setDataMask, modifiedOwnState);
},
[serverPagination, serverPaginationData, setDataMask],
[serverPaginationData, setDataMask],

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The callback dependency list is incomplete: it reads serverPagination inside handleSortByChange but does not include it in the useCallback dependencies. If serverPagination flips (for example when result truncation state changes), this callback can keep a stale value and either skip required server re-sorts or keep issuing server sort updates when it should not. Add serverPagination to the dependency array so sorting behavior stays in sync with current mode. [logic error]

Severity Level: Major ⚠️
- ❌ Table chart server-side sorting ignores runtime mode changes.
- ⚠️ Explore users see inconsistent sorting after toggling pagination.
Steps of Reproduction ✅
1. In `superset-frontend/plugins/plugin-chart-table/src/transformProps.ts` lines 521–523,
the table chart's `server_pagination` control from `rawFormData` is mapped into the
boolean `serverPagination` prop returned from `transformProps` and passed into
`TableChart` via `TableChartTransformedProps` (see `src/types.ts` lines 5–13 where
`serverPagination: boolean; serverPaginationData; setDataMask` are defined).

2. In `superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx` lines 392–52,
`TableChart` destructures `serverPagination = false`, `serverPaginationData`, and
`setDataMask` from `props`, and later defines `handleSortByChange` at lines 54–66 (from
the `Read` output) as:

   - `if (!serverPagination) return;`

   - build `modifiedOwnState` from `serverPaginationData` with the new `sortBy` and
   `currentPage: 0`

   - call `updateTableOwnState(setDataMask, modifiedOwnState)`

   This callback is memoized with `useCallback` and a dependency array that currently only
   includes `[serverPaginationData, setDataMask]` (line 1545 in the diff).

3. When a user toggles the "Server pagination" control for a table chart in Explore,
`transformProps` recomputes props with `serverPagination` flipped from `false` to `true`
(or vice versa) while keeping the same `serverPaginationData` object and `setDataMask`
hook reference (see `transformProps.ts` lines 36–43 and 39–41 where `serverPagination`
controls whether `serverPaginationData` comes from ownState or defaults). Because
`useCallback` in `TableChart.tsx` depends only on `[serverPaginationData, setDataMask]`,
React reuses the previous `handleSortByChange` function instance, whose closure still
captures the old `serverPagination` boolean.

4. After this toggle, when the user clicks a column header to sort, the `DataTable`
component in `superset-frontend/plugins/plugin-chart-table/src/DataTable/DataTable.tsx`
lines 142–162 detects a sort change (`sortBy`) and, if `serverPagination` is true, calls
`handleSortByChange`:

   - `const serverSortBy = serverPaginationData?.sortBy || [];`

   - `if (serverPagination && !isEqual(sortBy, serverSortBy)) { ...
   handleSortByChange([...]); }`

   At this point `DataTable` sees `serverPagination === true` (new prop) and invokes
   `handleSortByChange`, but inside that callback the captured `serverPagination` is still
   `false`, so it immediately returns and never calls `updateTableOwnState`. As a result,
   no server-side `sortBy` is pushed into `serverPaginationData`, the chart-data request
   is not reissued, and the "server-side column sorting" path described in this PR
   silently fails after toggling server pagination at runtime. Adding `serverPagination`
   to the `useCallback` dependency array forces `handleSortByChange` to be recreated when
   the mode changes, keeping the sort behavior in sync with the current pagination mode.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
**Line:** 1545:1545
**Comment:**
	*Logic Error: The callback dependency list is incomplete: it reads `serverPagination` inside `handleSortByChange` but does not include it in the `useCallback` dependencies. If `serverPagination` flips (for example when result truncation state changes), this callback can keep a stale value and either skip required server re-sorts or keep issuing server sort updates when it should not. Add `serverPagination` to the dependency array so sorting behavior stays in sync with current mode.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing dependency in useCallback

The handleSortByChange callback captures serverPagination in the function body (line 1535: if (!serverPagination) return), but this variable is missing from the dependency array. This creates a stale closure that can cause the callback to use an outdated serverPagination value after prop changes.

Code suggestion
Check the AI-generated fix before applying
 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
 +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx
 @@ -1542,7 +1542,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
        };
        updateTableOwnState(setDataMask, modifiedOwnState);
      },
 -    [serverPaginationData, setDataMask],
 +    [serverPagination, serverPaginationData, setDataMask],
    );
 
      const handleSearch = (searchText: string) => {

Code Review Run #0a5875


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

);

const handleSearch = (searchText: string) => {
Expand Down
19 changes: 12 additions & 7 deletions superset-frontend/plugins/plugin-chart-table/src/buildQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,18 @@ const buildQuery: BuildQuery<TableChartFormData> = (

if (queryMode === QueryMode.Aggregate) {
metrics = metrics || [];
// override orderby with timeseries metric when in aggregation mode
if (sortByMetric) {
orderby = [[sortByMetric, !orderDesc]];
} else if (metrics?.length > 0) {
// default to ordering by first metric in descending order
// when no "sort by" metric is set (regardless if "SORT DESC" is set to true)
orderby = [[metrics[0], false]];
// Fall back to a metric-based default sort only when no explicit orderby
// was supplied (e.g. a column sort from the "View as table" results pane).
// An explicit orderby from form data takes precedence.
if (orderby.length === 0) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing test coverage for orderby logic

The new if (orderby.length === 0) guard changes behavior to preserve explicit orderby, but buildQuery.test.ts has no tests validating this logic. Add tests covering: (a) explicit orderby preserved when sortByMetric exists, (b) explicit orderby preserved when metrics exist, (c) defaults applied only when orderby is empty.

Code Review Run #0a5875


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

// override orderby with timeseries metric when in aggregation mode
if (sortByMetric) {
orderby = [[sortByMetric, !orderDesc]];
} else if (metrics?.length > 0) {
// default to ordering by first metric in descending order
// when no "sort by" metric is set (regardless if "SORT DESC" is set to true)
orderby = [[metrics[0], false]];
}
}
// add postprocessing for percent metrics only when in aggregation mode
type PercentMetricCalculationMode = 'row_limit' | 'all_records';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2457,3 +2457,26 @@ describe('Drill-to-Detail Temporal Range Logic', () => {
expect(filter.val).toBeNull();
});
});

// Server-side full sort: accessibility of sortable headers.
// Every sortable column header must expose its sort state via
// aria-sort, and the value must track the asc/desc/none cycle.
const VALID_ARIA_SORT = ['none', 'ascending', 'descending'];

test('sortable column headers expose and update aria-sort (F3-T1, NFR-ACC-01)', () => {
render(<TableChart {...transformProps(testData.advanced)} sticky={false} />);

const headerCell = screen.getByText('Sum of Num').closest('th') as HTMLElement;
expect(headerCell).toBeInTheDocument();

// A sortable header must advertise a valid aria-sort token (the exact
// initial value depends on any default sort in formData, so accept any).
const initial = headerCell.getAttribute('aria-sort');
expect(VALID_ARIA_SORT).toContain(initial);

// Clicking the header changes the sort, and aria-sort tracks that change.
fireEvent.click(headerCell);
const afterClick = headerCell.getAttribute('aria-sort');
expect(VALID_ARIA_SORT).toContain(afterClick);
expect(afterClick).not.toEqual(initial);
});
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import Table, {
ColumnsType,
ETableAction,
SorterResult,
SortOrder,
TableSize,
} from '@superset-ui/core/components/Table';
import { RootState } from 'src/dashboard/types';
Expand Down Expand Up @@ -94,6 +97,8 @@ export default function DrillDetailPane({
const theme = useTheme();
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [sortColumn, setSortColumn] = useState<string | undefined>();
const [sortOrder, setSortOrder] = useState<SortOrder>(null);
const lastPageIndex = useRef(pageIndex);
const [filters, setFilters] = useState(initialFilters);
const [isLoading, setIsLoading] = useState(false);
Expand Down Expand Up @@ -147,6 +152,10 @@ export default function DrillDetailPane({
resultsPage?.colNames.map((column, index) => ({
key: column,
dataIndex: column,
// Server-side sort: antd only renders the indicator and fires onChange;
// the sorted page is fetched from the server (see `orderby`).
sorter: true,
sortOrder: sortColumn === column ? sortOrder : null,
title:
resultsPage?.colTypes[index] === GenericDataType.Temporal ? (
<HeaderWithRadioGroup
Expand Down Expand Up @@ -197,6 +206,8 @@ export default function DrillDetailPane({
resultsPage?.colTypes,
timeFormatting,
dataset?.verbose_map,
sortColumn,
sortOrder,
],
);

Expand All @@ -213,6 +224,15 @@ export default function DrillDetailPane({
[resultsPage?.colNames, resultsPage?.data],
);

// Server-side sort clause. Drill detail is a raw, paginated query, so sorting
// must happen on the server to order the full dataset across pages rather than
// just the current page.
const orderby = useMemo<[string, boolean][]>(
() =>
sortColumn && sortOrder ? [[sortColumn, sortOrder === 'ascend']] : [],
[sortColumn, sortOrder],
);

// Clear cache on reload button click
const handleReload = useCallback(() => {
setResponseError('');
Expand Down Expand Up @@ -306,7 +326,10 @@ export default function DrillDetailPane({
useEffect(() => {
if (!responseError && !isLoading && !resultsPages.has(pageIndex)) {
setIsLoading(true);
const jsonPayload = getDrillPayload(formData, filters) ?? {};
const jsonPayload = {
...getDrillPayload(formData, filters),
...(orderby.length > 0 && { orderby }),
};
const cachePageLimit = Math.ceil(SAMPLES_ROW_LIMIT / pageSize);
getDatasourceSamples(
datasourceType as DatasourceType,
Expand Down Expand Up @@ -348,6 +371,7 @@ export default function DrillDetailPane({
filters,
formData,
isLoading,
orderby,
pageIndex,
pageSize,
responseError,
Expand Down Expand Up @@ -389,7 +413,22 @@ export default function DrillDetailPane({
recordCount={resultsPage?.total}
usePagination
loading={isLoading}
onChange={pagination => {
onChange={(pagination, _filters, sorter, extra) => {
if (extra?.action === ETableAction.Sort) {
const nextSorter = (
Array.isArray(sorter) ? sorter[0] : sorter
) as SorterResult<DataType>;
const column = nextSorter?.columnKey as string | undefined;
const order = nextSorter?.order ?? null;
// antd cycles ascend -> descend -> unsorted; clear when unsorted.
setSortColumn(order ? column : undefined);
setSortOrder(order);
// Cached pages reflect the previous sort, so drop them and restart
// from the first page with the new ordering.
setResultsPages(new Map());
setPageIndex(0);
return;
}
const newPageSize = pagination.pageSize ?? pageSize;
if (newPageSize !== pageSize) {
setPageSize(newPageSize);
Expand Down
23 changes: 20 additions & 3 deletions superset-frontend/src/components/GridTable/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,6 @@ const gridComponents = {
agColumnHeader: Header,
};

const onSortChanged: AgGridReactProps['onSortChanged'] = ({ api }) =>
api.refreshCells();

export function GridTable<RecordType extends object>({
data,
columns,
Expand All @@ -46,8 +43,28 @@ export function GridTable<RecordType extends object>({
enableActions,
size = GridSize.Middle,
striped,
onServerSort,
}: TableProps<RecordType>) {
const theme = useTheme();
const onSortChanged = useCallback<
NonNullable<AgGridReactProps['onSortChanged']>
>(
({ api }) => {
api.refreshCells();
if (!onServerSort) {
return;
}
const orderby = api
.getColumnState()
.filter(state => state.sort)
// Order the columns by the sequence in which the user applied each
// sort so multi-column sort reaches the server in the right priority.
.sort((a, b) => (a.sortIndex ?? 0) - (b.sortIndex ?? 0))
.map(state => [state.colId, state.sort === 'asc'] as [string, boolean]);
onServerSort(orderby);
},
[onServerSort],
);
const isExternalFilterPresent = useCallback(
() => Boolean(externalFilter),
[externalFilter],
Expand Down
8 changes: 8 additions & 0 deletions superset-frontend/src/components/GridTable/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,12 @@ export interface TableProps<RecordType> {
usePagination?: boolean;

striped?: boolean;

/**
* Called when the sort state changes, with it translated to a query
* `orderby` (`[[columnId, isAscending]]`) so the consumer can re-request
* server-sorted data. Only the primary sort column is included, matching the
* chart data API's single-column sort constraint.
*/
Comment on lines +63 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doc/impl mismatch on multi-column sort

Update the JSDoc for onServerSort to clarify that when multi-column sorting is used, all sorted columns are passed to the callback in priority order, matching the implementation.

Code Review Run #0a5875


Should Bito avoid suggestions like this for future reviews? (Manage Rules)

  • Yes, avoid them

Comment on lines +63 to +68
onServerSort?: (orderby: [string, boolean][]) => void;
}
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ export const SingleQueryResultPane = ({
onDownloadCSV,
onDownloadXLSX,
onReload,
onServerSort,
isLoading = false,
}: SingleQueryResultPaneProp) => {
const [filterText, setFilterText] = useState('');
const { gridHeight, measuredRef } = useGridHeight();
Expand All @@ -81,7 +83,7 @@ export const SingleQueryResultPane = ({
rowcount={rowcount}
datasourceId={datasourceId}
onInputChange={handleInputChange}
isLoading={false}
isLoading={isLoading}
canDownload={canDownload}
rowLimit={rowLimit}
rowLimitOptions={rowLimitOptions}
Expand All @@ -99,6 +101,7 @@ export const SingleQueryResultPane = ({
size={GridSize.Small}
externalFilter={keywordFilter}
showRowNumber
onServerSort={onServerSort}
/>
</GridSizer>
</GridContainer>
Expand Down
Loading
Loading