From 2632b90ed38cd89a1691b77b8cf4bccb2cf19507 Mon Sep 17 00:00:00 2001 From: Hellen Date: Thu, 14 Nov 2024 16:24:53 +0300 Subject: [PATCH] feat: warn about unsaved changes in editor (#1620) --- package-lock.json | 11 + package.json | 1 + .../ConfirmationDialog.scss | 5 + .../ConfirmationDialog/ConfirmationDialog.tsx | 99 ++++++ .../ConfirmationDialog/i18n/en.json | 3 + .../ConfirmationDialog/i18n/index.ts | 7 + src/containers/App/Providers.tsx | 9 +- .../TenantOverview/TenantCpu/TopQueries.tsx | 5 +- .../Diagnostics/TopQueries/TopQueries.tsx | 5 +- .../ObjectSummary/SchemaTree/SchemaTree.tsx | 10 +- src/containers/Tenant/Query/NewSQL/NewSQL.tsx | 16 +- .../Query/QueriesHistory/QueriesHistory.tsx | 5 +- .../Tenant/Query/QueryEditor/QueryEditor.tsx | 125 +++----- .../Query/SavedQueries/SavedQueries.tsx | 23 +- .../Tenant/utils/newSQLQueryActions.ts | 6 +- src/containers/Tenant/utils/schemaActions.ts | 54 ++-- src/store/reducers/executeQuery.ts | 286 ++++++------------ .../reducers/queryActions/queryActions.ts | 2 +- src/store/reducers/schema/schema.ts | 42 ++- src/types/store/executeQuery.ts | 28 +- src/utils/hooks/withConfirmation/i18n/en.json | 4 + .../hooks/withConfirmation/i18n/index.ts | 7 + .../useChangeInputWithConfirmation.ts | 39 +++ 23 files changed, 427 insertions(+), 365 deletions(-) create mode 100644 src/components/ConfirmationDialog/ConfirmationDialog.scss create mode 100644 src/components/ConfirmationDialog/ConfirmationDialog.tsx create mode 100644 src/components/ConfirmationDialog/i18n/en.json create mode 100644 src/components/ConfirmationDialog/i18n/index.ts create mode 100644 src/utils/hooks/withConfirmation/i18n/en.json create mode 100644 src/utils/hooks/withConfirmation/i18n/index.ts create mode 100644 src/utils/hooks/withConfirmation/useChangeInputWithConfirmation.ts diff --git a/package-lock.json b/package-lock.json index 0ad7f8379..5e2424fe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -56,6 +56,7 @@ "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@ebay/nice-modal-react": "^1.2.13", "@gravity-ui/browserslist-config": "^4.3.0", "@gravity-ui/eslint-config": "^3.2.0", "@gravity-ui/prettier-config": "^1.1.0", @@ -3477,6 +3478,16 @@ "react": ">=16.8.0" } }, + "node_modules/@ebay/nice-modal-react": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@ebay/nice-modal-react/-/nice-modal-react-1.2.13.tgz", + "integrity": "sha512-jx8xIWe/Up4tpNuM02M+rbnLoxdngTGk3Y8LjJsLGXXcSoKd/+eZStZcAlIO/jwxyz/bhPZnpqPJZWAmhOofuA==", + "dev": true, + "peerDependencies": { + "react": ">16.8.0", + "react-dom": ">16.8.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", diff --git a/package.json b/package.json index 2aa4c5cd1..96553e134 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "devDependencies": { "@commitlint/cli": "^19.3.0", "@commitlint/config-conventional": "^19.2.2", + "@ebay/nice-modal-react": "^1.2.13", "@gravity-ui/browserslist-config": "^4.3.0", "@gravity-ui/eslint-config": "^3.2.0", "@gravity-ui/prettier-config": "^1.1.0", diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.scss b/src/components/ConfirmationDialog/ConfirmationDialog.scss new file mode 100644 index 000000000..44fce835c --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialog.scss @@ -0,0 +1,5 @@ +.confirmation-dialog { + &__message { + white-space: pre-wrap; + } +} diff --git a/src/components/ConfirmationDialog/ConfirmationDialog.tsx b/src/components/ConfirmationDialog/ConfirmationDialog.tsx new file mode 100644 index 000000000..0b4f47e68 --- /dev/null +++ b/src/components/ConfirmationDialog/ConfirmationDialog.tsx @@ -0,0 +1,99 @@ +import * as NiceModal from '@ebay/nice-modal-react'; +import type {ButtonView} from '@gravity-ui/uikit'; +import {Dialog} from '@gravity-ui/uikit'; + +import {cn} from '../../utils/cn'; + +import {confirmationDialogKeyset} from './i18n'; + +import './ConfirmationDialog.scss'; + +const block = cn('confirmation-dialog'); + +interface CommonDialogProps { + caption?: string; + message?: React.ReactNode; + body?: React.ReactNode; + + progress?: boolean; + textButtonCancel?: string; + textButtonApply?: string; + buttonApplyView?: ButtonView; + className?: string; + onConfirm?: () => void; +} + +interface ConfirmationDialogNiceModalProps extends CommonDialogProps { + onClose?: () => void; +} + +interface ConfirmationDialogProps extends CommonDialogProps { + onClose: () => void; + open: boolean; + children?: React.ReactNode; +} + +export const CONFIRMATION_DIALOG = 'confirmation-dialog'; +function ConfirmationDialog({ + caption = '', + children, + onConfirm, + onClose, + progress, + textButtonApply, + textButtonCancel, + buttonApplyView = 'normal', + className, + open, +}: ConfirmationDialogProps) { + return ( + + + {children} + + + ); +} + +export const ConfirmationDialogNiceModal = NiceModal.create( + (props: ConfirmationDialogNiceModalProps) => { + const modal = NiceModal.useModal(); + + const handleClose = () => { + modal.hide(); + modal.remove(); + }; + + return ( + { + await props.onConfirm?.(); + modal.resolve(true); + handleClose(); + }} + onClose={() => { + props.onClose?.(); + modal.resolve(false); + handleClose(); + }} + open={modal.visible} + /> + ); + }, +); + +NiceModal.register(CONFIRMATION_DIALOG, ConfirmationDialogNiceModal); diff --git a/src/components/ConfirmationDialog/i18n/en.json b/src/components/ConfirmationDialog/i18n/en.json new file mode 100644 index 000000000..88c90714e --- /dev/null +++ b/src/components/ConfirmationDialog/i18n/en.json @@ -0,0 +1,3 @@ +{ + "action_cancel": "Cancel" +} diff --git a/src/components/ConfirmationDialog/i18n/index.ts b/src/components/ConfirmationDialog/i18n/index.ts new file mode 100644 index 000000000..423b49075 --- /dev/null +++ b/src/components/ConfirmationDialog/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../utils/i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-confirmation-dialog'; + +export const confirmationDialogKeyset = registerKeysets(COMPONENT, {en}); diff --git a/src/containers/App/Providers.tsx b/src/containers/App/Providers.tsx index 3193b76c7..400d1fd18 100644 --- a/src/containers/App/Providers.tsx +++ b/src/containers/App/Providers.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import * as NiceModal from '@ebay/nice-modal-react'; import {ThemeProvider} from '@gravity-ui/uikit'; import type {Store} from '@reduxjs/toolkit'; import type {History} from 'history'; @@ -34,9 +35,11 @@ export function Providers({ - - {children} - + + + {children} + + diff --git a/src/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/TopQueries.tsx b/src/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/TopQueries.tsx index c72044c30..be722ce39 100644 --- a/src/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/TopQueries.tsx +++ b/src/containers/Tenant/Diagnostics/TenantOverview/TenantCpu/TopQueries.tsx @@ -15,6 +15,7 @@ import { TENANT_QUERY_TABS_ID, } from '../../../../../store/reducers/tenant/constants'; import {useAutoRefreshInterval, useTypedDispatch} from '../../../../../utils/hooks'; +import {useChangeInputWithConfirmation} from '../../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {parseQueryErrorToString} from '../../../../../utils/query'; import {TenantTabsGroups, getTenantPath} from '../../../TenantPages'; import { @@ -48,7 +49,7 @@ export function TopQueries({tenantName}: TopQueriesProps) { const loading = isFetching && currentData === undefined; const data = currentData?.resultSets?.[0]?.result || []; - const handleRowClick = React.useCallback( + const applyRowClick = React.useCallback( (row: any) => { const {QueryText: input} = row; @@ -67,6 +68,8 @@ export function TopQueries({tenantName}: TopQueriesProps) { [dispatch, history, location], ); + const handleRowClick = useChangeInputWithConfirmation(applyRowClick); + const title = getSectionTitle({ entity: i18n('queries'), postfix: i18n('by-cpu-time', {executionPeriod: i18n('executed-last-hour')}), diff --git a/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx b/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx index 65e90ca34..9d5fd19ad 100644 --- a/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx +++ b/src/containers/Tenant/Diagnostics/TopQueries/TopQueries.tsx @@ -20,6 +20,7 @@ import { } from '../../../../store/reducers/tenant/constants'; import {cn} from '../../../../utils/cn'; import {useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; +import {useChangeInputWithConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {TenantTabsGroups, getTenantPath} from '../../TenantPages'; import {RunningQueriesData} from './RunningQueriesData'; @@ -68,7 +69,7 @@ export const TopQueries = ({tenantName}: TopQueriesProps) => { const filters = useTypedSelector((state) => state.executeTopQueries); - const onRowClick = React.useCallback( + const applyRowClick = React.useCallback( (input: string) => { dispatch(changeUserInput({input})); @@ -85,6 +86,8 @@ export const TopQueries = ({tenantName}: TopQueriesProps) => { [dispatch, history, location], ); + const onRowClick = useChangeInputWithConfirmation(applyRowClick); + const handleTextSearchUpdate = (text: string) => { dispatch(setTopQueriesFilters({text})); }; diff --git a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx index 01cc42db9..5a7214a70 100644 --- a/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx +++ b/src/containers/Tenant/ObjectSummary/SchemaTree/SchemaTree.tsx @@ -6,13 +6,19 @@ import React from 'react'; import {NavigationTree} from 'ydb-ui-components'; import {useCreateDirectoryFeatureAvailable} from '../../../../store/reducers/capabilities/hooks'; +import {selectUserInput} from '../../../../store/reducers/executeQuery'; import {schemaApi} from '../../../../store/reducers/schema/schema'; import {tableSchemaDataApi} from '../../../../store/reducers/tableSchemaData'; import type {GetTableSchemaDataParams} from '../../../../store/reducers/tableSchemaData'; import type {EPathType, TEvDescribeSchemeResult} from '../../../../types/api/schema'; import {wait} from '../../../../utils'; import {SECOND_IN_MS} from '../../../../utils/constants'; -import {useQueryExecutionSettings, useTypedDispatch} from '../../../../utils/hooks'; +import { + useQueryExecutionSettings, + useTypedDispatch, + useTypedSelector, +} from '../../../../utils/hooks'; +import {getConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {getSchemaControls} from '../../utils/controls'; import {isChildlessPathType, mapPathTypeToNavigationTreeType} from '../../utils/schema'; import {getActions} from '../../utils/schemaActions'; @@ -33,6 +39,7 @@ export function SchemaTree(props: SchemaTreeProps) { const createDirectoryFeatureAvailable = useCreateDirectoryFeatureAvailable(); const {rootPath, rootName, rootType, currentPath, onActivePathUpdate} = props; const dispatch = useTypedDispatch(); + const input = useTypedSelector(selectUserInput); const [getTableSchemaDataMutation] = tableSchemaDataApi.useGetTableSchemaDataMutation(); const getTableSchemaDataPromise = React.useCallback( @@ -144,6 +151,7 @@ export function SchemaTree(props: SchemaTreeProps) { ? handleOpenCreateDirectoryDialog : undefined, getTableSchemaDataPromise, + getConfirmation: input ? getConfirmation : undefined, }, rootPath, )} diff --git a/src/containers/Tenant/Query/NewSQL/NewSQL.tsx b/src/containers/Tenant/Query/NewSQL/NewSQL.tsx index cfc12c154..51963b245 100644 --- a/src/containers/Tenant/Query/NewSQL/NewSQL.tsx +++ b/src/containers/Tenant/Query/NewSQL/NewSQL.tsx @@ -1,14 +1,28 @@ +import React from 'react'; + import {ChevronDown} from '@gravity-ui/icons'; import {Button, DropdownMenu} from '@gravity-ui/uikit'; +import {changeUserInput} from '../../../../store/reducers/executeQuery'; import {useTypedDispatch} from '../../../../utils/hooks'; +import {useChangeInputWithConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {bindActions} from '../../utils/newSQLQueryActions'; import i18n from './i18n'; export function NewSQL() { const dispatch = useTypedDispatch(); - const actions = bindActions(dispatch); + + const insertTemplate = React.useCallback( + (input: string) => { + dispatch(changeUserInput({input})); + }, + [dispatch], + ); + + const onTemplateClick = useChangeInputWithConfirmation(insertTemplate); + + const actions = bindActions(onTemplateClick); const items = [ { diff --git a/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx b/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx index 6b611a2bb..fdf7d3271 100644 --- a/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx +++ b/src/containers/Tenant/Query/QueriesHistory/QueriesHistory.tsx @@ -15,6 +15,7 @@ import type {QueryInHistory} from '../../../../types/store/executeQuery'; import {cn} from '../../../../utils/cn'; import {formatDateTime} from '../../../../utils/dataFormatters/dataFormatters'; import {useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; +import {useChangeInputWithConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {formatToMs, parseUsToMs} from '../../../../utils/timeParsers'; import {MAX_QUERY_HEIGHT, QUERY_TABLE_SETTINGS} from '../../utils/constants'; import i18n from '../i18n'; @@ -36,11 +37,13 @@ function QueriesHistory({changeUserInput}: QueriesHistoryProps) { const filter = useTypedSelector(selectQueriesHistoryFilter); const reversedHistory = [...queriesHistory].reverse(); - const onQueryClick = (query: QueryInHistory) => { + const applyQueryClick = (query: QueryInHistory) => { changeUserInput({input: query.queryText}); dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery)); }; + const onQueryClick = useChangeInputWithConfirmation(applyQueryClick); + const onChangeFilter = (value: string) => { dispatch(setQueryHistoryFilter(value)); }; diff --git a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx index eb188451e..2e365bf6d 100644 --- a/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx +++ b/src/containers/Tenant/Query/QueryEditor/QueryEditor.tsx @@ -3,27 +3,29 @@ import React from 'react'; import {isEqual} from 'lodash'; import throttle from 'lodash/throttle'; import type Monaco from 'monaco-editor'; -import {connect} from 'react-redux'; import {v4 as uuidv4} from 'uuid'; import {MonacoEditor} from '../../../../components/MonacoEditor/MonacoEditor'; import SplitPane from '../../../../components/SplitPane'; -import type {RootState} from '../../../../store'; import {useTracingLevelOptionAvailable} from '../../../../store/reducers/capabilities/hooks'; import { executeQueryApi, goToNextQuery, goToPreviousQuery, saveQueryToHistory, - setQueryResult, + selectQueriesHistory, + selectQueriesHistoryCurrentIndex, + selectResult, + selectTenantPath, + selectUserInput, setTenantPath, } from '../../../../store/reducers/executeQuery'; import {explainQueryApi} from '../../../../store/reducers/explainQuery/explainQuery'; import {setQueryAction} from '../../../../store/reducers/queryActions/queryActions'; -import {setShowPreview} from '../../../../store/reducers/schema/schema'; +import {selectShowPreview, setShowPreview} from '../../../../store/reducers/schema/schema'; import type {EPathType} from '../../../../types/api/schema'; import {ResultType} from '../../../../types/store/executeQuery'; -import type {ExecuteQueryState, QueryResult} from '../../../../types/store/executeQuery'; +import type {QueryResult} from '../../../../types/store/executeQuery'; import type {QueryAction} from '../../../../types/store/query'; import {cn} from '../../../../utils/cn'; import { @@ -31,7 +33,13 @@ import { DEFAULT_SIZE_RESULT_PANE_KEY, LAST_USED_QUERY_ACTION_KEY, } from '../../../../utils/constants'; -import {useEventHandler, useQueryExecutionSettings, useSetting} from '../../../../utils/hooks'; +import { + useEventHandler, + useQueryExecutionSettings, + useSetting, + useTypedDispatch, + useTypedSelector, +} from '../../../../utils/hooks'; import {useChangedQuerySettings} from '../../../../utils/hooks/useChangedQuerySettings'; import {useLastQueryExecutionSettings} from '../../../../utils/hooks/useLastQueryExecutionSettings'; import {YQL_LANGUAGE_ID} from '../../../../utils/monaco/constats'; @@ -68,35 +76,22 @@ interface QueryEditorProps { tenantName: string; path: string; changeUserInput: (arg: {input: string}) => void; - goToNextQuery: (...args: Parameters) => void; - goToPreviousQuery: (...args: Parameters) => void; - setTenantPath: (...args: Parameters) => void; - setQueryAction: (...args: Parameters) => void; - setQueryResult: (...args: Parameters) => void; - executeQuery: ExecuteQueryState; theme: string; type?: EPathType; - showPreview: boolean; - setShowPreview: (...args: Parameters) => void; - saveQueryToHistory: (...args: Parameters) => void; } -function QueryEditor(props: QueryEditorProps) { +export default function QueryEditor(props: QueryEditorProps) { const editorOptions = useEditorOptions(); - const { - tenantName, - path, - setTenantPath: setPath, - executeQuery, - type, - theme, - changeUserInput, - setQueryResult, - showPreview, - } = props; - const {tenantPath: savedPath} = executeQuery; - - const isResultLoaded = Boolean(executeQuery.result); + const dispatch = useTypedDispatch(); + const {tenantName, path, type, theme, changeUserInput} = props; + const savedPath = useTypedSelector(selectTenantPath); + const result = useTypedSelector(selectResult); + const historyQueries = useTypedSelector(selectQueriesHistory); + const historyCurrentIndex = useTypedSelector(selectQueriesHistoryCurrentIndex); + const input = useTypedSelector(selectUserInput); + const showPreview = useTypedSelector(selectShowPreview); + + const isResultLoaded = Boolean(result); const [querySettings] = useQueryExecutionSettings(); const enableTracingLevel = useTracingLevelOptionAvailable(); @@ -113,13 +108,9 @@ function QueryEditor(props: QueryEditorProps) { React.useEffect(() => { if (savedPath !== tenantName) { - if (savedPath) { - changeUserInput({input: ''}); - setQueryResult(); - } - setPath(tenantName); + dispatch(setTenantPath(tenantName)); } - }, [changeUserInput, setPath, setQueryResult, tenantName, savedPath]); + }, [dispatch, tenantName, savedPath]); const [resultVisibilityState, dispatchResultVisibilityState] = React.useReducer( paneVisibilityToggleReducerCreator(DEFAULT_IS_QUERY_RESULT_COLLAPSED), @@ -131,21 +122,21 @@ function QueryEditor(props: QueryEditorProps) { }, []); React.useEffect(() => { - if (props.showPreview || isResultLoaded) { + if (showPreview || isResultLoaded) { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); } else { dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerCollapse); } - }, [props.showPreview, isResultLoaded]); + }, [showPreview, isResultLoaded]); const getLastQueryText = useEventHandler(() => { - const {history} = executeQuery; - return history.queries[history.queries.length - 1]?.queryText || ''; + if (!historyQueries || historyQueries.length === 0) { + return ''; + } + return historyQueries[historyQueries.length - 1].queryText; }); const handleSendExecuteClick = useEventHandler((text?: string) => { - const {input, history} = executeQuery; - const query = text ?? input; setLastUsedQueryAction(QUERY_ACTIONS.execute); @@ -163,25 +154,22 @@ function QueryEditor(props: QueryEditorProps) { queryId, }); - props.setShowPreview(false); + dispatch(setShowPreview(false)); // Don't save partial queries in history if (!text) { - const {queries, currentIndex} = history; - if (query !== queries[currentIndex]?.queryText) { - props.saveQueryToHistory(input, queryId); + if (query !== historyQueries[historyCurrentIndex]?.queryText) { + dispatch(saveQueryToHistory({queryText: input, queryId})); } } dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); }); const handleSettingsClick = () => { - props.setQueryAction('settings'); + dispatch(setQueryAction('settings')); }; const handleGetExplainQueryClick = useEventHandler(() => { - const {input} = executeQuery; - setLastUsedQueryAction(QUERY_ACTIONS.explain); if (!isEqual(lastQueryExecutionSettings, querySettings)) { @@ -199,7 +187,7 @@ function QueryEditor(props: QueryEditorProps) { queryId, }); - props.setShowPreview(false); + dispatch(setShowPreview(false)); dispatchResultVisibilityState(PaneVisibilityActionTypes.triggerExpand); }); @@ -269,7 +257,7 @@ function QueryEditor(props: QueryEditorProps) { contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 2, run: () => { - props.goToPreviousQuery(); + dispatch(goToPreviousQuery()); }, }); editor.addAction({ @@ -279,7 +267,7 @@ function QueryEditor(props: QueryEditorProps) { contextMenuGroupId: CONTEXT_MENU_GROUP_ID, contextMenuOrder: 3, run: () => { - props.goToNextQuery(); + dispatch(goToNextQuery()); }, }); editor.addAction({ @@ -287,13 +275,13 @@ function QueryEditor(props: QueryEditorProps) { label: i18n('action.save-query'), keybindings: [keybindings.saveQuery], run: () => { - props.setQueryAction('save'); + dispatch(setQueryAction('save')); }, }); }; const onChange = (newValue: string) => { - props.changeUserInput({input: newValue}); + changeUserInput({input: newValue}); }; const onCollapseResultHandler = () => { @@ -312,9 +300,9 @@ function QueryEditor(props: QueryEditorProps) { ); @@ -340,7 +328,7 @@ function QueryEditor(props: QueryEditorProps) {
{ - return { - executeQuery: state.executeQuery, - showPreview: state.schema.showPreview, - }; -}; - -const mapDispatchToProps = { - saveQueryToHistory, - goToPreviousQuery, - goToNextQuery, - setShowPreview, - setTenantPath, - setQueryAction, - setQueryResult, -}; - -export default connect(mapStateToProps, mapDispatchToProps)(QueryEditor); - interface ResultProps { resultVisibilityState: InitialPaneState; onExpandResultHandler: VoidFunction; diff --git a/src/containers/Tenant/Query/SavedQueries/SavedQueries.tsx b/src/containers/Tenant/Query/SavedQueries/SavedQueries.tsx index b641ca842..6f91e8746 100644 --- a/src/containers/Tenant/Query/SavedQueries/SavedQueries.tsx +++ b/src/containers/Tenant/Query/SavedQueries/SavedQueries.tsx @@ -20,6 +20,7 @@ import {setQueryTab} from '../../../../store/reducers/tenant/tenant'; import type {SavedQuery} from '../../../../types/store/query'; import {cn} from '../../../../utils/cn'; import {useTypedDispatch, useTypedSelector} from '../../../../utils/hooks'; +import {useChangeInputWithConfirmation} from '../../../../utils/hooks/withConfirmation/useChangeInputWithConfirmation'; import {MAX_QUERY_HEIGHT, QUERY_TABLE_SETTINGS} from '../../utils/constants'; import i18n from '../i18n'; import {useSavedQueries} from '../utils/useSavedQueries'; @@ -88,11 +89,16 @@ export const SavedQueries = ({changeUserInput}: SavedQueriesProps) => { setQueryNameToDelete(''); }; - const onQueryClick = (queryText: string, queryName: string) => { - changeUserInput({input: queryText}); - dispatch(setQueryNameToEdit(queryName)); - dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery)); - }; + const applyQueryClick = React.useCallback( + ({queryText, queryName}: {queryText: string; queryName: string}) => { + changeUserInput({input: queryText}); + dispatch(setQueryNameToEdit(queryName)); + dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery)); + }, + [changeUserInput, dispatch], + ); + + const onQueryClick = useChangeInputWithConfirmation(applyQueryClick); const onDeleteQueryClick = (queryName: string) => { return (event: React.MouseEvent) => { @@ -154,7 +160,12 @@ export const SavedQueries = ({changeUserInput}: SavedQueriesProps) => { settings={QUERY_TABLE_SETTINGS} emptyDataMessage={i18n(filter ? 'history.empty-search' : 'saved.empty')} rowClassName={() => b('row')} - onRowClick={(row) => onQueryClick(row.body, row.name)} + onRowClick={(row) => + onQueryClick({ + queryText: row.body, + queryName: row.name, + }) + } initialSortOrder={{ columnId: 'name', order: DataTable.ASCENDING, diff --git a/src/containers/Tenant/utils/newSQLQueryActions.ts b/src/containers/Tenant/utils/newSQLQueryActions.ts index 3898160ca..0eb21fd0a 100644 --- a/src/containers/Tenant/utils/newSQLQueryActions.ts +++ b/src/containers/Tenant/utils/newSQLQueryActions.ts @@ -1,5 +1,3 @@ -import {changeUserInput} from '../../../store/reducers/executeQuery'; - import { addTableIndex, alterAsyncReplicationTemplate, @@ -29,9 +27,9 @@ import { upsertQueryTemplate, } from './schemaQueryTemplates'; -export const bindActions = (dispatch: React.Dispatch) => { +export const bindActions = (changeUserInput: (input: string) => void) => { const inputQuery = (query: () => string) => () => { - dispatch(changeUserInput({input: query()})); + changeUserInput(query()); }; return { diff --git a/src/containers/Tenant/utils/schemaActions.ts b/src/containers/Tenant/utils/schemaActions.ts index 8fd4f2f15..40c948d3c 100644 --- a/src/containers/Tenant/utils/schemaActions.ts +++ b/src/containers/Tenant/utils/schemaActions.ts @@ -43,6 +43,7 @@ interface ActionsAdditionalEffects { getTableSchemaDataPromise?: ( params: GetTableSchemaDataParams, ) => Promise; + getConfirmation?: () => Promise; } interface BindActionParams { @@ -57,28 +58,41 @@ const bindActions = ( dispatch: AppDispatch, additionalEffects: ActionsAdditionalEffects, ) => { - const {setActivePath, showCreateDirectoryDialog, getTableSchemaDataPromise} = additionalEffects; + const {setActivePath, showCreateDirectoryDialog, getTableSchemaDataPromise, getConfirmation} = + additionalEffects; const inputQuery = (tmpl: TemplateFn) => () => { - const pathType = nodeTableTypeToPathType[params.type]; - const withTableData = [selectQueryTemplate, upsertQueryTemplate].includes(tmpl); - - const userInputDataPromise = - withTableData && pathType && getTableSchemaDataPromise - ? getTableSchemaDataPromise({ - path: params.path, - tenantName: params.tenantName, - type: pathType, - }) - : Promise.resolve(undefined); - - userInputDataPromise.then((tableData) => { - dispatch(changeUserInput({input: tmpl({...params, tableData})})); - }); - - dispatch(setTenantPage(TENANT_PAGES_IDS.query)); - dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery)); - setActivePath(params.path); + const applyInsert = () => { + const pathType = nodeTableTypeToPathType[params.type]; + const withTableData = [selectQueryTemplate, upsertQueryTemplate].includes(tmpl); + + const userInputDataPromise = + withTableData && pathType && getTableSchemaDataPromise + ? getTableSchemaDataPromise({ + path: params.path, + tenantName: params.tenantName, + type: pathType, + }) + : Promise.resolve(undefined); + + userInputDataPromise.then((tableData) => { + dispatch(changeUserInput({input: tmpl({...params, tableData})})); + }); + + dispatch(setTenantPage(TENANT_PAGES_IDS.query)); + dispatch(setQueryTab(TENANT_QUERY_TABS_ID.newQuery)); + setActivePath(params.path); + }; + if (getConfirmation) { + const confirmedPromise = getConfirmation(); + confirmedPromise.then((confirmed) => { + if (confirmed) { + applyInsert(); + } + }); + } else { + applyInsert(); + } }; return { diff --git a/src/store/reducers/executeQuery.ts b/src/store/reducers/executeQuery.ts index b58376567..4c92b919d 100644 --- a/src/store/reducers/executeQuery.ts +++ b/src/store/reducers/executeQuery.ts @@ -1,16 +1,11 @@ -import type {Reducer} from '@reduxjs/toolkit'; +import {createSlice} from '@reduxjs/toolkit'; +import type {PayloadAction} from '@reduxjs/toolkit'; import {settingsManager} from '../../services/settings'; import {TracingLevelNumber} from '../../types/api/query'; import type {ExecuteActions} from '../../types/api/query'; import {ResultType} from '../../types/store/executeQuery'; -import type { - ExecuteQueryAction, - ExecuteQueryState, - ExecuteQueryStateSlice, - QueryInHistory, - QueryResult, -} from '../../types/store/executeQuery'; +import type {ExecuteQueryState, QueryInHistory, QueryResult} from '../../types/store/executeQuery'; import type {QueryRequestParams, QuerySettings, QuerySyntax} from '../../types/store/query'; import {QUERIES_HISTORY_KEY} from '../../utils/constants'; import {QUERY_SYNTAX, isQueryErrorResponse, parseQueryAPIExecuteResponse} from '../../utils/query'; @@ -20,16 +15,6 @@ import {api} from './api'; const MAXIMUM_QUERIES_IN_HISTORY = 20; -const CHANGE_USER_INPUT = 'query/CHANGE_USER_INPUT'; -const SET_QUERY_RESULT = 'query/SET_QUERY_RESULT'; -const SET_QUERY_TRACE_READY = 'query/SET_QUERY_TRACE_READY'; -const SAVE_QUERY_TO_HISTORY = 'query/SAVE_QUERY_TO_HISTORY'; -const UPDATE_QUERY_IN_HISTORY = 'query/UPDATE_QUERY_IN_HISTORY'; -const SET_QUERY_HISTORY_FILTER = 'query/SET_QUERY_HISTORY_FILTER'; -const GO_TO_PREVIOUS_QUERY = 'query/GO_TO_PREVIOUS_QUERY'; -const GO_TO_NEXT_QUERY = 'query/GO_TO_NEXT_QUERY'; -const SET_TENANT_PATH = 'query/SET_TENANT_PATH'; - const queriesHistoryInitial = settingsManager.readUserSettingsValue( QUERIES_HISTORY_KEY, [], @@ -37,8 +22,7 @@ const queriesHistoryInitial = settingsManager.readUserSettingsValue( const sliceLimit = queriesHistoryInitial.length - MAXIMUM_QUERIES_IN_HISTORY; -const initialState = { - loading: false, +const initialState: ExecuteQueryState = { input: '', history: { queries: queriesHistoryInitial @@ -52,41 +36,26 @@ const initialState = { }, }; -const executeQuery: Reducer = ( - state = initialState, - action, -) => { - switch (action.type) { - case CHANGE_USER_INPUT: { - return { - ...state, - input: action.data.input, - }; - } - - case SET_QUERY_TRACE_READY: { +const slice = createSlice({ + name: 'executeQuery', + initialState, + reducers: { + changeUserInput: (state, action: PayloadAction<{input: string}>) => { + state.input = action.payload.input; + }, + setQueryTraceReady: (state) => { if (state.result) { - return { - ...state, - result: { - ...state.result, - isTraceReady: true, - }, - }; + state.result.isTraceReady = true; } - - return state; - } - - case SET_QUERY_RESULT: { - return { - ...state, - result: action.data, - }; - } - - case SAVE_QUERY_TO_HISTORY: { - const {queryText, queryId} = action.data; + }, + setQueryResult: (state, action: PayloadAction) => { + state.result = action.payload; + }, + saveQueryToHistory: ( + state, + action: PayloadAction<{queryText: string; queryId: string}>, + ) => { + const {queryText, queryId} = action.payload; const newQueries = [...state.history.queries, {queryText, queryId}].slice( state.history.queries.length >= MAXIMUM_QUERIES_IN_HISTORY ? 1 : 0, @@ -94,26 +63,25 @@ const executeQuery: Reducer = ( settingsManager.setUserSettingsValue(QUERIES_HISTORY_KEY, newQueries); const currentIndex = newQueries.length - 1; - return { - ...state, - history: { - queries: newQueries, - currentIndex, - }, + state.history = { + queries: newQueries, + currentIndex, }; - } - - case UPDATE_QUERY_IN_HISTORY: { - const {queryId, stats} = action.data; + }, + updateQueryInHistory: ( + state, + action: PayloadAction<{queryId: string; stats: QueryStats}>, + ) => { + const {queryId, stats} = action.payload; if (!stats) { - return state; + return; } const index = state.history.queries.findIndex((item) => item.queryId === queryId); if (index === -1) { - return state; + return; } const newQueries = [...state.history.queries]; @@ -126,73 +94,72 @@ const executeQuery: Reducer = ( settingsManager.setUserSettingsValue(QUERIES_HISTORY_KEY, newQueries); - return { - ...state, - history: { - ...state.history, - queries: newQueries, - }, - }; - } - - case GO_TO_PREVIOUS_QUERY: { + state.history.queries = newQueries; + }, + goToPreviousQuery: (state) => { const currentIndex = state.history.currentIndex; if (currentIndex <= 0) { - return state; + return; } const newCurrentIndex = currentIndex - 1; const query = state.history.queries[newCurrentIndex]; - - return { - ...state, - history: { - ...state.history, - currentIndex: newCurrentIndex, - }, - input: query.queryText, - }; - } - - case GO_TO_NEXT_QUERY: { - const lastIndexInHistory = state.history.queries.length - 1; + state.input = query.queryText; + state.history.currentIndex = newCurrentIndex; + }, + goToNextQuery: (state) => { const currentIndex = state.history.currentIndex; - if (currentIndex >= lastIndexInHistory) { - return state; + if (currentIndex >= state.history.queries.length - 1) { + return; } const newCurrentIndex = currentIndex + 1; const query = state.history.queries[newCurrentIndex]; + state.input = query.queryText; + state.history.currentIndex = newCurrentIndex; + }, + setTenantPath: (state, action: PayloadAction) => { + state.tenantPath = action.payload; + }, + setQueryHistoryFilter: (state, action: PayloadAction) => { + state.history.filter = action.payload; + }, + }, + selectors: { + selectQueriesHistoryFilter: (state) => state.history.filter || '', + selectTenantPath: (state) => state.tenantPath, + selectResult: (state) => state.result, + selectQueriesHistory: (state) => { + const items = state.history.queries; + const filter = state.history.filter?.toLowerCase(); + + return filter + ? items.filter((item) => item.queryText.toLowerCase().includes(filter)) + : items; + }, + selectUserInput: (state) => state.input, + selectQueriesHistoryCurrentIndex: (state) => state.history?.currentIndex, + }, +}); - return { - ...state, - history: { - ...state.history, - currentIndex: newCurrentIndex, - }, - input: query.queryText, - }; - } - - case SET_TENANT_PATH: { - return { - ...state, - tenantPath: action.data, - }; - } - - case SET_QUERY_HISTORY_FILTER: { - return { - ...state, - history: { - ...state.history, - filter: action.data.filter, - }, - }; - } - - default: - return state; - } -}; +export default slice.reducer; +export const { + changeUserInput, + setQueryTraceReady, + setQueryResult, + saveQueryToHistory, + updateQueryInHistory, + goToPreviousQuery, + goToNextQuery, + setTenantPath, + setQueryHistoryFilter, +} = slice.actions; +export const { + selectQueriesHistoryFilter, + selectQueriesHistoryCurrentIndex, + selectQueriesHistory, + selectTenantPath, + selectResult, + selectUserInput, +} = slice.selectors; interface SendQueryParams extends QueryRequestParams { queryId: string; @@ -280,7 +247,7 @@ export const executeQueryApi = api.injectEndpoints({ queryStats.endTime = now; } - dispatch(updateQueryInHistory(queryStats, queryId)); + dispatch(updateQueryInHistory({stats: queryStats, queryId})); dispatch( setQueryResult({ type: ResultType.EXECUTE, @@ -307,70 +274,6 @@ export const executeQueryApi = api.injectEndpoints({ overrideExisting: 'throw', }); -export const saveQueryToHistory = (queryText: string, queryId: string) => { - return { - type: SAVE_QUERY_TO_HISTORY, - data: {queryText, queryId}, - } as const; -}; - -export function updateQueryInHistory(stats: QueryStats, queryId: string) { - return { - type: UPDATE_QUERY_IN_HISTORY, - data: {queryId, stats}, - } as const; -} - -export function setQueryResult(data?: QueryResult) { - return { - type: SET_QUERY_RESULT, - data, - } as const; -} - -export function setQueryTraceReady() { - return { - type: SET_QUERY_TRACE_READY, - } as const; -} - -export const goToPreviousQuery = () => { - return { - type: GO_TO_PREVIOUS_QUERY, - } as const; -}; - -export const goToNextQuery = () => { - return { - type: GO_TO_NEXT_QUERY, - } as const; -}; - -export const changeUserInput = ({input}: {input: string}) => { - return { - type: CHANGE_USER_INPUT, - data: {input}, - } as const; -}; - -export const setTenantPath = (value: string) => { - return { - type: SET_TENANT_PATH, - data: value, - } as const; -}; - -export const selectQueriesHistoryFilter = (state: ExecuteQueryStateSlice): string => { - return state.executeQuery.history.filter || ''; -}; - -export const selectQueriesHistory = (state: ExecuteQueryStateSlice): QueryInHistory[] => { - const items = state.executeQuery.history.queries; - const filter = state.executeQuery.history.filter?.toLowerCase(); - - return filter ? items.filter((item) => item.queryText.toLowerCase().includes(filter)) : items; -}; - function getQueryInHistory(rawQuery: string | QueryInHistory) { if (typeof rawQuery === 'string') { return { @@ -379,12 +282,3 @@ function getQueryInHistory(rawQuery: string | QueryInHistory) { } return rawQuery; } - -export const setQueryHistoryFilter = (filter: string) => { - return { - type: SET_QUERY_HISTORY_FILTER, - data: {filter}, - } as const; -}; - -export default executeQuery; diff --git a/src/store/reducers/queryActions/queryActions.ts b/src/store/reducers/queryActions/queryActions.ts index 4dea0013b..dd661f8b2 100644 --- a/src/store/reducers/queryActions/queryActions.ts +++ b/src/store/reducers/queryActions/queryActions.ts @@ -14,7 +14,7 @@ const initialState: QueryActionsState = { savedQueriesFilter: '', }; -export const slice = createSlice({ +const slice = createSlice({ name: 'queryActions', initialState, reducers: { diff --git a/src/store/reducers/schema/schema.ts b/src/store/reducers/schema/schema.ts index 1b07c12e5..d67f77174 100644 --- a/src/store/reducers/schema/schema.ts +++ b/src/store/reducers/schema/schema.ts @@ -1,14 +1,11 @@ import React from 'react'; -import type {Reducer} from '@reduxjs/toolkit'; +import {createSlice} from '@reduxjs/toolkit'; +import type {PayloadAction} from '@reduxjs/toolkit'; import type {TEvDescribeSchemeResult} from '../../../types/api/schema'; import {api} from '../api'; -import type {SchemaAction, SchemaState} from './types'; - -const SET_SHOW_PREVIEW = 'schema/SET_SHOW_PREVIEW'; - export const initialState = { loading: true, data: {}, @@ -16,27 +13,22 @@ export const initialState = { showPreview: false, }; -const schema: Reducer = (state = initialState, action) => { - switch (action.type) { - case SET_SHOW_PREVIEW: { - return { - ...state, - showPreview: action.data, - }; - } - default: - return state; - } -}; - -export function setShowPreview(value: boolean) { - return { - type: SET_SHOW_PREVIEW, - data: value, - } as const; -} +const slice = createSlice({ + name: 'schema', + initialState, + reducers: { + setShowPreview: (state, action: PayloadAction) => { + state.showPreview = action.payload; + }, + }, + selectors: { + selectShowPreview: (state) => state.showPreview, + }, +}); -export default schema; +export default slice.reducer; +export const {setShowPreview} = slice.actions; +export const {selectShowPreview} = slice.selectors; export const schemaApi = api.injectEndpoints({ endpoints: (builder) => ({ diff --git a/src/types/store/executeQuery.ts b/src/types/store/executeQuery.ts index 391a20ae3..b8c3a0f63 100644 --- a/src/types/store/executeQuery.ts +++ b/src/types/store/executeQuery.ts @@ -1,14 +1,3 @@ -import type { - changeUserInput, - goToNextQuery, - goToPreviousQuery, - saveQueryToHistory, - setQueryHistoryFilter, - setQueryResult, - setQueryTraceReady, - setTenantPath, - updateQueryInHistory, -} from '../../store/reducers/executeQuery'; import type {PreparedExplainResponse} from '../../store/reducers/explainQuery/types'; import type {IQueryResult} from './query'; @@ -48,7 +37,7 @@ export type QueryResult = ExecuteQueryResult | ExplainQueryResult; export interface ExecuteQueryState { input: string; - result?: QueryResult; + result?: QueryResult & {isTraceReady?: boolean}; history: { // String type for backward compatibility queries: QueryInHistory[]; @@ -57,18 +46,3 @@ export interface ExecuteQueryState { }; tenantPath?: string; } - -export type ExecuteQueryAction = - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType - | ReturnType; - -export interface ExecuteQueryStateSlice { - executeQuery: ExecuteQueryState; -} diff --git a/src/utils/hooks/withConfirmation/i18n/en.json b/src/utils/hooks/withConfirmation/i18n/en.json new file mode 100644 index 000000000..266e9abde --- /dev/null +++ b/src/utils/hooks/withConfirmation/i18n/en.json @@ -0,0 +1,4 @@ +{ + "action_apply": "Proceed", + "context_unsaved-changes-warning": "You have unsaved changes in query editor. Do you want to proceed?" +} diff --git a/src/utils/hooks/withConfirmation/i18n/index.ts b/src/utils/hooks/withConfirmation/i18n/index.ts new file mode 100644 index 000000000..f5e852283 --- /dev/null +++ b/src/utils/hooks/withConfirmation/i18n/index.ts @@ -0,0 +1,7 @@ +import {registerKeysets} from '../../../i18n'; + +import en from './en.json'; + +const COMPONENT = 'ydb-change-input-confirmation'; + +export default registerKeysets(COMPONENT, {en}); diff --git a/src/utils/hooks/withConfirmation/useChangeInputWithConfirmation.ts b/src/utils/hooks/withConfirmation/useChangeInputWithConfirmation.ts new file mode 100644 index 000000000..8ac958b89 --- /dev/null +++ b/src/utils/hooks/withConfirmation/useChangeInputWithConfirmation.ts @@ -0,0 +1,39 @@ +import React from 'react'; + +import NiceModal from '@ebay/nice-modal-react'; + +import {useTypedSelector} from '..'; +import {CONFIRMATION_DIALOG} from '../../../components/ConfirmationDialog/ConfirmationDialog'; +import {selectUserInput} from '../../../store/reducers/executeQuery'; + +import i18n from './i18n'; + +export async function getConfirmation(): Promise { + return await NiceModal.show(CONFIRMATION_DIALOG, { + id: CONFIRMATION_DIALOG, + caption: i18n('context_unsaved-changes-warning'), + textButtonApply: i18n('action_apply'), + }); +} + +export function changeInputWithConfirmation(callback: (args: T) => void) { + return async (args: T) => { + const confirmed = await getConfirmation(); + if (!confirmed) { + return; + } + callback(args); + }; +} + +export function useChangeInputWithConfirmation(callback: (args: T) => void) { + const userInput = useTypedSelector(selectUserInput); + const callbackWithConfirmation = React.useMemo( + () => changeInputWithConfirmation(callback), + [callback], + ); + if (!userInput) { + return callback; + } + return callbackWithConfirmation; +}