Skip to content

Commit d1d64f7

Browse files
authored
fix: unsaved changes in query editor (#2026)
1 parent 7af1ed3 commit d1d64f7

File tree

16 files changed

+333
-64
lines changed

16 files changed

+333
-64
lines changed

src/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/TopQueries.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import {
88
setTopQueriesFilters,
99
topQueriesApi,
1010
} from '../../../../../store/reducers/executeTopQueries/executeTopQueries';
11-
import {changeUserInput} from '../../../../../store/reducers/query/query';
11+
import {changeUserInput, setIsDirty} from '../../../../../store/reducers/query/query';
1212
import {
1313
TENANT_DIAGNOSTICS_TABS_IDS,
1414
TENANT_PAGE,
@@ -57,6 +57,7 @@ export function TopQueries({tenantName}: TopQueriesProps) {
5757
const {QueryText: input} = row;
5858

5959
dispatch(changeUserInput({input}));
60+
dispatch(setIsDirty(false));
6061

6162
const queryParams = parseQuery(location);
6263

src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {Search} from '../../../../components/Search';
1212
import {TableWithControlsLayout} from '../../../../components/TableWithControlsLayout/TableWithControlsLayout';
1313
import {parseQuery} from '../../../../routes';
1414
import {setTopQueriesFilters} from '../../../../store/reducers/executeTopQueries/executeTopQueries';
15-
import {changeUserInput} from '../../../../store/reducers/query/query';
15+
import {changeUserInput, setIsDirty} from '../../../../store/reducers/query/query';
1616
import {
1717
TENANT_PAGE,
1818
TENANT_PAGES_IDS,
@@ -72,6 +72,7 @@ export const TopQueries = ({tenantName}: TopQueriesProps) => {
7272
const applyRowClick = React.useCallback(
7373
(input: string) => {
7474
dispatch(changeUserInput({input}));
75+
dispatch(setIsDirty(false));
7576

7677
const queryParams = parseQuery(location);
7778

src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {NavigationTree} from 'ydb-ui-components';
77

88
import {getConnectToDBDialog} from '../../../../components/ConnectToDB/ConnectToDBDialog';
99
import {useCreateDirectoryFeatureAvailable} from '../../../../store/reducers/capabilities/hooks';
10-
import {selectUserInput} from '../../../../store/reducers/query/query';
10+
import {selectIsDirty, selectUserInput} from '../../../../store/reducers/query/query';
1111
import {schemaApi} from '../../../../store/reducers/schema/schema';
1212
import {tableSchemaDataApi} from '../../../../store/reducers/tableSchemaData';
1313
import type {EPathType, TEvDescribeSchemeResult} from '../../../../types/api/schema';
@@ -42,6 +42,7 @@ export function SchemaTree(props: SchemaTreeProps) {
4242
const {rootPath, rootName, rootType, currentPath, onActivePathUpdate} = props;
4343
const dispatch = useTypedDispatch();
4444
const input = useTypedSelector(selectUserInput);
45+
const isDirty = useTypedSelector(selectIsDirty);
4546
const [
4647
getTableSchemaDataQuery,
4748
{currentData: actionsSchemaData, isFetching: isActionsDataFetching},
@@ -132,7 +133,7 @@ export function SchemaTree(props: SchemaTreeProps) {
132133
showCreateDirectoryDialog: createDirectoryFeatureAvailable
133134
? handleOpenCreateDirectoryDialog
134135
: undefined,
135-
getConfirmation: input ? getConfirmation : undefined,
136+
getConfirmation: input && isDirty ? getConfirmation : undefined,
136137
getConnectToDBDialog,
137138
schemaData: actionsSchemaData,
138139
isSchemaDataLoading: isActionsDataFetching,

src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {TruncatedQuery} from '../../../../components/TruncatedQuery/TruncatedQue
77
import {
88
selectQueriesHistory,
99
selectQueriesHistoryFilter,
10+
setIsDirty,
1011
setQueryHistoryFilter,
1112
} from '../../../../store/reducers/query/query';
1213
import type {QueryInHistory} from '../../../../store/reducers/query/types';
@@ -39,6 +40,7 @@ function QueriesHistory({changeUserInput}: QueriesHistoryProps) {
3940

4041
const applyQueryClick = (query: QueryInHistory) => {
4142
changeUserInput({input: query.queryText});
43+
dispatch(setIsDirty(false));
4244
dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery));
4345
};
4446

src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
selectQueriesHistoryCurrentIndex,
1717
selectResult,
1818
selectTenantPath,
19+
setIsDirty,
1920
setTenantPath,
2021
} from '../../../../store/reducers/query/query';
2122
import type {QueryResult} from '../../../../store/reducers/query/types';
@@ -174,6 +175,7 @@ export default function QueryEditor(props: QueryEditorProps) {
174175
if (text !== historyQueries[historyCurrentIndex]?.queryText) {
175176
dispatch(saveQueryToHistory({queryText: text, queryId}));
176177
}
178+
dispatch(setIsDirty(false));
177179
}
178180
dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand);
179181
});

src/containers/Tenant/Query/QueryEditor/YqlEditor.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
goToPreviousQuery,
1212
selectQueriesHistory,
1313
selectUserInput,
14+
setIsDirty,
1415
} from '../../../../store/reducers/query/query';
1516
import type {QueryAction} from '../../../../types/store/query';
1617
import {ENABLE_CODE_ASSISTANT, LAST_USED_QUERY_ACTION_KEY} from '../../../../utils/constants';
@@ -186,6 +187,7 @@ export function YqlEditor({
186187
const onChange = (newValue: string) => {
187188
updateErrorsHighlighting();
188189
changeUserInput({input: newValue});
190+
dispatch(setIsDirty(true));
189191
};
190192
return (
191193
<MonacoEditor

src/containers/Tenant/Query/SaveQuery/SaveQuery.tsx

+3
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import NiceModal from '@ebay/nice-modal-react';
44
import type {ButtonProps} from '@gravity-ui/uikit';
55
import {Button, Dialog, DropdownMenu, TextInput} from '@gravity-ui/uikit';
66

7+
import {setIsDirty} from '../../../../store/reducers/query/query';
78
import {
89
clearQueryNameToEdit,
910
saveQuery,
@@ -55,6 +56,7 @@ export function SaveQuery({buttonProps = {}}: SaveQueryProps) {
5556

5657
const onEditQueryClick = () => {
5758
dispatch(saveQuery(queryNameToEdit));
59+
dispatch(setIsDirty(false));
5860
dispatch(clearQueryNameToEdit());
5961
};
6062

@@ -130,6 +132,7 @@ function SaveQueryDialog({onSuccess, onCancel, onClose, open}: SaveQueryDialogPr
130132

131133
const onSaveClick = () => {
132134
dispatch(saveQuery(queryName));
135+
dispatch(setIsDirty(false));
133136
onCloseDialog();
134137
onSuccess?.();
135138
};

src/containers/Tenant/Query/SavedQueries/SavedQueries.tsx

+2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {ResizeableDataTable} from '../../../../components/ResizeableDataTable/Re
99
import {Search} from '../../../../components/Search';
1010
import {TableWithControlsLayout} from '../../../../components/TableWithControlsLayout/TableWithControlsLayout';
1111
import {TruncatedQuery} from '../../../../components/TruncatedQuery/TruncatedQuery';
12+
import {setIsDirty} from '../../../../store/reducers/query/query';
1213
import {
1314
deleteSavedQuery,
1415
selectSavedQueriesFilter,
@@ -92,6 +93,7 @@ export const SavedQueries = ({changeUserInput}: SavedQueriesProps) => {
9293
const applyQueryClick = React.useCallback(
9394
({queryText, queryName}: {queryText: string; queryName: string}) => {
9495
changeUserInput({input: queryText});
96+
dispatch(setIsDirty(false));
9597
dispatch(setQueryNameToEdit(queryName));
9698
dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery));
9799
},

src/store/reducers/query/query.ts

+7
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const sliceLimit = queriesHistoryInitial.length - MAXIMUM_QUERIES_IN_HISTORY;
3131

3232
const initialState: QueryState = {
3333
input: '',
34+
isDirty: false,
3435
history: {
3536
queries: queriesHistoryInitial
3637
.slice(sliceLimit < 0 ? 0 : sliceLimit)
@@ -50,6 +51,9 @@ const slice = createSlice({
5051
changeUserInput: (state, action: PayloadAction<{input: string}>) => {
5152
state.input = action.payload.input;
5253
},
54+
setIsDirty: (state, action: PayloadAction<boolean>) => {
55+
state.isDirty = action.payload;
56+
},
5357
setQueryResult: (state, action: PayloadAction<QueryResult | undefined>) => {
5458
state.result = action.payload;
5559
},
@@ -145,6 +149,7 @@ const slice = createSlice({
145149
: items;
146150
},
147151
selectUserInput: (state) => state.input,
152+
selectIsDirty: (state) => state.isDirty,
148153
selectQueriesHistoryCurrentIndex: (state) => state.history?.currentIndex,
149154
},
150155
});
@@ -162,6 +167,7 @@ export const {
162167
addStreamingChunks,
163168
setStreamQueryResponse,
164169
setStreamSession,
170+
setIsDirty,
165171
} = slice.actions;
166172

167173
export const {
@@ -172,6 +178,7 @@ export const {
172178
selectResult,
173179
selectUserInput,
174180
selectQueryDuration,
181+
selectIsDirty,
175182
} = slice.selectors;
176183

177184
interface SendQueryParams extends QueryRequestParams {

src/store/reducers/query/types.ts

+1
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ export interface QueryResult {
6161
export interface QueryState {
6262
input: string;
6363
result?: QueryResult;
64+
isDirty?: boolean;
6465
history: {
6566
queries: QueryInHistory[];
6667
currentIndex: number;

src/utils/hooks/withConfirmation/useChangeInputWithConfirmation.tsx

+3-2
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import NiceModal from '@ebay/nice-modal-react';
55
import {useTypedSelector} from '..';
66
import {CONFIRMATION_DIALOG} from '../../../components/ConfirmationDialog/ConfirmationDialog';
77
import {SaveQueryButton} from '../../../containers/Tenant/Query/SaveQuery/SaveQuery';
8-
import {selectUserInput} from '../../../store/reducers/query/query';
8+
import {selectIsDirty, selectUserInput} from '../../../store/reducers/query/query';
99

1010
import i18n from './i18n';
1111

@@ -66,11 +66,12 @@ export function changeInputWithConfirmation<T>(callback: (args: T) => void) {
6666

6767
export function useChangeInputWithConfirmation<T>(callback: (args: T) => void) {
6868
const userInput = useTypedSelector(selectUserInput);
69+
const isDirty = useTypedSelector(selectIsDirty);
6970
const callbackWithConfirmation = React.useMemo(
7071
() => changeInputWithConfirmation<T>(callback),
7172
[callback],
7273
);
73-
if (!userInput) {
74+
if (!userInput || !isDirty) {
7475
return callback;
7576
}
7677
return callbackWithConfirmation;

tests/suites/tenant/TenantPage.ts

+59
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type {Locator, Page} from '@playwright/test';
33
import {PageModel} from '../../models/PageModel';
44
import {tenantPage} from '../../utils/constants';
55

6+
import {QueryEditor, QueryTabs} from './queryEditor/models/QueryEditor';
7+
import {SaveQueryDialog} from './queryEditor/models/SaveQueryDialog';
8+
import {UnsavedChangesModal} from './queryEditor/models/UnsavedChangesModal';
9+
import {SavedQueriesTable} from './savedQueries/models/SavedQueriesTable';
10+
611
export const VISIBILITY_TIMEOUT = 10 * 1000;
712

813
export enum NavigationTabs {
@@ -11,6 +16,11 @@ export enum NavigationTabs {
1116
}
1217

1318
export class TenantPage extends PageModel {
19+
queryEditor: QueryEditor;
20+
saveQueryDialog: SaveQueryDialog;
21+
savedQueriesTable: SavedQueriesTable;
22+
unsavedChangesModal: UnsavedChangesModal;
23+
1424
private navigation: Locator;
1525
private radioGroup: Locator;
1626
private diagnosticsContainer: Locator;
@@ -25,6 +35,11 @@ export class TenantPage extends PageModel {
2535
this.diagnosticsContainer = page.locator('.kv-tenant-diagnostics');
2636
this.emptyState = page.locator('.empty-state');
2737
this.emptyStateTitle = this.emptyState.locator('.empty-state__title');
38+
39+
this.queryEditor = new QueryEditor(page);
40+
this.saveQueryDialog = new SaveQueryDialog(page);
41+
this.savedQueriesTable = new SavedQueriesTable(page);
42+
this.unsavedChangesModal = new UnsavedChangesModal(page);
2843
}
2944

3045
async isDiagnosticsVisible() {
@@ -46,4 +61,48 @@ export class TenantPage extends PageModel {
4661
await tabInput.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
4762
await tabInput.click();
4863
}
64+
65+
async saveQuery(queryText: string, name?: string): Promise<string> {
66+
const queryName = name || `Query ${Date.now()}`;
67+
await this.queryEditor.setQuery(queryText);
68+
await this.queryEditor.clickSaveButton();
69+
await this.saveQueryDialog.setQueryName(queryName);
70+
await this.saveQueryDialog.clickSave();
71+
return queryName;
72+
}
73+
74+
async editAsNewQuery(queryText: string, name?: string): Promise<string> {
75+
const queryName = name || `Query ${Date.now()}`;
76+
await this.queryEditor.setQuery(queryText);
77+
await this.queryEditor.clickEditButton();
78+
await this.queryEditor.clickSaveAsNewEditButton();
79+
await this.saveQueryDialog.setQueryName(queryName);
80+
await this.saveQueryDialog.clickSave();
81+
return queryName;
82+
}
83+
84+
async openSavedQuery(queryName: string): Promise<void> {
85+
// Wait before switching to saved query tabs
86+
// https://github.com/microsoft/monaco-editor/issues/4702
87+
await this.page.waitForTimeout(500);
88+
await this.queryEditor.queryTabs.selectTab(QueryTabs.Saved);
89+
await this.savedQueriesTable.isVisible();
90+
await this.savedQueriesTable.selectQuery(queryName);
91+
}
92+
93+
async isUnsavedChangesModalVisible(): Promise<boolean> {
94+
try {
95+
return await this.unsavedChangesModal.isVisible();
96+
} catch {
97+
return false;
98+
}
99+
}
100+
101+
async isUnsavedChangesModalHidden(): Promise<boolean> {
102+
try {
103+
return await this.unsavedChangesModal.isHidden();
104+
} catch {
105+
return false;
106+
}
107+
}
49108
}

tests/suites/tenant/queryEditor/models/QueryEditor.ts

+39-3
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,14 @@ export enum ButtonNames {
2020
Explain = 'Explain',
2121
Cancel = 'Cancel',
2222
Save = 'Save',
23+
Edit = 'Edit',
2324
Stop = 'Stop',
2425
}
2526

27+
export enum EditSavedSubMenuNames {
28+
SaveAsNew = 'Save as new',
29+
}
30+
2631
export enum ResultTabNames {
2732
Result = 'Result',
2833
Stats = 'Stats',
@@ -52,6 +57,8 @@ export class QueryEditor {
5257
private stopButton: Locator;
5358
private stopBanner: Locator;
5459
private saveButton: Locator;
60+
private editButton: Locator;
61+
private dropdownMenu: Locator;
5562
private gearButton: Locator;
5663
private banner: Locator;
5764
private executionStatus: Locator;
@@ -68,6 +75,8 @@ export class QueryEditor {
6875
this.stopBanner = this.selector.locator('.ydb-query-stopped-banner');
6976
this.explainButton = this.selector.getByRole('button', {name: ButtonNames.Explain});
7077
this.saveButton = this.selector.getByRole('button', {name: ButtonNames.Save});
78+
this.editButton = this.selector.getByRole('button', {name: ButtonNames.Edit});
79+
this.dropdownMenu = page.locator('.g-dropdown-menu__menu');
7180
this.gearButton = this.selector.locator('.ydb-query-editor-button__gear-button');
7281
this.executionStatus = this.selector.locator('.kv-query-execution-status .g-text');
7382
this.resultsControls = this.selector.locator('.ydb-query-result__controls');
@@ -124,6 +133,20 @@ export class QueryEditor {
124133
await this.saveButton.click();
125134
}
126135

136+
async clickEditButton() {
137+
await this.editButton.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
138+
await this.editButton.click();
139+
}
140+
141+
async clickSaveAsNewEditButton() {
142+
const menuItem = this.dropdownMenu
143+
.getByRole('menuitem')
144+
.filter({hasText: EditSavedSubMenuNames.SaveAsNew});
145+
146+
await menuItem.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
147+
await menuItem.click();
148+
}
149+
127150
async getExplainResult(type: ExplainResultType) {
128151
await this.selectResultTypeRadio(type);
129152
const resultArea = this.selector.locator('.ydb-query-result__result');
@@ -203,9 +226,22 @@ export class QueryEditor {
203226
await this.gearButton.hover();
204227
}
205228

206-
async setQuery(query: string) {
207-
await this.editorTextArea.waitFor({state: 'visible', timeout: VISIBILITY_TIMEOUT});
208-
await this.editorTextArea.clear();
229+
async setQuery(query: string, timeout = VISIBILITY_TIMEOUT) {
230+
await this.editorTextArea.waitFor({state: 'visible', timeout});
231+
232+
await this.editorTextArea.evaluate(() => {
233+
const editor = window.ydbEditor;
234+
if (editor) {
235+
editor.setValue('');
236+
}
237+
return false;
238+
});
239+
240+
const currentValue = await this.editorTextArea.inputValue();
241+
if (currentValue !== '') {
242+
throw new Error('Failed to clear editor text area');
243+
}
244+
209245
await this.editorTextArea.fill(query);
210246
}
211247

0 commit comments

Comments
 (0)