From 99ee99713be9165ffd2140e3cab29ec26758b70f Mon Sep 17 00:00:00 2001 From: Anton Standrik Date: Wed, 19 Feb 2025 14:24:13 +0300 Subject: [PATCH 1/7] feat: support multipart responses in query (#1865) --- config-overrides.js | 2 +- package-lock.json | 26 ++- package.json | 7 +- .../QueryResultTable/QueryResultTable.scss | 6 + .../QueryResultTable/QueryResultTable.tsx | 33 ++-- .../CancelQueryButton/CancelQueryButton.tsx | 22 +-- .../Tenant/Query/QueryEditor/QueryEditor.tsx | 76 +++++++-- .../Query/QueryResult/QueryResultViewer.tsx | 35 +++- .../QueryResultError/QueryResultError.tsx | 7 +- .../ResultSetsViewer/ResultSetsViewer.tsx | 31 ++-- .../Query/utils/isQueryCancelledError.ts | 30 +++- src/containers/UserSettings/i18n/en.json | 3 + src/containers/UserSettings/settings.tsx | 9 +- src/services/api/index.ts | 3 + src/services/api/streaming.ts | 102 ++++++++++++ src/services/api/viewer.ts | 18 +- src/services/settings.ts | 2 + src/setupTests.js | 7 +- src/store/reducers/capabilities/hooks.ts | 4 + src/store/reducers/query/preparePlanData.ts | 74 +++++++++ src/store/reducers/query/prepareQueryData.ts | 70 +------- src/store/reducers/query/query.ts | 104 ++++++++++++ src/store/reducers/query/streamingReducers.ts | 157 ++++++++++++++++++ src/store/reducers/query/utils.ts | 18 ++ src/types/api/query.ts | 20 ++- src/types/store/query.ts | 10 ++ src/types/store/streaming.ts | 54 ++++++ src/utils/constants.ts | 2 + src/utils/query.ts | 60 +++---- src/utils/response.ts | 3 +- 30 files changed, 807 insertions(+), 188 deletions(-) create mode 100644 src/services/api/streaming.ts create mode 100644 src/store/reducers/query/preparePlanData.ts create mode 100644 src/store/reducers/query/streamingReducers.ts create mode 100644 src/types/store/streaming.ts diff --git a/config-overrides.js b/config-overrides.js index a16b73a0f..98185f097 100644 --- a/config-overrides.js +++ b/config-overrides.js @@ -58,7 +58,7 @@ module.exports = { // By default jest does not transform anything in node_modules // So this override excludes node_modules except @gravity-ui // see https://github.com/timarney/react-app-rewired/issues/241 - config.transformIgnorePatterns = ['node_modules/(?!(@gravity-ui)/)']; + config.transformIgnorePatterns = ['node_modules/(?!(@gravity-ui|@mjackson)/)']; // Add .github directory to roots config.roots = ['/src', '/.github']; diff --git a/package-lock.json b/package-lock.json index 9333b345c..6e215962c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,12 +19,13 @@ "@gravity-ui/icons": "^2.12.0", "@gravity-ui/navigation": "^2.30.0", "@gravity-ui/paranoid": "^2.0.1", - "@gravity-ui/react-data-table": "^2.1.1", + "@gravity-ui/react-data-table": "^2.2.1", "@gravity-ui/table": "^1.7.0", "@gravity-ui/uikit": "^6.40.0", "@gravity-ui/unipika": "^5.2.1", "@gravity-ui/websql-autocomplete": "^13.7.0", "@hookform/resolvers": "^3.10.0", + "@mjackson/multipart-parser": "^0.8.2", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.20.6", "@ydb-platform/monaco-ghost": "^0.6.1", @@ -100,6 +101,7 @@ "typescript": "^5.7.3" }, "peerDependencies": { + "monaco-yql-languages": ">=1.3.0", "prop-types": "^15.8.1", "react": "^18.3.1", "react-dom": "^18.3.1" @@ -3189,9 +3191,10 @@ } }, "node_modules/@gravity-ui/react-data-table": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/@gravity-ui/react-data-table/-/react-data-table-2.1.1.tgz", - "integrity": "sha512-7h5Idn1hRrdjVr46xDfJ57I5Zu9n4biV8ytuYCKBoj2LpuH4t93VHLGy+E1CTx87gjoDLPCVI6FEGAI/iyQJ3Q==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/@gravity-ui/react-data-table/-/react-data-table-2.2.1.tgz", + "integrity": "sha512-wPegyv9InoeiiGdT5vbvrfV5eRcEq/fFK/X3pt+W2SnNrexiKDy+18nz94gqQTvp1iNhL9VQJlZs91e+mpQiiA==", + "license": "MIT", "dependencies": { "@bem-react/classname": ">=1.6.0", "react-list": "^0.8.17", @@ -4238,6 +4241,21 @@ "node-pre-gyp": "bin/node-pre-gyp" } }, + "node_modules/@mjackson/headers": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@mjackson/headers/-/headers-0.10.0.tgz", + "integrity": "sha512-U1Eu1gF979k7ZoIBsJyD+T5l9MjtPONsZfoXfktsQHPJD0s7SokBGx+tLKDLsOY+gzVYAWS0yRFDNY8cgbQzWQ==", + "license": "MIT" + }, + "node_modules/@mjackson/multipart-parser": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/@mjackson/multipart-parser/-/multipart-parser-0.8.2.tgz", + "integrity": "sha512-KltttyypazaJ9kD1GpiOTEop9/YA5aZPwKfpbmuMYoYSyJhQc+0pqaQcZSHUJVdJBvIWgx7TTQSDJdnNqP5dxA==", + "license": "MIT", + "dependencies": { + "@mjackson/headers": "^0.10.0" + } + }, "node_modules/@nicolo-ribaudo/eslint-scope-5-internals": { "version": "5.1.1-v1", "resolved": "https://registry.npmjs.org/@nicolo-ribaudo/eslint-scope-5-internals/-/eslint-scope-5-internals-5.1.1-v1.tgz", diff --git a/package.json b/package.json index d099b4b89..e5e9a9b8c 100644 --- a/package.json +++ b/package.json @@ -21,12 +21,13 @@ "@gravity-ui/icons": "^2.12.0", "@gravity-ui/navigation": "^2.30.0", "@gravity-ui/paranoid": "^2.0.1", - "@gravity-ui/react-data-table": "^2.1.1", + "@gravity-ui/react-data-table": "^2.2.1", "@gravity-ui/table": "^1.7.0", "@gravity-ui/uikit": "^6.40.0", "@gravity-ui/unipika": "^5.2.1", "@gravity-ui/websql-autocomplete": "^13.7.0", "@hookform/resolvers": "^3.10.0", + "@mjackson/multipart-parser": "^0.8.2", "@reduxjs/toolkit": "^2.5.0", "@tanstack/react-table": "^8.20.6", "@ydb-platform/monaco-ghost": "^0.6.1", @@ -163,10 +164,10 @@ "typescript": "^5.7.3" }, "peerDependencies": { + "monaco-yql-languages": ">=1.3.0", "prop-types": "^15.8.1", "react": "^18.3.1", - "react-dom": "^18.3.1", - "monaco-yql-languages": ">=1.3.0" + "react-dom": "^18.3.1" }, "overrides": { "fork-ts-checker-webpack-plugin": "^9.0.2", diff --git a/src/components/QueryResultTable/QueryResultTable.scss b/src/components/QueryResultTable/QueryResultTable.scss index 89129744d..ee984ff46 100644 --- a/src/components/QueryResultTable/QueryResultTable.scss +++ b/src/components/QueryResultTable/QueryResultTable.scss @@ -9,4 +9,10 @@ &__message { padding: 15px 10px; } + + // Must have fixed height for frequent data updates (to avoid interface shaking). + // Will be fixed after migration to new @gravity-ui/table. + &__table-wrapper { + height: 0px; + } } diff --git a/src/components/QueryResultTable/QueryResultTable.tsx b/src/components/QueryResultTable/QueryResultTable.tsx index 8319e46cb..a7d7c1f7f 100644 --- a/src/components/QueryResultTable/QueryResultTable.tsx +++ b/src/components/QueryResultTable/QueryResultTable.tsx @@ -7,7 +7,7 @@ import type {ColumnType, KeyValueRow} from '../../types/api/query'; import {cn} from '../../utils/cn'; import {DEFAULT_TABLE_SETTINGS} from '../../utils/constants'; import {getColumnWidth} from '../../utils/getColumnWidth'; -import {getColumnType, prepareQueryResponse} from '../../utils/query'; +import {getColumnType} from '../../utils/query'; import {isNumeric} from '../../utils/utils'; import type {ResizeableDataTableProps} from '../ResizeableDataTable/ResizeableDataTable'; import {ResizeableDataTable} from '../ResizeableDataTable/ResizeableDataTable'; @@ -49,8 +49,8 @@ const prepareTypedColumns = (columns: ColumnType[], data?: KeyValueRow[]) => { }); }; -const prepareGenericColumns = (data: KeyValueRow[]) => { - if (!data.length) { +const prepareGenericColumns = (data?: KeyValueRow[]) => { + if (!data?.length) { return []; } @@ -77,35 +77,42 @@ interface QueryResultTableProps extends Omit, 'data' | 'columns'> { data?: KeyValueRow[]; columns?: ColumnType[]; + settings?: Partial; } export const QueryResultTable = (props: QueryResultTableProps) => { - const {columns: rawColumns, data: rawData, ...restProps} = props; + const {columns, data, settings: propsSettings} = props; - const data = React.useMemo(() => prepareQueryResponse(rawData), [rawData]); - const columns = React.useMemo(() => { - return rawColumns ? prepareTypedColumns(rawColumns, data) : prepareGenericColumns(data); - }, [data, rawColumns]); + const preparedColumns = React.useMemo(() => { + return columns ? prepareTypedColumns(columns, data) : prepareGenericColumns(data); + }, [data, columns]); + + const settings = React.useMemo(() => { + return { + ...TABLE_SETTINGS, + ...propsSettings, + }; + }, [propsSettings]); // empty data is expected to be be an empty array // undefined data is not rendered at all - if (!Array.isArray(rawData)) { + if (!Array.isArray(data)) { return null; } - if (!columns.length) { + if (!preparedColumns.length) { return
{i18n('empty')}
; } return ( ); }; diff --git a/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx b/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx index 937b292e7..9a1b35f01 100644 --- a/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx +++ b/src/containers/Tenant/Query/CancelQueryButton/CancelQueryButton.tsx @@ -1,9 +1,6 @@ -import React from 'react'; - import {StopFill} from '@gravity-ui/icons'; import {Button, Icon} from '@gravity-ui/uikit'; -import {cancelQueryApi} from '../../../../store/reducers/cancelQuery'; import {cn} from '../../../../utils/cn'; import i18n from '../i18n'; @@ -12,22 +9,17 @@ import './CancelQueryButton.scss'; const b = cn('cancel-query-button'); interface CancelQueryButtonProps { - queryId: string; - tenantName: string; + isLoading: boolean; + isError: boolean; + onClick?: VoidFunction; } -export function CancelQueryButton({queryId, tenantName}: CancelQueryButtonProps) { - const [sendCancelQuery, cancelQueryResponse] = cancelQueryApi.useCancelQueryMutation(); - - const onStopButtonClick = React.useCallback(() => { - sendCancelQuery({queryId, database: tenantName}); - }, [queryId, sendCancelQuery, tenantName]); - +export function CancelQueryButton({isLoading, isError, onClick}: CancelQueryButtonProps) { return ( + ); + }; + return (
@@ -36,13 +54,7 @@ export function YdbInternalUser({login}: {login?: string}) { ) : ( - + renderLoginButton() )}
); diff --git a/src/containers/Authentication/Authentication.tsx b/src/containers/Authentication/Authentication.tsx index fbee833cf..5f87792b4 100644 --- a/src/containers/Authentication/Authentication.tsx +++ b/src/containers/Authentication/Authentication.tsx @@ -6,9 +6,10 @@ import {useHistory, useLocation} from 'react-router-dom'; import {parseQuery} from '../../routes'; import {authenticationApi} from '../../store/reducers/authentication/authentication'; +import {useLoginWithDatabase} from '../../store/reducers/capabilities/hooks'; import {cn} from '../../utils/cn'; -import {isPasswordError, isUserError} from './utils'; +import {isDatabaseError, isPasswordError, isUserError} from './utils'; import ydbLogoIcon from '../../assets/icons/ydb.svg'; @@ -24,20 +25,28 @@ function Authentication({closable = false}: AuthenticationProps) { const history = useHistory(); const location = useLocation(); + const needDatabase = useLoginWithDatabase(); + const [authenticate, {isLoading}] = authenticationApi.useAuthenticateMutation(undefined); - const {returnUrl} = parseQuery(location); + const {returnUrl, database: databaseFromQuery} = parseQuery(location); const [login, setLogin] = React.useState(''); + const [database, setDatabase] = React.useState(databaseFromQuery?.toString() ?? ''); const [password, setPass] = React.useState(''); const [loginError, setLoginError] = React.useState(''); const [passwordError, setPasswordError] = React.useState(''); + const [databaseError, setDatabaseError] = React.useState(''); const [showPassword, setShowPassword] = React.useState(false); const onLoginUpdate = (value: string) => { setLogin(value); setLoginError(''); }; + const onDatabaseUpdate = (value: string) => { + setDatabase(value); + setDatabaseError(''); + }; const onPassUpdate = (value: string) => { setPass(value); @@ -45,7 +54,7 @@ function Authentication({closable = false}: AuthenticationProps) { }; const onLoginClick = () => { - authenticate({user: login, password}) + authenticate({user: login, password, database}) .unwrap() .then(() => { if (returnUrl) { @@ -66,6 +75,9 @@ function Authentication({closable = false}: AuthenticationProps) { if (isPasswordError(error)) { setPasswordError(error.data.error); } + if (isDatabaseError(error)) { + setDatabaseError(error.data.error); + } }); }; @@ -125,6 +137,18 @@ function Authentication({closable = false}: AuthenticationProps) {
+ {needDatabase && ( +
+ +
+ )}