From 26f4817733cea13ec45c3bb026d1d7a43a25b13f Mon Sep 17 00:00:00 2001 From: Dave Russell Date: Mon, 16 Dec 2024 11:08:51 +0000 Subject: [PATCH] Adding GQL compliant errors and notifications to Browser (#1989) * migrate notifications to gql status objects * implementing gql errors * update error frame tests * add copyright notice to string utils * place errors behind feature flag * update tooltip * rename feature flag, change to hidden config, remove protocol version, change to server version * update state in tests * regen snaps --- .../Stream/CypherFrame/CypherFrame.test.tsx | 6 +- .../ErrorsView/ErrorsView.test.tsx | 121 +++++++- .../CypherFrame/ErrorsView/ErrorsView.tsx | 76 +++-- .../__snapshots__/ErrorsView.test.tsx.snap | 129 +++++++- .../Stream/CypherFrame/WarningsView.test.tsx | 275 +++++++++++++----- .../Stream/CypherFrame/WarningsView.tsx | 119 +++++--- .../__snapshots__/WarningsView.test.tsx.snap | 175 ++++++++++- .../Stream/CypherFrame/errorUtils.test.ts | 176 +++++++++++ .../modules/Stream/CypherFrame/errorUtils.ts | 99 +++++++ .../Stream/CypherFrame/gqlStatusUtils.test.ts | 76 +++++ .../Stream/CypherFrame/gqlStatusUtils.ts | 65 +++++ .../Stream/CypherFrame/warningUtils.test.ts | 169 +++++++++++ .../Stream/CypherFrame/warningUtilts.ts | 96 ++++++ src/browser/modules/Stream/styled.tsx | 8 +- .../modules/connections/connectionsDuck.ts | 5 +- .../modules/features/versionedFeatures.ts | 3 + src/shared/modules/settings/settingsDuck.ts | 2 + .../services/bolt/boltWorkerMessages.ts | 21 +- .../services/bolt/handleBoltWorkerMessage.ts | 13 +- src/shared/services/exceptions.ts | 9 +- src/shared/utils/deepPartial.ts | 25 ++ src/shared/utils/strings.ts | 25 ++ 22 files changed, 1533 insertions(+), 160 deletions(-) create mode 100644 src/browser/modules/Stream/CypherFrame/errorUtils.test.ts create mode 100644 src/browser/modules/Stream/CypherFrame/errorUtils.ts create mode 100644 src/browser/modules/Stream/CypherFrame/gqlStatusUtils.test.ts create mode 100644 src/browser/modules/Stream/CypherFrame/gqlStatusUtils.ts create mode 100644 src/browser/modules/Stream/CypherFrame/warningUtils.test.ts create mode 100644 src/browser/modules/Stream/CypherFrame/warningUtilts.ts create mode 100644 src/shared/utils/deepPartial.ts create mode 100644 src/shared/utils/strings.ts diff --git a/src/browser/modules/Stream/CypherFrame/CypherFrame.test.tsx b/src/browser/modules/Stream/CypherFrame/CypherFrame.test.tsx index 71b2973e5f7..21bf9319829 100644 --- a/src/browser/modules/Stream/CypherFrame/CypherFrame.test.tsx +++ b/src/browser/modules/Stream/CypherFrame/CypherFrame.test.tsx @@ -28,6 +28,8 @@ import { BrowserRequestResult } from 'shared/modules/requests/requestsDuck' +import { initialState as initialExperimentalFeatureState } from 'shared/modules/experimentalFeatures/experimentalFeaturesDuck' + const createProps = ( status: string, result: BrowserRequestResult @@ -62,7 +64,9 @@ describe('CypherFrame', () => { maxRows: 1000, maxFieldItems: 1000 }, - app: {} + app: {}, + connections: {}, + experimentalFeatures: initialExperimentalFeatureState }) } test('renders accordingly from pending to success to error to success', () => { diff --git a/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.test.tsx b/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.test.tsx index f950178558a..7b319b6e3a2 100644 --- a/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.test.tsx +++ b/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.test.tsx @@ -19,29 +19,50 @@ */ import { render } from '@testing-library/react' import React from 'react' -import { combineReducers, createStore } from 'redux' import { createBus } from 'suber' import { ErrorsView, ErrorsViewProps } from './ErrorsView' -import reducers from 'project-root/src/shared/rootReducer' import { BrowserError } from 'services/exceptions' +import { Provider } from 'react-redux' +import { initialState as initialMetaState } from 'shared/modules/dbMeta/dbMetaDuck' +import { initialState as initialSettingsState } from 'shared/modules/settings/settingsDuck' -const mount = (partOfProps: Partial) => { +const withProvider = (store: any, children: any) => { + return {children} +} + +const mount = (props: Partial, state?: any) => { const defaultProps: ErrorsViewProps = { result: null, bus: createBus(), params: {}, executeCmd: jest.fn(), setEditorContent: jest.fn(), - neo4jVersion: null + neo4jVersion: null, + gqlErrorsEnabled: true } - const props = { + + const combinedProps = { ...defaultProps, - ...partOfProps + ...props + } + + const initialState = { + meta: initialMetaState, + settings: initialSettingsState + } + + const combinedState = { ...initialState, ...state } + + const store = { + subscribe: () => {}, + dispatch: () => {}, + getState: () => ({ + ...combinedState + }) } - const reducer = combineReducers({ ...(reducers as any) }) - const store: any = createStore(reducer) - return render() + + return render(withProvider(store, )) } describe('ErrorsView', () => { @@ -57,7 +78,8 @@ describe('ErrorsView', () => { // Then expect(container).toMatchSnapshot() }) - test('does displays an error', () => { + + test('does display an error', () => { // Given const error: BrowserError = { code: 'Test.Error', @@ -74,6 +96,84 @@ describe('ErrorsView', () => { // Then expect(container).toMatchSnapshot() }) + + test('does display an error for gql status codes', () => { + // Given + const error: BrowserError = { + code: 'Test.Error', + message: 'Test error description', + type: 'Neo4jError', + gqlStatus: '22N14', + gqlStatusDescription: + "error: data exception - invalid temporal value combination. Cannot select both epochSeconds and 'datetime'.", + cause: undefined + } + + const props = { + result: error + } + + const state = { + meta: { + server: { + version: '5.26.0' + } + }, + settings: { + enableGqlErrorsAndNotifications: true + } + } + + // When + const { container } = mount(props, state) + + // Then + expect(container).toMatchSnapshot() + }) + + test('does display a nested error for gql status codes', () => { + // Given + const error: BrowserError = { + code: 'Test.Error', + message: 'Test error description', + type: 'Neo4jError', + gqlStatus: '42N51', + gqlStatusDescription: + 'error: syntax error or access rule violation - invalid parameter. Invalid parameter $`param`. ', + cause: { + gqlStatus: '22G03', + gqlStatusDescription: 'error: data exception - invalid value type', + cause: { + gqlStatus: '22N27', + gqlStatusDescription: + "error: data exception - invalid entity type. Invalid input '******' for $`param`. Expected to be STRING.", + cause: undefined + } + } + } + + const props = { + result: error + } + + const state = { + meta: { + server: { + version: '5.26.0' + } + }, + settings: { + enableGqlErrorsAndNotifications: true + } + } + + // When + const { container } = mount(props, state) + + // Then + expect(container).toMatchSnapshot() + }) + test('displays procedure link if unknown procedure', () => { // Given const error: BrowserError = { @@ -92,6 +192,7 @@ describe('ErrorsView', () => { expect(container).toMatchSnapshot() expect(getByText('List available procedures')).not.toBeUndefined() }) + test('displays procedure link if periodic commit error', () => { // Given const error: BrowserError = { diff --git a/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.tsx b/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.tsx index 32a91bb7910..465bba3b2c3 100644 --- a/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.tsx +++ b/src/browser/modules/Stream/CypherFrame/ErrorsView/ErrorsView.tsx @@ -25,7 +25,6 @@ import { Bus } from 'suber' import { PlayIcon } from 'browser-components/icons/LegacyIcons' -import { errorMessageFormater } from '../../errorMessageFormater' import { StyledCypherErrorMessage, StyledDiv, @@ -58,7 +57,14 @@ import { import { BrowserError } from 'services/exceptions' import { deepEquals } from 'neo4j-arc/common' import { getSemanticVersion } from 'shared/modules/dbMeta/dbMetaDuck' -import { SemVer } from 'semver' +import { gte, SemVer } from 'semver' +import { + formatError, + formatErrorGqlStatusObject, + hasPopulatedGqlFields +} from '../errorUtils' +import { FIRST_GQL_ERRORS_SUPPORT } from 'shared/modules/features/versionedFeatures' +import { shouldShowGqlErrorsAndNotifications } from 'shared/modules/settings/settingsDuck' export type ErrorsViewProps = { result: BrowserRequestResult @@ -67,6 +73,8 @@ export type ErrorsViewProps = { params: Record executeCmd: (cmd: string) => void setEditorContent: (cmd: string) => void + depth?: number + gqlErrorsEnabled: boolean } class ErrorsViewComponent extends Component { @@ -78,31 +86,53 @@ class ErrorsViewComponent extends Component { } render(): null | JSX.Element { - const { bus, params, executeCmd, setEditorContent, neo4jVersion } = - this.props + const { + bus, + params, + executeCmd, + setEditorContent, + neo4jVersion, + depth = 0, + gqlErrorsEnabled + } = this.props const error = this.props.result as BrowserError - if (!error || !error.code) { + if (!error) { + return null + } + + const formattedError = + gqlErrorsEnabled && hasPopulatedGqlFields(error) + ? formatErrorGqlStatusObject(error) + : formatError(error) + + if (!formattedError?.title) { return null } - const fullError = errorMessageFormater(null, error.message) const handleSetMissingParamsTemplateHelpMessageClick = () => { bus.send(GENERATE_SET_MISSING_PARAMS_TEMPLATE, undefined) } return ( - + 0}> - ERROR - {error.code} + {depth === 0 && ( + ERROR + )} + {formattedError.title} - - - {fullError.message} - - + {formattedError.description && ( + + + {formattedError?.description} + + + )} + {formattedError.innerError && ( + + )} {isUnknownProcedureError(error) && ( { } } -const mapStateToProps = (state: GlobalState) => { - return { - params: getParams(state), - neo4jVersion: getSemanticVersion(state) - } +const gqlErrorsEnabled = (state: GlobalState): boolean => { + const featureEnabled = shouldShowGqlErrorsAndNotifications(state) + const version = getSemanticVersion(state) + return version + ? featureEnabled && gte(version, FIRST_GQL_ERRORS_SUPPORT) + : false } + +const mapStateToProps = (state: GlobalState) => ({ + params: getParams(state), + neo4jVersion: getSemanticVersion(state), + gqlErrorsEnabled: gqlErrorsEnabled(state) +}) + const mapDispatchToProps = ( _dispatch: Dispatch, ownProps: ErrorsViewProps diff --git a/src/browser/modules/Stream/CypherFrame/ErrorsView/__snapshots__/ErrorsView.test.tsx.snap b/src/browser/modules/Stream/CypherFrame/ErrorsView/__snapshots__/ErrorsView.test.tsx.snap index 09afb2cfb69..3cd5929ddd4 100644 --- a/src/browser/modules/Stream/CypherFrame/ErrorsView/__snapshots__/ErrorsView.test.tsx.snap +++ b/src/browser/modules/Stream/CypherFrame/ErrorsView/__snapshots__/ErrorsView.test.tsx.snap @@ -5,7 +5,7 @@ exports[`ErrorsView displays nothing if no errors 1`] = `
`; exports[`ErrorsView displays procedure link if unknown procedure 1`] = `
ERROR
@@ -52,10 +52,10 @@ exports[`ErrorsView displays procedure link if unknown procedure 1`] = `
`; -exports[`ErrorsView does displays an error 1`] = ` +exports[`ErrorsView does display a nested error for gql status codes 1`] = `
+ ERROR +
+

+ 42N51: Syntax error or access rule violation - invalid parameter +

+
+
+
+          Invalid parameter $\`param\`.
+        
+
+
+
+
+

+ 22G03: Data exception - invalid value type +

+
+ +
+
+
+

+ 22N27: Data exception - invalid entity type +

+
+
+
+                  Invalid input '******' for $\`param\`. Expected to be STRING.
+                
+
+
+
+
+
+
+
+
+`; + +exports[`ErrorsView does display an error 1`] = ` +
+
+
+
+
ERROR
@@ -88,3 +170,40 @@ exports[`ErrorsView does displays an error 1`] = `
`; + +exports[`ErrorsView does display an error for gql status codes 1`] = ` +
+
+
+
+
+ ERROR +
+

+ 22N14: Data exception - invalid temporal value combination +

+
+
+
+          Cannot select both epochSeconds and 'datetime'.
+        
+
+
+
+
+`; diff --git a/src/browser/modules/Stream/CypherFrame/WarningsView.test.tsx b/src/browser/modules/Stream/CypherFrame/WarningsView.test.tsx index ffe5c42b5b7..c05ca983e77 100644 --- a/src/browser/modules/Stream/CypherFrame/WarningsView.test.tsx +++ b/src/browser/modules/Stream/CypherFrame/WarningsView.test.tsx @@ -20,88 +20,233 @@ import { render } from '@testing-library/react' import React from 'react' -import { WarningsView } from './WarningsView' - -describe('WarningsViews', () => { - describe('WarningsView', () => { - test('displays nothing if no notifications', () => { - // Given - const props = { - result: {} - } +import { WarningsView, WarningsViewProps } from './WarningsView' +import { Provider } from 'react-redux' + +import { initialState as initialMetaState } from 'shared/modules/dbMeta/dbMetaDuck' +import { initialState as initialSettingsState } from 'shared/modules/settings/settingsDuck' +import { createBus } from 'suber' +import { DeepPartial } from 'shared/utils/deepPartial' +import { notificationFilterMinimumSeverityLevel } from 'neo4j-driver-core' + +const withProvider = (store: any, children: any) => { + return {children} +} + +const mount = (props: DeepPartial, state?: any) => { + const defaultProps: WarningsViewProps = { + result: null, + bus: createBus(), + gqlWarningsEnabled: false + } + + const combinedProps = { + ...defaultProps, + ...props + } + + const initialState = { + app: {}, + meta: initialMetaState, + settings: initialSettingsState + } - // When - const { container } = render() + const combinedState = { ...initialState, ...state } - // Then - expect(container).toMatchSnapshot() + const store = { + subscribe: () => {}, + dispatch: () => {}, + getState: () => ({ + ...combinedState }) - test('does displays a warning', () => { - // Given - const props = { - result: { - summary: { - notifications: [ - { - severity: 'WARNING xx0', - title: 'My xx1 warning', - description: 'This is xx2 warning', - position: { - offset: 7, - line: 1 - } + } + + return render(withProvider(store, )) +} + +describe('WarningsView', () => { + test('displays nothing if no notifications', () => { + // Given + const props = { + result: null + } + + // When + const { container } = mount(props) + + // Then + expect(container).toMatchSnapshot() + }) + + test('does display a warning', () => { + // Given + const props = { + result: { + summary: { + notifications: [ + { + severity: 'WARNING', + title: 'My xx1 warning', + description: 'This is xx2 warning', + position: { + offset: 7, + line: 1 + }, + code: 'xx3.Warning' + } + ], + query: { + text: 'EXPLAIN MATCH xx3' + } + } + } + } + + // When + const { container } = mount(props) + + // Then + expect(container).toMatchSnapshot() + }) + + test('does display a warning for GQL status codes', () => { + // Given + const props = { + result: { + summary: { + server: { + protocolVersion: 5.6 + }, + gqlStatusObjects: [ + { + severity: notificationFilterMinimumSeverityLevel.WARNING, + gqlStatus: '03N90', + statusDescription: + "info: cartesian product. The disconnected pattern 'p = ()--(), q = ()--()' builds a cartesian product. A cartesian product may produce a large amount of data and slow down query processing.", + position: { + offset: 7, + line: 1 } - ], - query: { - text: 'EXPLAIN MATCH xx3' } + ], + query: { + text: 'MATCH p=()--(), q=()--() RETURN p, q' } } } + } - // When - const { container } = render() + const state = { + meta: { + server: { + version: '5.23.0' + } + }, + settings: { + enableGqlErrorsAndNotifications: true + } + } - // Then - expect(container).toMatchSnapshot() - }) - test('does displays multiple warnings', () => { - // Given - const props = { - result: { - summary: { - notifications: [ - { - severity: 'WARNING xx0', - title: 'My xx1 warning', - description: 'This is xx2 warning', - position: { - offset: 7, - line: 1 - } + // When + const { container } = mount(props, state) + + // Then + expect(container).toMatchSnapshot() + }) + + test('does display multiple warnings', () => { + // Given + const props = { + result: { + summary: { + notifications: [ + { + severity: 'WARNING', + title: 'My xx1 warning', + description: 'This is xx2 warning', + position: { + offset: 7, + line: 1 + }, + code: 'xx3.Warning' + }, + { + severity: 'WARNING', + title: 'My yy1 warning', + description: 'This is yy2 warning', + position: { + offset: 3, + line: 1 }, - { - severity: 'WARNING yy0', - title: 'My yy1 warning', - description: 'This is yy2 warning', - position: { - offset: 3, - line: 1 - } + code: 'yy3.Warning' + } + ], + query: { + text: 'EXPLAIN MATCH zz3' + } + } + } + } + + // When + const { container } = mount(props) + + // Then + expect(container).toMatchSnapshot() + }) + + test('does display multiple warnings for GQL status codes', () => { + // Given + const props = { + result: { + summary: { + server: { + protocolVersion: 5.6 + }, + gqlStatusObjects: [ + { + severity: notificationFilterMinimumSeverityLevel.WARNING, + gqlStatus: '03N90', + statusDescription: + "info: cartesian product. The disconnected pattern 'p = ()--(), q = ()--()' builds a cartesian product. A cartesian product may produce a large amount of data and slow down query processing.", + position: { + offset: 7, + line: 1 + } + }, + { + severity: notificationFilterMinimumSeverityLevel.WARNING, + gqlStatus: '01N50', + statusDescription: + 'warn: label does not exist. The label `A` does not exist. Verify that the spelling is correct.', + position: { + offset: 3, + line: 1 } - ], - query: { - text: 'EXPLAIN MATCH zz3' } + ], + query: { + text: 'MATCH p=()--(), q=()--() RETURN p, q' } } } + } - // When - const { container } = render() + const state = { + meta: { + server: { + version: '5.23.0' + } + }, + settings: { + enableGqlErrorsAndNotifications: true + } + } - // Then - expect(container).toMatchSnapshot() - }) + // When + const { container } = mount(props, state) + + // Then + expect(container).toMatchSnapshot() }) }) diff --git a/src/browser/modules/Stream/CypherFrame/WarningsView.tsx b/src/browser/modules/Stream/CypherFrame/WarningsView.tsx index 07871ee881b..5c554e44272 100644 --- a/src/browser/modules/Stream/CypherFrame/WarningsView.tsx +++ b/src/browser/modules/Stream/CypherFrame/WarningsView.tsx @@ -34,8 +34,22 @@ import { StyledCypherInfoMessage } from '../styled' import { deepEquals } from 'neo4j-arc/common' +import { + formatSummaryFromGqlStatusObjects, + formatSummaryFromNotifications, + FormattedNotification +} from './warningUtilts' +import { NotificationSeverityLevel, QueryResult } from 'neo4j-driver-core' +import { connect } from 'react-redux' +import { withBus } from 'react-suber' +import { GlobalState } from 'shared/globalState' +import { Bus } from 'suber' +import { getSemanticVersion } from 'shared/modules/dbMeta/dbMetaDuck' +import { gte } from 'semver' +import { FIRST_GQL_NOTIFICATIONS_SUPPORT } from 'shared/modules/features/versionedFeatures' +import { shouldShowGqlErrorsAndNotifications } from 'shared/modules/settings/settingsDuck' -const getWarningComponent = (severity: any) => { +const getWarningComponent = (severity?: string | NotificationSeverityLevel) => { if (severity === 'ERROR') { return {severity} } else if (severity === 'WARNING') { @@ -47,56 +61,91 @@ const getWarningComponent = (severity: any) => { } } -export class WarningsView extends Component { - shouldComponentUpdate(props: any) { +export type WarningsViewProps = { + result?: QueryResult | null + bus: Bus + gqlWarningsEnabled: boolean +} + +class WarningsViewComponent extends Component { + shouldComponentUpdate(props: WarningsViewProps) { if (!this.props.result) return true - return !deepEquals(props.result.summary, this.props.result.summary) + return !deepEquals(props.result?.summary, this.props.result.summary) } render() { - if (this.props.result === undefined) return null - const { summary = {} } = this.props.result - const { notifications = [], query = {} } = summary - const { text: cypher = '' } = query + if ( + this.props.result === undefined || + this.props.result === null || + this.props.result.summary === undefined + ) + return null + + const { summary } = this.props.result + const notifications = this.props.gqlWarningsEnabled + ? formatSummaryFromGqlStatusObjects(summary) + : formatSummaryFromNotifications(summary) + const { text: cypher = '' } = summary.query + if (!notifications || !cypher) { return null } + const cypherLines = cypher.split('\n') - const notificationsList = notifications.map((notification: any) => { - // Detect generic warning without position information - const position = Object.keys(notification.position).length - ? notification.position - : { line: 1, offset: 0 } - return ( - - - {getWarningComponent(notification.severity)} - {notification.title} - - + const notificationsList = notifications.map( + (notification: FormattedNotification) => { + // Detect generic warning without position information + const { code, description, severity } = notification + const position = notification.position ?? { line: 1, offset: 0 } + const title = notification.title ?? '' + const line = position.line ?? 1 + const offset = position.offset ?? 0 + + return ( + - {notification.description} + {getWarningComponent(severity)} + {title} - - {cypherLines[position.line - 1]} - - {Array(position.offset + 1).join(' ')}^ - + {description} + + + {cypherLines[line - 1]} + + {Array(offset + 1).join(' ')}^ + + - - - Status code: {notification.code} - - - ) - }) + {code && ( + + Status code: {code} + + )} + + ) + } + ) return {notificationsList} } } +const gqlWarningsEnabled = (state: GlobalState): boolean => { + const featureEnabled = shouldShowGqlErrorsAndNotifications(state) + const version = getSemanticVersion(state) + return version + ? featureEnabled && gte(version, FIRST_GQL_NOTIFICATIONS_SUPPORT) + : false +} + +const mapStateToProps = (state: GlobalState) => ({ + gqlWarningsEnabled: gqlWarningsEnabled(state) +}) + +export const WarningsView = withBus( + connect(mapStateToProps, null)(WarningsViewComponent) +) + export class WarningsStatusbar extends Component { shouldComponentUpdate() { return false diff --git a/src/browser/modules/Stream/CypherFrame/__snapshots__/WarningsView.test.tsx.snap b/src/browser/modules/Stream/CypherFrame/__snapshots__/WarningsView.test.tsx.snap index b89cff414d3..1f4580d240f 100644 --- a/src/browser/modules/Stream/CypherFrame/__snapshots__/WarningsView.test.tsx.snap +++ b/src/browser/modules/Stream/CypherFrame/__snapshots__/WarningsView.test.tsx.snap @@ -1,11 +1,11 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`WarningsViews WarningsView displays nothing if no notifications 1`] = `
`; +exports[`WarningsView displays nothing if no notifications 1`] = `
`; -exports[`WarningsViews WarningsView does displays a warning 1`] = ` +exports[`WarningsView does display a warning 1`] = `
- WARNING xx0 + WARNING

+ > + xx3.Warning +

`; -exports[`WarningsViews WarningsView does displays multiple warnings 1`] = ` +exports[`WarningsView does display a warning for GQL status codes 1`] = `
- WARNING xx0 + WARNING +
+

+ 03N90: Cartesian product +

+
+
+
+ The disconnected pattern 'p = ()--(), q = ()--()' builds a cartesian product. A cartesian product may produce a large amount of data and slow down query processing. +
+
+
+            MATCH p=()--(), q=()--() RETURN p, q
+            
+ + ^ +
+
+
+
+
+
+`; + +exports[`WarningsView does display multiple warnings 1`] = ` +
+
+
+
+
+ WARNING

+ > + xx3.Warning +

- WARNING yy0 + WARNING

+ > + yy3.Warning + +

+
+
+
+`; + +exports[`WarningsView does display multiple warnings for GQL status codes 1`] = ` +
+
+
+
+
+ WARNING +
+

+ 03N90: Cartesian product +

+
+
+
+ The disconnected pattern 'p = ()--(), q = ()--()' builds a cartesian product. A cartesian product may produce a large amount of data and slow down query processing. +
+
+
+            MATCH p=()--(), q=()--() RETURN p, q
+            
+ + ^ +
+
+
+
+
+
+
+ WARNING +
+

+ 01N50: Label does not exist +

+
+
+
+ The label \`A\` does not exist. Verify that the spelling is correct. +
+
+
+            MATCH p=()--(), q=()--() RETURN p, q
+            
+ + ^ +
+
diff --git a/src/browser/modules/Stream/CypherFrame/errorUtils.test.ts b/src/browser/modules/Stream/CypherFrame/errorUtils.test.ts new file mode 100644 index 00000000000..78409e825e8 --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/errorUtils.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { ErrorType } from 'services/exceptions' +import { formatError, formatErrorGqlStatusObject } from './errorUtils' + +describe('error formatting', () => { + test('formats an error with no gql fields correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: 'epochSeconds cannot be selected together with datetime.', + code: 'Neo.ClientError.Statement.ArgumentError' + } + + const result = formatError(error) + expect(result).toEqual({ + description: 'epochSeconds cannot be selected together with datetime.', + title: 'Neo.ClientError.Statement.ArgumentError' + }) + }) + + test('formats a long error with no gql fields correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: + 'The shortest path algorithm does not work when the start and end nodes are the same. This can happen if you perform a shortestPath search after a cartesian product that might have the same start and end nodes for some of the rows passed to shortestPath. If you would rather not experience this exception, and can accept the possibility of missing results for those rows, disable this in the Neo4j configuration by setting `dbms.cypher.forbid_shortestpath_common_nodes` to false. If you cannot accept missing results, and really want the shortestPath between two common nodes, then re-write the query using a standard Cypher variable length pattern expression followed by ordering by path length and limiting to one result.', + code: 'Neo.DatabaseError.Statement.ExecutionFailed' + } + + const result = formatError(error) + expect(result).toEqual({ + description: + 'The shortest path algorithm does not work when the start and end nodes are the same. This can happen if you perform a shortestPath search after a cartesian product that might have the same start and end nodes for some of the rows passed to shortestPath. If you would rather not experience this exception, and can accept the possibility of missing results for those rows, disable this in the Neo4j configuration by setting `dbms.cypher.forbid_shortestpath_common_nodes` to false. If you cannot accept missing results, and really want the shortestPath between two common nodes, then re-write the query using a standard Cypher variable length pattern expression followed by ordering by path length and limiting to one result.', + title: 'Neo.DatabaseError.Statement.ExecutionFailed' + }) + }) + + test('formats a gql error correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: 'Expected parameter(s): param', + code: 'Neo.ClientError.Statement.ParameterMissing', + gqlStatus: '42N51', + gqlStatusDescription: + 'error: syntax error or access rule violation - invalid parameter. Invalid parameter $`param`.', + cause: { + gqlStatus: '22G03', + gqlStatusDescription: '22G03', + cause: { + gqlStatus: '22N27', + gqlStatusDescription: + "error: data exception - invalid entity type. Invalid input '******' for $`param`. Expected to be STRING." + } + } + } + + const result = formatErrorGqlStatusObject(error) + expect(result).toEqual({ + description: 'Invalid parameter $`param`.', + innerError: { + cause: { + gqlStatus: '22N27', + gqlStatusDescription: + "error: data exception - invalid entity type. Invalid input '******' for $`param`. Expected to be STRING." + }, + gqlStatus: '22G03', + gqlStatusDescription: '22G03' + }, + title: '42N51: Syntax error or access rule violation - invalid parameter' + }) + }) + + test('formats a gql error with no description correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: 'epochSeconds cannot be selected together with datetime.', + code: 'Neo.ClientError.Statement.ArgumentError', + gqlStatus: '22007', + gqlStatusDescription: + 'error: data exception - invalid date, time, or datetime format', + cause: { + gqlStatus: '22N14', + gqlStatusDescription: + "error: data exception - invalid temporal value combination. Cannot select both epochSeconds and 'datetime'." + } + } + + const result = formatErrorGqlStatusObject(error) + expect(result).toEqual({ + description: '', + title: '22007: Data exception - invalid date, time, or datetime format', + innerError: { + gqlStatus: '22N14', + gqlStatusDescription: + "error: data exception - invalid temporal value combination. Cannot select both epochSeconds and 'datetime'." + } + }) + }) + + test('formats a gql error with only a gql status correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: '', + code: '', + gqlStatus: '22G03', + gqlStatusDescription: '22G03', + cause: undefined + } + + const result = formatErrorGqlStatusObject(error) + expect(result).toEqual({ + description: '', + title: '22G03', + innerError: undefined + }) + }) + + test('formats a gql error with a cause correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: '', + code: '', + gqlStatus: '22N27', + gqlStatusDescription: + "error: data exception - invalid entity type. Invalid input '******' for $`param`. Expected to be STRING.", + cause: undefined + } + + const result = formatErrorGqlStatusObject(error) + expect(result).toEqual({ + description: + "Invalid input '******' for $`param`. Expected to be STRING.", + title: '22N27: Data exception - invalid entity type', + innerError: undefined + }) + }) + + test('formats a long gql error correctly', () => { + const error = { + type: 'Neo4jError' as ErrorType, + message: + 'The shortest path algorithm does not work when the start and end nodes are the same. This can happen if you perform a shortestPath search after a cartesian product that might have the same start and end nodes for some of the rows passed to shortestPath. If you would rather not experience this exception, and can accept the possibility of missing results for those rows, disable this in the Neo4j configuration by setting `dbms.cypher.forbid_shortestpath_common_nodes` to false. If you cannot accept missing results, and really want the shortestPath between two common nodes, then re-write the query using a standard Cypher variable length pattern expression followed by ordering by path length and limiting to one result.', + code: 'Neo.DatabaseError.Statement.ExecutionFailed', + gqlStatus: '51N23', + gqlStatusDescription: + "error: system configuration or operation exception - cyclic shortest path search disabled. Cannot find the shortest path when the start and end nodes are the same. To enable this behavior, set 'dbms.cypher.forbid_shortestpath_common_nodes' to false.", + cause: undefined + } + + const result = formatErrorGqlStatusObject(error) + expect(result).toEqual({ + description: + "Cannot find the shortest path when the start and end nodes are the same. To enable this behavior, set 'dbms.cypher.forbid_shortestpath_common_nodes' to false.", + title: + '51N23: System configuration or operation exception - cyclic shortest path search disabled', + innerError: undefined + }) + }) +}) diff --git a/src/browser/modules/Stream/CypherFrame/errorUtils.ts b/src/browser/modules/Stream/CypherFrame/errorUtils.ts new file mode 100644 index 00000000000..8fd6b757157 --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/errorUtils.ts @@ -0,0 +1,99 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { isNonEmptyString } from 'shared/utils/strings' +import { + formatDescriptionFromGqlStatusDescription, + formatTitleFromGqlStatusDescription +} from './gqlStatusUtils' +import { BrowserError } from 'services/exceptions' + +export function isBrowserError(object: unknown): object is BrowserError { + if (object !== null && typeof object === 'object') { + return ( + 'type' in object || + 'message' in object || + 'code' in object || + 'gqlStatus' in object + ) + } + + return false +} + +type FormattedError = { + title?: string + description?: string + innerError?: Pick< + BrowserError, + 'gqlStatus' | 'gqlStatusDescription' | 'cause' + > +} + +const mapBrowserErrorToFormattedError = ( + error: BrowserError +): FormattedError => { + const gqlStatusTitle = formatTitleFromGqlStatusDescription( + error.gqlStatusDescription + ) + const { gqlStatus } = error + const description = formatDescriptionFromGqlStatusDescription( + error.gqlStatusDescription + ) + const title = isNonEmptyString(gqlStatusTitle) ? gqlStatusTitle : description + return { + title: isNonEmptyString(title) ? `${gqlStatus}: ${title}` : gqlStatus, + description + } +} + +export const hasPopulatedGqlFields = ( + error: BrowserError | Error +): error is BrowserError & { + gqlStatus: string + gqlStatusDescription: string + cause?: BrowserError +} => { + return ( + 'gqlStatus' in error && + error.gqlStatus !== undefined && + 'gqlStatusDescription' in error && + error.gqlStatusDescription !== undefined && + 'cause' in error + ) +} + +export const formatErrorGqlStatusObject = ( + error: BrowserError +): FormattedError => { + return { + ...mapBrowserErrorToFormattedError(error), + innerError: error.cause + } +} + +export const formatError = (error: BrowserError): FormattedError => { + const { code: title, message: description } = error + + return { + title, + description + } +} diff --git a/src/browser/modules/Stream/CypherFrame/gqlStatusUtils.test.ts b/src/browser/modules/Stream/CypherFrame/gqlStatusUtils.test.ts new file mode 100644 index 00000000000..c1f14a0222b --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/gqlStatusUtils.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { + formatDescriptionFromGqlStatusDescription, + formatTitleFromGqlStatusDescription +} from './gqlStatusUtils' + +describe('gql status formatting', () => { + test('formats a title from a gql status description correctly', () => { + const gqlStatusDescription = + 'error: syntax error or access rule violation - invalid parameter. Invalid parameter $`param`.' + + const result = formatTitleFromGqlStatusDescription(gqlStatusDescription) + + expect(result).toEqual( + 'Syntax error or access rule violation - invalid parameter' + ) + }) + + test('formats a description from a gql status description correctly', () => { + const gqlStatusDescription = + "error: system configuration or operation exception - cyclic shortest path search disabled. Cannot find the shortest path when the start and end nodes are the same. To enable this behavior, set 'dbms.cypher.forbid_shortestpath_common_nodes' to false." + + const result = + formatDescriptionFromGqlStatusDescription(gqlStatusDescription) + + expect(result).toEqual( + "Cannot find the shortest path when the start and end nodes are the same. To enable this behavior, set 'dbms.cypher.forbid_shortestpath_common_nodes' to false." + ) + }) + + test('formats a description with no period correctly', () => { + const gqlStatusDescription = + 'error: system configuration or operation exception - cyclic shortest path search disabled. Cannot find the shortest path when the start and end nodes are the same' + const result = + formatDescriptionFromGqlStatusDescription(gqlStatusDescription) + + expect(result).toEqual( + 'Cannot find the shortest path when the start and end nodes are the same.' + ) + }) + + test('formats a title from a gql status description with no matches correctly', () => { + const gqlStatusDescription = + 'Unfortunately, no one can be told what the Matrix is. You have to see it for yourself' + const result = formatTitleFromGqlStatusDescription(gqlStatusDescription) + + expect(result).toEqual('') + }) + + test('formats a description from a gql status description with no matches correctly', () => { + const gqlStatusDescription = 'Believe the unbelievable' + const result = + formatDescriptionFromGqlStatusDescription(gqlStatusDescription) + + expect(result).toEqual('') + }) +}) diff --git a/src/browser/modules/Stream/CypherFrame/gqlStatusUtils.ts b/src/browser/modules/Stream/CypherFrame/gqlStatusUtils.ts new file mode 100644 index 00000000000..72095e80554 --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/gqlStatusUtils.ts @@ -0,0 +1,65 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import { capitalize, isNonEmptyString } from 'shared/utils/strings' + +const gqlStatusIndexes = { + title: 1, + description: 2 +} + +const formatPropertyFromStatusDescripton = ( + index: number, + gqlStatusDescription?: string +): string | undefined => { + const matches = + gqlStatusDescription?.match( + /^(?:error|info|warn):\s(.+?)(?:\.(.+?))?\.?$/ + ) ?? [] + + return matches[index] === undefined + ? undefined + : capitalize(matches[index].trim()) +} + +export const formatTitleFromGqlStatusDescription = ( + gqlStatusDescription?: string +): string => { + return ( + formatPropertyFromStatusDescripton( + gqlStatusIndexes.title, + gqlStatusDescription + )?.trim() ?? '' + ) +} + +export const formatDescriptionFromGqlStatusDescription = ( + gqlStatusDescription?: string +): string => { + const description = + formatPropertyFromStatusDescripton( + gqlStatusIndexes.description, + gqlStatusDescription + )?.trim() ?? '' + + return isNonEmptyString(description) && !description.endsWith('.') + ? `${description}.` + : description +} diff --git a/src/browser/modules/Stream/CypherFrame/warningUtils.test.ts b/src/browser/modules/Stream/CypherFrame/warningUtils.test.ts new file mode 100644 index 00000000000..a9fd5a9075e --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/warningUtils.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import type { GqlStatusObject } from 'neo4j-driver-core' +import { + notificationCategory, + notificationSeverityLevel +} from 'neo4j-driver-core' +import { + formatSummaryFromGqlStatusObjects, + formatSummaryFromNotifications +} from './warningUtilts' + +describe('format rseult summary', () => { + test('formats result summary for notifications', () => { + const resultSummary = { + server: { + protocolVersion: 5.5 + }, + notifications: [ + { + code: 'Neo.ClientNotification.Statement.CartesianProduct', + title: + 'This query builds a cartesian product between disconnected patterns.', + description: + 'If a part of a query contains multiple disconnected patterns, this will build a cartesian product between all those parts. This may produce a large amount of data and slow down query processing. While occasionally intended, it may often be possible to reformulate the query that avoids the use of this cross product, perhaps by adding a relationship between the different parts or by using OPTIONAL MATCH (identifiers are: ())', + severity: 'INFORMATION', + position: { offset: 0, line: 1, column: 1 }, + severityLevel: notificationSeverityLevel.INFORMATION, + rawSeverityLevel: 'INFORMATION', + category: notificationCategory.PERFORMANCE, + rawCategory: 'PERFORMANCE' + }, + { + code: 'Neo.ClientNotification.Statement.UnknownLabelWarning', + title: 'The provided label is not in the database.', + description: + "One of the labels in your query is not available in the database, make sure you didn't misspell it or that the label is available when you run this statement in your application (the missing label name is: A)", + severity: 'WARNING', + position: { offset: 9, line: 1, column: 10 }, + severityLevel: notificationSeverityLevel.WARNING, + rawSeverityLevel: 'WARNING', + category: notificationCategory.UNRECOGNIZED, + rawCategory: 'UNRECOGNIZED' + } + ] + } + + const result = formatSummaryFromNotifications(resultSummary) + + expect(result).toEqual([ + { + code: 'Neo.ClientNotification.Statement.CartesianProduct', + description: + 'If a part of a query contains multiple disconnected patterns, this will build a cartesian product between all those parts. This may produce a large amount of data and slow down query processing. While occasionally intended, it may often be possible to reformulate the query that avoids the use of this cross product, perhaps by adding a relationship between the different parts or by using OPTIONAL MATCH (identifiers are: ())', + position: { + column: 1, + line: 1, + offset: 0 + }, + title: + 'This query builds a cartesian product between disconnected patterns.', + severity: 'INFORMATION' + }, + { + code: 'Neo.ClientNotification.Statement.UnknownLabelWarning', + description: + "One of the labels in your query is not available in the database, make sure you didn't misspell it or that the label is available when you run this statement in your application (the missing label name is: A)", + position: { + column: 10, + line: 1, + offset: 9 + }, + title: 'The provided label is not in the database.', + severity: 'WARNING' + } + ]) + }) + + test('formats result summary for gql status objects', () => { + const gqlStatusObjects: [GqlStatusObject, ...GqlStatusObject[]] = [ + { + gqlStatus: '03N90', + statusDescription: + "info: cartesian product. The disconnected pattern 'p = ()--(), q = ()--()' builds a cartesian product. A cartesian product may produce a large amount of data and slow down query processing.", + diagnosticRecord: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/', + classification: 'PERFORMANCE' + }, + position: { offset: 0, line: 1, column: 1 }, + severity: 'INFORMATION', + rawSeverity: 'INFORMATION', + classification: 'PERFORMANCE', + rawClassification: 'PERFORMANCE', + isNotification: true, + diagnosticRecordAsJsonString: '' + }, + { + gqlStatus: '01N50', + statusDescription: + 'warn: label does not exist. The label `A` does not exist. Verify that the spelling is correct.', + diagnosticRecord: { + OPERATION: '', + OPERATION_CODE: '0', + CURRENT_SCHEMA: '/' + }, + position: { offset: 9, line: 1, column: 10 }, + severity: 'WARNING', + rawSeverity: 'WARNING', + classification: 'UNRECOGNIZED', + rawClassification: 'UNRECOGNIZED', + isNotification: true, + diagnosticRecordAsJsonString: '' + } + ] + const resultSummary = { + server: { + protocolVersion: 5.7 + }, + gqlStatusObjects + } + + const result = formatSummaryFromGqlStatusObjects(resultSummary) + + expect(result).toEqual([ + { + description: + "The disconnected pattern 'p = ()--(), q = ()--()' builds a cartesian product. A cartesian product may produce a large amount of data and slow down query processing.", + position: { + column: 1, + line: 1, + offset: 0 + }, + title: '03N90: Cartesian product', + severity: 'INFORMATION' + }, + { + description: + 'The label `A` does not exist. Verify that the spelling is correct.', + position: { + column: 10, + line: 1, + offset: 9 + }, + title: '01N50: Label does not exist', + severity: 'WARNING' + } + ]) + }) +}) diff --git a/src/browser/modules/Stream/CypherFrame/warningUtilts.ts b/src/browser/modules/Stream/CypherFrame/warningUtilts.ts new file mode 100644 index 00000000000..6b373180910 --- /dev/null +++ b/src/browser/modules/Stream/CypherFrame/warningUtilts.ts @@ -0,0 +1,96 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +import type { Notification, NotificationSeverityLevel } from 'neo4j-driver' +import { + GqlStatusObject, + NotificationPosition, + ResultSummary +} from 'neo4j-driver-core' +import { + formatDescriptionFromGqlStatusDescription, + formatTitleFromGqlStatusDescription +} from './gqlStatusUtils' +import { isNonEmptyString } from 'shared/utils/strings' + +export type FormattedNotification = { + title?: string + description: string + position?: NotificationPosition + code?: string | null + severity?: NotificationSeverityLevel | string +} + +const mapGqlStatusObjectsToFormattedNotifications = ( + statusObjects: Omit[] +): FormattedNotification[] => { + return statusObjects.map(statusObject => { + const gqlStatusTitle = formatTitleFromGqlStatusDescription( + statusObject.statusDescription + ) + const { gqlStatus } = statusObject + const description = formatDescriptionFromGqlStatusDescription( + statusObject.statusDescription + ) + const title = isNonEmptyString(gqlStatusTitle) + ? gqlStatusTitle + : description + return { + title: isNonEmptyString(title) ? `${gqlStatus}: ${title}` : gqlStatus, + description, + position: statusObject.position, + severity: statusObject.severity + } + }) +} + +const mapNotificationsToFormattedNotifications = ( + notifications: Notification[] +): FormattedNotification[] => { + return notifications.map(notification => ({ + title: notification.title, + description: notification.description, + position: notification.position, + severity: notification.severity, + code: notification.code + })) +} + +const SEVERITY_LEVELS = ['ERROR', 'WARNING', 'INFORMATION'] + +export const formatSummaryFromNotifications = ( + resultSummary?: Partial +): FormattedNotification[] => { + const filteredNotifications = + resultSummary?.notifications?.filter(x => + SEVERITY_LEVELS.includes(x.severity) + ) ?? [] + return mapNotificationsToFormattedNotifications(filteredNotifications) +} + +export const formatSummaryFromGqlStatusObjects = ( + resultSummary?: Partial +): FormattedNotification[] => { + const filteredStatusObjects = + resultSummary?.gqlStatusObjects?.filter(x => + SEVERITY_LEVELS.includes(x.severity) + ) ?? [] + return mapGqlStatusObjectsToFormattedNotifications(filteredStatusObjects) +} diff --git a/src/browser/modules/Stream/styled.tsx b/src/browser/modules/Stream/styled.tsx index e6c4e624170..8fa26d88aa3 100644 --- a/src/browser/modules/Stream/styled.tsx +++ b/src/browser/modules/Stream/styled.tsx @@ -49,8 +49,10 @@ export const DottedLineHover = styled.span` text-overflow: ellipsis; ` -export const StyledHelpFrame = styled.div` - padding: 30px; +export const StyledHelpFrame = styled.div<{ nested?: boolean }>` + padding: 0 30px 30px 0; + padding-top: ${props => (props.nested ? '0' : '30px')}; + padding-left: ${props => (props.nested ? '80px' : '30px')}; ` export const StyledHelpContent = styled.div` padding-top: 10px; @@ -100,6 +102,8 @@ export const StyledCypherErrorMessage = styled(StyledCypherMessage)` background-color: ${props => props.theme.error}; color: #ffffff; display: inline-block; + position: relative; + top: -4px; ` export const StyledCypherSuccessMessage = styled(StyledCypherMessage)` diff --git a/src/shared/modules/connections/connectionsDuck.ts b/src/shared/modules/connections/connectionsDuck.ts index 9dd5e1d7bc9..eea15721b66 100644 --- a/src/shared/modules/connections/connectionsDuck.ts +++ b/src/shared/modules/connections/connectionsDuck.ts @@ -695,9 +695,8 @@ export const connectionLostEpic = (action$: any, store: any) => )?.SSOProviders if (SSOProviders) { try { - const credentials = await handleRefreshingToken( - SSOProviders - ) + const credentials = + await handleRefreshingToken(SSOProviders) store.dispatch( discovery.updateDiscoveryConnection(credentials) ) diff --git a/src/shared/modules/features/versionedFeatures.ts b/src/shared/modules/features/versionedFeatures.ts index dac355f84a6..1813b38b46a 100644 --- a/src/shared/modules/features/versionedFeatures.ts +++ b/src/shared/modules/features/versionedFeatures.ts @@ -32,6 +32,9 @@ export const FIRST_MULTI_DB_SUPPORT = NEO4J_4_0 // compatible bolt server. export const FIRST_NO_MULTI_DB_SUPPORT = '3.4.0' +export const FIRST_GQL_NOTIFICATIONS_SUPPORT = '5.23.0' +export const FIRST_GQL_ERRORS_SUPPORT = '5.26.0' + export const getShowCurrentUserProcedure = (serverVersion: string) => { const serverVersionGuessed = guessSemverVersion(serverVersion) diff --git a/src/shared/modules/settings/settingsDuck.ts b/src/shared/modules/settings/settingsDuck.ts index 2b2eaaa4fc3..d9fd71e2641 100644 --- a/src/shared/modules/settings/settingsDuck.ts +++ b/src/shared/modules/settings/settingsDuck.ts @@ -94,6 +94,8 @@ export const getAllowUserStats = (state: GlobalState): boolean => state[NAME].allowUserStats ?? initialState.allowUserStats export const shouldShowWheelZoomInfo = (state: GlobalState) => state[NAME].showWheelZoomInfo +export const shouldShowGqlErrorsAndNotifications = (state: any) => + state[NAME].enableGqlErrorsAndNotifications // Ideally the string | number types would be only numbers // but they're saved as strings in the settings component diff --git a/src/shared/services/bolt/boltWorkerMessages.ts b/src/shared/services/bolt/boltWorkerMessages.ts index 1ce45ca0ae2..312621238e2 100644 --- a/src/shared/services/bolt/boltWorkerMessages.ts +++ b/src/shared/services/bolt/boltWorkerMessages.ts @@ -63,13 +63,28 @@ export const cypherResponseMessage = (result: unknown): AnyAction => { } } -export const cypherErrorMessage = (error: { +type CypherError = { code: number message: string -}): AnyAction => { + gqlStatus: string | null + gqlStatusDescription: string | null + cause: CypherError | null +} + +const flattenError = (error: CypherError): CypherError => { + return { + code: error.code, + message: error.message, + gqlStatus: error.gqlStatus, + gqlStatusDescription: error.gqlStatusDescription, + cause: error.cause ? flattenError(error.cause) : null + } +} + +export const cypherErrorMessage = (error: CypherError): AnyAction => { return { type: CYPHER_ERROR_MESSAGE, - error + error: flattenError(error) } } diff --git a/src/shared/services/bolt/handleBoltWorkerMessage.ts b/src/shared/services/bolt/handleBoltWorkerMessage.ts index 1f0cea35da2..6dc64dcb88f 100644 --- a/src/shared/services/bolt/handleBoltWorkerMessage.ts +++ b/src/shared/services/bolt/handleBoltWorkerMessage.ts @@ -136,7 +136,13 @@ export const handleBoltWorkerMessage = runningCypherQuery = false execCloseConnectionQueue() postMessage( - maybeCypherErrorMessage({ code: err.code, message: err.message }) + maybeCypherErrorMessage({ + code: err.code, + message: err.message, + gqlStatus: err.gqlStatus, + gqlStatusDescription: err.gqlStatusDescription, + cause: err.cause + }) ) }) } else if (messageType === CANCEL_TRANSACTION_MESSAGE) { @@ -150,7 +156,10 @@ export const handleBoltWorkerMessage = postMessage( cypherErrorMessage({ code: -1, - message: `Unknown message to Bolt Worker: ${messageType}` + message: `Unknown message to Bolt Worker: ${messageType}`, + gqlStatus: null, + gqlStatusDescription: null, + cause: null }) ) } diff --git a/src/shared/services/exceptions.ts b/src/shared/services/exceptions.ts index 5bed2b326d6..a5d12a29966 100644 --- a/src/shared/services/exceptions.ts +++ b/src/shared/services/exceptions.ts @@ -32,7 +32,14 @@ export type ErrorType = | 'DatabaseNotFoundError' | 'DatabaseUnavailableError' -export type BrowserError = { type: ErrorType; message: string; code: string } +export type BrowserError = { + type: ErrorType + message: string + code: string + gqlStatus?: string + gqlStatusDescription?: string + cause?: Pick +} // All errors except bolt errors have their type as their error code export function BoltConnectionError(): BrowserError { diff --git a/src/shared/utils/deepPartial.ts b/src/shared/utils/deepPartial.ts new file mode 100644 index 00000000000..607750e387e --- /dev/null +++ b/src/shared/utils/deepPartial.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export type DeepPartial = T extends object + ? { + [P in keyof T]?: DeepPartial + } + : T diff --git a/src/shared/utils/strings.ts b/src/shared/utils/strings.ts new file mode 100644 index 00000000000..23472f2bbd9 --- /dev/null +++ b/src/shared/utils/strings.ts @@ -0,0 +1,25 @@ +/* + * Copyright (c) "Neo4j" + * Neo4j Sweden AB [http://neo4j.com] + * + * This file is part of Neo4j. + * + * Neo4j is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +export const capitalize = (s: string): string => + s.charAt(0).toUpperCase() + s.slice(1) + +export const isNonEmptyString = (s: unknown): s is string => + typeof s === 'string' && s !== ''