mirror of
https://github.com/apache/superset.git
synced 2026-04-27 03:55:47 +00:00
feat(sqllab): non-blocking persistence mode (#24539)
Co-authored-by: Justin Park <justinpark@apache.org>
This commit is contained in:
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
|
||||
import { initialState, defaultQueryEditor } from 'src/SqlLab/fixtures';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import EditorAutoSync from '.';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
logging: {
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const editorTabLastUpdatedAt = Date.now();
|
||||
const unsavedSqlLabState = {
|
||||
...initialState.sqlLab,
|
||||
unsavedQueryEditor: {
|
||||
id: defaultQueryEditor.id,
|
||||
name: 'updated tab name',
|
||||
updatedAt: editorTabLastUpdatedAt + 100,
|
||||
},
|
||||
editorTabLastUpdatedAt,
|
||||
};
|
||||
beforeAll(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
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);
|
||||
render(<EditorAutoSync />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
...initialState,
|
||||
sqlLab: unsavedSqlLabState,
|
||||
},
|
||||
});
|
||||
await waitFor(() => jest.runAllTimers());
|
||||
expect(fetchMock.calls(updateEditorTabState)).toHaveLength(1);
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
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);
|
||||
render(<EditorAutoSync />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
...initialState,
|
||||
sqlLab: {
|
||||
...initialState.sqlLab,
|
||||
unsavedQueryEditor: {
|
||||
id: defaultQueryEditor.id,
|
||||
name: 'updated tab name',
|
||||
updatedAt: editorTabLastUpdatedAt - 100,
|
||||
},
|
||||
editorTabLastUpdatedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
await waitFor(() => jest.runAllTimers());
|
||||
expect(fetchMock.calls(updateEditorTabState)).toHaveLength(0);
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('renders an error toast when the sync failed', async () => {
|
||||
const updateEditorTabState = `glob:*/tabstateview/${defaultQueryEditor.id}`;
|
||||
fetchMock.put(updateEditorTabState, {
|
||||
throws: new Error('errorMessage'),
|
||||
});
|
||||
expect(fetchMock.calls(updateEditorTabState)).toHaveLength(0);
|
||||
render(
|
||||
<>
|
||||
<EditorAutoSync />
|
||||
<ToastContainer />
|
||||
</>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
...initialState,
|
||||
sqlLab: unsavedSqlLabState,
|
||||
},
|
||||
},
|
||||
);
|
||||
await waitFor(() => jest.runAllTimers());
|
||||
|
||||
expect(logging.warn).toHaveBeenCalledTimes(1);
|
||||
expect(logging.warn).toHaveBeenCalledWith(
|
||||
'An error occurred while saving your editor state.',
|
||||
expect.anything(),
|
||||
);
|
||||
fetchMock.restore();
|
||||
});
|
||||
106
superset-frontend/src/SqlLab/components/EditorAutoSync/index.tsx
Normal file
106
superset-frontend/src/SqlLab/components/EditorAutoSync/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import {
|
||||
SqlLabRootState,
|
||||
QueryEditor,
|
||||
UnsavedQueryEditor,
|
||||
} from 'src/SqlLab/types';
|
||||
import { useUpdateSqlEditorTabMutation } from 'src/hooks/apiResources/sqlEditorTabs';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import { setEditorTabLastUpdate } from 'src/SqlLab/actions/sqlLab';
|
||||
|
||||
const INTERVAL = 5000;
|
||||
|
||||
function hasUnsavedChanges(
|
||||
queryEditor: QueryEditor,
|
||||
lastSavedTimestamp: number,
|
||||
) {
|
||||
return (
|
||||
queryEditor.inLocalStorage ||
|
||||
(queryEditor.updatedAt && queryEditor.updatedAt > lastSavedTimestamp)
|
||||
);
|
||||
}
|
||||
|
||||
export function filterUnsavedQueryEditorList(
|
||||
queryEditors: QueryEditor[],
|
||||
unsavedQueryEditor: UnsavedQueryEditor,
|
||||
lastSavedTimestamp: number,
|
||||
) {
|
||||
return queryEditors
|
||||
.map(queryEditor => ({
|
||||
...queryEditor,
|
||||
...(unsavedQueryEditor.id === queryEditor.id && unsavedQueryEditor),
|
||||
}))
|
||||
.filter(queryEditor => hasUnsavedChanges(queryEditor, lastSavedTimestamp));
|
||||
}
|
||||
|
||||
const EditorAutoSync: React.FC = () => {
|
||||
const queryEditors = useSelector<SqlLabRootState, QueryEditor[]>(
|
||||
state => state.sqlLab.queryEditors,
|
||||
);
|
||||
const unsavedQueryEditor = useSelector<SqlLabRootState, UnsavedQueryEditor>(
|
||||
state => state.sqlLab.unsavedQueryEditor,
|
||||
);
|
||||
const editorTabLastUpdatedAt = useSelector<SqlLabRootState, number>(
|
||||
state => state.sqlLab.editorTabLastUpdatedAt,
|
||||
);
|
||||
const dispatch = useDispatch();
|
||||
const lastSavedTimestampRef = useRef<number>(editorTabLastUpdatedAt);
|
||||
const [updateSqlEditor, { error }] = useUpdateSqlEditorTabMutation();
|
||||
|
||||
const debouncedUnsavedQueryEditor = useDebounceValue(
|
||||
unsavedQueryEditor,
|
||||
INTERVAL,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const unsaved = filterUnsavedQueryEditorList(
|
||||
queryEditors,
|
||||
debouncedUnsavedQueryEditor,
|
||||
lastSavedTimestampRef.current,
|
||||
);
|
||||
|
||||
Promise.all(
|
||||
unsaved
|
||||
// TODO: Migrate migrateQueryEditorFromLocalStorage
|
||||
// in TabbedSqlEditors logic by addSqlEditor mutation later
|
||||
.filter(({ inLocalStorage }) => !inLocalStorage)
|
||||
.map(queryEditor => updateSqlEditor({ queryEditor })),
|
||||
).then(resolvers => {
|
||||
if (!resolvers.some(result => 'error' in result)) {
|
||||
lastSavedTimestampRef.current = Date.now();
|
||||
dispatch(setEditorTabLastUpdate(lastSavedTimestampRef.current));
|
||||
}
|
||||
});
|
||||
}, [debouncedUnsavedQueryEditor, dispatch, queryEditors, updateSqlEditor]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
logging.warn('An error occurred while saving your editor state.', error);
|
||||
}
|
||||
}, [dispatch, error]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default EditorAutoSync;
|
||||
Reference in New Issue
Block a user