mirror of
https://github.com/apache/superset.git
synced 2026-06-29 11:25:34 +00:00
Compare commits
8 Commits
chore/ci/s
...
fix-interm
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31dcc1d954 | ||
|
|
eea7402e4e | ||
|
|
813d184ea2 | ||
|
|
13553a7205 | ||
|
|
ecce53e12f | ||
|
|
bd35faa8a0 | ||
|
|
69879986e6 | ||
|
|
41fd703a8c |
@@ -16,7 +16,13 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { FunctionComponent, useState, useEffect, useCallback } from 'react';
|
import {
|
||||||
|
FunctionComponent,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
} from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
styled,
|
styled,
|
||||||
@@ -108,6 +114,14 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isEditing, setIsEditing] = useState<boolean>(false);
|
const [isEditing, setIsEditing] = useState<boolean>(false);
|
||||||
const [modal, contextHolder] = Modal.useModal();
|
const [modal, contextHolder] = Modal.useModal();
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
isMountedRef.current = true;
|
||||||
|
return () => {
|
||||||
|
isMountedRef.current = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
const buildPayload = (datasource: Record<string, any>) => {
|
const buildPayload = (datasource: Record<string, any>) => {
|
||||||
const payload: Record<string, any> = {
|
const payload: Record<string, any> = {
|
||||||
table_name: datasource.table_name,
|
table_name: datasource.table_name,
|
||||||
@@ -185,6 +199,9 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
};
|
};
|
||||||
const onConfirmSave = async () => {
|
const onConfirmSave = async () => {
|
||||||
// Pull out extra fields into the extra object
|
// Pull out extra fields into the extra object
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await SupersetClient.put({
|
await SupersetClient.put({
|
||||||
@@ -195,6 +212,10 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
const { json } = await SupersetClient.get({
|
const { json } = await SupersetClient.get({
|
||||||
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
endpoint: `/api/v1/dataset/${currentDatasource?.id}`,
|
||||||
});
|
});
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(false);
|
||||||
addSuccessToast(t('The dataset has been saved'));
|
addSuccessToast(t('The dataset has been saved'));
|
||||||
// eslint-disable-next-line no-param-reassign
|
// eslint-disable-next-line no-param-reassign
|
||||||
json.result.type = 'table';
|
json.result.type = 'table';
|
||||||
@@ -204,8 +225,11 @@ const DatasourceModal: FunctionComponent<DatasourceModalProps> = ({
|
|||||||
});
|
});
|
||||||
onHide();
|
onHide();
|
||||||
} catch (response) {
|
} catch (response) {
|
||||||
setIsSaving(false);
|
|
||||||
const error = await getClientErrorObject(response);
|
const error = await getClientErrorObject(response);
|
||||||
|
if (!isMountedRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setIsSaving(false);
|
||||||
let errorResponse: SupersetError | undefined;
|
let errorResponse: SupersetError | undefined;
|
||||||
let errorText: string | undefined;
|
let errorText: string | undefined;
|
||||||
// sip-40 error response
|
// sip-40 error response
|
||||||
|
|||||||
@@ -622,6 +622,7 @@ const ResultTable =
|
|||||||
class DatasourceEditor extends PureComponent {
|
class DatasourceEditor extends PureComponent {
|
||||||
constructor(props) {
|
constructor(props) {
|
||||||
super(props);
|
super(props);
|
||||||
|
this.isComponentMounted = false;
|
||||||
this.state = {
|
this.state = {
|
||||||
datasource: {
|
datasource: {
|
||||||
...props.datasource,
|
...props.datasource,
|
||||||
@@ -760,11 +761,17 @@ class DatasourceEditor extends PureComponent {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.props.formatQuery(datasource.sql);
|
const response = await this.props.formatQuery(datasource.sql);
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.onDatasourcePropChange('sql', response.json.result);
|
this.onDatasourcePropChange('sql', response.json.result);
|
||||||
this.props.addSuccessToast(t('SQL was formatted'));
|
this.props.addSuccessToast(t('SQL was formatted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { error: clientError, statusText } =
|
const { error: clientError, statusText } =
|
||||||
await getClientErrorObject(error);
|
await getClientErrorObject(error);
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
clientError ||
|
clientError ||
|
||||||
statusText ||
|
statusText ||
|
||||||
@@ -808,11 +815,17 @@ class DatasourceEditor extends PureComponent {
|
|||||||
body: JSON.stringify({ sql: datasource.sql }),
|
body: JSON.stringify({ sql: datasource.sql }),
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
});
|
});
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.onDatasourcePropChange('sql', response.json.result);
|
this.onDatasourcePropChange('sql', response.json.result);
|
||||||
this.props.addSuccessToast(t('SQL was formatted'));
|
this.props.addSuccessToast(t('SQL was formatted'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { error: clientError, statusText } =
|
const { error: clientError, statusText } =
|
||||||
await getClientErrorObject(error);
|
await getClientErrorObject(error);
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.props.addDangerToast(
|
this.props.addDangerToast(
|
||||||
clientError ||
|
clientError ||
|
||||||
statusText ||
|
statusText ||
|
||||||
@@ -823,28 +836,41 @@ class DatasourceEditor extends PureComponent {
|
|||||||
|
|
||||||
async syncMetadata() {
|
async syncMetadata() {
|
||||||
const { datasource } = this.state;
|
const { datasource } = this.state;
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setState({ metadataLoading: true });
|
this.setState({ metadataLoading: true });
|
||||||
try {
|
try {
|
||||||
const newCols = await fetchSyncedColumns(datasource);
|
const newCols = await fetchSyncedColumns(datasource);
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const columnChanges = updateColumns(
|
const columnChanges = updateColumns(
|
||||||
datasource.columns,
|
datasource.columns,
|
||||||
newCols,
|
newCols,
|
||||||
this.props.addSuccessToast,
|
this.props.addSuccessToast,
|
||||||
);
|
);
|
||||||
|
if (!this.isComponentMounted) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.setColumns({
|
this.setColumns({
|
||||||
databaseColumns: columnChanges.finalColumns.filter(
|
databaseColumns: columnChanges.finalColumns.filter(
|
||||||
col => !col.expression, // remove calculated columns
|
col => !col.expression, // remove calculated columns
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
this.props.addSuccessToast(t('Metadata has been synced'));
|
if (this.isComponentMounted) {
|
||||||
this.setState({ metadataLoading: false });
|
this.props.addSuccessToast(t('Metadata has been synced'));
|
||||||
|
this.setState({ metadataLoading: false });
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { error: clientError, statusText } =
|
const { error: clientError, statusText } =
|
||||||
await getClientErrorObject(error);
|
await getClientErrorObject(error);
|
||||||
this.props.addDangerToast(
|
if (this.isComponentMounted) {
|
||||||
clientError || statusText || t('An error has occurred'),
|
this.props.addDangerToast(
|
||||||
);
|
clientError || statusText || t('An error has occurred'),
|
||||||
this.setState({ metadataLoading: false });
|
);
|
||||||
|
this.setState({ metadataLoading: false });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,10 +927,12 @@ class DatasourceEditor extends PureComponent {
|
|||||||
id: ids[index],
|
id: ids[index],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.setState({
|
if (this.isComponentMounted) {
|
||||||
usageCharts: chartsWithIds,
|
this.setState({
|
||||||
usageChartsCount: json?.count || 0,
|
usageCharts: chartsWithIds,
|
||||||
});
|
usageChartsCount: json?.count || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
charts: chartsWithIds,
|
charts: chartsWithIds,
|
||||||
@@ -914,15 +942,17 @@ class DatasourceEditor extends PureComponent {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const { error: clientError, statusText } =
|
const { error: clientError, statusText } =
|
||||||
await getClientErrorObject(error);
|
await getClientErrorObject(error);
|
||||||
this.props.addDangerToast(
|
if (this.isComponentMounted) {
|
||||||
clientError ||
|
this.props.addDangerToast(
|
||||||
statusText ||
|
clientError ||
|
||||||
t('An error occurred while fetching usage data'),
|
statusText ||
|
||||||
);
|
t('An error occurred while fetching usage data'),
|
||||||
this.setState({
|
);
|
||||||
usageCharts: [],
|
this.setState({
|
||||||
usageChartsCount: 0,
|
usageCharts: [],
|
||||||
});
|
usageChartsCount: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
charts: [],
|
charts: [],
|
||||||
count: 0,
|
count: 0,
|
||||||
@@ -1883,6 +1913,7 @@ class DatasourceEditor extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentDidMount() {
|
componentDidMount() {
|
||||||
|
this.isComponentMounted = true;
|
||||||
Mousetrap.bind('ctrl+shift+f', e => {
|
Mousetrap.bind('ctrl+shift+f', e => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this.state.isEditMode) {
|
if (this.state.isEditMode) {
|
||||||
@@ -1894,6 +1925,7 @@ class DatasourceEditor extends PureComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
componentWillUnmount() {
|
componentWillUnmount() {
|
||||||
|
this.isComponentMounted = false;
|
||||||
Mousetrap.unbind('ctrl+shift+f');
|
Mousetrap.unbind('ctrl+shift+f');
|
||||||
this.props.resetQuery();
|
this.props.resetQuery();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,14 +17,149 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import {
|
import {
|
||||||
|
act,
|
||||||
createWrapper,
|
createWrapper,
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from 'spec/helpers/testing-library';
|
} from 'spec/helpers/testing-library';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
|
import {
|
||||||
|
TableProps,
|
||||||
|
OnChangeFunction,
|
||||||
|
} from '@superset-ui/core/components/Table';
|
||||||
import DatasetUsageTab from '.';
|
import DatasetUsageTab from '.';
|
||||||
|
|
||||||
|
interface Chart {
|
||||||
|
id?: number;
|
||||||
|
slice_name: string;
|
||||||
|
url: string;
|
||||||
|
certified_by?: string;
|
||||||
|
certification_details?: string;
|
||||||
|
description?: string;
|
||||||
|
owners: Array<{
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
id: number;
|
||||||
|
}>;
|
||||||
|
changed_on_delta_humanized: string;
|
||||||
|
changed_on?: string;
|
||||||
|
changed_by: {
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
id: number;
|
||||||
|
} | null;
|
||||||
|
dashboards: Array<{
|
||||||
|
id: number;
|
||||||
|
dashboard_title: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock('@superset-ui/core/components/Table', () => {
|
||||||
|
const actual = jest.requireActual('@superset-ui/core/components/Table');
|
||||||
|
const ActualTable = actual.default;
|
||||||
|
|
||||||
|
let latestPaginationHandler:
|
||||||
|
| ((page: number, pageSize?: number) => void)
|
||||||
|
| null = null;
|
||||||
|
let latestPaginationPageSize: number | undefined;
|
||||||
|
let latestPaginationCurrent: number | undefined;
|
||||||
|
let latestPaginationTotal: number | undefined;
|
||||||
|
let latestOnChangeHandler: OnChangeFunction<Chart> | null = null;
|
||||||
|
|
||||||
|
const MockTable = <RecordType extends object>({
|
||||||
|
pagination,
|
||||||
|
onChange,
|
||||||
|
recordCount,
|
||||||
|
defaultPageSize = 15,
|
||||||
|
usePagination = true,
|
||||||
|
...rest
|
||||||
|
}: TableProps<RecordType>) => {
|
||||||
|
// Simulate the Table wrapper's pagination merging logic
|
||||||
|
let mergedPagination = pagination;
|
||||||
|
|
||||||
|
if (usePagination) {
|
||||||
|
// Build default pagination settings like the wrapper does
|
||||||
|
const paginationDefaults: {
|
||||||
|
hideOnSinglePage: boolean;
|
||||||
|
pageSize: number;
|
||||||
|
total?: number;
|
||||||
|
} = {
|
||||||
|
hideOnSinglePage: true,
|
||||||
|
pageSize: defaultPageSize,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add total from recordCount if provided
|
||||||
|
if (recordCount) {
|
||||||
|
paginationDefaults.total = recordCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge with pagination overrides
|
||||||
|
if (pagination && typeof pagination !== 'boolean') {
|
||||||
|
mergedPagination = { ...paginationDefaults, ...pagination };
|
||||||
|
} else if (!pagination) {
|
||||||
|
mergedPagination = paginationDefaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture the merged pagination for assertions
|
||||||
|
if (mergedPagination && typeof mergedPagination !== 'boolean') {
|
||||||
|
latestPaginationPageSize = mergedPagination.pageSize;
|
||||||
|
latestPaginationCurrent = mergedPagination.current;
|
||||||
|
latestPaginationTotal = mergedPagination.total;
|
||||||
|
latestPaginationHandler = mergedPagination.onChange || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast to Chart type since we know DatasetUsageTab uses Chart
|
||||||
|
latestOnChangeHandler =
|
||||||
|
(onChange as OnChangeFunction<Chart> | undefined) || null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ActualTable
|
||||||
|
pagination={mergedPagination}
|
||||||
|
onChange={onChange}
|
||||||
|
recordCount={recordCount}
|
||||||
|
defaultPageSize={defaultPageSize}
|
||||||
|
usePagination={usePagination}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
__esModule: true,
|
||||||
|
...actual,
|
||||||
|
default: MockTable,
|
||||||
|
__getLatestPaginationHandler: () => latestPaginationHandler,
|
||||||
|
__getLatestPaginationPageSize: () => latestPaginationPageSize,
|
||||||
|
__getLatestPaginationCurrent: () => latestPaginationCurrent,
|
||||||
|
__getLatestPaginationTotal: () => latestPaginationTotal,
|
||||||
|
__getLatestOnChangeHandler: () => latestOnChangeHandler,
|
||||||
|
__resetPaginationHandler: () => {
|
||||||
|
latestPaginationHandler = null;
|
||||||
|
latestPaginationPageSize = undefined;
|
||||||
|
latestPaginationCurrent = undefined;
|
||||||
|
latestPaginationTotal = undefined;
|
||||||
|
latestOnChangeHandler = null;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tableMock = jest.requireMock('@superset-ui/core/components/Table') as {
|
||||||
|
__getLatestPaginationHandler: () =>
|
||||||
|
| ((page: number, pageSize?: number) => void)
|
||||||
|
| null;
|
||||||
|
__getLatestPaginationPageSize: () => number | undefined;
|
||||||
|
__getLatestPaginationCurrent: () => number | undefined;
|
||||||
|
__getLatestPaginationTotal: () => number | undefined;
|
||||||
|
__resetPaginationHandler: () => void;
|
||||||
|
__getLatestOnChangeHandler: () =>
|
||||||
|
| ((pagination: { current: number; pageSize?: number }) => void)
|
||||||
|
| null;
|
||||||
|
};
|
||||||
|
|
||||||
const mockChartsResponse = {
|
const mockChartsResponse = {
|
||||||
result: [
|
result: [
|
||||||
{
|
{
|
||||||
@@ -83,6 +218,9 @@ const mockChartsResponse = {
|
|||||||
ids: [1, 2],
|
ids: [1, 2],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Store original scrollTo to restore after tests
|
||||||
|
let originalScrollTo: typeof Element.prototype.scrollTo;
|
||||||
|
|
||||||
const setupTest = (props = {}) => {
|
const setupTest = (props = {}) => {
|
||||||
const mockOnFetchCharts = jest.fn(() =>
|
const mockOnFetchCharts = jest.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve({
|
||||||
@@ -101,21 +239,48 @@ const setupTest = (props = {}) => {
|
|||||||
...props,
|
...props,
|
||||||
};
|
};
|
||||||
|
|
||||||
return render(<DatasetUsageTab {...defaultProps} />, {
|
const result = render(<DatasetUsageTab {...defaultProps} />, {
|
||||||
wrapper: createWrapper({
|
wrapper: createWrapper({
|
||||||
useRedux: true,
|
useRedux: true,
|
||||||
useRouter: true,
|
useRouter: true,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return { ...result, mockOnFetchCharts };
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock.reset();
|
fetchMock.reset();
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|
||||||
|
// Save original scrollTo and mock it for JSDOM compatibility
|
||||||
|
originalScrollTo = Element.prototype.scrollTo;
|
||||||
|
Object.defineProperty(Element.prototype, 'scrollTo', {
|
||||||
|
value: jest.fn(),
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
tableMock.__resetPaginationHandler();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fetchMock.restore();
|
fetchMock.restore();
|
||||||
|
|
||||||
|
// Restore real timers in case a test with fakeTimers failed mid-test
|
||||||
|
jest.useRealTimers();
|
||||||
|
|
||||||
|
// Restore original scrollTo to avoid test pollution
|
||||||
|
if (originalScrollTo) {
|
||||||
|
Object.defineProperty(Element.prototype, 'scrollTo', {
|
||||||
|
value: originalScrollTo,
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
delete (Element.prototype as any).scrollTo;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders empty state when no charts provided', () => {
|
test('renders empty state when no charts provided', () => {
|
||||||
@@ -212,3 +377,363 @@ test('enables sorting for Chart and Last modified columns', async () => {
|
|||||||
expect(dashboardHeader).not.toHaveClass('ant-table-column-has-sorters');
|
expect(dashboardHeader).not.toHaveClass('ant-table-column-has-sorters');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('clears scroll timeout on unmount to prevent async updates', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
const scrollToMock = Element.prototype.scrollTo as jest.Mock;
|
||||||
|
scrollToMock.mockClear();
|
||||||
|
|
||||||
|
const manyCharts = Array.from({ length: 51 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { unmount, mockOnFetchCharts } = setupTest({
|
||||||
|
charts: manyCharts.slice(0, 25),
|
||||||
|
totalCount: 51,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const onChangeHandler = tableMock.__getLatestOnChangeHandler();
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const pageSize = tableMock.__getLatestPaginationPageSize();
|
||||||
|
expect(onChangeHandler).toBeDefined();
|
||||||
|
|
||||||
|
onChangeHandler?.({ current: 2, pageSize } as {
|
||||||
|
current: number;
|
||||||
|
pageSize?: number;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollToMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clears pending scroll timeout on rapid pagination clicks', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
const scrollToMock = Element.prototype.scrollTo as jest.Mock;
|
||||||
|
scrollToMock.mockClear();
|
||||||
|
|
||||||
|
const manyCharts = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockOnFetchCharts } = setupTest({
|
||||||
|
charts: manyCharts.slice(0, 25),
|
||||||
|
totalCount: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const firstOnChange = tableMock.__getLatestOnChangeHandler();
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const pageSize = tableMock.__getLatestPaginationPageSize();
|
||||||
|
expect(firstOnChange).toBeDefined();
|
||||||
|
|
||||||
|
firstOnChange?.({ current: 2, pageSize } as {
|
||||||
|
current: number;
|
||||||
|
pageSize?: number;
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(50);
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const secondOnChange = tableMock.__getLatestOnChangeHandler();
|
||||||
|
expect(secondOnChange).toBeDefined();
|
||||||
|
|
||||||
|
secondOnChange?.({ current: 3, pageSize } as {
|
||||||
|
current: number;
|
||||||
|
pageSize?: number;
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
3,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.runAllTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollToMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(scrollToMock).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('scrolls table to top after pagination with delay', async () => {
|
||||||
|
jest.useFakeTimers();
|
||||||
|
|
||||||
|
const scrollToMock = Element.prototype.scrollTo as jest.Mock;
|
||||||
|
scrollToMock.mockClear();
|
||||||
|
|
||||||
|
const manyCharts = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockOnFetchCharts } = setupTest({
|
||||||
|
charts: manyCharts.slice(0, 25),
|
||||||
|
totalCount: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const onChangeHandler = tableMock.__getLatestOnChangeHandler();
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
const pageSize = tableMock.__getLatestPaginationPageSize();
|
||||||
|
expect(onChangeHandler).toBeDefined();
|
||||||
|
|
||||||
|
onChangeHandler?.({ current: 2, pageSize } as {
|
||||||
|
current: number;
|
||||||
|
pageSize?: number;
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(scrollToMock).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
jest.advanceTimersByTime(100);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(scrollToMock).toHaveBeenCalledWith({
|
||||||
|
top: 0,
|
||||||
|
behavior: 'smooth',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('displays correct pagination and navigates between pages', async () => {
|
||||||
|
const manyCharts = Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockOnFetchCharts } = setupTest({
|
||||||
|
charts: manyCharts.slice(0, 25),
|
||||||
|
totalCount: 30,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Page 1: Verify UI shows page 1 is active (using Ant Design active class)
|
||||||
|
await waitFor(() => {
|
||||||
|
const page1Item = screen.getByRole('listitem', { name: '1' });
|
||||||
|
expect(page1Item).toHaveClass('ant-pagination-item-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft assertion: verify component passes correct props to Table
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationCurrent()).toBe(1);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(30);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
|
||||||
|
|
||||||
|
// User clicks page 2 (using userEvent for realistic interaction)
|
||||||
|
const page2Item = screen.getByRole('listitem', { name: '2' });
|
||||||
|
await userEvent.click(page2Item);
|
||||||
|
|
||||||
|
// Data fetch called for page 2
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify UI reflects page 2 is now active (using Ant Design active class)
|
||||||
|
await waitFor(() => {
|
||||||
|
const page2Item = screen.getByRole('listitem', { name: '2' });
|
||||||
|
expect(page2Item).toHaveClass('ant-pagination-item-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft assertion: Table still receives correct total (catches the bug!)
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationCurrent()).toBe(2);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(30);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maintains pagination state across multiple page changes', async () => {
|
||||||
|
const manyCharts = Array.from({ length: 60 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { mockOnFetchCharts } = setupTest({
|
||||||
|
charts: manyCharts.slice(0, 25),
|
||||||
|
totalCount: 60,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify initial state (page 1) using Ant Design active class
|
||||||
|
await waitFor(() => {
|
||||||
|
const page1Item = screen.getByRole('listitem', { name: '1' });
|
||||||
|
expect(page1Item).toHaveClass('ant-pagination-item-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft assertion: initial props are correct
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(60);
|
||||||
|
|
||||||
|
// User navigates to page 2
|
||||||
|
await userEvent.click(screen.getByRole('listitem', { name: '2' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
2,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
const page2Item = screen.getByRole('listitem', { name: '2' });
|
||||||
|
expect(page2Item).toHaveClass('ant-pagination-item-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft assertion: props update correctly after navigation
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationCurrent()).toBe(2);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(60);
|
||||||
|
|
||||||
|
// User navigates to page 3
|
||||||
|
await userEvent.click(screen.getByRole('listitem', { name: '3' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockOnFetchCharts).toHaveBeenCalledWith(
|
||||||
|
3,
|
||||||
|
25,
|
||||||
|
expect.any(String),
|
||||||
|
expect.any(String),
|
||||||
|
);
|
||||||
|
const page3Item = screen.getByRole('listitem', { name: '3' });
|
||||||
|
expect(page3Item).toHaveClass('ant-pagination-item-active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Soft assertion: total remains consistent across all navigations
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationCurrent()).toBe(3);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(60);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles total count below one page (no pagination needed)', async () => {
|
||||||
|
const fewCharts = Array.from({ length: 5 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
setupTest({
|
||||||
|
charts: fewCharts,
|
||||||
|
totalCount: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Pagination should not show page 2 item since only 1 page exists
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('listitem', { name: '2' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table receives correct props
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(5);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationCurrent()).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles server returning fewer rows than page size', async () => {
|
||||||
|
const partialPage = Array.from({ length: 12 }, (_, i) => ({
|
||||||
|
id: i + 1,
|
||||||
|
slice_name: `Test Chart ${i + 1}`,
|
||||||
|
url: `/explore/${i + 1}/`,
|
||||||
|
owners: [],
|
||||||
|
changed_on_delta_humanized: '1 day ago',
|
||||||
|
changed_by: null,
|
||||||
|
dashboards: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
setupTest({
|
||||||
|
charts: partialPage,
|
||||||
|
totalCount: 12,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only page 1 should exist
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('listitem', { name: '2' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Table receives correct total (not data.length!)
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationTotal()).toBe(12);
|
||||||
|
// eslint-disable-next-line no-underscore-dangle
|
||||||
|
expect(tableMock.__getLatestPaginationPageSize()).toBe(25);
|
||||||
|
});
|
||||||
|
|||||||
@@ -99,6 +99,8 @@ const DatasetUsageTab = ({
|
|||||||
}: DatasetUsageTabProps) => {
|
}: DatasetUsageTabProps) => {
|
||||||
const addDangerToastRef = useRef(addDangerToast);
|
const addDangerToastRef = useRef(addDangerToast);
|
||||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const scrollTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const isMountedRef = useRef(true);
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
@@ -115,14 +117,19 @@ const DatasetUsageTab = ({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await onFetchCharts(page, PAGE_SIZE, column, direction);
|
await onFetchCharts(page, PAGE_SIZE, column, direction);
|
||||||
setCurrentPage(page);
|
// Only update state if component is still mounted
|
||||||
setSortColumn(column);
|
if (isMountedRef.current) {
|
||||||
setSortDirection(direction);
|
setCurrentPage(page);
|
||||||
|
setSortColumn(column);
|
||||||
|
setSortDirection(direction);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (addDangerToastRef.current)
|
if (isMountedRef.current && addDangerToastRef.current)
|
||||||
addDangerToastRef.current(t('Error fetching charts'));
|
addDangerToastRef.current(t('Error fetching charts'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
if (isMountedRef.current) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[datasourceId, onFetchCharts, sortColumn, sortDirection],
|
[datasourceId, onFetchCharts, sortColumn, sortDirection],
|
||||||
@@ -132,11 +139,31 @@ const DatasetUsageTab = ({
|
|||||||
addDangerToastRef.current = addDangerToast;
|
addDangerToastRef.current = addDangerToast;
|
||||||
}, [addDangerToast]);
|
}, [addDangerToast]);
|
||||||
|
|
||||||
|
// Cleanup on unmount
|
||||||
|
useEffect(() => {
|
||||||
|
// Set mounted flag to true on mount (important for Strict Mode double-mount)
|
||||||
|
isMountedRef.current = true;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
// Mark component as unmounted to prevent state updates
|
||||||
|
isMountedRef.current = false;
|
||||||
|
// Clear pending scroll timeout
|
||||||
|
if (scrollTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handlePageChange = useCallback(
|
const handlePageChange = useCallback(
|
||||||
(page: number) => {
|
(page: number) => {
|
||||||
handleFetchCharts(page);
|
handleFetchCharts(page);
|
||||||
|
|
||||||
setTimeout(() => {
|
// Clear any pending scroll timeout
|
||||||
|
if (scrollTimeoutRef.current) {
|
||||||
|
clearTimeout(scrollTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollTimeoutRef.current = setTimeout(() => {
|
||||||
const tableBody =
|
const tableBody =
|
||||||
tableContainerRef.current?.querySelector('.ant-table-body');
|
tableContainerRef.current?.querySelector('.ant-table-body');
|
||||||
if (tableBody) {
|
if (tableBody) {
|
||||||
@@ -145,6 +172,7 @@ const DatasetUsageTab = ({
|
|||||||
behavior: 'smooth',
|
behavior: 'smooth',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
scrollTimeoutRef.current = null;
|
||||||
}, 100);
|
}, 100);
|
||||||
},
|
},
|
||||||
[handleFetchCharts],
|
[handleFetchCharts],
|
||||||
@@ -261,14 +289,9 @@ const DatasetUsageTab = ({
|
|||||||
sticky
|
sticky
|
||||||
columns={columns}
|
columns={columns}
|
||||||
data={charts}
|
data={charts}
|
||||||
pagination={{
|
recordCount={totalCount}
|
||||||
current: currentPage,
|
usePagination
|
||||||
total: totalCount,
|
defaultPageSize={PAGE_SIZE}
|
||||||
pageSize: PAGE_SIZE,
|
|
||||||
onChange: handlePageChange,
|
|
||||||
showSizeChanger: false,
|
|
||||||
size: 'default',
|
|
||||||
}}
|
|
||||||
loading={loading}
|
loading={loading}
|
||||||
size={TableSize.Middle}
|
size={TableSize.Middle}
|
||||||
rowKey={(record: Chart) =>
|
rowKey={(record: Chart) =>
|
||||||
@@ -276,6 +299,12 @@ const DatasetUsageTab = ({
|
|||||||
}
|
}
|
||||||
tableLayout="fixed"
|
tableLayout="fixed"
|
||||||
scroll={{ y: 293, x: '100%' }}
|
scroll={{ y: 293, x: '100%' }}
|
||||||
|
pagination={{
|
||||||
|
current: currentPage,
|
||||||
|
total: totalCount,
|
||||||
|
pageSize: PAGE_SIZE,
|
||||||
|
hideOnSinglePage: false,
|
||||||
|
}}
|
||||||
css={css`
|
css={css`
|
||||||
.ant-table-pagination.ant-pagination {
|
.ant-table-pagination.ant-pagination {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
|||||||
Reference in New Issue
Block a user