Skip to content
Merged
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
2 changes: 2 additions & 0 deletions superset-frontend/src/components/ListView/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { type JsonObject } from '@superset-ui/core';
import { type ReactNode } from 'react';

export interface SortColumn {
Expand Down Expand Up @@ -102,6 +103,7 @@ export interface ListViewFetchDataConfig {
pageSize: number;
sortBy: SortColumn[];
filters: ListViewFilterValue[];
extraQueryParams?: JsonObject;
}

export interface InternalFilter extends ListViewFilterValue {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,12 @@ import fetchMock from 'fetch-mock';
import { mockUserSubjectsBootstrapData } from 'spec/helpers/mockBootstrapData';
import { screen, waitFor, within } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { isFeatureEnabled } from '@superset-ui/core';
import rison from 'rison';
import {
ChartMetadata,
getChartMetadataRegistry,
isFeatureEnabled,
} from '@superset-ui/core';
import {
mockCharts,
mockHandleResourceExport,
Expand Down Expand Up @@ -279,6 +284,75 @@ test('sorts table when clicking column headers', async () => {
});
});

test('sends display-ordered chart type slugs only for Type sorting', async () => {
const registry = getChartMetadataRegistry();
const customVizTypes = Array.from(
{ length: 260 },
(_, index) => `custom_display_order_${index}`,
);
registry
.registerValue(
'slug_a',
new ChartMetadata({ name: '001 Zulu', thumbnail: '', behaviors: [] }),
)
.registerValue(
'slug_z',
new ChartMetadata({ name: '000 Alpha', thumbnail: '', behaviors: [] }),
);
customVizTypes.forEach((vizType, index) =>
registry.registerValue(
vizType,
new ChartMetadata({
name: `Plugin ${index}`,
thumbnail: '',
behaviors: [],
}),
),
);

try {
renderChartList(mockUser);

const table = await screen.findByTestId('listview-table');
const initialCall = fetchMock.callHistory
.calls(/chart\/\?q/)
.find(call => !call.url.includes('order_column:viz_type'));
expect(initialCall).toBeDefined();
const initialQuery = new URL(
initialCall!.url,
'http://localhost',
).searchParams.get('q');
expect(rison.decode(initialQuery!)).not.toHaveProperty('viz_type_order');

await userEvent.click(within(table).getByTitle('Type'));

await waitFor(() => {
const typeSortCall = fetchMock.callHistory
.calls(/chart\/\?q/)
.find(call => call.url.includes('order_column:viz_type'));
expect(typeSortCall).toBeDefined();

const query = new URL(
typeSortCall!.url,
'http://localhost',
).searchParams.get('q');
const decoded = rison.decode(query!) as {
order_column: string;
viz_type_order: string[];
};
expect(decoded.order_column).toBe('viz_type');
expect(decoded.viz_type_order).toHaveLength(256);
expect(decoded.viz_type_order.indexOf('slug_z')).toBeLessThan(
decoded.viz_type_order.indexOf('slug_a'),
);
});
} finally {
registry.remove('slug_a');
registry.remove('slug_z');
customVizTypes.forEach(vizType => registry.remove(vizType));
}
});

test('displays chart data correctly in table rows', async () => {
/**
* @todo Implement test logic for tagging.
Expand Down
28 changes: 27 additions & 1 deletion superset-frontend/src/pages/ChartList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import {
ListViewFilterOperator as FilterOperator,
DashboardCrossLinks,
type ListViewProps,
type ListViewFetchDataConfig,
type ListViewFilters,
type ListViewFilter,
} from 'src/components';
Expand Down Expand Up @@ -198,6 +199,7 @@ const CONFIRM_OVERWRITE_MESSAGE = t(
);

const registry = getChartMetadataRegistry();
const MAX_VIZ_TYPE_ORDER_LENGTH = 256;

const createFetchDatasets = async (
filterValue = '',
Expand Down Expand Up @@ -260,11 +262,35 @@ function ChartList(props: ChartListProps) {
},
setResourceCollection: setCharts,
hasPerm,
fetchData,
fetchData: fetchChartData,
toggleBulkSelect,
refreshData,
} = useListViewResource<Chart>('chart', t('chart'), addDangerToast);

const fetchData = useCallback(
(config: ListViewFetchDataConfig) =>
fetchChartData({
...config,
...(config.sortBy[0]?.id === 'viz_type'
? {
extraQueryParams: {
...config.extraQueryParams,
viz_type_order: registry
.keys()
.sort((left, right) => {
const nameComparison = (
registry.get(left)?.name || left
).localeCompare(registry.get(right)?.name || right);
return nameComparison || left.localeCompare(right);
})
.slice(0, MAX_VIZ_TYPE_ORDER_LENGTH),
},
}
: {}),
}),
[fetchChartData],
);

const chartIds = useMemo(() => charts.map(c => c.id), [charts]);
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
Expand Down
102 changes: 102 additions & 0 deletions superset-frontend/src/views/CRUD/hooks.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
* under the License.
*/
import { renderHook, act } from '@testing-library/react';
import rison from 'rison';
import { waitFor } from 'spec/helpers/testing-library';
import { JsonResponse, SupersetClient } from '@superset-ui/core';

Expand Down Expand Up @@ -551,6 +552,107 @@ test('useListViewResource: uses desc sort direction when desc is true', async ()
expect(endpoint).toContain('order_direction:desc');
});

test('useListViewResource: includes extra list query parameters', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);

const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);

await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'viz_type' }],
filters: [],
extraQueryParams: {
viz_type_order: ['slug_z', 'slug_a'],
},
});
});

