From 80c7b7dace41efb3cff22d28de669f809665f64d Mon Sep 17 00:00:00 2001 From: justinpark Date: Thu, 6 Jun 2024 09:11:23 -0700 Subject: [PATCH] refactor(sqllab): nonblocking switch query editor --- .../src/SqlLab/actions/sqlLab.js | 110 +++++++----------- .../src/SqlLab/actions/sqlLab.test.js | 35 +++--- .../EditorAutoSync/EditorAutoSync.test.tsx | 38 ++++++ .../components/EditorAutoSync/index.tsx | 39 ++++++- .../src/SqlLab/components/SqlEditor/index.tsx | 4 +- .../components/TabbedSqlEditors/index.tsx | 5 +- .../middlewares/persistSqlLabStateEnhancer.js | 8 +- .../SqlLab/reducers/getInitialState.test.ts | 3 + .../src/SqlLab/reducers/getInitialState.ts | 3 + .../src/SqlLab/reducers/sqlLab.js | 3 + superset-frontend/src/SqlLab/types.ts | 1 + .../hooks/apiResources/sqlEditorTabs.test.ts | 25 +++- .../src/hooks/apiResources/sqlEditorTabs.ts | 12 +- 13 files changed, 186 insertions(+), 100 deletions(-) diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.js b/superset-frontend/src/SqlLab/actions/sqlLab.js index 0a5a655a06de9..732301c831842 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.js @@ -102,6 +102,7 @@ export const CREATE_DATASOURCE_SUCCESS = 'CREATE_DATASOURCE_SUCCESS'; export const CREATE_DATASOURCE_FAILED = 'CREATE_DATASOURCE_FAILED'; export const SET_EDITOR_TAB_LAST_UPDATE = 'SET_EDITOR_TAB_LAST_UPDATE'; +export const SET_LAST_UPDATED_ACTIVE_TAB = 'SET_LAST_UPDATED_ACTIVE_TAB'; export const addInfoToast = addInfoToastAction; export const addSuccessToast = addSuccessToastAction; @@ -610,29 +611,17 @@ export function cloneQueryToNewTab(query, autorun) { }; } -export function setActiveQueryEditor(queryEditor) { - return function (dispatch) { - const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) - ? SupersetClient.post({ - endpoint: encodeURI(`/tabstateview/${queryEditor.id}/activate`), - }) - : Promise.resolve(); +export function setLastUpdatedActiveTab(queryEditorId) { + return { + type: SET_LAST_UPDATED_ACTIVE_TAB, + queryEditorId, + }; +} - return sync - .then(() => dispatch({ type: SET_ACTIVE_QUERY_EDITOR, queryEditor })) - .catch(response => { - if (response.status !== 404) { - return dispatch( - addDangerToast( - t( - 'An error occurred while setting the active tab. Please contact ' + - 'your administrator.', - ), - ), - ); - } - return dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }); - }); +export function setActiveQueryEditor(queryEditor) { + return { + type: SET_ACTIVE_QUERY_EDITOR, + queryEditor, }; } @@ -673,51 +662,42 @@ export function setTables(tableSchemas) { return { type: SET_TABLES, tables }; } -export function switchQueryEditor(queryEditor, displayLimit) { +export function fetchQueryEditor(queryEditor, displayLimit) { return function (dispatch) { - if ( - isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) && - queryEditor && - !queryEditor.loaded - ) { - SupersetClient.get({ - endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + SupersetClient.get({ + endpoint: encodeURI(`/tabstateview/${queryEditor.id}`), + }) + .then(({ json }) => { + const loadedQueryEditor = { + id: json.id.toString(), + loaded: true, + name: json.label, + sql: json.sql, + selectedText: null, + latestQueryId: json.latest_query?.id, + autorun: json.autorun, + dbId: json.database_id, + templateParams: json.template_params, + catalog: json.catalog, + schema: json.schema, + queryLimit: json.query_limit, + remoteId: json.saved_query?.id, + hideLeftBar: json.hide_left_bar, + }; + dispatch(loadQueryEditor(loadedQueryEditor)); + dispatch(setTables(json.table_schemas || [])); + if (json.latest_query && json.latest_query.resultsKey) { + dispatch(fetchQueryResults(json.latest_query, displayLimit)); + } }) - .then(({ json }) => { - const loadedQueryEditor = { - id: json.id.toString(), - loaded: true, - name: json.label, - sql: json.sql, - selectedText: null, - latestQueryId: json.latest_query?.id, - autorun: json.autorun, - dbId: json.database_id, - templateParams: json.template_params, - catalog: json.catalog, - schema: json.schema, - queryLimit: json.query_limit, - remoteId: json.saved_query?.id, - hideLeftBar: json.hide_left_bar, - }; - dispatch(loadQueryEditor(loadedQueryEditor)); - dispatch(setTables(json.table_schemas || [])); - dispatch(setActiveQueryEditor(loadedQueryEditor)); - if (json.latest_query && json.latest_query.resultsKey) { - dispatch(fetchQueryResults(json.latest_query, displayLimit)); - } - }) - .catch(response => { - if (response.status !== 404) { - return dispatch( - addDangerToast(t('An error occurred while fetching tab state')), - ); - } - return dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }); - }); - } else { - dispatch(setActiveQueryEditor(queryEditor)); - } + .catch(response => { + if (response.status !== 404) { + return dispatch( + addDangerToast(t('An error occurred while fetching tab state')), + ); + } + return dispatch({ type: REMOVE_QUERY_EDITOR, queryEditor }); + }); }; } diff --git a/superset-frontend/src/SqlLab/actions/sqlLab.test.js b/superset-frontend/src/SqlLab/actions/sqlLab.test.js index 9dca13a11ba75..248692091cfd6 100644 --- a/superset-frontend/src/SqlLab/actions/sqlLab.test.js +++ b/superset-frontend/src/SqlLab/actions/sqlLab.test.js @@ -505,6 +505,21 @@ describe('async actions', () => { }); }); + it('set current query editor', () => { + expect.assertions(1); + + const store = mockStore(initialState); + const expectedActions = [ + { + type: actions.SET_ACTIVE_QUERY_EDITOR, + queryEditor: defaultQueryEditor, + }, + ]; + store.dispatch(actions.setActiveQueryEditor(defaultQueryEditor)); + + expect(store.getActions()).toEqual(expectedActions); + }); + describe('backend sync', () => { const updateTabStateEndpoint = 'glob:*/tabstateview/*'; fetchMock.put(updateTabStateEndpoint, {}); @@ -570,26 +585,6 @@ describe('async actions', () => { }); }); - describe('setActiveQueryEditor', () => { - it('updates the tab state in the backend', () => { - expect.assertions(2); - - const store = mockStore({}); - const expectedActions = [ - { - type: actions.SET_ACTIVE_QUERY_EDITOR, - queryEditor, - }, - ]; - return store - .dispatch(actions.setActiveQueryEditor(queryEditor)) - .then(() => { - expect(store.getActions()).toEqual(expectedActions); - expect(fetchMock.calls(updateTabStateEndpoint)).toHaveLength(1); - }); - }); - }); - describe('removeQueryEditor', () => { it('updates the tab state in the backend', () => { expect.assertions(2); diff --git a/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx b/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx index 197d90f38afc0..6ccae610a1fce 100644 --- a/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx +++ b/superset-frontend/src/SqlLab/components/EditorAutoSync/EditorAutoSync.test.tsx @@ -58,6 +58,7 @@ const unsavedSqlLabState = { }, editorTabLastUpdatedAt, }; + beforeAll(() => { jest.useFakeTimers(); }); @@ -66,6 +67,16 @@ afterAll(() => { jest.useRealTimers(); }); +const updateActiveEditorTabState = `glob:*/tabstateview/*/activate`; + +beforeEach(() => { + fetchMock.post(updateActiveEditorTabState, {}); +}); + +afterEach(() => { + fetchMock.reset(); +}); + 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); @@ -108,6 +119,33 @@ test('sync the unsaved NEW editor state when there are new in local storage', as fetchMock.restore(); }); +test('sync the active editor id when there are updates in tab history', async () => { + expect(fetchMock.calls(updateActiveEditorTabState)).toHaveLength(0); + render(, { + useRedux: true, + initialState: { + ...initialState, + sqlLab: { + ...initialState.sqlLab, + lastUpdatedActiveTab: 'old-tab-id', + queryEditors: [ + ...initialState.sqlLab.queryEditors, + { + id: 'rnd-new-id12', + name: 'new tab name', + inLocalStorage: false, + }, + ], + tabHistory: ['old-tab-id', 'rnd-new-id12'], + }, + }, + }); + await waitFor(() => jest.advanceTimersByTime(INTERVAL)); + expect(fetchMock.calls(updateActiveEditorTabState)).toHaveLength(1); + await waitFor(() => jest.advanceTimersByTime(INTERVAL)); + expect(fetchMock.calls(updateActiveEditorTabState)).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); diff --git a/superset-frontend/src/SqlLab/components/EditorAutoSync/index.tsx b/superset-frontend/src/SqlLab/components/EditorAutoSync/index.tsx index ff204e10020b3..788a44d316bd3 100644 --- a/superset-frontend/src/SqlLab/components/EditorAutoSync/index.tsx +++ b/superset-frontend/src/SqlLab/components/EditorAutoSync/index.tsx @@ -26,11 +26,15 @@ import { QueryEditor, UnsavedQueryEditor, } from 'src/SqlLab/types'; -import { useUpdateSqlEditorTabMutation } from 'src/hooks/apiResources/sqlEditorTabs'; +import { + useUpdateCurrentQueryEditorMutation, + useUpdateSqlEditorTabMutation, +} from 'src/hooks/apiResources/sqlEditorTabs'; import { useDebounceValue } from 'src/hooks/useDebounceValue'; import { syncQueryEditor, setEditorTabLastUpdate, + setLastUpdatedActiveTab, } from 'src/SqlLab/actions/sqlLab'; import useEffectEvent from 'src/hooks/useEffectEvent'; @@ -71,7 +75,15 @@ const EditorAutoSync: FC = () => { ); const dispatch = useDispatch(); const lastSavedTimestampRef = useRef(editorTabLastUpdatedAt); + + const currentQueryEditorId = useSelector( + ({ sqlLab }) => sqlLab.tabHistory.slice(-1)[0] || '', + ); + const lastUpdatedActiveTab = useSelector( + ({ sqlLab }) => sqlLab.lastUpdatedActiveTab, + ); const [updateSqlEditor, { error }] = useUpdateSqlEditorTabMutation(); + const [updateCurrentSqlEditor] = useUpdateCurrentQueryEditorMutation(); const debouncedUnsavedQueryEditor = useDebounceValue( unsavedQueryEditor, @@ -94,21 +106,36 @@ const EditorAutoSync: FC = () => { ).find(({ inLocalStorage }) => Boolean(inLocalStorage)), ); + const syncCurrentQueryEditor = useEffectEvent(() => { + if ( + currentQueryEditorId && + currentQueryEditorId !== lastUpdatedActiveTab && + !queryEditors.find(({ id }) => id === currentQueryEditorId) + ?.inLocalStorage + ) { + updateCurrentSqlEditor(currentQueryEditorId).then(() => { + dispatch(setLastUpdatedActiveTab(currentQueryEditorId)); + }); + } + }); + useEffect(() => { - let timer: NodeJS.Timeout; + let saveTimer: NodeJS.Timeout; function saveUnsavedQueryEditor() { const firstUnsavedQueryEditor = getUnsavedNewQueryEditor(); if (firstUnsavedQueryEditor) { dispatch(syncQueryEditor(firstUnsavedQueryEditor)); } - timer = setTimeout(saveUnsavedQueryEditor, INTERVAL); + saveTimer = setTimeout(saveUnsavedQueryEditor, INTERVAL); } - timer = setTimeout(saveUnsavedQueryEditor, INTERVAL); + const syncTimer = setInterval(syncCurrentQueryEditor, INTERVAL); + saveTimer = setTimeout(saveUnsavedQueryEditor, INTERVAL); return () => { - clearTimeout(timer); + clearTimeout(saveTimer); + clearInterval(syncTimer); }; - }, [getUnsavedNewQueryEditor, dispatch]); + }, [getUnsavedNewQueryEditor, syncCurrentQueryEditor, dispatch]); useEffect(() => { const unsaved = getUnsavedItems(debouncedUnsavedQueryEditor); diff --git a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx index a92c2811662b6..1673220b379ad 100644 --- a/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx +++ b/superset-frontend/src/SqlLab/components/SqlEditor/index.tsx @@ -79,7 +79,7 @@ import { setActiveSouthPaneTab, updateSavedQuery, formatQuery, - switchQueryEditor, + fetchQueryEditor, } from 'src/SqlLab/actions/sqlLab'; import { STATE_TYPE_MAP, @@ -506,7 +506,7 @@ const SqlEditor: FC = ({ const loadQueryEditor = useEffectEvent(() => { if (shouldLoadQueryEditor) { - dispatch(switchQueryEditor(queryEditor, displayLimit)); + dispatch(fetchQueryEditor(queryEditor, displayLimit)); } }); diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx index b26067556c8f9..d5ed291501496 100644 --- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx +++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx @@ -181,10 +181,7 @@ class TabbedSqlEditors extends PureComponent { if (!queryEditor) { return; } - this.props.actions.switchQueryEditor( - queryEditor, - this.props.displayLimit, - ); + this.props.actions.setActiveQueryEditor(queryEditor); } } diff --git a/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js b/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js index 25dda5cd3d38a..d83e06d316f86 100644 --- a/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js +++ b/superset-frontend/src/SqlLab/middlewares/persistSqlLabStateEnhancer.js @@ -49,13 +49,16 @@ const sqlLabPersistStateConfig = { tables, queries, tabHistory, + lastUpdatedActiveTab, } = state.sqlLab; const unsavedQueryEditors = filterUnsavedQueryEditorList( queryEditors, unsavedQueryEditor, editorTabLastUpdatedAt, ); - if (unsavedQueryEditors.length > 0) { + const hasUnsavedActiveTabState = + tabHistory.slice(-1)[0] !== lastUpdatedActiveTab; + if (unsavedQueryEditors.length > 0 || hasUnsavedActiveTabState) { const hasFinishedMigrationFromLocalStorage = unsavedQueryEditors.every( ({ inLocalStorage }) => !inLocalStorage, @@ -70,6 +73,9 @@ const sqlLabPersistStateConfig = { query => query.inLocalStorage && !query.isDataPreview, ), }), + ...(hasUnsavedActiveTabState && { + tabHistory, + }), }; } return; diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts b/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts index 6d6e65bad3bd8..1e7745f6d0471 100644 --- a/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts +++ b/superset-frontend/src/SqlLab/reducers/getInitialState.test.ts @@ -301,6 +301,9 @@ describe('getInitialState', () => { name: expectedValue, }), ); + expect( + getInitialState(apiDataWithLocalStorage).sqlLab.lastUpdatedActiveTab, + ).toEqual(apiDataWithTabState.active_tab.id.toString()); }); it('skip unsaved changes for expired data', () => { diff --git a/superset-frontend/src/SqlLab/reducers/getInitialState.ts b/superset-frontend/src/SqlLab/reducers/getInitialState.ts index 52a9770854a06..6a156bacfd2f2 100644 --- a/superset-frontend/src/SqlLab/reducers/getInitialState.ts +++ b/superset-frontend/src/SqlLab/reducers/getInitialState.ts @@ -110,6 +110,7 @@ export default function getInitialState({ }; }); const tabHistory = activeTab ? [activeTab.id.toString()] : []; + let lastUpdatedActiveTab = activeTab ? activeTab.id.toString() : ''; let tables = {} as Record; let editorTabLastUpdatedAt = Date.now(); if (activeTab) { @@ -217,6 +218,7 @@ export default function getInitialState({ if (sqlLab.tabHistory) { tabHistory.push(...sqlLab.tabHistory); } + lastUpdatedActiveTab = tabHistory.slice(tabHistory.length - 1)[0] || ''; } } } catch (error) { @@ -250,6 +252,7 @@ export default function getInitialState({ editorTabLastUpdatedAt, queryCostEstimates: {}, unsavedQueryEditor, + lastUpdatedActiveTab, }, localStorageUsageInKilobytes: 0, common, diff --git a/superset-frontend/src/SqlLab/reducers/sqlLab.js b/superset-frontend/src/SqlLab/reducers/sqlLab.js index 24acc721c32aa..f290d29f6a306 100644 --- a/superset-frontend/src/SqlLab/reducers/sqlLab.js +++ b/superset-frontend/src/SqlLab/reducers/sqlLab.js @@ -742,6 +742,9 @@ export default function sqlLabReducer(state = {}, action) { [actions.SET_EDITOR_TAB_LAST_UPDATE]() { return { ...state, editorTabLastUpdatedAt: action.timestamp }; }, + [actions.SET_LAST_UPDATED_ACTIVE_TAB]() { + return { ...state, lastUpdatedActiveTab: action.queryEditorId }; + }, }; if (action.type in actionHandlers) { return actionHandlers[action.type](); diff --git a/superset-frontend/src/SqlLab/types.ts b/superset-frontend/src/SqlLab/types.ts index b1dea6f2e31de..2dee58c22372c 100644 --- a/superset-frontend/src/SqlLab/types.ts +++ b/superset-frontend/src/SqlLab/types.ts @@ -109,6 +109,7 @@ export type SqlLabRootState = { unsavedQueryEditor: UnsavedQueryEditor; queryCostEstimates?: Record; editorTabLastUpdatedAt: number; + lastUpdatedActiveTab: string; }; localStorageUsageInKilobytes: number; messageToasts: toastState[]; diff --git a/superset-frontend/src/hooks/apiResources/sqlEditorTabs.test.ts b/superset-frontend/src/hooks/apiResources/sqlEditorTabs.test.ts index d0f2230f13d90..16c337e82738e 100644 --- a/superset-frontend/src/hooks/apiResources/sqlEditorTabs.test.ts +++ b/superset-frontend/src/hooks/apiResources/sqlEditorTabs.test.ts @@ -24,7 +24,10 @@ import { } from 'spec/helpers/testing-library'; import { api } from 'src/hooks/apiResources/queryApi'; import { LatestQueryEditorVersion } from 'src/SqlLab/types'; -import { useUpdateSqlEditorTabMutation } from './sqlEditorTabs'; +import { + useUpdateCurrentQueryEditorMutation, + useUpdateSqlEditorTabMutation, +} from './sqlEditorTabs'; const expectedQueryEditor = { version: LatestQueryEditorVersion, @@ -97,3 +100,23 @@ test('puts api request with formData', async () => { ), ); }); + +test('posts activate request with queryEditorId', async () => { + const tabStateMutationApiRoute = `glob:*/tabstateview/${expectedQueryEditor.id}/activate`; + fetchMock.post(tabStateMutationApiRoute, 200); + const { result, waitFor } = renderHook( + () => useUpdateCurrentQueryEditorMutation(), + { + wrapper: createWrapper({ + useRedux: true, + store, + }), + }, + ); + act(() => { + result.current[0](expectedQueryEditor.id); + }); + await waitFor(() => + expect(fetchMock.calls(tabStateMutationApiRoute).length).toBe(1), + ); +}); diff --git a/superset-frontend/src/hooks/apiResources/sqlEditorTabs.ts b/superset-frontend/src/hooks/apiResources/sqlEditorTabs.ts index f25e9b4021aee..03c4c494a0e8e 100644 --- a/superset-frontend/src/hooks/apiResources/sqlEditorTabs.ts +++ b/superset-frontend/src/hooks/apiResources/sqlEditorTabs.ts @@ -66,7 +66,17 @@ const sqlEditorApi = api.injectEndpoints({ ), }), }), + updateCurrentQueryEditor: builder.mutation({ + query: queryEditorId => ({ + method: 'POST', + endpoint: encodeURI(`/tabstateview/${queryEditorId}/activate`), + transformResponse: () => queryEditorId, + }), + }), }), }); -export const { useUpdateSqlEditorTabMutation } = sqlEditorApi; +export const { + useUpdateSqlEditorTabMutation, + useUpdateCurrentQueryEditorMutation, +} = sqlEditorApi;