feat(sqllab): non-blocking persistence mode (#24539)

Co-authored-by: Justin Park <justinpark@apache.org>
This commit is contained in:
JUST.in DO IT
2023-11-20 11:13:54 -08:00
committed by GitHub
parent 628cd345f2
commit e2bfb1216b
20 changed files with 746 additions and 433 deletions

View File

@@ -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();
});

View 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;