const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
const query = new URL(endpoint, 'http://localhost').searchParams.get('q');
expect(rison.decode(query!)).toMatchObject({
order_column: 'viz_type',
viz_type_order: ['slug_z', 'slug_a'],
});
});

test('useListViewResource: refresh reuses extra list query parameters', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);

const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);

await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'viz_type' }],
filters: [],
extraQueryParams: { viz_type_order: ['slug_z', 'slug_a'] },
});
await result.current.refreshData();
});

const listQueries = getSpy.mock.calls
.map(call => (call[0] as { endpoint: string }).endpoint)
.filter(endpoint => endpoint.includes('/api/v1/chart/?q='))
.map(endpoint => {
const query = new URL(endpoint, 'http://localhost').searchParams.get('q');
return rison.decode(query!);
});
expect(listQueries).toHaveLength(2);
expect(listQueries).toEqual([
expect.objectContaining({ viz_type_order: ['slug_z', 'slug_a'] }),
expect.objectContaining({ viz_type_order: ['slug_z', 'slug_a'] }),
]);
});

test('useListViewResource: extra parameters cannot replace list controls', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
} as unknown as JsonResponse);

const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn()),
);

await act(async () => {
await result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'viz_type' }],
filters: [],
extraQueryParams: {
custom_param: 'preserved',
filters: [{ col: 'slice_name', opr: 'eq', value: 'injected' }],
order_column: 'slice_name',
order_direction: 'desc',
page: 99,
page_size: 1,
select_columns: ['slice_name'],
},
});
});

const endpoint = findEndpoint(getSpy, '/api/v1/chart/?q=');
const query = new URL(endpoint, 'http://localhost').searchParams.get('q');
expect(rison.decode(query!)).toEqual({
custom_param: 'preserved',
order_column: 'viz_type',
order_direction: 'asc',
page: 0,
page_size: 25,
});
});

// useSingleViewResource
test('useSingleViewResource: initial state has loading false and null resource', () => {
const { result } = renderHook(() =>
Expand Down
17 changes: 17 additions & 0 deletions superset-frontend/src/views/CRUD/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ interface ListViewResourceState<D extends object = any> {
lastFetched?: string;
}

const reservedListQueryParams = new Set([
'filters',
'order_column',
'order_direction',
'page',
'page_size',
'select_columns',
]);

const parsedErrorMessage = (
errorMessage: Record<string, string[] | string> | string,
) => {
Expand Down Expand Up @@ -156,6 +165,7 @@ export function useListViewResource<D extends object = any>(
pageSize,
sortBy,
filters: filterValues,
extraQueryParams,
}: FetchDataConfig) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
Expand All @@ -165,6 +175,7 @@ export function useListViewResource<D extends object = any>(
pageIndex,
pageSize,
sortBy,
extraQueryParams,
};
lastFetchDataConfigRef.current = config;
// set loading state, cache the last config for refreshing data.
Expand All @@ -186,7 +197,13 @@ export function useListViewResource<D extends object = any>(
: value,
}));

const safeExtraQueryParams = Object.fromEntries(
Object.entries(extraQueryParams ?? {}).filter(
([key]) => !reservedListQueryParams.has(key),
),
);
const queryParams = rison.encode_uri({
...safeExtraQueryParams,
order_column: sortBy[0].id,
order_direction: sortBy[0].desc ? 'desc' : 'asc',
page: pageIndex,
Expand Down
Loading
Loading