diff --git a/docs/package.json b/docs/package.json index dd7b05dbfe66..15ff2332a931 100644 --- a/docs/package.json +++ b/docs/package.json @@ -67,7 +67,7 @@ "@storybook/theming": "^8.6.11", "@superset-ui/core": "^0.20.4", "antd": "^6.2.2", - "babel-loader": "^9.2.1", + "babel-loader": "^10.0.0", "caniuse-lite": "^1.0.30001766", "docusaurus-plugin-less": "^2.0.2", "docusaurus-plugin-openapi-docs": "^4.6.0", diff --git a/docs/yarn.lock b/docs/yarn.lock index a003a7c640aa..7f5156481269 100644 --- a/docs/yarn.lock +++ b/docs/yarn.lock @@ -5386,6 +5386,13 @@ axios@^1.12.2: form-data "^4.0.4" proxy-from-env "^1.1.0" +babel-loader@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.0.0.tgz#b9743714c0e1e084b3e4adef3cd5faee33089977" + integrity sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA== + dependencies: + find-up "^5.0.0" + babel-loader@^9.2.1: version "9.2.1" resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.2.1.tgz#04c7835db16c246dd19ba0914418f3937797587b" diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index abf040df8a52..8fc4c695e8bc 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -100,6 +100,7 @@ "query-string": "6.14.1", "re-resizable": "^6.11.2", "react": "^17.0.2", + "react-arborist": "^3.4.3", "react-checkbox-tree": "^1.8.0", "react-diff-viewer-continued": "^3.4.0", "react-dnd": "^11.1.3", @@ -190,7 +191,7 @@ "@types/js-levenshtein": "^1.1.3", "@types/json-bigint": "^1.0.4", "@types/mousetrap": "^1.6.15", - "@types/node": "^25.0.10", + "@types/node": "^25.1.0", "@types/react": "^17.0.83", "@types/react-dom": "^17.0.26", "@types/react-loadable": "^5.5.11", @@ -212,7 +213,7 @@ "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-lodash": "^3.3.4", "babel-plugin-typescript-to-proptypes": "^2.0.0", - "baseline-browser-mapping": "^2.9.18", + "baseline-browser-mapping": "^2.9.19", "cheerio": "1.2.0", "concurrently": "^9.2.1", "copy-webpack-plugin": "^13.0.1", @@ -240,7 +241,7 @@ "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^7.15.4", "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", - "fetch-mock": "^11.1.5", + "fetch-mock": "^12.6.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "history": "^5.3.0", "html-webpack-plugin": "^5.6.6", @@ -19903,9 +19904,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.10", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz", - "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", + "version": "25.1.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.1.0.tgz", + "integrity": "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA==", "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -24080,9 +24081,9 @@ "license": "MIT" }, "node_modules/baseline-browser-mapping": { - "version": "2.9.18", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.18.tgz", - "integrity": "sha512-e23vBV1ZLfjb9apvfPk4rHVu2ry6RIr2Wfs+O324okSidrX7pTAnEJPCh/O5BtRlr7QtZI7ktOP3vsqr7Z5XoA==", + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", "dev": true, "license": "Apache-2.0", "bin": { @@ -32118,25 +32119,19 @@ } }, "node_modules/fetch-mock": { - "version": "11.1.5", - "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-11.1.5.tgz", - "integrity": "sha512-KHmZDnZ1ry0pCTrX4YG5DtThHi0MH+GNI9caESnzX/nMJBrvppUHMvLx47M0WY9oAtKOMiPfZDRpxhlHg89BOA==", + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-12.6.0.tgz", + "integrity": "sha512-oAy0OqAvjAvduqCeWveBix7LLuDbARPqZZ8ERYtBcCURA3gy7EALA3XWq0tCNxsSg+RmmJqyaeeZlOCV9abv6w==", "dev": true, "license": "MIT", "dependencies": { "@types/glob-to-regexp": "^0.4.4", "dequal": "^2.0.3", "glob-to-regexp": "^0.4.1", - "is-subset": "^0.1.1", "regexparam": "^3.0.0" }, "engines": { - "node": ">=8.0.0" - }, - "peerDependenciesMeta": { - "node-fetch": { - "optional": true - } + "node": ">=18.11.0" } }, "node_modules/fetch-retry": { @@ -36914,7 +36909,9 @@ "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", "dev": true, - "license": "MIT" + "license": "MIT", + "optional": true, + "peer": true }, "node_modules/is-symbol": { "version": "1.1.1", @@ -49668,6 +49665,88 @@ "react-dom": "^0.13.0 || ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/react-arborist": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz", + "integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==", + "license": "MIT", + "dependencies": { + "react-dnd": "^14.0.3", + "react-dnd-html5-backend": "^14.0.3", + "react-window": "^1.8.11", + "redux": "^5.0.0", + "use-sync-external-store": "^1.2.0" + }, + "peerDependencies": { + "react": ">= 16.14", + "react-dom": ">= 16.14" + } + }, + "node_modules/react-arborist/node_modules/dnd-core": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz", + "integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==", + "license": "MIT", + "dependencies": { + "@react-dnd/asap": "^4.0.0", + "@react-dnd/invariant": "^2.0.0", + "redux": "^4.1.1" + } + }, + "node_modules/react-arborist/node_modules/dnd-core/node_modules/redux": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz", + "integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.9.2" + } + }, + "node_modules/react-arborist/node_modules/react-dnd": { + "version": "14.0.5", + "resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz", + "integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==", + "license": "MIT", + "dependencies": { + "@react-dnd/invariant": "^2.0.0", + "@react-dnd/shallowequal": "^2.0.0", + "dnd-core": "14.0.1", + "fast-deep-equal": "^3.1.3", + "hoist-non-react-statics": "^3.3.2" + }, + "peerDependencies": { + "@types/hoist-non-react-statics": ">= 3.3.1", + "@types/node": ">= 12", + "@types/react": ">= 16", + "react": ">= 16.14" + }, + "peerDependenciesMeta": { + "@types/hoist-non-react-statics": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-arborist/node_modules/react-dnd-html5-backend": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz", + "integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==", + "license": "MIT", + "dependencies": { + "dnd-core": "14.0.1" + } + }, + "node_modules/react-arborist/node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, "node_modules/react-async-script": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/react-async-script/-/react-async-script-1.2.0.tgz", @@ -57889,6 +57968,15 @@ "react-dom": ">=16.8.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", @@ -63675,13 +63763,13 @@ "@types/d3-time-format": "^4.0.3", "@types/jquery": "^3.5.33", "@types/lodash": "^4.17.23", - "@types/node": "^25.0.10", + "@types/node": "^25.1.0", "@types/prop-types": "^15.7.15", "@types/react-syntax-highlighter": "^15.5.13", "@types/react-table": "^7.7.20", "@types/rison": "0.1.0", "@types/seedrandom": "^3.0.8", - "fetch-mock": "^11.1.4", + "fetch-mock": "^12.6.0", "jest-mock-console": "^2.0.0", "resize-observer-polyfill": "1.5.1", "timezone-mock": "1.3.6" diff --git a/superset-frontend/package.json b/superset-frontend/package.json index dff5390cb384..45a5a3379f73 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -182,6 +182,7 @@ "query-string": "6.14.1", "re-resizable": "^6.11.2", "react": "^17.0.2", + "react-arborist": "^3.4.3", "react-checkbox-tree": "^1.8.0", "react-diff-viewer-continued": "^3.4.0", "react-dnd": "^11.1.3", @@ -272,7 +273,7 @@ "@types/js-levenshtein": "^1.1.3", "@types/json-bigint": "^1.0.4", "@types/mousetrap": "^1.6.15", - "@types/node": "^25.0.10", + "@types/node": "^25.1.0", "@types/react": "^17.0.83", "@types/react-dom": "^17.0.26", "@types/react-loadable": "^5.5.11", @@ -294,7 +295,7 @@ "babel-plugin-jsx-remove-data-test-id": "^3.0.0", "babel-plugin-lodash": "^3.3.4", "babel-plugin-typescript-to-proptypes": "^2.0.0", - "baseline-browser-mapping": "^2.9.18", + "baseline-browser-mapping": "^2.9.19", "cheerio": "1.2.0", "concurrently": "^9.2.1", "copy-webpack-plugin": "^13.0.1", @@ -322,7 +323,7 @@ "eslint-plugin-storybook": "^0.8.0", "eslint-plugin-testing-library": "^7.15.4", "eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors", - "fetch-mock": "^11.1.5", + "fetch-mock": "^12.6.0", "fork-ts-checker-webpack-plugin": "^9.1.0", "history": "^5.3.0", "html-webpack-plugin": "^5.6.6", diff --git a/superset-frontend/packages/superset-ui-core/package.json b/superset-frontend/packages/superset-ui-core/package.json index 52dc747f816a..1275e8cf1c16 100644 --- a/superset-frontend/packages/superset-ui-core/package.json +++ b/superset-frontend/packages/superset-ui-core/package.json @@ -78,11 +78,11 @@ "@types/react-syntax-highlighter": "^15.5.13", "@types/jquery": "^3.5.33", "@types/lodash": "^4.17.23", - "@types/node": "^25.0.10", + "@types/node": "^25.1.0", "@types/prop-types": "^15.7.15", "@types/rison": "0.1.0", "@types/seedrandom": "^3.0.8", - "fetch-mock": "^11.1.4", + "fetch-mock": "^12.6.0", "jest-mock-console": "^2.0.0", "resize-observer-polyfill": "1.5.1", "timezone-mock": "1.3.6" diff --git a/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx b/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx index 6d90ef2fd08d..0266680d655a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Button/index.tsx @@ -126,7 +126,7 @@ export function Button(props: ButtonProps) { minWidth: cta ? theme.sizeUnit * 36 : undefined, minHeight: cta ? theme.sizeUnit * 8 : undefined, marginLeft: 0, - '& + .superset-button': { + '& + .superset-button:not(.ant-btn-compact-item)': { marginLeft: theme.sizeUnit * 2, }, '& > span > :first-of-type': { diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 319227687b18..9c6db6ac6a6a 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -76,6 +76,9 @@ import { FileOutlined, FileTextOutlined, FireOutlined, + FolderAddOutlined, + FolderOpenOutlined, + FolderOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, @@ -94,15 +97,18 @@ import { MenuFoldOutlined, MenuUnfoldOutlined, MinusCircleOutlined, + MinusSquareOutlined, MoonOutlined, LoadingOutlined, LoginOutlined, MonitorOutlined, MoreOutlined, OrderedListOutlined, + PartitionOutlined, PieChartOutlined, PicCenterOutlined, PlusCircleOutlined, + PlusSquareOutlined, PlusOutlined, ProfileOutlined, QuestionCircleOutlined, @@ -217,6 +223,9 @@ const AntdIcons = { FileOutlined, FileTextOutlined, FireOutlined, + FolderAddOutlined, + FolderOpenOutlined, + FolderOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, @@ -240,13 +249,16 @@ const AntdIcons = { MenuFoldOutlined, MenuUnfoldOutlined, MinusCircleOutlined, + MinusSquareOutlined, MonitorOutlined, MoonOutlined, MoreOutlined, OrderedListOutlined, + PartitionOutlined, PieChartOutlined, PicCenterOutlined, PlusCircleOutlined, + PlusSquareOutlined, PlusOutlined, ProfileOutlined, ReloadOutlined, diff --git a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx index abbc6c21cd5a..ad31e512f293 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/ListViewCard/ImageLoader.test.tsx @@ -24,12 +24,18 @@ import { ImageLoader, type BackgroundPosition } from './ImageLoader'; global.URL.createObjectURL = jest.fn(() => '/local_url'); const blob = new Blob([], { type: 'image/png' }); +beforeAll(() => { + fetchMock.mockGlobal(); +}); + +afterAll(() => { + fetchMock.hardReset(); +}); + fetchMock.get( - '/thumbnail', + 'glob:*/thumbnail', { body: blob, headers: { 'Content-Type': 'image/png' } }, - { - sendAsJson: false, - }, + { name: 'thumbnail' }, ); describe('ImageLoader', () => { @@ -44,7 +50,7 @@ describe('ImageLoader', () => { return render(); }; - afterEach(() => fetchMock.resetHistory()); + afterEach(() => fetchMock.clearHistory()); it('is a valid element', async () => { setup(); @@ -57,7 +63,7 @@ describe('ImageLoader', () => { 'src', '/fallback', ); - expect(fetchMock.calls(/thumbnail/)).toHaveLength(1); + expect(fetchMock.callHistory.calls(/thumbnail/)).toHaveLength(1); expect(global.URL.createObjectURL).toHaveBeenCalled(); expect(await screen.findByTestId('image-loader')).toHaveAttribute( 'src', @@ -66,13 +72,14 @@ describe('ImageLoader', () => { }); it('displays fallback image when response is not an image', async () => { - fetchMock.once('/thumbnail2', {}); - setup({ src: '/thumbnail2' }); + fetchMock.once('glob:*/thumbnail2', {}, { name: 'thumbnail2' }); + + setup({ src: 'glob:*/thumbnail2' }); expect(screen.getByTestId('image-loader')).toHaveAttribute( 'src', '/fallback', ); - expect(fetchMock.calls(/thumbnail2/)).toHaveLength(1); + expect(fetchMock.callHistory.calls(/thumbnail2/)).toHaveLength(1); expect(await screen.findByTestId('image-loader')).toHaveAttribute( 'src', '/fallback', diff --git a/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts b/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts index b1c879cc221e..17182227323a 100644 --- a/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/chart/clients/ChartClient.test.ts @@ -37,6 +37,9 @@ import { SliceIdAndOrFormData } from '../../../src/chart/clients/ChartClient'; configureTranslation(); +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('ChartClient', () => { let chartClient: ChartClient; @@ -50,7 +53,7 @@ describe('ChartClient', () => { chartClient = new ChartClient(); }); - afterEach(() => fetchMock.restore()); + afterEach(() => fetchMock.removeRoutes().clearHistory()); describe('new ChartClient(config)', () => { it('creates a client without argument', () => { diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts index 7f29db6123bb..8174fb8ad433 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClient.test.ts @@ -21,10 +21,13 @@ import fetchMock from 'fetch-mock'; import { SupersetClient, SupersetClientClass } from '@superset-ui/core'; import { LOGIN_GLOB } from './fixtures/constants'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('SupersetClient', () => { - beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '' })); + beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' })); - afterAll(() => fetchMock.restore()); + afterAll(() => fetchMock.removeRoutes().clearHistory()); afterEach(() => SupersetClient.reset()); @@ -108,9 +111,11 @@ describe('SupersetClient', () => { mockDeleteUrl, ]; networkCalls.map((url: string) => - expect(fetchMock.calls(url)[0][1]?.headers).toStrictEqual({ - Accept: 'application/json', - 'X-CSRFToken': '1234', + expect( + fetchMock.callHistory.calls(url)[0].options?.headers, + ).toStrictEqual({ + accept: 'application/json', + 'x-csrftoken': '1234', }), ); @@ -137,6 +142,6 @@ describe('SupersetClient', () => { authenticatedSpy.mockRestore(); csrfSpy.mockRestore(); - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); }); }); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts index 6a778cdd35d6..a5e0fe6cc669 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/SupersetClientClass.test.ts @@ -20,14 +20,15 @@ import fetchMock from 'fetch-mock'; import { SupersetClientClass, ClientConfig, CallApi } from '@superset-ui/core'; import { LOGIN_GLOB } from './fixtures/constants'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('SupersetClientClass', () => { beforeEach(() => { - fetchMock.reset(); - fetchMock.get(LOGIN_GLOB, { result: '' }); + fetchMock.clearHistory().removeRoutes(); + fetchMock.get(LOGIN_GLOB, { result: '' }, { name: LOGIN_GLOB }); }); - afterAll(() => fetchMock.restore()); - describe('new SupersetClientClass()', () => { it('fallback protocol to https when setting only host', () => { const client = new SupersetClientClass({ host: 'TEST-HOST' }); @@ -89,21 +90,22 @@ describe('SupersetClientClass', () => { }); describe('.init()', () => { - beforeEach(() => - fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true }), - ); - afterEach(() => fetchMock.reset()); + beforeEach(() => { + fetchMock.removeRoute(LOGIN_GLOB); + fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB }); + }); + afterEach(() => fetchMock.clearHistory().removeRoutes()); it('calls api/v1/security/csrf_token/ when init() is called if no CSRF token is passed', async () => { - expect.assertions(1); + // expect.assertions(1); await new SupersetClientClass().init(); - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); + expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1); }); it('does NOT call api/v1/security/csrf_token/ when init() is called if a CSRF token is passed', async () => { expect.assertions(1); await new SupersetClientClass({ csrfToken: 'abc' }).init(); - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); + expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0); }); it('calls api/v1/security/csrf_token/ when init(force=true) is called even if a CSRF token is passed', async () => { @@ -112,20 +114,19 @@ describe('SupersetClientClass', () => { const client = new SupersetClientClass({ csrfToken: initialToken }); await client.init(); - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(0); + expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(0); expect(client.csrfToken).toBe(initialToken); await client.init(true); - expect(fetchMock.calls(LOGIN_GLOB)).toHaveLength(1); + expect(fetchMock.callHistory.calls(LOGIN_GLOB)).toHaveLength(1); expect(client.csrfToken).not.toBe(initialToken); }); it('throws if api/v1/security/csrf_token/ returns an error', async () => { expect.assertions(1); const rejectError = { status: 403 }; - fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectError), { - overwriteRoutes: true, - }); + fetchMock.removeRoute(LOGIN_GLOB); + fetchMock.get(LOGIN_GLOB, { throws: rejectError }, { name: LOGIN_GLOB }); let error; try { @@ -141,7 +142,7 @@ describe('SupersetClientClass', () => { it('throws if api/v1/security/csrf_token/ does not return a token', async () => { expect.assertions(1); - fetchMock.get(LOGIN_GLOB, {}, { overwriteRoutes: true }); + fetchMock.modifyRoute(LOGIN_GLOB, { response: {} }); let error; try { @@ -157,9 +158,8 @@ describe('SupersetClientClass', () => { it('does not set csrfToken if response is not json', async () => { expect.assertions(1); - fetchMock.get(LOGIN_GLOB, '123', { - overwriteRoutes: true, - }); + fetchMock.removeRoute(LOGIN_GLOB); + fetchMock.get(LOGIN_GLOB, { response: '123' }, { name: LOGIN_GLOB }); let error; try { @@ -175,7 +175,7 @@ describe('SupersetClientClass', () => { }); describe('.isAuthenticated()', () => { - afterEach(() => fetchMock.reset()); + afterEach(() => fetchMock.clearHistory().removeRoutes()); it('returns true if there is a token and false if not', async () => { expect.assertions(2); @@ -227,9 +227,8 @@ describe('SupersetClientClass', () => { expect.assertions(4); const rejectValue = { status: 403 }; - fetchMock.get(LOGIN_GLOB, () => Promise.reject(rejectValue), { - overwriteRoutes: true, - }); + fetchMock.removeRoutes(); + fetchMock.get(LOGIN_GLOB, { throws: rejectValue }, { name: LOGIN_GLOB }); const client = new SupersetClientClass({}); let error; @@ -253,18 +252,19 @@ describe('SupersetClientClass', () => { } // reset + fetchMock.removeRoutes(); fetchMock.get( LOGIN_GLOB, { result: 1234 }, { - overwriteRoutes: true, + name: LOGIN_GLOB, }, ); }); }); describe('requests', () => { - afterEach(() => fetchMock.restore()); + afterEach(() => fetchMock.clearHistory().removeRoutes()); const protocol = 'https:'; const host = 'host'; @@ -306,11 +306,11 @@ describe('SupersetClientClass', () => { await client.delete({ url: mockDeleteUrl }); await client.request({ url: mockRequestUrl, method: 'DELETE' }); - expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); - expect(fetchMock.calls(mockDeleteUrl)).toHaveLength(1); - expect(fetchMock.calls(mockPutUrl)).toHaveLength(1); - expect(fetchMock.calls(mockRequestUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockDeleteUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockRequestUrl)).toHaveLength(1); expect(authSpy).toHaveBeenCalledTimes(5); authSpy.mockRestore(); @@ -331,7 +331,8 @@ describe('SupersetClientClass', () => { await client.init(); await client.get({ url: mockGetUrl }); - const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi; + const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0] + .options as CallApi; expect(fetchRequest.mode).toBe(clientConfig.mode); expect(fetchRequest.credentials).toBe(clientConfig.credentials); expect(fetchRequest.headers).toEqual( @@ -354,10 +355,11 @@ describe('SupersetClientClass', () => { await client.init(); await client.get({ url: mockGetUrl }); - const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi; + const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0] + .options as CallApi; expect(fetchRequest.headers).toEqual( expect.objectContaining({ - guestTokenHeader: 'abc123', + guesttokenheader: 'abc123', }), ); }); @@ -370,10 +372,10 @@ describe('SupersetClientClass', () => { await client.init(); await client.get({ url: mockGetUrl }); - expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1); await client.get({ endpoint: mockGetEndpoint }); - expect(fetchMock.calls(mockGetUrl)).toHaveLength(2); + expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(2); }); it('supports parsing a response as text', async () => { @@ -384,7 +386,7 @@ describe('SupersetClientClass', () => { url: mockTextUrl, parseMethod: 'text', }); - expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1); expect(text).toBe(mockTextJsonResponse); }); @@ -409,7 +411,8 @@ describe('SupersetClientClass', () => { await client.init(); await client.get({ url: mockGetUrl, ...overrideConfig }); - const fetchRequest = fetchMock.calls(mockGetUrl)[0][1] as CallApi; + const fetchRequest = fetchMock.callHistory.calls(mockGetUrl)[0] + .options as CallApi; expect(fetchRequest.mode).toBe(overrideConfig.mode); expect(fetchRequest.credentials).toBe(overrideConfig.credentials); expect(fetchRequest.headers).toEqual( @@ -428,10 +431,10 @@ describe('SupersetClientClass', () => { await client.init(); await client.post({ url: mockPostUrl }); - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1); await client.post({ endpoint: mockPostEndpoint }); - expect(fetchMock.calls(mockPostUrl)).toHaveLength(2); + expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(2); }); it('allows overriding host, headers, mode, and credentials per-request', async () => { @@ -454,7 +457,8 @@ describe('SupersetClientClass', () => { await client.init(); await client.post({ url: mockPostUrl, ...overrideConfig }); - const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi; + const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0] + .options as CallApi; expect(fetchRequest.mode).toBe(overrideConfig.mode); expect(fetchRequest.credentials).toBe(overrideConfig.credentials); @@ -473,7 +477,7 @@ describe('SupersetClientClass', () => { url: mockTextUrl, parseMethod: 'text', }); - expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1); expect(text).toBe(mockTextJsonResponse); }); @@ -485,10 +489,11 @@ describe('SupersetClientClass', () => { await client.init(); await client.post({ url: mockPostUrl, postPayload }); - const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi; + const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0] + .options as CallApi; const formData = fetchRequest.body as FormData; - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1); Object.entries(postPayload).forEach(([key, value]) => { expect(formData.get(key)).toBe(JSON.stringify(value)); }); @@ -502,10 +507,11 @@ describe('SupersetClientClass', () => { await client.init(); await client.post({ url: mockPostUrl, postPayload, stringify: false }); - const fetchRequest = fetchMock.calls(mockPostUrl)[0][1] as CallApi; + const fetchRequest = fetchMock.callHistory.calls(mockPostUrl)[0] + .options as CallApi; const formData = fetchRequest.body as FormData; - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1); Object.entries(postPayload).forEach(([key, value]) => { expect(formData.get(key)).toBe(String(value)); }); @@ -528,6 +534,7 @@ describe('SupersetClientClass', () => { // @ts-ignore window.location = { pathname: mockRequestPath, + // @ts-ignore search: mockRequestSearch, href: mockHref, }; @@ -535,9 +542,7 @@ describe('SupersetClientClass', () => { .spyOn(SupersetClientClass.prototype, 'ensureAuth') .mockImplementation(); const rejectValue = { status: 401 }; - fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue), { - overwriteRoutes: true, - }); + fetchMock.get(mockRequestUrl, () => Promise.reject(rejectValue)); }); afterEach(() => { @@ -563,10 +568,11 @@ describe('SupersetClientClass', () => { it('should not redirect again if already on login page', async () => { const client = new SupersetClientClass({}); - // @ts-expect-error + // @ts-ignore window.location = { href: '/login?next=something', pathname: '/login', + // @ts-ignore search: '?next=something', }; @@ -636,7 +642,8 @@ describe('SupersetClientClass', () => { let createElement: any; beforeEach(async () => { - fetchMock.get(LOGIN_GLOB, { result: 1234 }, { overwriteRoutes: true }); + fetchMock.removeRoute(LOGIN_GLOB); + fetchMock.get(LOGIN_GLOB, { result: 1234 }, { name: LOGIN_GLOB }); client = new SupersetClientClass({ protocol, host }); authSpy = jest.spyOn(SupersetClientClass.prototype, 'ensureAuth'); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts index 387b96575be5..35e8ea24fdbb 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApi.test.ts @@ -29,14 +29,17 @@ const corruptObject = new BadObject(); /* @ts-expect-error */ BadObject.prototype.toString = undefined; -const mockGetUrl = '/mock/get/url'; -const mockPostUrl = '/mock/post/url'; -const mockPutUrl = '/mock/put/url'; -const mockPatchUrl = '/mock/patch/url'; -const mockCacheUrl = '/mock/cache/url'; -const mockNotFound = '/mock/notfound'; -const mockErrorUrl = '/mock/error/url'; -const mock503 = '/mock/503'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + +const mockGetUrl = 'glob:*/mock/get/url'; +const mockPostUrl = 'glob:*/mock/post/url'; +const mockPutUrl = 'glob:*/mock/put/url'; +const mockPatchUrl = 'glob:*/mock/patch/url'; +const mockCacheUrl = 'glob:*/mock/cache/url'; +const mockNotFound = 'glob:*/mock/notfound'; +const mockErrorUrl = 'glob:*/mock/error/url'; +const mock503 = 'glob:*/mock/503'; const mockGetPayload = { get: 'payload' }; const mockPostPayload = { post: 'payload' }; @@ -50,20 +53,23 @@ const mockCachePayload = { const mockErrorPayload = { status: 500, statusText: 'Internal error' }; describe('callApi()', () => { - beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' })); + beforeAll(() => { + fetchMock.mockGlobal(); + fetchMock.get(LOGIN_GLOB, { result: '1234' }); + }); beforeEach(() => { fetchMock.get(mockGetUrl, mockGetPayload); fetchMock.post(mockPostUrl, mockPostPayload); fetchMock.put(mockPutUrl, mockPutPayload); fetchMock.patch(mockPatchUrl, mockPatchPayload); - fetchMock.get(mockCacheUrl, mockCachePayload); + fetchMock.get(mockCacheUrl, mockCachePayload, { name: mockCacheUrl }); fetchMock.get(mockNotFound, { status: 404 }); fetchMock.get(mock503, { status: 503 }); fetchMock.get(mockErrorUrl, () => Promise.reject(mockErrorPayload)); }); - afterEach(() => fetchMock.reset()); + afterEach(() => fetchMock.clearHistory().removeRoutes()); describe('request config', () => { it('calls the right url with the specified method', async () => { @@ -74,10 +80,10 @@ describe('callApi()', () => { callApi({ url: mockPutUrl, method: 'PUT' }), callApi({ url: mockPatchUrl, method: 'PATCH' }), ]); - expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); - expect(fetchMock.calls(mockPostUrl)).toHaveLength(1); - expect(fetchMock.calls(mockPutUrl)).toHaveLength(1); - expect(fetchMock.calls(mockPatchUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPostUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPutUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockPatchUrl)).toHaveLength(1); }); it('passes along mode, cache, credentials, headers, body, signal, and redirect parameters in the request', async () => { @@ -92,12 +98,11 @@ describe('callApi()', () => { }, redirect: 'follow', signal: undefined, - body: 'BODY', }; await callApi(mockRequest); - const calls = fetchMock.calls(mockGetUrl); - const fetchParams = calls[0][1] as RequestInit; + const calls = fetchMock.callHistory.calls(mockGetUrl); + const fetchParams = calls[0].options as RequestInit; expect(calls).toHaveLength(1); expect(fetchParams.mode).toBe(mockRequest.mode); expect(fetchParams.cache).toBe(mockRequest.cache); @@ -119,10 +124,10 @@ describe('callApi()', () => { const postPayload = { key: 'value', anotherKey: 1237 }; await callApi({ url: mockPostUrl, method: 'POST', postPayload }); - const calls = fetchMock.calls(mockPostUrl); + const calls = fetchMock.callHistory.calls(mockPostUrl); expect(calls).toHaveLength(1); - const fetchParams = calls[0][1] as RequestInit; + const fetchParams = calls[0].options as RequestInit; const body = fetchParams.body as FormData; Object.entries(postPayload).forEach(([key, value]) => { @@ -136,10 +141,10 @@ describe('callApi()', () => { const postPayload = { key: 'value', noValue: undefined }; await callApi({ url: mockPostUrl, method: 'POST', postPayload }); - const calls = fetchMock.calls(mockPostUrl); + const calls = fetchMock.callHistory.calls(mockPostUrl); expect(calls).toHaveLength(1); - const fetchParams = calls[0][1] as RequestInit; + const fetchParams = calls[0].options as RequestInit; const body = fetchParams.body as FormData; expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('noValue')).toBeNull(); @@ -167,13 +172,13 @@ describe('callApi()', () => { }), callApi({ url: mockPostUrl, method: 'POST', jsonPayload: postPayload }), ]); - const calls = fetchMock.calls(mockPostUrl); + const calls = fetchMock.callHistory.calls(mockPostUrl); expect(calls).toHaveLength(3); - const stringified = (calls[0][1] as RequestInit).body as FormData; - const unstringified = (calls[1][1] as RequestInit).body as FormData; + const stringified = (calls[0].options as RequestInit).body as FormData; + const unstringified = (calls[1].options as RequestInit).body as FormData; const jsonRequestBody = JSON.parse( - (calls[2][1] as RequestInit).body as string, + (calls[2].options as RequestInit).body as string, ) as JsonObject; Object.entries(postPayload).forEach(([key, value]) => { @@ -211,9 +216,9 @@ describe('callApi()', () => { stringify: false, }); - const calls = fetchMock.calls(mockPostUrl); + const calls = fetchMock.callHistory.calls(mockPostUrl); expect(calls).toHaveLength(1); - const unstringified = (calls[0][1] as RequestInit).body as FormData; + const unstringified = (calls[0].options as RequestInit).body as FormData; const hasCorruptKey = unstringified.has('corrupt'); expect(hasCorruptKey).toBeFalsy(); // When a corrupt attribute is encountered, a console.error call is made with info about the corrupt attribute @@ -228,10 +233,10 @@ describe('callApi()', () => { const postPayload = { key: 'value', anotherKey: 1237 }; await callApi({ url: mockPutUrl, method: 'PUT', postPayload }); - const calls = fetchMock.calls(mockPutUrl); + const calls = fetchMock.callHistory.calls(mockPutUrl); expect(calls).toHaveLength(1); - const fetchParams = calls[0][1] as RequestInit; + const fetchParams = calls[0].options as RequestInit; const body = fetchParams.body as FormData; Object.entries(postPayload).forEach(([key, value]) => { @@ -245,10 +250,10 @@ describe('callApi()', () => { const postPayload = { key: 'value', noValue: undefined }; await callApi({ url: mockPutUrl, method: 'PUT', postPayload }); - const calls = fetchMock.calls(mockPutUrl); + const calls = fetchMock.callHistory.calls(mockPutUrl); expect(calls).toHaveLength(1); - const fetchParams = calls[0][1] as RequestInit; + const fetchParams = calls[0].options as RequestInit; const body = fetchParams.body as FormData; expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('noValue')).toBeNull(); @@ -275,11 +280,11 @@ describe('callApi()', () => { stringify: false, }), ]); - const calls = fetchMock.calls(mockPutUrl); + const calls = fetchMock.callHistory.calls(mockPutUrl); expect(calls).toHaveLength(2); - const stringified = (calls[0][1] as RequestInit).body as FormData; - const unstringified = (calls[1][1] as RequestInit).body as FormData; + const stringified = (calls[0].options as RequestInit).body as FormData; + const unstringified = (calls[1].options as RequestInit).body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(stringified.get(key)).toBe(JSON.stringify(value)); @@ -294,10 +299,10 @@ describe('callApi()', () => { const postPayload = { key: 'value', anotherKey: 1237 }; await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }); - const calls = fetchMock.calls(mockPatchUrl); + const calls = fetchMock.callHistory.calls(mockPatchUrl); expect(calls).toHaveLength(1); - const fetchParams = calls[0][1] as RequestInit; + const fetchParams = calls[0].options as RequestInit; const body = fetchParams.body as FormData; Object.entries(postPayload).forEach(([key, value]) => { @@ -311,10 +316,10 @@ describe('callApi()', () => { const postPayload = { key: 'value', noValue: undefined }; await callApi({ url: mockPatchUrl, method: 'PATCH', postPayload }); - const calls = fetchMock.calls(mockPatchUrl); + const calls = fetchMock.callHistory.calls(mockPatchUrl); expect(calls).toHaveLength(1); - const fetchParams = calls[0][1] as RequestInit; + const fetchParams = calls[0].options as RequestInit; const body = fetchParams.body as FormData; expect(body.get('key')).toBe(JSON.stringify(postPayload.key)); expect(body.get('noValue')).toBeNull(); @@ -341,11 +346,11 @@ describe('callApi()', () => { stringify: false, }), ]); - const calls = fetchMock.calls(mockPatchUrl); + const calls = fetchMock.callHistory.calls(mockPatchUrl); expect(calls).toHaveLength(2); - const stringified = (calls[0][1] as RequestInit).body as FormData; - const unstringified = (calls[1][1] as RequestInit).body as FormData; + const stringified = (calls[0].options as RequestInit).body as FormData; + const unstringified = (calls[1].options as RequestInit).body as FormData; Object.entries(postPayload).forEach(([key, value]) => { expect(stringified.get(key)).toBe(JSON.stringify(value)); @@ -373,7 +378,7 @@ describe('callApi()', () => { it('caches requests with ETags', async () => { expect.assertions(2); await callApi({ url: mockCacheUrl, method: 'GET' }); - const calls = fetchMock.calls(mockCacheUrl); + const calls = fetchMock.callHistory.calls(mockCacheUrl); expect(calls).toHaveLength(1); const supersetCache = await caches.open(constants.CACHE_KEY); const cachedResponse = await supersetCache.match(mockCacheUrl); @@ -385,7 +390,7 @@ describe('callApi()', () => { window.location.protocol = 'http:'; await callApi({ url: mockCacheUrl, method: 'GET' }); - const calls = fetchMock.calls(mockCacheUrl); + const calls = fetchMock.callHistory.calls(mockCacheUrl); expect(calls).toHaveLength(1); const supersetCache = await caches.open(constants.CACHE_KEY); @@ -399,7 +404,7 @@ describe('callApi()', () => { Object.defineProperty(constants, 'CACHE_AVAILABLE', { value: false }); const firstResponse = await callApi({ url: mockCacheUrl, method: 'GET' }); - let calls = fetchMock.calls(mockCacheUrl); + let calls = fetchMock.callHistory.calls(mockCacheUrl); expect(calls).toHaveLength(1); const firstBody = await firstResponse.text(); expect(firstBody).toEqual('BODY'); @@ -408,8 +413,8 @@ describe('callApi()', () => { url: mockCacheUrl, method: 'GET', }); - calls = fetchMock.calls(mockCacheUrl); - const fetchParams = calls[1][1] as RequestInit; + calls = fetchMock.callHistory.calls(mockCacheUrl); + const fetchParams = calls[1].options as RequestInit; expect(calls).toHaveLength(2); // second call should not have If-None-Match header expect(fetchParams.headers).toBeUndefined(); @@ -424,14 +429,14 @@ describe('callApi()', () => { expect.assertions(3); // first call sets the cache await callApi({ url: mockCacheUrl, method: 'GET' }); - let calls = fetchMock.calls(mockCacheUrl); + let calls = fetchMock.callHistory.calls(mockCacheUrl); expect(calls).toHaveLength(1); // second call sends the Etag in the If-None-Match header await callApi({ url: mockCacheUrl, method: 'GET' }); - calls = fetchMock.calls(mockCacheUrl); - const fetchParams = calls[1][1] as RequestInit; - const headers = { 'If-None-Match': 'etag' }; + calls = fetchMock.callHistory.calls(mockCacheUrl); + const fetchParams = calls[1].options as RequestInit; + const headers = { 'if-none-match': 'etag' }; expect(calls).toHaveLength(2); expect(fetchParams.headers).toEqual( expect.objectContaining(headers) as typeof fetchParams.headers, @@ -442,16 +447,16 @@ describe('callApi()', () => { expect.assertions(3); // first call sets the cache await callApi({ url: mockCacheUrl, method: 'GET' }); - expect(fetchMock.calls(mockCacheUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(1); // second call reuses the cached payload on a 304 const mockCachedPayload = { status: 304 }; - fetchMock.get(mockCacheUrl, mockCachedPayload, { overwriteRoutes: true }); + fetchMock.modifyRoute(mockCacheUrl, { response: mockCachedPayload }); const secondResponse = await callApi({ url: mockCacheUrl, method: 'GET', }); - expect(fetchMock.calls(mockCacheUrl)).toHaveLength(2); + expect(fetchMock.callHistory.calls(mockCacheUrl)).toHaveLength(2); const secondBody = await secondResponse.text(); expect(secondBody).toEqual('BODY'); }); @@ -461,7 +466,7 @@ describe('callApi()', () => { // this should never happen, since a 304 is only returned if we have // the cached response and sent the If-None-Match header - const mockUncachedUrl = '/mock/uncached/url'; + const mockUncachedUrl = 'glob:*/mock/uncached/url'; const mockCachedPayload = { status: 304 }; let error; fetchMock.get(mockUncachedUrl, mockCachedPayload); @@ -471,7 +476,7 @@ describe('callApi()', () => { } catch (err) { error = err; } finally { - const calls = fetchMock.calls(mockUncachedUrl); + const calls = fetchMock.callHistory.calls(mockUncachedUrl); expect(calls).toHaveLength(1); expect((error as { message: string }).message).toEqual( 'Received 304 but no content is cached!', @@ -483,7 +488,7 @@ describe('callApi()', () => { expect.assertions(3); const url = mockGetUrl; const response = await callApi({ url, method: 'GET' }); - const calls = fetchMock.calls(url); + const calls = fetchMock.callHistory.calls(url); expect(calls).toHaveLength(1); expect(response.status).toEqual(200); const body = await response.json(); @@ -494,7 +499,7 @@ describe('callApi()', () => { expect.assertions(2); const url = mockNotFound; const response = await callApi({ url, method: 'GET' }); - const calls = fetchMock.calls(url); + const calls = fetchMock.callHistory.calls(url); expect(calls).toHaveLength(1); expect(response.status).toEqual(404); }); @@ -513,7 +518,7 @@ describe('callApi()', () => { error = err; } finally { const err = error as { status: number; statusText: string }; - expect(fetchMock.calls(mockErrorUrl)).toHaveLength(4); + expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(4); expect(err.status).toBe(mockErrorPayload.status); expect(err.statusText).toBe(mockErrorPayload.statusText); } @@ -531,7 +536,7 @@ describe('callApi()', () => { } catch (err) { error = err as { status: number; statusText: string }; } finally { - expect(fetchMock.calls(mockErrorUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockErrorUrl)).toHaveLength(1); expect(error?.status).toBe(mockErrorPayload.status); expect(error?.statusText).toBe(mockErrorPayload.statusText); } @@ -545,7 +550,7 @@ describe('callApi()', () => { url, method: 'GET', }); - const calls = fetchMock.calls(url); + const calls = fetchMock.callHistory.calls(url); expect(calls).toHaveLength(4); expect(response.status).toEqual(503); }); @@ -581,7 +586,9 @@ describe('callApi()', () => { const result = await response.json(); expect(response.status).toEqual(200); expect(result).toEqual({ yes: 'ok' }); - expect(fetchMock.lastUrl()).toEqual(`http://localhost/get-search?abc=1`); + expect(fetchMock.callHistory.lastCall()?.url).toEqual( + `http://localhost/get-search?abc=1`, + ); }); it('should accept URLSearchParams', async () => { @@ -596,8 +603,10 @@ describe('callApi()', () => { method: 'POST', jsonPayload: { request: 'ok' }, }); - expect(fetchMock.lastUrl()).toEqual(`http://localhost/post-search?abc=1`); - expect(fetchMock.lastOptions()).toEqual( + expect(fetchMock.callHistory.lastCall()?.url).toEqual( + `http://localhost/post-search?abc=1`, + ); + expect(fetchMock.callHistory.lastCall()?.options).toEqual( expect.objectContaining({ body: JSON.stringify({ request: 'ok' }), }), @@ -634,7 +643,7 @@ describe('callApi()', () => { method: 'POST', postPayload: payload, }); - expect(fetchMock.lastOptions()?.body).toBe(payload); + expect(fetchMock.callHistory.lastCall()?.options.body).toBe(payload); }); it('should ignore "null" postPayload string', async () => { @@ -646,6 +655,6 @@ describe('callApi()', () => { method: 'POST', postPayload: 'null', }); - expect(fetchMock.lastOptions()?.body).toBeUndefined(); + expect(fetchMock.callHistory.lastCall()?.options.body).toBeUndefined(); }); }); diff --git a/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApiAndParseWithTimeout.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApiAndParseWithTimeout.test.ts index e0bf14e6c8ee..a31dd48d3489 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApiAndParseWithTimeout.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/callApi/callApiAndParseWithTimeout.test.ts @@ -30,15 +30,16 @@ import { LOGIN_GLOB } from '../fixtures/constants'; const mockGetUrl = '/mock/get/url'; const mockGetPayload = { get: 'payload' }; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('callApiAndParseWithTimeout()', () => { beforeAll(() => fetchMock.get(LOGIN_GLOB, { result: '1234' })); beforeEach(() => fetchMock.get(mockGetUrl, mockGetPayload)); - afterAll(() => fetchMock.restore()); - afterEach(() => { - fetchMock.reset(); + fetchMock.removeRoutes().clearHistory(); jest.useRealTimers(); }); @@ -108,7 +109,7 @@ describe('callApiAndParseWithTimeout()', () => { } catch (err) { error = err; } finally { - expect(fetchMock.calls(mockTimeoutUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockTimeoutUrl)).toHaveLength(1); expect(error).toEqual({ error: 'Request timed out', statusText: 'timeout', diff --git a/superset-frontend/packages/superset-ui-core/test/connection/callApi/parseResponse.test.ts b/superset-frontend/packages/superset-ui-core/test/connection/callApi/parseResponse.test.ts index 4b6192e65ac0..030c90f4a82a 100644 --- a/superset-frontend/packages/superset-ui-core/test/connection/callApi/parseResponse.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/connection/callApi/parseResponse.test.ts @@ -22,12 +22,15 @@ import parseResponse from '../../../src/connection/callApi/parseResponse'; import { LOGIN_GLOB } from '../fixtures/constants'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('parseResponse()', () => { beforeAll(() => { fetchMock.get(LOGIN_GLOB, { result: '1234' }); }); - afterAll(() => fetchMock.restore()); + afterAll(() => fetchMock.removeRoutes().clearHistory()); const mockGetUrl = '/mock/get/url'; const mockPostUrl = '/mock/post/url'; @@ -45,7 +48,7 @@ describe('parseResponse()', () => { fetchMock.get(mockNoParseUrl, new Response('test response')); }); - afterEach(() => fetchMock.reset()); + afterEach(() => fetchMock.removeRoutes().clearHistory()); it('returns a Promise', () => { const apiPromise = callApi({ url: mockGetUrl, method: 'GET' }); @@ -58,7 +61,7 @@ describe('parseResponse()', () => { const args = await parseResponse( callApi({ url: mockGetUrl, method: 'GET' }), ); - expect(fetchMock.calls(mockGetUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockGetUrl)).toHaveLength(1); const keys = Object.keys(args); expect(keys).toContain('response'); expect(keys).toContain('json'); @@ -81,7 +84,7 @@ describe('parseResponse()', () => { } catch (err) { error = err as Error; } finally { - expect(fetchMock.calls(mockTextUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockTextUrl)).toHaveLength(1); expect(error?.stack).toBeDefined(); expect(error?.message).toContain('Unexpected token'); } @@ -99,7 +102,7 @@ describe('parseResponse()', () => { callApi({ url: mockTextParseUrl, method: 'GET' }), 'text', ); - expect(fetchMock.calls(mockTextParseUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockTextParseUrl)).toHaveLength(1); const keys = Object.keys(args); expect(keys).toContain('response'); expect(keys).toContain('text'); @@ -134,7 +137,7 @@ describe('parseResponse()', () => { callApi({ url: mockNoParseUrl, method: 'GET' }), 'raw', ); - expect(fetchMock.calls(mockNoParseUrl)).toHaveLength(2); + expect(fetchMock.callHistory.calls(mockNoParseUrl)).toHaveLength(2); expect(responseNull.bodyUsed).toBe(false); expect(responseRaw.bodyUsed).toBe(false); }); @@ -193,7 +196,7 @@ describe('parseResponse()', () => { } catch (err) { error = err as { ok: boolean; status: number }; } finally { - expect(fetchMock.calls(mockNotOkayUrl)).toHaveLength(1); + expect(fetchMock.callHistory.calls(mockNotOkayUrl)).toHaveLength(1); expect(error?.ok).toBe(false); expect(error?.status).toBe(404); } diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts index c5bb3fcd83a1..e5cc8a19beb0 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getDatasourceMetadata.test.ts @@ -21,10 +21,13 @@ import { getDatasourceMetadata } from '../../../../src/query/api/legacy'; import setupClientForTest from '../setupClientForTest'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('getFormData()', () => { beforeAll(() => setupClientForTest()); - afterEach(() => fetchMock.restore()); + afterEach(() => fetchMock.clearHistory().removeRoutes()); it('returns datasource metadata for given datasource key', () => { const mockData = { diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getFormData.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getFormData.test.ts index 4987d8b91d65..7976e5e8870c 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getFormData.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/legacy/getFormData.test.ts @@ -22,10 +22,13 @@ import { getFormData } from '../../../../src/query/api/legacy'; import setupClientForTest from '../setupClientForTest'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('getFormData()', () => { beforeAll(() => setupClientForTest()); - afterEach(() => fetchMock.restore()); + afterEach(() => fetchMock.clearHistory().removeRoutes()); const mockData = { datasource: '1__table', diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/v1/getChartData.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/v1/getChartData.test.ts index f88c44a2312f..e2c4d61ac3c3 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/v1/getChartData.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/v1/getChartData.test.ts @@ -20,9 +20,13 @@ import fetchMock from 'fetch-mock'; import { buildQueryContext, ApiV1, VizType } from '@superset-ui/core'; import setupClientForTest from '../setupClientForTest'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('API v1 > getChartData()', () => { beforeAll(() => setupClientForTest()); - afterEach(() => fetchMock.restore()); + + afterEach(() => fetchMock.clearHistory().removeRoutes()); it('returns a promise of ChartDataResponse', async () => { const response = { diff --git a/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts b/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts index d7fcf1c04c85..5e39c6f8cfe2 100644 --- a/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/query/api/v1/makeApi.test.ts @@ -21,9 +21,13 @@ import { JsonValue, SupersetClientClass } from '@superset-ui/core'; import { makeApi, SupersetApiError } from '../../../../src/query'; import setupClientForTest from '../setupClientForTest'; +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + describe('makeApi()', () => { beforeAll(() => setupClientForTest()); - afterEach(() => fetchMock.restore()); + + afterEach(() => fetchMock.clearHistory().removeRoutes()); it('should expose method and endpoint', () => { const api = makeApi({ @@ -95,7 +99,7 @@ describe('makeApi()', () => { const expected = new FormData(); expected.append('request', JSON.stringify('test')); - const received = fetchMock.lastOptions()?.body as FormData; + const received = fetchMock.callHistory.lastCall()?.options.body as FormData; expect(received).toBeInstanceOf(FormData); expect(received.get('request')).toEqual(expected.get('request')); @@ -109,7 +113,7 @@ describe('makeApi()', () => { }); fetchMock.get('glob:*/test-get-search*', { search: 'get' }); await api({ p1: 1, p2: 2, p3: [1, 2] }); - expect(fetchMock.lastUrl()).toContain( + expect(fetchMock.callHistory.lastCall()?.url).toContain( '/test-get-search?p1=1&p2=2&p3=1%2C2', ); }); @@ -123,7 +127,7 @@ describe('makeApi()', () => { }); fetchMock.get('glob:*/test-post-search*', { rison: 'get' }); await api({ p1: 1, p3: [1, 2] }); - expect(fetchMock.lastUrl()).toContain( + expect(fetchMock.callHistory.lastCall()?.url).toContain( '/test-post-search?q=(p1:1,p3:!(1,2))', ); }); @@ -137,7 +141,9 @@ describe('makeApi()', () => { }); fetchMock.post('glob:*/test-post-search*', { search: 'post' }); await api({ p1: 1, p3: [1, 2] }); - expect(fetchMock.lastUrl()).toContain('/test-post-search?p1=1&p3=1%2C2'); + expect(fetchMock.callHistory.lastCall()?.url).toContain( + '/test-post-search?p1=1&p3=1%2C2', + ); }); it('should throw when requestType is invalid', () => { @@ -215,6 +221,8 @@ describe('makeApi()', () => { fetchMock.delete('glob:*/test-raw-response?*', 'ok'); const result = await api({ field1: 11 }, {}); expect(result).toEqual(200); - expect(fetchMock.lastUrl()).toContain('/test-raw-response?field1=11'); + expect(fetchMock.callHistory.lastCall()?.url).toContain( + '/test-raw-response?field1=11', + ); }); }); diff --git a/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts b/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts index 152cdb2b684a..84bbaa5e6282 100644 --- a/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts +++ b/superset-frontend/packages/superset-ui-core/test/time-comparison/fetchTimeRange.test.ts @@ -25,7 +25,10 @@ import { formatTimeRangeComparison, } from '../../src/time-comparison/fetchTimeRange'; -afterEach(() => fetchMock.restore()); +beforeAll(() => fetchMock.mockGlobal()); +afterAll(() => fetchMock.hardReset()); + +afterEach(() => fetchMock.clearHistory().removeRoutes()); test('generates proper time range string', () => { expect( @@ -84,34 +87,41 @@ test('returns a formatted time range from empty response', async () => { }); test('returns a formatted error message from response', async () => { - fetchMock.get('glob:*/api/v1/time_range/?q=%27Last+day%27', { - throws: new Response(JSON.stringify({ message: 'Network error' })), - }); + const getTimeRangeUrl = 'glob:*/api/v1/time_range/?q=%27Last+day%27'; + fetchMock.get( + getTimeRangeUrl, + { + throws: new Response(JSON.stringify({ message: 'Network error' })), + }, + { name: getTimeRangeUrl }, + ); let timeRange = await fetchTimeRange('Last day'); expect(timeRange).toEqual({ error: 'Network error', }); + fetchMock.removeRoute(getTimeRangeUrl); fetchMock.get( - 'glob:*/api/v1/time_range/?q=%27Last+day%27', + getTimeRangeUrl, { throws: new Error('Internal Server Error'), }, - { overwriteRoutes: true }, + { name: getTimeRangeUrl }, ); timeRange = await fetchTimeRange('Last day'); expect(timeRange).toEqual({ error: 'Internal Server Error', }); + fetchMock.removeRoute(getTimeRangeUrl); fetchMock.get( - 'glob:*/api/v1/time_range/?q=%27Last+day%27', + getTimeRangeUrl, { throws: new Response(JSON.stringify({ statusText: 'Network error' }), { statusText: 'Network error', }), }, - { overwriteRoutes: true }, + { name: getTimeRangeUrl }, ); timeRange = await fetchTimeRange('Last day'); expect(timeRange).toEqual({ diff --git a/superset-frontend/spec/helpers/jsDomWithFetchAPI.ts b/superset-frontend/spec/helpers/jsDomWithFetchAPI.ts index 81279356c35f..1db1bafac386 100644 --- a/superset-frontend/spec/helpers/jsDomWithFetchAPI.ts +++ b/superset-frontend/spec/helpers/jsDomWithFetchAPI.ts @@ -31,6 +31,7 @@ export default class FixJSDOMEnvironment extends JSDOMEnvironment { this.global.Response = Response; this.global.AbortSignal = AbortSignal; this.global.AbortController = AbortController; + this.global.ReadableStream = ReadableStream; // Mock MessageChannel to prevent hanging Jest tests with rc-overflow@1.4.1 // Forces rc-overflow to use requestAnimationFrame fallback instead diff --git a/superset-frontend/spec/helpers/shim.tsx b/superset-frontend/spec/helpers/shim.tsx index 112b0256f849..9e49116df420 100644 --- a/superset-frontend/spec/helpers/shim.tsx +++ b/superset-frontend/spec/helpers/shim.tsx @@ -23,6 +23,7 @@ import jQuery from 'jquery'; // https://jestjs.io/docs/jest-object#jestmockmodulename-factory-options // in order to mock modules in test case, so avoid absolute import module import { configure as configureTranslation } from '@apache-superset/core/ui'; +import fetchMock from 'fetch-mock'; import { Worker } from './Worker'; import { IntersectionObserver } from './IntersectionObserver'; import { ResizeObserver } from './ResizeObserver'; @@ -43,6 +44,9 @@ if (defaultView != null) { }); } +fetchMock.mockGlobal(); +fetchMock.config.allowRelativeUrls = true; + const g = global as any; g.window ??= Object.create(window); g.window.location ??= { href: 'about:blank' }; diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js b/superset-frontend/src/SqlLab/actions/sqlLab.test.js index ac6263d72dd5..ae547da87d3d 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js @@ -86,21 +86,30 @@ describe('async actions', () => { }; let dispatch; + const fetchQueryEndpoint = 'glob:*/api/v1/sqllab/results/*'; + const runQueryEndpoint = 'glob:*/api/v1/sqllab/execute/'; beforeEach(() => { dispatch = sinon.spy(); - }); - - afterEach(() => fetchMock.resetHistory()); + fetchMock.removeRoute(fetchQueryEndpoint); + fetchMock.get( + fetchQueryEndpoint, + JSON.stringify({ + data: mockBigNumber, + query: { sqlEditorId: 'dfsadfs' }, + }), + { name: fetchQueryEndpoint }, + ); - const fetchQueryEndpoint = 'glob:*/api/v1/sqllab/results/*'; - fetchMock.get( - fetchQueryEndpoint, - JSON.stringify({ data: mockBigNumber, query: { sqlEditorId: 'dfsadfs' } }), - ); + fetchMock.removeRoute(runQueryEndpoint); + fetchMock.post(runQueryEndpoint, `{ "data": ${mockBigNumber} }`, { + name: runQueryEndpoint, + }); + }); - const runQueryEndpoint = 'glob:*/api/v1/sqllab/execute/'; - fetchMock.post(runQueryEndpoint, `{ "data": ${mockBigNumber} }`); + afterEach(() => { + fetchMock.clearHistory(); + }); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('saveQuery', () => { @@ -117,15 +126,15 @@ describe('async actions', () => { const store = mockStore(initialState); return store.dispatch(actions.saveQuery(query, queryId)).then(() => { - expect(fetchMock.calls(saveQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(saveQueryEndpoint)).toHaveLength(1); }); }); test('posts the correct query object', () => { const store = mockStore(initialState); return store.dispatch(actions.saveQuery(query, queryId)).then(() => { - const call = fetchMock.calls(saveQueryEndpoint)[0]; - const formData = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(saveQueryEndpoint)[0]; + const formData = JSON.parse(call.options.body); const mappedQueryToServer = actions.convertQueryToServer(query); Object.keys(mappedQueryToServer).forEach(key => { @@ -172,11 +181,12 @@ describe('async actions', () => { const expectedSql = 'SELECT 1'; beforeEach(() => { + fetchMock.removeRoute(formatQueryEndpoint); fetchMock.post( formatQueryEndpoint, { result: expectedSql }, { - overwriteRoutes: true, + name: formatQueryEndpoint, }, ); }); @@ -185,7 +195,9 @@ describe('async actions', () => { const store = mockStore(initialState); store.dispatch(actions.formatQuery(query, queryId)); await waitFor(() => - expect(fetchMock.calls(formatQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( + 1, + ), ); expect(store.getActions()[0].type).toBe(actions.QUERY_EDITOR_SET_SQL); expect(store.getActions()[0].sql).toBe(expectedSql); @@ -209,11 +221,13 @@ describe('async actions', () => { store.dispatch(actions.formatQuery(queryEditorWithoutExtras)); await waitFor(() => - expect(fetchMock.calls(formatQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( + 1, + ), ); - const call = fetchMock.calls(formatQueryEndpoint)[0]; - const body = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; + const body = JSON.parse(call.options.body); expect(body).toEqual({ sql: 'SELECT * FROM table' }); expect(body.database_id).toBeUndefined(); @@ -238,11 +252,13 @@ describe('async actions', () => { store.dispatch(actions.formatQuery(queryEditorWithDb)); await waitFor(() => - expect(fetchMock.calls(formatQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( + 1, + ), ); - const call = fetchMock.calls(formatQueryEndpoint)[0]; - const body = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; + const body = JSON.parse(call.options.body); expect(body).toEqual({ sql: 'SELECT * FROM table', @@ -268,11 +284,13 @@ describe('async actions', () => { store.dispatch(actions.formatQuery(queryEditorWithTemplateString)); await waitFor(() => - expect(fetchMock.calls(formatQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( + 1, + ), ); - const call = fetchMock.calls(formatQueryEndpoint)[0]; - const body = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; + const body = JSON.parse(call.options.body); expect(body).toEqual({ sql: 'SELECT * FROM table WHERE id = {{ user_id }}', @@ -299,11 +317,13 @@ describe('async actions', () => { store.dispatch(actions.formatQuery(queryEditorWithTemplateObject)); await waitFor(() => - expect(fetchMock.calls(formatQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( + 1, + ), ); - const call = fetchMock.calls(formatQueryEndpoint)[0]; - const body = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; + const body = JSON.parse(call.options.body); expect(body).toEqual({ sql: 'SELECT * FROM table WHERE id = {{ user_id }}', @@ -314,12 +334,11 @@ describe('async actions', () => { test('dispatches QUERY_EDITOR_SET_SQL with formatted result', async () => { const formattedSql = 'SELECT\n *\nFROM\n table'; - fetchMock.post( + fetchMock.removeRoute(formatQueryEndpoint); + fetchMock.route( formatQueryEndpoint, { result: formattedSql }, - { - overwriteRoutes: true, - }, + { name: formatQueryEndpoint }, ); const queryEditorToFormat = { @@ -365,11 +384,13 @@ describe('async actions', () => { store.dispatch(actions.formatQuery(outdatedQueryEditor)); await waitFor(() => - expect(fetchMock.calls(formatQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(formatQueryEndpoint)).toHaveLength( + 1, + ), ); - const call = fetchMock.calls(formatQueryEndpoint)[0]; - const body = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(formatQueryEndpoint)[0]; + const body = JSON.parse(call.options.body); expect(body.sql).toBe('SELECT * FROM updated_table'); expect(body.database_id).toBe(10); @@ -388,7 +409,7 @@ describe('async actions', () => { expect.assertions(1); return makeRequest().then(() => { - expect(fetchMock.calls(fetchQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(fetchQueryEndpoint)).toHaveLength(1); }); }); @@ -402,7 +423,7 @@ describe('async actions', () => { test.skip('parses large number result without losing precision', () => makeRequest().then(() => { - expect(fetchMock.calls(fetchQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(fetchQueryEndpoint)).toHaveLength(1); expect(dispatch.callCount).toBe(2); expect(dispatch.getCall(1).lastArg.results.data.toString()).toBe( mockBigNumber, @@ -427,10 +448,11 @@ describe('async actions', () => { test('calls queryFailed on fetch error', () => { expect.assertions(1); + fetchMock.removeRoute(fetchQueryEndpoint); fetchMock.get( fetchQueryEndpoint, { throws: { message: 'error text' } }, - { overwriteRoutes: true }, + { name: fetchQueryEndpoint }, ); const store = mockStore({}); @@ -457,7 +479,7 @@ describe('async actions', () => { expect.assertions(1); return makeRequest().then(() => { - expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); }); }); @@ -469,9 +491,9 @@ describe('async actions', () => { }); }); - test.skip('parses large number result without losing precision', () => + test('parses large number result without losing precision', () => makeRequest().then(() => { - expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); expect(dispatch.callCount).toBe(2); expect(dispatch.getCall(1).lastArg.results.data.toString()).toBe( mockBigNumber, @@ -495,6 +517,7 @@ describe('async actions', () => { test('calls queryFailed on fetch error and logs the error details', () => { expect.assertions(2); + fetchMock.removeRoute(runQueryEndpoint); fetchMock.post( runQueryEndpoint, { @@ -504,7 +527,7 @@ describe('async actions', () => { statusText: 'timeout', }, }, - { overwriteRoutes: true }, + { name: runQueryEndpoint }, ); const store = mockStore({}); @@ -550,7 +573,9 @@ describe('async actions', () => { `{ "data": ${mockBigNumber} }`, ); await makeRequest().then(() => { - expect(fetchMock.calls(runQueryEndpointWithParams)).toHaveLength(1); + expect( + fetchMock.callHistory.calls(runQueryEndpointWithParams), + ).toHaveLength(1); }); }); }); @@ -591,7 +616,7 @@ describe('async actions', () => { expect.assertions(1); return makeRequest().then(() => { - expect(fetchMock.calls(stopQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(stopQueryEndpoint)).toHaveLength(1); }); }); @@ -607,8 +632,8 @@ describe('async actions', () => { expect.assertions(1); return makeRequest().then(() => { - const call = fetchMock.calls(stopQueryEndpoint)[0]; - const body = JSON.parse(call[1].body); + const call = fetchMock.callHistory.calls(stopQueryEndpoint)[0]; + const body = JSON.parse(call.options.body); expect(body.client_id).toBe(baseQuery.id); }); }); @@ -955,7 +980,7 @@ describe('async actions', () => { isFeatureEnabled.mockRestore(); }); - afterEach(() => fetchMock.resetHistory()); + afterEach(() => fetchMock.clearHistory()); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('addQueryEditor', () => { @@ -978,7 +1003,9 @@ describe('async actions', () => { store.dispatch(actions.addQueryEditor(queryEditor)); expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(0); }); }); @@ -1121,7 +1148,9 @@ describe('async actions', () => { const request = actions.queryEditorSetAndSaveSql(queryEditor, sql); return request(store.dispatch, store.getState).then(() => { expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(1); }); }); }); @@ -1143,7 +1172,9 @@ describe('async actions', () => { request(store.dispatch, store.getState); expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(0); isFeatureEnabled.mockRestore(); }); }); @@ -1325,10 +1356,14 @@ describe('async actions', () => { expectedActionTypes, ); expect(store.getActions()[0].prepend).toBeFalsy(); - expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + expect( + fetchMock.callHistory.calls(updateTableSchemaEndpoint), + ).toHaveLength(1); // tab state is not updated, since no query was run - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(0); }); }); }); @@ -1354,14 +1389,15 @@ describe('async actions', () => { }); beforeEach(() => { + fetchMock.removeRoute(runQueryEndpoint); fetchMock.post(runQueryEndpoint, JSON.stringify(results), { - overwriteRoutes: true, + name: runQueryEndpoint, }); }); afterEach(() => { store.clearActions(); - fetchMock.resetHistory(); + fetchMock.clearHistory(); }); test('updates and runs data preview query when configured', () => { @@ -1382,9 +1418,11 @@ describe('async actions', () => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); - expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); // tab state is not updated, since the query is a data preview - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(0); }); }); @@ -1406,9 +1444,11 @@ describe('async actions', () => { expect(store.getActions().map(a => a.type)).toEqual( expectedActionTypes, ); - expect(fetchMock.calls(runQueryEndpoint)).toHaveLength(1); + expect(fetchMock.callHistory.calls(runQueryEndpoint)).toHaveLength(1); // tab state is not updated, since the query is a data preview - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(0); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(0); }); }); }); @@ -1428,13 +1468,13 @@ describe('async actions', () => { ]; return store.dispatch(actions.expandTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => - call[0] && - call[0].includes('/tableschemaview/') && - call[0].includes('/expanded'), + call.url && + call.url.includes('/tableschemaview/') && + call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(1); }); @@ -1454,7 +1494,7 @@ describe('async actions', () => { return store.dispatch(actions.expandTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); // Check all POST calls to find the expanded endpoint - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => @@ -1480,13 +1520,13 @@ describe('async actions', () => { return store.dispatch(actions.expandTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); // Check all POST calls to find the expanded endpoint - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => - call[0] && - call[0].includes('/tableschemaview/') && - call[0].includes('/expanded'), + call.url && + call.url.includes('/tableschemaview/') && + call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); @@ -1510,13 +1550,13 @@ describe('async actions', () => { return store.dispatch(actions.expandTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); // Check all POST calls to find the expanded endpoint - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => - call[0] && - call[0].includes('/tableschemaview/') && - call[0].includes('/expanded'), + call.url && + call.url.includes('/tableschemaview/') && + call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); isFeatureEnabled.mockRestore(); @@ -1539,13 +1579,13 @@ describe('async actions', () => { ]; return store.dispatch(actions.collapseTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => - call[0] && - call[0].includes('/tableschemaview/') && - call[0].includes('/expanded'), + call.url && + call.url.includes('/tableschemaview/') && + call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(1); }); @@ -1564,13 +1604,13 @@ describe('async actions', () => { ]; return store.dispatch(actions.collapseTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => - call[0] && - call[0].includes('/tableschemaview/') && - call[0].includes('/expanded'), + call.url && + call.url.includes('/tableschemaview/') && + call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); @@ -1589,13 +1629,13 @@ describe('async actions', () => { ]; return store.dispatch(actions.collapseTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => - call[0] && - call[0].includes('/tableschemaview/') && - call[0].includes('/expanded'), + call.url && + call.url.includes('/tableschemaview/') && + call.url.includes('/expanded'), ); expect(expandedCalls).toHaveLength(0); }); @@ -1618,7 +1658,7 @@ describe('async actions', () => { ]; return store.dispatch(actions.collapseTable(table)).then(() => { expect(store.getActions()).toEqual(expectedActions); - const expandedCalls = fetchMock + const expandedCalls = fetchMock.callHistory .calls() .filter( call => @@ -1647,7 +1687,9 @@ describe('async actions', () => { ]; return store.dispatch(actions.removeTables([table])).then(() => { expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + expect( + fetchMock.callHistory.calls(updateTableSchemaEndpoint), + ).toHaveLength(1); }); }); @@ -1667,7 +1709,9 @@ describe('async actions', () => { ]; return store.dispatch(actions.removeTables(tables)).then(() => { expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(2); + expect( + fetchMock.callHistory.calls(updateTableSchemaEndpoint), + ).toHaveLength(2); }); }); @@ -1684,7 +1728,9 @@ describe('async actions', () => { ]; return store.dispatch(actions.removeTables(tables)).then(() => { expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(1); + expect( + fetchMock.callHistory.calls(updateTableSchemaEndpoint), + ).toHaveLength(1); }); }); }); @@ -1699,8 +1745,9 @@ describe('async actions', () => { query: { sqlEditorId: 'null' }, query_id: 'efgh', }; + fetchMock.removeRoute(runQueryEndpoint); fetchMock.post(runQueryEndpoint, JSON.stringify(results), { - overwriteRoutes: true, + name: runQueryEndpoint, }); const oldQueryEditor = { ...queryEditor, inLocalStorage: true }; @@ -1777,10 +1824,14 @@ describe('async actions', () => { .dispatch(actions.syncQueryEditor(oldQueryEditor)) .then(() => { expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(3); + expect( + fetchMock.callHistory.calls(updateTabStateEndpoint), + ).toHaveLength(3); // query editor has 2 tables loaded in the schema viewer - expect(fetchMock.calls(updateTableSchemaEndpoint)).toHaveLength(2); + expect( + fetchMock.callHistory.calls(updateTableSchemaEndpoint), + ).toHaveLength(2); }); }); }); diff --git a/superset-frontend/src/SqlLab/components/AppLayout/index.tsx b/superset-frontend/src/SqlLab/components/AppLayout/index.tsx index bb86e631eb04..1515b9a1694a 100644 --- a/superset-frontend/src/SqlLab/components/AppLayout/index.tsx +++ b/superset-frontend/src/SqlLab/components/AppLayout/index.tsx @@ -53,7 +53,11 @@ const StyledContainer = styled.div` const StyledSidebar = styled.div` position: relative; - padding: ${({ theme }) => theme.sizeUnit * 2.5}px; + padding: ${({ theme }) => theme.sizeUnit * 2.5}px 0; + margin: 0 ${({ theme }) => theme.sizeUnit * 2.5}px; + flex: 1; + height: 100%; + background-color: ${({ theme }) => theme.colorBgBase}; `; const ContentWrapper = styled.div` diff --git a/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx b/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx index 2116340b7754..0870724176b5 100644 --- a/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx +++ b/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx @@ -74,13 +74,13 @@ beforeEach(() => { }); afterEach(() => { - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); }); test('sync the unsaved editor tab state when there are new changes since the last update', async () => { const updateEditorTabState = `glob:*/tabstateview/${defaultQueryEditor.id}`; fetchMock.put(updateEditorTabState, 200); - expect(fetchMock.calls(updateEditorTabState)).toHaveLength(0); + expect(fetchMock.callHistory.calls(updateEditorTabState)).toHaveLength(0); render(, { useRedux: true, initialState: { @@ -91,14 +91,14 @@ test('sync the unsaved editor tab state when there are new changes since the las await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(updateEditorTabState)).toHaveLength(1); - fetchMock.restore(); + expect(fetchMock.callHistory.calls(updateEditorTabState)).toHaveLength(1); + fetchMock.clearHistory().removeRoutes(); }); test('sync the unsaved NEW editor state when there are new in local storage', async () => { const createEditorTabState = `glob:*/tabstateview/`; fetchMock.post(createEditorTabState, { id: 123 }); - expect(fetchMock.calls(createEditorTabState)).toHaveLength(0); + expect(fetchMock.callHistory.calls(createEditorTabState)).toHaveLength(0); render(, { useRedux: true, initialState: { @@ -119,12 +119,14 @@ test('sync the unsaved NEW editor state when there are new in local storage', as await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(createEditorTabState)).toHaveLength(1); - fetchMock.restore(); + expect(fetchMock.callHistory.calls(createEditorTabState)).toHaveLength(1); + fetchMock.clearHistory().removeRoutes(); }); test('sync the active editor id when there are updates in tab history', async () => { - expect(fetchMock.calls(updateActiveEditorTabState)).toHaveLength(0); + expect(fetchMock.callHistory.calls(updateActiveEditorTabState)).toHaveLength( + 0, + ); render(, { useRedux: true, initialState: { @@ -147,18 +149,22 @@ test('sync the active editor id when there are updates in tab history', async () await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(updateActiveEditorTabState)).toHaveLength(1); + expect(fetchMock.callHistory.calls(updateActiveEditorTabState)).toHaveLength( + 1, + ); await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(updateActiveEditorTabState)).toHaveLength(1); + expect(fetchMock.callHistory.calls(updateActiveEditorTabState)).toHaveLength( + 1, + ); }); test('sync the destroyed editor id when there are updates in destroyed editors', async () => { const removeId = 'removed-tab-id'; const deleteEditorState = `glob:*/tabstateview/${removeId}`; fetchMock.delete(deleteEditorState, { id: removeId }); - expect(fetchMock.calls(deleteEditorState)).toHaveLength(0); + expect(fetchMock.callHistory.calls(deleteEditorState)).toHaveLength(0); render(, { useRedux: true, initialState: { @@ -174,17 +180,17 @@ test('sync the destroyed editor id when there are updates in destroyed editors', await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(deleteEditorState)).toHaveLength(1); + expect(fetchMock.callHistory.calls(deleteEditorState)).toHaveLength(1); await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(deleteEditorState)).toHaveLength(1); + expect(fetchMock.callHistory.calls(deleteEditorState)).toHaveLength(1); }); test('skip syncing the unsaved editor tab state when the updates are already synced', async () => { const updateEditorTabState = `glob:*/tabstateview/${defaultQueryEditor.id}`; fetchMock.put(updateEditorTabState, 200); - expect(fetchMock.calls(updateEditorTabState)).toHaveLength(0); + expect(fetchMock.callHistory.calls(updateEditorTabState)).toHaveLength(0); render(, { useRedux: true, initialState: { @@ -203,8 +209,8 @@ test('skip syncing the unsaved editor tab state when the updates are already syn await act(async () => { jest.advanceTimersByTime(INTERVAL); }); - expect(fetchMock.calls(updateEditorTabState)).toHaveLength(0); - fetchMock.restore(); + expect(fetchMock.callHistory.calls(updateEditorTabState)).toHaveLength(0); + fetchMock.clearHistory().removeRoutes(); }); test('renders an error toast when the sync failed', async () => { @@ -212,7 +218,7 @@ test('renders an error toast when the sync failed', async () => { fetchMock.put(updateEditorTabState, { throws: new Error('errorMessage'), }); - expect(fetchMock.calls(updateEditorTabState)).toHaveLength(0); + expect(fetchMock.callHistory.calls(updateEditorTabState)).toHaveLength(0); render( <> @@ -235,5 +241,5 @@ test('renders an error toast when the sync failed', async () => { 'An error occurred while saving your editor state.', expect.anything(), ); - fetchMock.restore(); + fetchMock.clearHistory().removeRoutes(); }); diff --git a/superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.test.ts b/superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.test.ts index 99377c43be07..4fcf7132b3c9 100644 --- a/superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.test.ts +++ b/superset-frontend/src/SqlLab/components/EditorWrapper/useAnnotations.test.ts @@ -50,14 +50,16 @@ jest.mock('@superset-ui/core', () => ({ })); afterEach(() => { - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); act(() => { store.dispatch(api.util.resetApiState()); }); }); beforeEach(() => { - fetchMock.post(queryValidationApiRoute, fakeApiResult); + fetchMock.post(queryValidationApiRoute, fakeApiResult, { + name: queryValidationApiRoute, + }); }); const initialize = (withValidator = false) => { @@ -115,13 +117,15 @@ const initialize = (withValidator = false) => { test('skips fetching validation if validator is undefined', () => { const { result } = initialize(); expect(result.current.data).toEqual([]); - expect(fetchMock.calls(queryValidationApiRoute)).toHaveLength(0); + expect(fetchMock.callHistory.calls(queryValidationApiRoute)).toHaveLength(0); }); test('returns validation if validator is configured', async () => { const { result, waitFor } = initialize(true); await waitFor(() => - expect(fetchMock.calls(queryValidationApiRoute)).toHaveLength(1), + expect(fetchMock.callHistory.calls(queryValidationApiRoute)).toHaveLength( + 1, + ), ); expect(result.current.data).toEqual( fakeApiResult.result.map(err => ({ @@ -135,13 +139,10 @@ test('returns validation if validator is configured', async () => { test('returns server error description', async () => { const errorMessage = 'Unexpected validation api error'; - fetchMock.post( - queryValidationApiRoute, - { - throws: new Error(errorMessage), - }, - { overwriteRoutes: true }, - ); + fetchMock.removeRoute(queryValidationApiRoute); + fetchMock.post(queryValidationApiRoute, { + throws: new Error(errorMessage), + }); const { result, waitFor } = initialize(true); await waitFor( () => @@ -159,13 +160,10 @@ test('returns server error description', async () => { test('returns session expire description when CSRF token expired', async () => { const errorMessage = 'CSRF token expired'; - fetchMock.post( - queryValidationApiRoute, - { - throws: new Error(errorMessage), - }, - { overwriteRoutes: true }, - ); + fetchMock.removeRoute(queryValidationApiRoute); + fetchMock.post(queryValidationApiRoute, { + throws: new Error(errorMessage), + }); const { result, waitFor } = initialize(true); await waitFor( () => diff --git a/superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.test.ts b/superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.test.ts index 3e4a75646733..e54322cca182 100644 --- a/superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.test.ts +++ b/superset-frontend/src/SqlLab/components/EditorWrapper/useKeywords.test.ts @@ -94,7 +94,7 @@ beforeEach(() => { }); afterEach(() => { - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); act(() => { store.dispatch(api.util.resetApiState()); }); @@ -120,7 +120,7 @@ test('returns keywords including fetched function_names data', async () => { ); await waitFor(() => - expect(fetchMock.calls(dbFunctionNamesApiRoute).length).toBe(1), + expect(fetchMock.callHistory.calls(dbFunctionNamesApiRoute).length).toBe(1), ); fakeSchemaApiResult.forEach(schema => { expect(result.current).toContainEqual( @@ -171,7 +171,7 @@ test('skip fetching if autocomplete skipped', () => { }, ); expect(result.current).toEqual([]); - expect(fetchMock.calls()).toEqual([]); + expect(fetchMock.callHistory.calls()).toEqual([]); }); test('returns column keywords among selected tables', async () => { diff --git a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx index 16cb8374ce70..bcd7e3aa02f7 100644 --- a/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx +++ b/superset-frontend/src/SqlLab/components/ExploreCtasResultsButton/ExploreCtasResultsButton.test.tsx @@ -62,7 +62,7 @@ describe('ExploreCtasResultsButton', () => { const { getByText } = setup({}, mockStore(initialState)); postFormSpy.mockClear(); - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); fetchMock.post(getOrCreateTableEndpoint, { result: { table_id: 1234 } }); fireEvent.click(getByText('Explore')); @@ -80,7 +80,7 @@ describe('ExploreCtasResultsButton', () => { const { getByText } = setup({}, mockStore(initialState)); postFormSpy.mockClear(); - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); fetchMock.post(getOrCreateTableEndpoint, { throws: new Error('Unexpected all to v1 API'), }); diff --git a/superset-frontend/src/SqlLab/components/PopEditorTab/PopEditorTab.test.tsx b/superset-frontend/src/SqlLab/components/PopEditorTab/PopEditorTab.test.tsx index e66e5bba91c4..1e94927fef54 100644 --- a/superset-frontend/src/SqlLab/components/PopEditorTab/PopEditorTab.test.tsx +++ b/superset-frontend/src/SqlLab/components/PopEditorTab/PopEditorTab.test.tsx @@ -57,7 +57,7 @@ beforeEach(() => { }); afterEach(() => { - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); }); let replaceState = jest.spyOn(window.history, 'replaceState'); @@ -78,7 +78,7 @@ test('should handle id', async () => { setup('/sqllab?id=1'); await waitFor(() => expect( - fetchMock.calls(`glob:*/api/v1/sqllab/permalink/kv:${id}`), + fetchMock.callHistory.calls(`glob:*/api/v1/sqllab/permalink/kv:${id}`), ).toHaveLength(1), ); expect(replaceState).toHaveBeenCalledWith( @@ -86,7 +86,7 @@ test('should handle id', async () => { expect.anything(), '/sqllab', ); - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); }); test('should handle permalink', async () => { const key = '9sadkfl'; @@ -98,7 +98,7 @@ test('should handle permalink', async () => { setup('/sqllab/p/9sadkfl'); await waitFor(() => expect( - fetchMock.calls(`glob:*/api/v1/sqllab/permalink/${key}`), + fetchMock.callHistory.calls(`glob:*/api/v1/sqllab/permalink/${key}`), ).toHaveLength(1), ); expect(replaceState).toHaveBeenCalledWith( @@ -106,12 +106,14 @@ test('should handle permalink', async () => { expect.anything(), '/sqllab', ); - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); }); test('should handle savedQueryId', async () => { setup('/sqllab?savedQueryId=1'); await waitFor(() => - expect(fetchMock.calls('glob:*/api/v1/saved_query/1')).toHaveLength(1), + expect( + fetchMock.callHistory.calls('glob:*/api/v1/saved_query/1'), + ).toHaveLength(1), ); expect(replaceState).toHaveBeenCalledWith( expect.anything(), diff --git a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx index edce1f2ce073..d04361153257 100644 --- a/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx +++ b/superset-frontend/src/SqlLab/components/QueryAutoRefresh/QueryAutoRefresh.test.tsx @@ -57,7 +57,7 @@ describe('QueryAutoRefresh', () => { }); afterEach(() => { - fetchMock.reset(); + fetchMock.clearHistory().removeRoutes(); cleanup(); jest.runOnlyPendingTimers(); jest.useRealTimers(); @@ -162,7 +162,7 @@ describe('QueryAutoRefresh', () => { expect( store.getActions().filter(({ type }) => type === REFRESH_QUERIES), ).toHaveLength(0); - expect(fetchMock.calls(refreshApi)).toHaveLength(1); + expect(fetchMock.callHistory.calls(refreshApi)).toHaveLength(1); }); test('Does not fail and attempts to refresh with mixed valid/invalid queries', async () => { @@ -217,7 +217,7 @@ describe('QueryAutoRefresh', () => { ), ); - expect(fetchMock.calls(refreshApi)).toHaveLength(0); + expect(fetchMock.callHistory.calls(refreshApi)).toHaveLength(0); }); test('logs the failed error for async queries', async () => { diff --git a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx index 52edf19aaf74..a1d2185f3c55 100644 --- a/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx +++ b/superset-frontend/src/SqlLab/components/QueryHistory/QueryHistory.test.tsx @@ -81,7 +81,7 @@ const setup = (overrides = {}) => ( ); -afterEach(() => fetchMock.reset()); +afterEach(() => fetchMock.clearHistory().removeRoutes()); test('Renders an empty state for query history', () => { render(setup(), { useRedux: true, initialState }); @@ -102,7 +102,7 @@ test('fetches the query history when the persistence mode is enabled', async () fetchMock.get(editorQueryApiRoute, fakeApiResult); render(setup(), { useRedux: true, initialState }); await waitFor(() => - expect(fetchMock.calls(editorQueryApiRoute).length).toBe(1), + expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1), ); const queryResultText = screen.getByText(fakeApiResult.result[0].rows); expect(queryResultText).toBeInTheDocument(); @@ -127,7 +127,7 @@ test('fetches the query history by the tabViewId', async () => { }, }); await waitFor(() => - expect(fetchMock.calls(editorQueryApiRoute).length).toBe(1), + expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1), ); const queryResultText = screen.getByText(fakeApiResult.result[0].rows); expect(queryResultText).toBeInTheDocument(); @@ -213,7 +213,7 @@ test('displays multiple queries with newest query first', async () => { const { container } = render(setup(), { useRedux: true, initialState }); await waitFor(() => - expect(fetchMock.calls(editorQueryApiRoute).length).toBe(1), + expect(fetchMock.callHistory.calls(editorQueryApiRoute).length).toBe(1), ); expect(screen.getByTestId('listview-table')).toBeVisible(); diff --git a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx index e12627777f47..be5a1e22fc37 100644 --- a/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx +++ b/superset-frontend/src/SqlLab/components/ResultSet/ResultSet.test.tsx @@ -127,7 +127,7 @@ fetchMock.post(reRunQueryEndpoint, { result: [] }); fetchMock.get('glob:*/api/v1/sqllab/results/*', { result: [] }); beforeEach(() => { - fetchMock.resetHistory(); + fetchMock.clearHistory(); }); const middlewares = [thunk]; @@ -151,7 +151,7 @@ describe('ResultSet', () => { // Add cleanup after each test afterEach(async () => { - fetchMock.resetHistory(); + fetchMock.clearHistory(); // Wait for any pending effects to complete await new Promise(resolve => setTimeout(resolve, 0)); }); @@ -250,7 +250,7 @@ describe('ResultSet', () => { }, }); - expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(0); + expect(fetchMock.callHistory.calls(reRunQueryEndpoint)).toHaveLength(0); setup(mockedProps, store); expect(store.getActions()).toHaveLength(1); expect(store.getActions()[0].query.errorMessage).toEqual( @@ -258,7 +258,7 @@ describe('ResultSet', () => { ); expect(store.getActions()[0].type).toEqual('START_QUERY'); await waitFor(() => - expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(1), + expect(fetchMock.callHistory.calls(reRunQueryEndpoint)).toHaveLength(1), ); }); @@ -276,7 +276,7 @@ describe('ResultSet', () => { }); setup(mockedProps, store); expect(store.getActions()).toEqual([]); - expect(fetchMock.calls(reRunQueryEndpoint)).toHaveLength(0); + expect(fetchMock.callHistory.calls(reRunQueryEndpoint)).toHaveLength(0); }); test('should render cached query', async () => { @@ -622,7 +622,9 @@ describe('ResultSet', () => { }); // Verify the API was called - const resultsCalls = fetchMock.calls('glob:*/api/v1/sqllab/results/*'); + const resultsCalls = fetchMock.callHistory.calls( + 'glob:*/api/v1/sqllab/results/*', + ); expect(resultsCalls).toHaveLength(1); }); diff --git a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx index 6d46c4cd78aa..fa09eddb8ade 100644 --- a/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx +++ b/superset-frontend/src/SqlLab/components/SaveQuery/index.tsx @@ -63,6 +63,7 @@ export type QueryPayload = { const Styles = styled.span` display: contents; + white-space: nowrap; span[role='img']:not([aria-label='down']) { display: flex; margin: 0; diff --git a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx index cd83a05eafcb..33b408eeeddd 100644 --- a/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx +++ b/superset-frontend/src/SqlLab/components/ShareSqlLabQuery/ShareSqlLabQuery.test.tsx @@ -82,18 +82,17 @@ describe('ShareSqlLabQuery', () => { const storeQueryMockId = 'ci39c3'; beforeEach(async () => { + fetchMock.removeRoute(storeQueryUrl); fetchMock.post( storeQueryUrl, () => ({ key: storeQueryMockId, url: `/p/${storeQueryMockId}` }), - { - overwriteRoutes: true, - }, + { name: storeQueryUrl }, ); - fetchMock.resetHistory(); + fetchMock.clearHistory(); jest.clearAllMocks(); }); - afterAll(() => fetchMock.reset()); + afterAll(() => fetchMock.hardReset()); // eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks describe('via permalink api', () => { @@ -116,10 +115,12 @@ describe('ShareSqlLabQuery', () => { const expected = omit(mockQueryEditor, ['id', 'remoteId']); userEvent.click(button); await waitFor(() => - expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1), + expect(fetchMock.callHistory.calls(storeQueryUrl)).toHaveLength(1), ); expect( - JSON.parse(fetchMock.calls(storeQueryUrl)[0][1]?.body as string), + JSON.parse( + fetchMock.callHistory.calls(storeQueryUrl)[0].options?.body as string, + ), ).toEqual(expected); }); @@ -140,10 +141,12 @@ describe('ShareSqlLabQuery', () => { const expected = omit(unsavedQueryEditor, ['id']); userEvent.click(button); await waitFor(() => - expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1), + expect(fetchMock.callHistory.calls(storeQueryUrl)).toHaveLength(1), ); expect( - JSON.parse(fetchMock.calls(storeQueryUrl)[0][1]?.body as string), + JSON.parse( + fetchMock.callHistory.calls(storeQueryUrl)[0].options?.body as string, + ), ).toEqual(expected); }); }); diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx index 6eab25215f3b..86c4a6b23da8 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/SqlEditor.test.tsx @@ -75,6 +75,15 @@ jest.mock('@superset-ui/core/components/AsyncAceEditor', () => ({ })); jest.mock('src/SqlLab/components/ResultSet', () => jest.fn()); +jest.mock('src/components/DatabaseSelector', () => ({ + __esModule: true, + DatabaseSelector: ({ sqlLabMode }: { sqlLabMode?: boolean }) => ( +
+ Mock DatabaseSelector +
+ ), +})); + fetchMock.get('glob:*/api/v1/database/*/function_names/', { function_names: [], }); @@ -389,8 +398,8 @@ describe('SqlEditor', () => { // click button fireEvent.click(button); await waitFor(() => { - expect(fetchMock.lastUrl()).toEqual(estimateApi); - expect(fetchMock.lastOptions()).toEqual( + expect(fetchMock.callHistory.lastCall()?.url).toEqual(estimateApi); + expect(fetchMock.callHistory.lastCall()?.options).toEqual( expect.objectContaining({ body: JSON.stringify({ database_id: 2023, @@ -402,11 +411,11 @@ describe('SqlEditor', () => { cache: 'default', credentials: 'same-origin', headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - 'X-CSRFToken': '1234', + accept: 'application/json', + 'content-type': 'application/json', + 'x-csrftoken': '1234', }, - method: 'POST', + method: 'post', mode: 'same-origin', redirect: 'follow', signal: undefined, @@ -443,10 +452,12 @@ describe('SqlEditor', () => { const indicator = getByTestId('sqlEditor-loading'); expect(indicator).toBeInTheDocument(); await waitFor(() => - expect(fetchMock.calls('glob:*/tabstateview/*').length).toBe(1), + expect( + fetchMock.callHistory.calls('glob:*/tabstateview/*').length, + ).toBe(1), ); // it will be called from EditorAutoSync - expect(fetchMock.calls(switchTabApi).length).toBe(0); + expect(fetchMock.callHistory.calls(switchTabApi).length).toBe(0); }); }); }); diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx index dee5e69d1d6e..673171787aeb 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/SqlEditorLeftBar.test.tsx @@ -22,7 +22,6 @@ import { screen, userEvent, waitFor, - within, } from 'spec/helpers/testing-library'; import SqlEditorLeftBar, { SqlEditorLeftBarProps, @@ -31,12 +30,31 @@ import { table, initialState, defaultQueryEditor, - extraQueryEditor1, extraQueryEditor2, } from 'src/SqlLab/fixtures'; import type { RootState } from 'src/views/store'; import type { Store } from 'redux'; +// Mock TableExploreTree to avoid complex tree rendering in tests +jest.mock('../TableExploreTree', () => ({ + __esModule: true, + default: () => ( +
TableExploreTree
+ ), +})); + +// Helper to switch from default TreeView to SelectView +const switchToSelectView = async () => { + const changeButton = screen.getByTestId('DatabaseSelector'); + // Click Change button to open database selector modal + await userEvent.click(changeButton); + + // Verify popup is opened + await waitFor(() => { + expect(screen.getByText('Select Database and Schema')).toBeInTheDocument(); + }); +}; + const mockedProps = { queryEditorId: defaultQueryEditor.id, height: 0, @@ -92,7 +110,7 @@ beforeEach(() => { }); afterEach(() => { - fetchMock.restore(); + fetchMock.clearHistory().removeRoutes(); jest.clearAllMocks(); }); @@ -109,118 +127,16 @@ const renderAndWait = ( }), ); -test('renders a TableElement', async () => { - const { findByText, getAllByTestId } = await renderAndWait( - mockedProps, - undefined, - { - ...initialState, - sqlLab: { - ...initialState.sqlLab, - tables: [table], - databases: { [mockData.database.id]: { ...mockData.database } }, - }, - }, - ); - expect(await findByText(/Database/i)).toBeInTheDocument(); - const tableElement = getAllByTestId('table-element'); - expect(tableElement.length).toBeGreaterThanOrEqual(1); -}); - -test('table should be visible when expanded is true', async () => { - const { container, getByText, getByRole, getAllByLabelText } = - await renderAndWait(mockedProps, undefined, { - ...initialState, - sqlLab: { - ...initialState.sqlLab, - tables: [table], - databases: { [mockData.database.id]: { ...mockData.database } }, - }, - }); - - const dbSelect = getByRole('combobox', { - name: 'Select database or type to search databases', - }); - const schemaSelect = getByRole('combobox', { - name: 'Select schema or type to search schemas: main', - }); - const tableSelect = getAllByLabelText( - /Select table or type to search tables/i, - )[0]; - const tableOption = within(tableSelect).getByText(/ab_user/i); - - expect(getByText(/Database/i)).toBeInTheDocument(); - expect(dbSelect).toBeInTheDocument(); - expect(schemaSelect).toBeInTheDocument(); - expect(tableSelect).toBeInTheDocument(); - expect(tableOption).toBeInTheDocument(); - expect( - container.querySelector('.ant-collapse-content-active'), - ).toBeInTheDocument(); - table.columns.forEach(({ name }) => { - expect(getByText(name)).toBeInTheDocument(); - }); -}); - test('catalog selector should be visible when enabled in the database', async () => { - const { container, getByText, getByRole } = await renderAndWait( - mockedProps, - undefined, - { - ...initialState, - sqlLab: { - ...initialState.sqlLab, - unsavedQueryEditor: { - id: mockedProps.queryEditorId, - dbId: mockData.database.id, - }, - tables: [table], - databases: { - [mockData.database.id]: { - ...mockData.database, - allow_multi_catalog: true, - }, - }, - }, - }, - ); - - const dbSelect = getByRole('combobox', { - name: 'Select database or type to search databases', - }); - const catalogSelect = getByRole('combobox', { - name: 'Select catalog or type to search catalogs', - }); - const schemaSelect = getByRole('combobox', { - name: 'Select schema or type to search schemas', - }); - const dropdown = getByText(/Select table/i); - const abUser = getByText(/ab_user/i); - - expect(getByText(/Database/i)).toBeInTheDocument(); - expect(dbSelect).toBeInTheDocument(); - expect(catalogSelect).toBeInTheDocument(); - expect(schemaSelect).toBeInTheDocument(); - expect(dropdown).toBeInTheDocument(); - expect(abUser).toBeInTheDocument(); - expect( - container.querySelector('.ant-collapse-content-active'), - ).toBeInTheDocument(); - table.columns.forEach(({ name }) => { - expect(getByText(name)).toBeInTheDocument(); - }); -}); - -test('should toggle the table when the header is clicked', async () => { - const { container } = await renderAndWait(mockedProps, undefined, { + const { getByRole } = await renderAndWait(mockedProps, undefined, { ...initialState, sqlLab: { ...initialState.sqlLab, - tables: [table], unsavedQueryEditor: { id: mockedProps.queryEditorId, dbId: mockData.database.id, }, + tables: [table], databases: { [mockData.database.id]: { ...mockData.database, @@ -229,93 +145,17 @@ test('should toggle the table when the header is clicked', async () => { }, }, }); + await switchToSelectView(); - const header = container.querySelector('.ant-collapse-header'); - expect(header).toBeInTheDocument(); - - if (header) { - userEvent.click(header); - } - - await waitFor(() => - expect( - container.querySelector('.ant-collapse-content-inactive'), - ).toBeInTheDocument(), - ); -}); - -test('When changing database the schema and table list must be updated', async () => { - const reduxState = { - ...initialState, - sqlLab: { - ...initialState.sqlLab, - unsavedQueryEditor: { - id: defaultQueryEditor.id, - schema: 'db1_schema', - dbId: mockData.database.id, - }, - queryEditors: [ - defaultQueryEditor, - { - ...extraQueryEditor1, - schema: 'new_schema', - dbId: 2, - }, - ], - tables: [ - { - ...table, - dbId: defaultQueryEditor.dbId, - schema: 'db1_schema', - }, - { - ...table, - dbId: 2, - schema: 'new_schema', - name: 'new_table', - queryEditorId: extraQueryEditor1.id, - }, - ], - databases: { - [mockData.database.id]: { - ...mockData.database, - allow_multi_catalog: true, - }, - 2: { - id: 2, - database_name: 'new_db', - backend: 'postgresql', - }, - }, - }, - }; - const { rerender } = await renderAndWait(mockedProps, undefined, reduxState); - - expect(screen.getAllByText(/main/i)[0]).toBeInTheDocument(); - expect(screen.getAllByText(/ab_user/i)[0]).toBeInTheDocument(); - - rerender( - , - ); - const updatedDbSelector = await screen.findAllByText(/new_db/i); - expect(updatedDbSelector[0]).toBeInTheDocument(); - - const select = screen.getByRole('combobox', { - name: 'Select schema or type to search schemas', + const dbSelect = getByRole('combobox', { + name: 'Select database or type to search databases', + }); + const catalogSelect = getByRole('combobox', { + name: 'Select catalog or type to search catalogs', }); - userEvent.click(select); - - expect( - await screen.findByRole('option', { name: 'main' }), - ).toBeInTheDocument(); - expect( - await screen.findByRole('option', { name: 'new_schema' }), - ).toBeInTheDocument(); - - userEvent.click(screen.getByText('new_schema')); - const updatedTableSelector = await screen.findAllByText(/new_table/i); - expect(updatedTableSelector[0]).toBeInTheDocument(); + expect(dbSelect).toBeInTheDocument(); + expect(catalogSelect).toBeInTheDocument(); }); test('display no compatible schema found when schema api throws errors', async () => { @@ -351,10 +191,12 @@ test('display no compatible schema found when schema api throws errors', async ( undefined, reduxState, ); + await switchToSelectView(); + await waitFor(() => - expect(fetchMock.calls('glob:*/api/v1/database/3/schemas/?*')).toHaveLength( - 1, - ), + expect( + fetchMock.callHistory.calls('glob:*/api/v1/database/3/schemas/?*').length, + ).toBeGreaterThanOrEqual(1), ); const select = screen.getByRole('combobox', { name: 'Select schema or type to search schemas', @@ -384,17 +226,12 @@ test('ignore schema api when current schema is deprecated', async () => { }, }, }); - - expect(await screen.findByText(/Database/i)).toBeInTheDocument(); - expect(fetchMock.calls()).not.toContainEqual( + await switchToSelectView(); + expect(fetchMock.callHistory.calls()).not.toContainEqual( expect.arrayContaining([ expect.stringContaining( `/tables/${mockData.database.id}/${invalidSchemaName}/`, ), ]), ); - // Deselect the deprecated schema selection - await waitFor(() => - expect(screen.queryByText(/None/i)).not.toBeInTheDocument(), - ); }); diff --git a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx index 5010298da0a2..91c8fe4e7e99 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditorLeftBar/index.tsx @@ -16,36 +16,35 @@ * specific language governing permissions and limitations * under the License. */ -import { useCallback, useMemo, useState } from 'react'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { useCallback, useState } from 'react'; +import { useDispatch } from 'react-redux'; -import { SqlLabRootState, Table } from 'src/SqlLab/types'; +import { resetState } from 'src/SqlLab/actions/sqlLab'; import { - addTable, - removeTables, - collapseTable, - expandTable, - resetState, -} from 'src/SqlLab/actions/sqlLab'; -import { Button, EmptyState, Icons } from '@superset-ui/core/components'; + Button, + EmptyState, + Flex, + Icons, + Popover, + Typography, +} from '@superset-ui/core/components'; import { t } from '@apache-superset/core'; import { styled, css } from '@apache-superset/core/ui'; -import { TableSelectorMultiple } from 'src/components/TableSelector'; -import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor'; -import { noop } from 'lodash'; -import TableElement from '../TableElement'; +import type { SchemaOption, CatalogOption } from 'src/hooks/apiResources'; +import { DatabaseSelector, type DatabaseObject } from 'src/components'; + import useDatabaseSelector from '../SqlEditorTopBar/useDatabaseSelector'; +import TableExploreTree from '../TableExploreTree'; export interface SqlEditorLeftBarProps { queryEditorId: string; } -const StyledScrollbarContainer = styled.div` - flex: 1 1 auto; - overflow: auto; -`; - const LeftBarStyles = styled.div` + display: flex; + flex-direction: column; + gap: ${({ theme }) => theme.sizeUnit * 2}px; + ${({ theme }) => css` height: 100%; display: flex; @@ -53,117 +52,153 @@ const LeftBarStyles = styled.div` .divider { border-bottom: 1px solid ${theme.colorSplit}; - margin: ${theme.sizeUnit * 4}px 0; + margin: ${theme.sizeUnit * 1}px 0; } `} `; +const StyledDivider = styled.div` + border-bottom: 1px solid ${({ theme }) => theme.colorSplit}; + margin: 0 -${({ theme }) => theme.sizeUnit * 2.5}px 0; +`; + const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => { - const { db: userSelectedDb, ...dbSelectorProps } = - useDatabaseSelector(queryEditorId); - const allSelectedTables = useSelector( - ({ sqlLab }) => - sqlLab.tables.filter(table => table.queryEditorId === queryEditorId), - shallowEqual, - ); + const dbSelectorProps = useDatabaseSelector(queryEditorId); + const { db, catalog, schema, onDbChange, onCatalogChange, onSchemaChange } = + dbSelectorProps; + const dispatch = useDispatch(); - const queryEditor = useQueryEditor(queryEditorId, [ - 'dbId', - 'catalog', - 'schema', - 'tabViewId', - ]); - const [_emptyResultsWithSearch, setEmptyResultsWithSearch] = useState(false); - const { dbId, schema } = queryEditor; - const tables = useMemo( - () => - allSelectedTables.filter( - table => table.dbId === dbId && table.schema === schema, - ), - [allSelectedTables, dbId, schema], - ); + const shouldShowReset = window.location.search === '?reset=1'; - noop(_emptyResultsWithSearch); // This is to avoid unused variable warning, can be removed if not needed + // Modal state for Database/Catalog/Schema selector + const [selectorModalOpen, setSelectorModalOpen] = useState(false); + const [modalDb, setModalDb] = useState(undefined); + const [modalCatalog, setModalCatalog] = useState< + CatalogOption | null | undefined + >(undefined); + const [modalSchema, setModalSchema] = useState( + undefined, + ); - const onEmptyResults = useCallback((searchText?: string) => { - setEmptyResultsWithSearch(!!searchText); + const openSelectorModal = useCallback(() => { + setModalDb(db ?? undefined); + setModalCatalog( + catalog ? { label: catalog, value: catalog, title: catalog } : undefined, + ); + setModalSchema( + schema ? { label: schema, value: schema, title: schema } : undefined, + ); + setSelectorModalOpen(true); + }, [db, catalog, schema]); + + const closeSelectorModal = useCallback(() => { + setSelectorModalOpen(false); }, []); - const selectedTableNames = useMemo( - () => tables?.map(table => table.name) || [], - [tables], - ); - - const onTablesChange = ( - tableNames: string[], - catalogName: string | null, - schemaName: string, - ) => { - if (!schemaName) { - return; + const handleModalOk = useCallback(() => { + if (modalDb && modalDb.id !== db?.id) { + onDbChange?.(modalDb); } - - const currentTables = [...tables]; - const tablesToAdd = tableNames.filter(name => { - const index = currentTables.findIndex(table => table.name === name); - if (index >= 0) { - currentTables.splice(index, 1); - return false; - } - - return true; - }); - - tablesToAdd.forEach(tableName => { - dispatch(addTable(queryEditor, tableName, catalogName, schemaName)); - }); - - dispatch(removeTables(currentTables)); - }; - - const onToggleTable = (updatedTables: string[]) => { - tables.forEach(table => { - if (!updatedTables.includes(table.id.toString()) && table.expanded) { - dispatch(collapseTable(table)); - } else if ( - updatedTables.includes(table.id.toString()) && - !table.expanded - ) { - dispatch(expandTable(table)); - } - }); - }; - - const shouldShowReset = window.location.search === '?reset=1'; + if (modalCatalog?.value !== catalog) { + onCatalogChange?.(modalCatalog?.value); + } + if (modalSchema?.value !== schema) { + onSchemaChange?.(modalSchema?.value ?? ''); + } + setSelectorModalOpen(false); + }, [ + modalDb, + modalCatalog, + modalSchema, + db, + catalog, + schema, + onDbChange, + onCatalogChange, + onSchemaChange, + ]); const handleResetState = useCallback(() => { dispatch(resetState()); }, [dispatch]); - return ( - - + + {t('Select Database and Schema')} + + } - database={userSelectedDb} - onTableSelectChange={onTablesChange} - tableValue={selectedTableNames} - sqlLabMode + getDbList={dbSelectorProps.getDbList} + handleError={dbSelectorProps.handleError} + onDbChange={setModalDb} + onCatalogChange={cat => + setModalCatalog( + cat ? { label: cat, value: cat, title: cat } : undefined, + ) + } + catalog={modalCatalog?.value} + onSchemaChange={sch => + setModalSchema( + sch ? { label: sch, value: sch, title: sch } : undefined, + ) + } + schema={modalSchema?.value} + sqlLabMode={false} /> -
- - {tables.map(table => ( - expanded) - .map(({ id }) => id)} - onChange={onToggleTable} - /> - ))} - + + + + + + ); + + return ( + + !open && closeSelectorModal()} + placement="bottomLeft" + trigger="click" + > + } + sqlLabMode + onOpenModal={openSelectorModal} + /> + + + {shouldShowReset && (