From 4dd8ddbefd77fe5308f9929a7ad8fb7dbf3c3441 Mon Sep 17 00:00:00 2001 From: sergeyteleshev Date: Mon, 14 Apr 2025 18:35:27 +0200 Subject: [PATCH] CB-4067 Migrate Connection form to form template (#3288) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * CB-4067 adds connection form state, service and options part * CB-4067 adds connection origin info tab to new form * CB-4067 adds SSH part * CB-4067 adds driver properties part * CB-4067 changes driverId and submitType location for connection form * CB-4067 adds SSL part * CB-4067 adds connection form access part * CB-4067 uses new form parts for connection form * CB-4067 resets form on open * CB-4067 fixes configure case for connection form * CB-4067 adds selected driver on edit mode form * CB-4067 reloads form when it is needed * CB-4067 adds button actions for the form * CB-4067 adds notifications on form save/error * CB-4067 fixes form ssl saving and driver properties * CB-4067 refactors some parts * CB-4067 refactors form options part so it works fine * CB-4067 fixes driver properties part * CB-4067 fixes SSH part * CB-4067 fixes SSL part * CB-4067 fixes Access part * CB-4067 refactors origin tab * CB-4067 refactors shared credentials part * CB-4067 fixes: secret providers, SSH, SSL, test - features * CB-4067 removes refactor postfix for new state, service and types * CB-4067 fixes connection test * CB-4067 cleanup * CB-4067 gets back ask credentials window and removes connectionCredentialsStateContext * CB-4067 adds translation to test connection success message * CB-4067 fixes ssh and ssl adding to networkHandlers. also fixes extra request for driver properties * CB-4067 fixes host and port for switching connections * CB-4067 removes redundant code * CB-4067 fixes exception error on retest connection + fixes SSH field key saved incorrect prop * CB-4067 cleanup testContext info * CB-4076 fixes SSH and SSL ask credentials and parameters saving for both: test and submit connection create/edit action * CB-4067 fixes access tab reminder * CB-4067 fixes handling of info message for cloud connection origin tabs * CB-4067 fixes checkboxes behavior for TE shared credentials feature * CB-4067 fixes create connection template popup SSH form * CB-4067 fixes notification for closing ask credentials popup if before we had an error of test submit * CB-6115 adds dispose API for forms * CB-6115 adds git workspace helper extension for repo recommendations * CB-4067 cleanup * CB-6115 pr fixes * CB-4067 gets back behavior with dispose connection form state * CB-4067 cleanup * СB-4067 splits states to ui and form part in access form part * СB-4067 pr fixes * CB-4067 removes config from form state * CB-4067 removes console warnings + clean up * CB-4067 moves synchronization mechanisms from ConnectionFormState to ConnectionOptionsFormPart * CB-4067 fixes bug with incorrect host or port during search or changing driverId during connection creation * CB-4067 fixes opening connection forms from different entry points so it shows consistent initial data * СB-4067 brings back saving the form * CB-4067 fixes logic of showing popup askCredentials * CB-4067 fixes origin tab name so it shows correctly + fixes unneeded loaders of resources * CB-4067 reverts isOutdated * CB-4067 fixes loading of driver properties * CB-4067 cleanup for connection access part * Revert "CB-4067 reverts isOutdated" This reverts commit ecb195ee2a9bc03e5ea51165e5ff36d262166b59. * CB-4067 adds isOutdated to the parts * CB-4067 fixes origin tab info message * CB-4067 fixes driver properties state + origin info status message shows on load * CB-4067 fixes password autofill during form save * CB-4067 fixes linter * CB-4067 fixes linter * CB-4067 cleanup + fixes bug with search database host,name fields * CB-4067 linter fix * CB-4067 pr fixes * CB-4067 removes unused post handler for formstate loaded task * CB-4067 small pr fixes * CB-4067 adds setDriver + handles name autocomplete * chore: review * CB-4067 connection part correct loading * CB-4067 adds formState.state.connectionId default value * refactor: pr review * CB-4067 fixes bug with infinite loading of the origin info details * CB-4067 fix: allows askCredentials to edit fields + does not affects part state * CB-4067 resets driver properties if changed driverId * CB-4067 updates yarn lock for common-react * CB-4067 lint fix * CB-6265 fix: do not include index files for import/export eslint rules * CB-4067 fixes behavior for aksCredentials so it acts correctly during testing/saving/editing inputs for the form * CB-4067 cleanup * CB-4067 fixes connection templates initial loaded state * CB-4067 pr fixes: dialog credentials; adds todo to the ssh tab; eslint revert; cleanup * CB-4067 makes askCredentials to be part state independent * CB-4067 qa fixes: test translation + removes db name form connection name * CB-4067 adds readonly state for connection form * CB-4067 fixes application freezes on disconnect * CB-4067 fixes empty properties when switching cloud connections * CB-4067 connection is created -> connection was created * CB-4067 connection is updated -> connection was updated * CB-4067 fixes outdated logic for parts * CB-4067 build fix * CB-4067 fix: do not show askCredential popup for drivers with only anonymous access * CB-4067 removes includeCredentialsSaved * CB-4067 removes includeAuthProperties * CB-4067 removes customIncludeOptions * CB-4067 removes includeProperties * CB-4067 removes includeProviderProperties * CB-4067 removes includeNetworkHandlers * CB-4067 refactors isOutdated for originInfoPart and AccessPart * CB-4067 removes savedCredentials resource + updates CustomOptionsResource + reuses new include resources in connection form components * CB-4067 reverts spreading of new connectionInfoResources * CB-4067 fixes sharedCreds checkbox default value on form switch * CB-4067 closes connection form when layout closes * CB-4067 disposes connection form on change * CB-4067 handles closing of the connection form correctly * Revert "CB-4067 handles closing of the connection form correctly" This reverts commit 3a6f1a6a0a0fc2f98da0e32ea128d1b7910e831d. * CB-4067 handles closing of the connection form correctly [2] * CB-4067 fits info messages in one line * CB-4067 presaves isChanged state for form part before applying initialState to prevent bugs * CB-4067 fixes length of the error message for connection form top bar --------- Co-authored-by: Alexey Co-authored-by: mr-anton-t <42037741+mr-anton-t@users.noreply.github.com> Co-authored-by: Daria Marutkina <125263541+dariamarutkina@users.noreply.github.com> --- .vscode/tasks.json | 8 - common-react/yarn.lock | 10 + .../@dbeaver/jdbc-uri-parser/README.md | 1 + .../@dbeaver/jdbc-uri-parser/package.json | 32 + .../jdbc-uri-parser/src/JdbcUriBase.ts | 5 + .../jdbc-uri-parser/src/JdbcUriCommon.ts | 10 + .../@dbeaver/jdbc-uri-parser/src/index.ts | 3 + .../jdbc-uri-parser/src/parseJdbcUri.ts | 141 +++++ .../@dbeaver/jdbc-uri-parser/tsconfig.json | 16 + common-typescript/yarn.lock | 10 + .../ConfigurationWizardService.ts | 2 +- .../ConnectionInfoAuthPropertiesResource.ts | 112 ++++ .../ConnectionInfoCustomOptionsResource.ts | 112 ++++ .../ConnectionInfoNetworkHandlersResource.ts | 112 ++++ .../src/ConnectionInfoPropertiesResource.ts | 112 ++++ ...onnectionInfoProviderPropertiesResource.ts | 112 ++++ .../src/ConnectionInfoResource.ts | 9 +- webapp/packages/core-connections/src/index.ts | 5 + .../core-connections/src/locales/de.ts | 2 + .../core-connections/src/locales/en.ts | 2 + .../core-connections/src/locales/fr.ts | 2 + .../core-connections/src/locales/it.ts | 2 + .../core-connections/src/locales/ru.ts | 2 + .../core-connections/src/locales/zh.ts | 2 + .../packages/core-connections/src/manifest.ts | 5 + .../getUserConnectionsAuthProperties.gql | 5 + .../getUserConnectionsCustomOptions.gql | 5 + .../getUserConnectionsNetworkHandlers.gql | 5 + .../getUserConnectionsProperties.gql | 5 + .../getUserConnectionsProviderProperties.gql | 5 + .../DatabaseConnectionAuthProperties.gql | 11 + .../DatabaseConnectionCustomOptions.gql | 20 + .../DatabaseConnectionNetworkHandlers.gql | 14 + .../DatabaseConnectionProperties.gql | 5 + .../DatabaseConnectionProviderProperties.gql | 5 + webapp/packages/core-ui/src/Form/FormPart.ts | 14 +- webapp/packages/core-ui/src/Form/FormState.ts | 23 +- webapp/packages/core-ui/src/Form/IFormPart.ts | 1 + .../packages/core-ui/src/Form/IFormState.ts | 5 +- .../core-ui/src/Form/formSubmitContext.ts | 27 + .../core-ui/src/Tabs/Tab/TabDefault.tsx | 25 +- webapp/packages/core-ui/src/index.ts | 3 +- .../core-utils/src/getSubjectDifferences.ts | 14 + webapp/packages/core-utils/src/index.ts | 1 + .../eslint-plugin/noSyncComponentImport.js | 1 - .../src/Search/ConnectionSearchService.ts | 84 ++- .../src/Search/SearchDatabase.tsx | 2 +- .../package.json | 1 + .../ConnectionsTable/ConnectionEdit.tsx | 35 +- .../CreateConnection/CreateConnection.tsx | 10 +- .../Manual/ConnectionManualService.ts | 7 +- .../Connections/CreateConnectionService.ts | 46 +- .../ConnectionAccess/ConnectionAccess.tsx | 92 ++- .../ConnectionAccessTabService.ts | 137 +---- .../ConnectionFormAccessPart.ts | 80 +++ .../getConnectionFormAccessPart.ts | 24 + .../useConnectionAccessState.ts | 106 ---- .../tsconfig.json | 3 + .../packages/plugin-connections/package.json | 1 + .../ConnectionAuthenticationDialog.tsx | 2 +- .../ConnectFormActionsContext.ts | 7 +- .../src/ConnectionForm/ConnectionForm.tsx | 117 ++-- .../ConnectionFormBaseActions.tsx | 44 +- .../ConnectionForm/ConnectionFormService.ts | 176 +----- .../src/ConnectionForm/ConnectionFormState.ts | 340 +---------- .../Contexts/connectionConfigContext.ts | 12 - .../connectionCredentialsStateContext.ts | 30 - .../ConnectionDriverPropertiesTabService.ts | 108 +--- .../ConnectionFormDriverPropertiesPart.ts | 77 +++ .../DriverProperties/DriverProperties.tsx | 25 +- .../DriverPropertiesLoader.tsx | 13 - .../getConnectionFormDriverPropertiesPart.ts | 27 + .../ConnectionForm/IConnectionFormProps.ts | 70 --- .../ConnectionForm/IConnectionFormState.ts | 24 + .../Options/ConnectionFormOptionsPart.ts | 565 ++++++++++++++++++ .../Options/ConnectionOptionsTabService.ts | 434 +------------- .../Options/IConnectionConfig.ts | 45 ++ .../Options/IConnectionFormOptionsState.ts | 13 + .../Options/IConnectionNetworkHanler.ts | 23 + .../src/ConnectionForm/Options/Options.tsx | 187 +++--- .../Options/getConnectionFormOptionsPart.ts | 55 ++ .../Options/getConnectionName.ts | 11 +- .../src/ConnectionForm/Options/useOptions.ts | 136 ----- .../ConnectionFormAuthenticationAction.tsx | 46 +- .../ConnectionFormOriginInfoFormPart.ts | 167 ++++++ .../ConnectionOriginInfoTabService.ts | 46 +- .../IConnectionFormOriginInfoState.ts | 28 + .../ConnectionForm/OriginInfo/OriginInfo.tsx | 80 +-- .../OriginInfo/OriginInfoTab.tsx | 16 +- .../getConnectionFormOriginInfoFormPart.ts | 51 ++ .../SSH/ConnectionFormSSHPart.ts | 177 ++++++ .../SSH/ConnectionSSHTabService.ts | 245 +------- .../SSH/IConnectionFromSSHState.ts | 13 + .../src/ConnectionForm/SSH/SSH.tsx | 59 +- .../src/ConnectionForm/SSH/SSHPanel.tsx | 15 +- .../src/ConnectionForm/SSH/SSHTab.tsx | 12 +- .../SSH/getConnectionFormSSHPart.ts | 25 + .../SSL/ConnectionFormSSLPart.ts | 192 ++++++ .../SSL/ConnectionSSLTabService.ts | 212 +------ .../SSL/IConnectionFromSSLState.ts | 12 + .../src/ConnectionForm/SSL/SSL.tsx | 50 +- .../src/ConnectionForm/SSL/SSLPanel.tsx | 23 +- .../src/ConnectionForm/SSL/SSLTab.tsx | 16 +- .../SSL/getConnectionFormSSLPart.ts | 27 + .../connectionFormConfigureContext.ts | 41 -- .../ConnectionForm/useConnectionFormState.ts | 57 +- .../DatabaseAuthDialog/DatabaseAuthDialog.tsx | 2 +- .../PublicConnectionForm.tsx | 21 +- .../PublicConnectionFormService.ts | 98 ++- .../packages/plugin-connections/src/index.ts | 9 +- .../plugin-connections/src/locales/en.ts | 4 + .../plugin-connections/src/locales/fr.ts | 4 + .../plugin-connections/src/locales/it.ts | 4 + .../plugin-connections/src/locales/ru.ts | 4 + .../plugin-connections/src/locales/zh.ts | 4 + .../packages/plugin-connections/tsconfig.json | 3 + webapp/yarn.lock | 12 + 117 files changed, 3369 insertions(+), 2598 deletions(-) create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/README.md create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/package.json create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriBase.ts create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriCommon.ts create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/src/index.ts create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/src/parseJdbcUri.ts create mode 100644 common-typescript/@dbeaver/jdbc-uri-parser/tsconfig.json create mode 100644 webapp/packages/core-connections/src/ConnectionInfoAuthPropertiesResource.ts create mode 100644 webapp/packages/core-connections/src/ConnectionInfoCustomOptionsResource.ts create mode 100644 webapp/packages/core-connections/src/ConnectionInfoNetworkHandlersResource.ts create mode 100644 webapp/packages/core-connections/src/ConnectionInfoPropertiesResource.ts create mode 100644 webapp/packages/core-connections/src/ConnectionInfoProviderPropertiesResource.ts create mode 100644 webapp/packages/core-sdk/src/queries/connections/getUserConnectionsAuthProperties.gql create mode 100644 webapp/packages/core-sdk/src/queries/connections/getUserConnectionsCustomOptions.gql create mode 100644 webapp/packages/core-sdk/src/queries/connections/getUserConnectionsNetworkHandlers.gql create mode 100644 webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProperties.gql create mode 100644 webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProviderProperties.gql create mode 100644 webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionAuthProperties.gql create mode 100644 webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionCustomOptions.gql create mode 100644 webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionNetworkHandlers.gql create mode 100644 webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProperties.gql create mode 100644 webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProviderProperties.gql create mode 100644 webapp/packages/core-ui/src/Form/formSubmitContext.ts create mode 100644 webapp/packages/core-utils/src/getSubjectDifferences.ts create mode 100644 webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionFormAccessPart.ts create mode 100644 webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/getConnectionFormAccessPart.ts delete mode 100644 webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts delete mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionConfigContext.ts delete mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionCredentialsStateContext.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionFormDriverPropertiesPart.ts delete mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/DriverPropertiesLoader.tsx create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/getConnectionFormDriverPropertiesPart.ts delete mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormProps.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormState.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionFormOptionsPart.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionConfig.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionFormOptionsState.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionNetworkHanler.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionFormOptionsPart.ts delete mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/Options/useOptions.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormOriginInfoFormPart.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/IConnectionFormOriginInfoState.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/getConnectionFormOriginInfoFormPart.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionFormSSHPart.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/SSH/IConnectionFromSSHState.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/SSH/getConnectionFormSSHPart.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionFormSSLPart.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/SSL/IConnectionFromSSLState.ts create mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/SSL/getConnectionFormSSLPart.ts delete mode 100644 webapp/packages/plugin-connections/src/ConnectionForm/connectionFormConfigureContext.ts diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b21891514d..f0be392f95 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -92,14 +92,6 @@ "clear": false } }, - { - "label": "Update TS references CE", - "type": "shell", - "command": "yarn run update-ts-references", - "options": { - "cwd": "${workspaceFolder}/webapp" - } - }, { "label": "Clean CE", "type": "shell", diff --git a/common-react/yarn.lock b/common-react/yarn.lock index ace87a8efb..ff34d06eb5 100644 --- a/common-react/yarn.lock +++ b/common-react/yarn.lock @@ -335,6 +335,16 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/jdbc-uri-parser@workspace:../common-typescript/@dbeaver/jdbc-uri-parser": + version: 0.0.0-use.local + resolution: "@dbeaver/jdbc-uri-parser@workspace:../common-typescript/@dbeaver/jdbc-uri-parser" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/prettier-config@workspace:../common-typescript/@dbeaver/prettier-config, @dbeaver/prettier-config@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/prettier-config@workspace:../common-typescript/@dbeaver/prettier-config" diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/README.md b/common-typescript/@dbeaver/jdbc-uri-parser/README.md new file mode 100644 index 0000000000..83a4716729 --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/README.md @@ -0,0 +1 @@ +# @dbeaver/jdbc-uri-parser diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/package.json b/common-typescript/@dbeaver/jdbc-uri-parser/package.json new file mode 100644 index 0000000000..fe7471954e --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/package.json @@ -0,0 +1,32 @@ +{ + "name": "@dbeaver/jdbc-uri-parser", + "type": "module", + "sideEffects": [], + "exports": { + ".": "./lib/index.js", + "./*": "./lib/*.js" + }, + "scripts": { + "clear": "rimraf lib" + }, + "files": [ + "package.json", + "LICENSE", + "README.md", + "CHANGELOG.md", + "lib", + "!lib/**/*.d.ts.map", + "!lib/**/*.test.js", + "!lib/**/*.test.d.ts", + "!lib/**/*.test.d.ts.map", + "!lib/**/*.test.js.map", + "!lib/tests", + "!.tsbuildinfo" + ], + "packageManager": "yarn@4.6.0", + "devDependencies": { + "@dbeaver/tsconfig": "workspace:^", + "rimraf": "^6", + "typescript": "^5" + } +} diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriBase.ts b/common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriBase.ts new file mode 100644 index 0000000000..32e79196b9 --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriBase.ts @@ -0,0 +1,5 @@ +export interface JdbcUriBase { + raw: string; + subprotocol: string; + subname: string; +} diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriCommon.ts b/common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriCommon.ts new file mode 100644 index 0000000000..ecd3089e8c --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/src/JdbcUriCommon.ts @@ -0,0 +1,10 @@ +import type { JdbcUriBase } from './JdbcUriBase.js'; + +export interface JdbcUriCommon extends JdbcUriBase { + host?: string; + port?: string; + database?: string; + username?: string; + password?: string; + queryParams: Record; +} diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/src/index.ts b/common-typescript/@dbeaver/jdbc-uri-parser/src/index.ts new file mode 100644 index 0000000000..2b847dadfe --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/src/index.ts @@ -0,0 +1,3 @@ +export * from './JdbcUriBase.js'; +export * from './JdbcUriCommon.js'; +export * from './parseJdbcUri.js'; diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/src/parseJdbcUri.ts b/common-typescript/@dbeaver/jdbc-uri-parser/src/parseJdbcUri.ts new file mode 100644 index 0000000000..ffaf4fcc98 --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/src/parseJdbcUri.ts @@ -0,0 +1,141 @@ +import type { JdbcUriBase } from './JdbcUriBase.js'; +import type { JdbcUriCommon } from './JdbcUriCommon.js'; + +export type JdbcUri = JdbcUriCommon; + +// Helper function to parse query parameters safely +function parseQueryParams(query?: string): Record { + if (!query) return {}; + + const params: Record = {}; + try { + for (const param of query.split('&')) { + const [key, value] = param.split('='); + if (key) { + params[key] = value ? decodeURIComponent(value) : ''; + } + } + } catch {} + + return params; +} + +export function parseJdbcUri(jdbcUrl: string | undefined | null): JdbcUri { + if (!jdbcUrl || typeof jdbcUrl !== 'string') { + return { + raw: String(jdbcUrl || ''), + subprotocol: '', + subname: '', + queryParams: {}, + }; + } + + if (!jdbcUrl.startsWith('jdbc:')) { + return { + raw: jdbcUrl, + subprotocol: '', + subname: jdbcUrl, + queryParams: {}, + }; + } + + const raw = jdbcUrl; + const match = jdbcUrl.match(/^jdbc:([^:]+):(.*)$/); + + if (!match) { + return { + raw: jdbcUrl, + subprotocol: '', + subname: jdbcUrl.substring(5), + queryParams: {}, + }; + } + + const [, subprotocol, subname] = match as [string, string, string]; + const base: JdbcUriBase = { raw, subprotocol, subname }; + + // Protocol-specific handling + + // SQLite: jdbc:sqlite:/path/to/file.db + if (subprotocol === 'sqlite') { + return { + ...base, + database: subname, + queryParams: {}, + }; + } + + // Oracle Thin: jdbc:oracle:thin:@host:port:sid + if (subprotocol === 'oracle' && subname.startsWith('thin:@')) { + const match = subname.match(/^thin:@([^:]+):(\d+):(.+)$/); + if (match) { + const [, host, port, database] = match; + return { ...base, host, port, database, queryParams: {} }; + } + return { ...base, queryParams: {} }; + } + + // Handle URL-like formats (most JDBC URLs) + if (subname.startsWith('//')) { + // Extract query string if present + const queryMatch = subname.match(/\?(.+)$/); + const queryString = queryMatch?.[1]; + const queryParams = parseQueryParams(queryString); + + // Strip query string for further parsing + const subnameWithoutQuery = queryString ? subname.substring(0, subname.length - queryString.length - 1) : subname; + + // Check for template placeholders + if (/\{[^{}]+\}/.test(subnameWithoutQuery)) { + const hostMatch = subnameWithoutQuery.match(/\/\/([^:/]+|\{[^{}]+\})/); + const portMatch = subnameWithoutQuery.match(/\/\/(?:[^:/]+|\{[^{}]+\})(?::([^/]+|\{[^{}]+\}))?/); + const databaseMatch = subnameWithoutQuery.match(/\/([^?]*)/); + + const host = hostMatch?.[1]; + const port = portMatch?.[1]; + const database = databaseMatch?.[1]; + + return { + ...base, + host: host?.startsWith('{') ? undefined : host, + port: port?.startsWith('{') ? undefined : port, + database: database?.startsWith('{') ? undefined : database, + queryParams, + }; + } + + // Try standard URL parsing + try { + const fakeUrl = new URL(`${subprotocol}:${subnameWithoutQuery}`); + return { + ...base, + host: fakeUrl.hostname || undefined, + port: fakeUrl.port || undefined, + database: fakeUrl.pathname?.slice(1) || undefined, + username: fakeUrl.username || undefined, + password: fakeUrl.password || undefined, + queryParams, + }; + } catch { + // Fallback regex parsing for malformed URLs + const regex = /^\/\/([^:/?#]+)?(?::(\d+))?(?:\/([^?]*))?/; + const match = subnameWithoutQuery.match(regex); + const [, host, port, database] = match || []; + + return { + ...base, + host, + port, + database, + queryParams, + }; + } + } + + // Any other format - treat subname as database + return { + ...base, + database: subname, + queryParams: {}, + }; +} diff --git a/common-typescript/@dbeaver/jdbc-uri-parser/tsconfig.json b/common-typescript/@dbeaver/jdbc-uri-parser/tsconfig.json new file mode 100644 index 0000000000..9b5f89e66a --- /dev/null +++ b/common-typescript/@dbeaver/jdbc-uri-parser/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "@dbeaver/tsconfig/tsconfig.webview.json", + "compilerOptions": { + "tsBuildInfoFile": "./lib/.tsbuildinfo", + "rootDir": "src", + "rootDirs": [ + "src" + ], + "outDir": "lib", + "incremental": true, + "composite": true + }, + "include": [ + "src" + ] +} diff --git a/common-typescript/yarn.lock b/common-typescript/yarn.lock index 63663ef669..51693966bd 100644 --- a/common-typescript/yarn.lock +++ b/common-typescript/yarn.lock @@ -107,6 +107,16 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/jdbc-uri-parser@workspace:@dbeaver/jdbc-uri-parser": + version: 0.0.0-use.local + resolution: "@dbeaver/jdbc-uri-parser@workspace:@dbeaver/jdbc-uri-parser" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/prettier-config@workspace:@dbeaver/prettier-config, @dbeaver/prettier-config@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/prettier-config@workspace:@dbeaver/prettier-config" diff --git a/webapp/packages/core-administration/src/AdministrationScreen/ConfigurationWizard/ConfigurationWizardService.ts b/webapp/packages/core-administration/src/AdministrationScreen/ConfigurationWizard/ConfigurationWizardService.ts index f2cd0c12a5..20c1c7e496 100644 --- a/webapp/packages/core-administration/src/AdministrationScreen/ConfigurationWizard/ConfigurationWizardService.ts +++ b/webapp/packages/core-administration/src/AdministrationScreen/ConfigurationWizard/ConfigurationWizardService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/packages/core-connections/src/ConnectionInfoAuthPropertiesResource.ts b/webapp/packages/core-connections/src/ConnectionInfoAuthPropertiesResource.ts new file mode 100644 index 0000000000..fd97ab1ba2 --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionInfoAuthPropertiesResource.ts @@ -0,0 +1,112 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { runInAction, toJS } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { ProjectsService } from '@cloudbeaver/core-projects'; +import { CachedMapResource, isResourceAlias, type ResourceKey, resourceKeyList, ResourceKeyUtils } from '@cloudbeaver/core-resource'; +import { type DatabaseConnectionAuthPropertiesFragment, GraphQLService } from '@cloudbeaver/core-sdk'; +import { schemaValidationError } from '@cloudbeaver/core-utils'; + +import { CONNECTION_INFO_PARAM_SCHEMA, type IConnectionInfoParams } from './CONNECTION_INFO_PARAM_SCHEMA.js'; +import { + ConnectionInfoActiveProjectKey, + ConnectionInfoProjectKey, + ConnectionInfoResource, + createConnectionParam, + isConnectionInfoParamEqual, +} from './ConnectionInfoResource.js'; +import { parseConnectionKey } from './parseConnectionKey.js'; + +export type ConnectionInfoAuthProperties = DatabaseConnectionAuthPropertiesFragment; + +// TODO reuse it everywhere where include "includeAuthProperties" is used +@injectable() +export class ConnectionInfoAuthPropertiesResource extends CachedMapResource { + constructor( + private readonly projectsService: ProjectsService, + private readonly graphQLService: GraphQLService, + private readonly connectionInfoResource: ConnectionInfoResource, + ) { + super(); + + this.aliases.add(ConnectionInfoProjectKey, param => resourceKeyList(this.keys.filter(key => param.options.projectIds.includes(key.projectId)))); + + this.aliases.add(ConnectionInfoActiveProjectKey, () => + resourceKeyList(this.keys.filter(key => projectsService.activeProjects.some(({ id }) => id === key.projectId))), + ); + + this.sync(this.connectionInfoResource); + this.connectionInfoResource.onItemDelete.addHandler(this.delete.bind(this)); + } + + protected async loader( + originalKey: ResourceKey, + _: any, + refresh?: boolean, + ): Promise> { + const connectionsList: ConnectionInfoAuthProperties[] = []; + let removedConnections: IConnectionInfoParams[] = []; + const parsed = parseConnectionKey({ + originalKey, + aliases: this.aliases, + isOutdated: this.isOutdated.bind(this), + activeProjects: this.projectsService.activeProjects, + refresh, + }); + + let { projectId } = parsed; + const { projectIds, key } = parsed; + + await ResourceKeyUtils.forEachAsync(key, async connectionKey => { + let connectionId: string | undefined; + if (!isResourceAlias(connectionKey)) { + projectId = connectionKey.projectId; + connectionId = connectionKey.connectionId; + } + + const { connections } = await this.graphQLService.sdk.getUserConnectionsAuthProperties({ + projectId, + connectionId, + projectIds, + }); + + if (connectionId && !connections.some(connection => connection.id === connectionId)) { + throw new Error(`Connection is not found (${connectionId})`); + } + + connectionsList.push(...connections); + }); + + runInAction(() => { + if (isResourceAlias(key)) { + removedConnections = ResourceKeyUtils.toList(this.aliases.transformToKey(key)).filter( + filterKey => !connectionsList.some(connection => isConnectionInfoParamEqual(filterKey, createConnectionParam(connection))), + ); + } + + this.delete(resourceKeyList(removedConnections)); + const keys = resourceKeyList(connectionsList.map(createConnectionParam)); + this.set(keys, connectionsList); + }); + + return this.data; + } + + override isKeyEqual(param: IConnectionInfoParams, second: IConnectionInfoParams): boolean { + return isConnectionInfoParamEqual(param, second); + } + + protected validateKey(key: IConnectionInfoParams): boolean { + const parse = CONNECTION_INFO_PARAM_SCHEMA.safeParse(toJS(key)); + if (!parse.success) { + this.logger.warn(`Invalid resource key ${(schemaValidationError(parse.error).toString(), { prefix: null })}`); + } + return parse.success; + } +} diff --git a/webapp/packages/core-connections/src/ConnectionInfoCustomOptionsResource.ts b/webapp/packages/core-connections/src/ConnectionInfoCustomOptionsResource.ts new file mode 100644 index 0000000000..190770045f --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionInfoCustomOptionsResource.ts @@ -0,0 +1,112 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { runInAction, toJS } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { ProjectsService } from '@cloudbeaver/core-projects'; +import { CachedMapResource, isResourceAlias, type ResourceKey, resourceKeyList, ResourceKeyUtils } from '@cloudbeaver/core-resource'; +import { type DatabaseConnectionCustomOptionsFragment, GraphQLService } from '@cloudbeaver/core-sdk'; +import { schemaValidationError } from '@cloudbeaver/core-utils'; + +import { CONNECTION_INFO_PARAM_SCHEMA, type IConnectionInfoParams } from './CONNECTION_INFO_PARAM_SCHEMA.js'; +import { + ConnectionInfoActiveProjectKey, + ConnectionInfoProjectKey, + ConnectionInfoResource, + createConnectionParam, + isConnectionInfoParamEqual, +} from './ConnectionInfoResource.js'; +import { parseConnectionKey } from './parseConnectionKey.js'; + +export type ConnectionInfoCustomOptions = DatabaseConnectionCustomOptionsFragment; + +// TODO reuse it everywhere where include "customIncludeOptions" is used +@injectable() +export class ConnectionInfoCustomOptionsResource extends CachedMapResource { + constructor( + private readonly projectsService: ProjectsService, + private readonly graphQLService: GraphQLService, + private readonly connectionInfoResource: ConnectionInfoResource, + ) { + super(); + + this.aliases.add(ConnectionInfoProjectKey, param => resourceKeyList(this.keys.filter(key => param.options.projectIds.includes(key.projectId)))); + + this.aliases.add(ConnectionInfoActiveProjectKey, () => + resourceKeyList(this.keys.filter(key => projectsService.activeProjects.some(({ id }) => id === key.projectId))), + ); + + this.sync(this.connectionInfoResource); + this.connectionInfoResource.onItemDelete.addHandler(this.delete.bind(this)); + } + + protected async loader( + originalKey: ResourceKey, + _: any, + refresh?: boolean, + ): Promise> { + const connectionsList: ConnectionInfoCustomOptions[] = []; + let removedConnections: IConnectionInfoParams[] = []; + const parsed = parseConnectionKey({ + originalKey, + aliases: this.aliases, + isOutdated: this.isOutdated.bind(this), + activeProjects: this.projectsService.activeProjects, + refresh, + }); + + let { projectId } = parsed; + const { projectIds, key } = parsed; + + await ResourceKeyUtils.forEachAsync(key, async connectionKey => { + let connectionId: string | undefined; + if (!isResourceAlias(connectionKey)) { + projectId = connectionKey.projectId; + connectionId = connectionKey.connectionId; + } + + const { connections } = await this.graphQLService.sdk.getUserConnectionsCustomOptions({ + projectId, + connectionId, + projectIds, + }); + + if (connectionId && !connections.some(connection => connection.id === connectionId)) { + throw new Error(`Connection is not found (${connectionId})`); + } + + connectionsList.push(...connections); + }); + + runInAction(() => { + if (isResourceAlias(key)) { + removedConnections = ResourceKeyUtils.toList(this.aliases.transformToKey(key)).filter( + filterKey => !connectionsList.some(connection => isConnectionInfoParamEqual(filterKey, createConnectionParam(connection))), + ); + } + + this.delete(resourceKeyList(removedConnections)); + const keys = resourceKeyList(connectionsList.map(createConnectionParam)); + this.set(keys, connectionsList); + }); + + return this.data; + } + + override isKeyEqual(param: IConnectionInfoParams, second: IConnectionInfoParams): boolean { + return isConnectionInfoParamEqual(param, second); + } + + protected validateKey(key: IConnectionInfoParams): boolean { + const parse = CONNECTION_INFO_PARAM_SCHEMA.safeParse(toJS(key)); + if (!parse.success) { + this.logger.warn(`Invalid resource key ${(schemaValidationError(parse.error).toString(), { prefix: null })}`); + } + return parse.success; + } +} diff --git a/webapp/packages/core-connections/src/ConnectionInfoNetworkHandlersResource.ts b/webapp/packages/core-connections/src/ConnectionInfoNetworkHandlersResource.ts new file mode 100644 index 0000000000..479e75186a --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionInfoNetworkHandlersResource.ts @@ -0,0 +1,112 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { runInAction, toJS } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { ProjectsService } from '@cloudbeaver/core-projects'; +import { CachedMapResource, isResourceAlias, type ResourceKey, resourceKeyList, ResourceKeyUtils } from '@cloudbeaver/core-resource'; +import { type DatabaseConnectionNetworkHandlersFragment, GraphQLService } from '@cloudbeaver/core-sdk'; +import { schemaValidationError } from '@cloudbeaver/core-utils'; + +import { CONNECTION_INFO_PARAM_SCHEMA, type IConnectionInfoParams } from './CONNECTION_INFO_PARAM_SCHEMA.js'; +import { + ConnectionInfoActiveProjectKey, + ConnectionInfoProjectKey, + ConnectionInfoResource, + createConnectionParam, + isConnectionInfoParamEqual, +} from './ConnectionInfoResource.js'; +import { parseConnectionKey } from './parseConnectionKey.js'; + +export type ConnectionInfoNetworkHandlers = DatabaseConnectionNetworkHandlersFragment; + +// TODO reuse it everywhere where include "includeNetworkHandlers" is used +@injectable() +export class ConnectionInfoNetworkHandlersResource extends CachedMapResource { + constructor( + private readonly projectsService: ProjectsService, + private readonly graphQLService: GraphQLService, + private readonly connectionInfoResource: ConnectionInfoResource, + ) { + super(); + + this.aliases.add(ConnectionInfoProjectKey, param => resourceKeyList(this.keys.filter(key => param.options.projectIds.includes(key.projectId)))); + + this.aliases.add(ConnectionInfoActiveProjectKey, () => + resourceKeyList(this.keys.filter(key => projectsService.activeProjects.some(({ id }) => id === key.projectId))), + ); + + this.sync(this.connectionInfoResource); + this.connectionInfoResource.onItemDelete.addHandler(this.delete.bind(this)); + } + + protected async loader( + originalKey: ResourceKey, + _: any, + refresh?: boolean, + ): Promise> { + const connectionsList: ConnectionInfoNetworkHandlers[] = []; + let removedConnections: IConnectionInfoParams[] = []; + const parsed = parseConnectionKey({ + originalKey, + aliases: this.aliases, + isOutdated: this.isOutdated.bind(this), + activeProjects: this.projectsService.activeProjects, + refresh, + }); + + let { projectId } = parsed; + const { projectIds, key } = parsed; + + await ResourceKeyUtils.forEachAsync(key, async connectionKey => { + let connectionId: string | undefined; + if (!isResourceAlias(connectionKey)) { + projectId = connectionKey.projectId; + connectionId = connectionKey.connectionId; + } + + const { connections } = await this.graphQLService.sdk.getUserConnectionsNetworkHandlers({ + projectId, + connectionId, + projectIds, + }); + + if (connectionId && !connections.some(connection => connection.id === connectionId)) { + throw new Error(`Connection is not found (${connectionId})`); + } + + connectionsList.push(...connections); + }); + + runInAction(() => { + if (isResourceAlias(key)) { + removedConnections = ResourceKeyUtils.toList(this.aliases.transformToKey(key)).filter( + filterKey => !connectionsList.some(connection => isConnectionInfoParamEqual(filterKey, createConnectionParam(connection))), + ); + } + + this.delete(resourceKeyList(removedConnections)); + const keys = resourceKeyList(connectionsList.map(createConnectionParam)); + this.set(keys, connectionsList); + }); + + return this.data; + } + + override isKeyEqual(param: IConnectionInfoParams, second: IConnectionInfoParams): boolean { + return isConnectionInfoParamEqual(param, second); + } + + protected validateKey(key: IConnectionInfoParams): boolean { + const parse = CONNECTION_INFO_PARAM_SCHEMA.safeParse(toJS(key)); + if (!parse.success) { + this.logger.warn(`Invalid resource key ${(schemaValidationError(parse.error).toString(), { prefix: null })}`); + } + return parse.success; + } +} diff --git a/webapp/packages/core-connections/src/ConnectionInfoPropertiesResource.ts b/webapp/packages/core-connections/src/ConnectionInfoPropertiesResource.ts new file mode 100644 index 0000000000..5602b5ff62 --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionInfoPropertiesResource.ts @@ -0,0 +1,112 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { runInAction, toJS } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { ProjectsService } from '@cloudbeaver/core-projects'; +import { CachedMapResource, isResourceAlias, type ResourceKey, resourceKeyList, ResourceKeyUtils } from '@cloudbeaver/core-resource'; +import { type DatabaseConnectionPropertiesFragment, GraphQLService } from '@cloudbeaver/core-sdk'; +import { schemaValidationError } from '@cloudbeaver/core-utils'; + +import { CONNECTION_INFO_PARAM_SCHEMA, type IConnectionInfoParams } from './CONNECTION_INFO_PARAM_SCHEMA.js'; +import { + ConnectionInfoActiveProjectKey, + ConnectionInfoProjectKey, + ConnectionInfoResource, + createConnectionParam, + isConnectionInfoParamEqual, +} from './ConnectionInfoResource.js'; +import { parseConnectionKey } from './parseConnectionKey.js'; + +export type ConnectionInfoProperties = DatabaseConnectionPropertiesFragment; + +// TODO reuse it everywhere where include "includeProperties" is used +@injectable() +export class ConnectionInfoPropertiesResource extends CachedMapResource { + constructor( + private readonly projectsService: ProjectsService, + private readonly graphQLService: GraphQLService, + private readonly connectionInfoResource: ConnectionInfoResource, + ) { + super(); + + this.aliases.add(ConnectionInfoProjectKey, param => resourceKeyList(this.keys.filter(key => param.options.projectIds.includes(key.projectId)))); + + this.aliases.add(ConnectionInfoActiveProjectKey, () => + resourceKeyList(this.keys.filter(key => projectsService.activeProjects.some(({ id }) => id === key.projectId))), + ); + + this.sync(this.connectionInfoResource); + this.connectionInfoResource.onItemDelete.addHandler(this.delete.bind(this)); + } + + protected async loader( + originalKey: ResourceKey, + _: any, + refresh?: boolean, + ): Promise> { + const connectionsList: ConnectionInfoProperties[] = []; + let removedConnections: IConnectionInfoParams[] = []; + const parsed = parseConnectionKey({ + originalKey, + aliases: this.aliases, + isOutdated: this.isOutdated.bind(this), + activeProjects: this.projectsService.activeProjects, + refresh, + }); + + let { projectId } = parsed; + const { projectIds, key } = parsed; + + await ResourceKeyUtils.forEachAsync(key, async connectionKey => { + let connectionId: string | undefined; + if (!isResourceAlias(connectionKey)) { + projectId = connectionKey.projectId; + connectionId = connectionKey.connectionId; + } + + const { connections } = await this.graphQLService.sdk.getUserConnectionsProperties({ + projectId, + connectionId, + projectIds, + }); + + if (connectionId && !connections.some(connection => connection.id === connectionId)) { + throw new Error(`Connection is not found (${connectionId})`); + } + + connectionsList.push(...connections); + }); + + runInAction(() => { + if (isResourceAlias(key)) { + removedConnections = ResourceKeyUtils.toList(this.aliases.transformToKey(key)).filter( + filterKey => !connectionsList.some(connection => isConnectionInfoParamEqual(filterKey, createConnectionParam(connection))), + ); + } + + this.delete(resourceKeyList(removedConnections)); + const keys = resourceKeyList(connectionsList.map(createConnectionParam)); + this.set(keys, connectionsList); + }); + + return this.data; + } + + override isKeyEqual(param: IConnectionInfoParams, second: IConnectionInfoParams): boolean { + return isConnectionInfoParamEqual(param, second); + } + + protected validateKey(key: IConnectionInfoParams): boolean { + const parse = CONNECTION_INFO_PARAM_SCHEMA.safeParse(toJS(key)); + if (!parse.success) { + this.logger.warn(`Invalid resource key ${(schemaValidationError(parse.error).toString(), { prefix: null })}`); + } + return parse.success; + } +} diff --git a/webapp/packages/core-connections/src/ConnectionInfoProviderPropertiesResource.ts b/webapp/packages/core-connections/src/ConnectionInfoProviderPropertiesResource.ts new file mode 100644 index 0000000000..13577bd0c6 --- /dev/null +++ b/webapp/packages/core-connections/src/ConnectionInfoProviderPropertiesResource.ts @@ -0,0 +1,112 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { runInAction, toJS } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { ProjectsService } from '@cloudbeaver/core-projects'; +import { CachedMapResource, isResourceAlias, type ResourceKey, resourceKeyList, ResourceKeyUtils } from '@cloudbeaver/core-resource'; +import { type DatabaseConnectionProviderPropertiesFragment, GraphQLService } from '@cloudbeaver/core-sdk'; +import { schemaValidationError } from '@cloudbeaver/core-utils'; + +import { CONNECTION_INFO_PARAM_SCHEMA, type IConnectionInfoParams } from './CONNECTION_INFO_PARAM_SCHEMA.js'; +import { + ConnectionInfoActiveProjectKey, + ConnectionInfoProjectKey, + ConnectionInfoResource, + createConnectionParam, + isConnectionInfoParamEqual, +} from './ConnectionInfoResource.js'; +import { parseConnectionKey } from './parseConnectionKey.js'; + +export type ConnectionInfoProviderProperties = DatabaseConnectionProviderPropertiesFragment; + +// TODO reuse it everywhere where include "includeProviderProperties" is used +@injectable() +export class ConnectionInfoProviderPropertiesResource extends CachedMapResource { + constructor( + private readonly projectsService: ProjectsService, + private readonly graphQLService: GraphQLService, + private readonly connectionInfoResource: ConnectionInfoResource, + ) { + super(); + + this.aliases.add(ConnectionInfoProjectKey, param => resourceKeyList(this.keys.filter(key => param.options.projectIds.includes(key.projectId)))); + + this.aliases.add(ConnectionInfoActiveProjectKey, () => + resourceKeyList(this.keys.filter(key => projectsService.activeProjects.some(({ id }) => id === key.projectId))), + ); + + this.sync(this.connectionInfoResource); + this.connectionInfoResource.onItemDelete.addHandler(this.delete.bind(this)); + } + + protected async loader( + originalKey: ResourceKey, + _: any, + refresh?: boolean, + ): Promise> { + const connectionsList: ConnectionInfoProviderProperties[] = []; + let removedConnections: IConnectionInfoParams[] = []; + const parsed = parseConnectionKey({ + originalKey, + aliases: this.aliases, + isOutdated: this.isOutdated.bind(this), + activeProjects: this.projectsService.activeProjects, + refresh, + }); + + let { projectId } = parsed; + const { projectIds, key } = parsed; + + await ResourceKeyUtils.forEachAsync(key, async connectionKey => { + let connectionId: string | undefined; + if (!isResourceAlias(connectionKey)) { + projectId = connectionKey.projectId; + connectionId = connectionKey.connectionId; + } + + const { connections } = await this.graphQLService.sdk.getUserConnectionsProviderProperties({ + projectId, + connectionId, + projectIds, + }); + + if (connectionId && !connections.some(connection => connection.id === connectionId)) { + throw new Error(`Connection is not found (${connectionId})`); + } + + connectionsList.push(...connections); + }); + + runInAction(() => { + if (isResourceAlias(key)) { + removedConnections = ResourceKeyUtils.toList(this.aliases.transformToKey(key)).filter( + filterKey => !connectionsList.some(connection => isConnectionInfoParamEqual(filterKey, createConnectionParam(connection))), + ); + } + + this.delete(resourceKeyList(removedConnections)); + const keys = resourceKeyList(connectionsList.map(createConnectionParam)); + this.set(keys, connectionsList); + }); + + return this.data; + } + + override isKeyEqual(param: IConnectionInfoParams, second: IConnectionInfoParams): boolean { + return isConnectionInfoParamEqual(param, second); + } + + protected validateKey(key: IConnectionInfoParams): boolean { + const parse = CONNECTION_INFO_PARAM_SCHEMA.safeParse(toJS(key)); + if (!parse.success) { + this.logger.warn(`Invalid resource key ${(schemaValidationError(parse.error).toString(), { prefix: null })}`); + } + return parse.success; + } +} diff --git a/webapp/packages/core-connections/src/ConnectionInfoResource.ts b/webapp/packages/core-connections/src/ConnectionInfoResource.ts index 3113d01dd6..ee5905bf51 100644 --- a/webapp/packages/core-connections/src/ConnectionInfoResource.ts +++ b/webapp/packages/core-connections/src/ConnectionInfoResource.ts @@ -5,6 +5,7 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { action, makeObservable, observable, runInAction, toJS } from 'mobx'; import { AppAuthService, UserInfoResource } from '@cloudbeaver/core-authentication'; @@ -270,13 +271,6 @@ export class ConnectionInfoResource extends CachedMapResource): boolean; isConnecting(key: ResourceKey): boolean; @@ -484,6 +478,7 @@ export class ConnectionInfoResource extends CachedMapResource { const { connection } = await this.graphQLService.sdk.updateConnection({ projectId: key.projectId, + connectionId: key.connectionId, config, ...this.getDefaultIncludes(), ...this.getIncludesMap(key), diff --git a/webapp/packages/core-connections/src/index.ts b/webapp/packages/core-connections/src/index.ts index 2671981792..22936133b2 100644 --- a/webapp/packages/core-connections/src/index.ts +++ b/webapp/packages/core-connections/src/index.ts @@ -38,6 +38,11 @@ export * from './ConnectionInfoEventHandler.js'; export * from './ConnectionInfoResource.js'; export * from './ConnectionInfoOriginResource.js'; export * from './ConnectionInfoOriginDetailsResource.js'; +export * from './ConnectionInfoAuthPropertiesResource.js'; +export * from './ConnectionInfoCustomOptionsResource.js'; +export * from './ConnectionInfoPropertiesResource.js'; +export * from './ConnectionInfoProviderPropertiesResource.js'; +export * from './ConnectionInfoNetworkHandlersResource.js'; export * from './CONNECTIONS_SETTINGS_GROUP.js'; export * from './EConnectionFeature.js'; export * from './ConnectionsSettingsService.js'; diff --git a/webapp/packages/core-connections/src/locales/de.ts b/webapp/packages/core-connections/src/locales/de.ts index 4775fa9902..d2f7910bd7 100644 --- a/webapp/packages/core-connections/src/locales/de.ts +++ b/webapp/packages/core-connections/src/locales/de.ts @@ -62,4 +62,6 @@ export default [ ['connections_templates_deprecated_message', 'Template connections are deprecated and will be removed in future releases'], ['core_connections_connection_driver_not_installed', 'Driver is not installed'], ['core_connections_connection_temporary', 'Temporary connection'], + ['core_connections_connection_create_success', 'Connection was created'], + ['core_connections_connection_update_success', 'Connection was updated'], ]; diff --git a/webapp/packages/core-connections/src/locales/en.ts b/webapp/packages/core-connections/src/locales/en.ts index 90bc89a416..26a6987071 100644 --- a/webapp/packages/core-connections/src/locales/en.ts +++ b/webapp/packages/core-connections/src/locales/en.ts @@ -66,6 +66,8 @@ export default [ ['connections_connection_host', 'Host'], ['connections_connection_port', 'Port'], ['connections_connection_template', 'Template'], + ['core_connections_connection_create_success', 'Connection was created'], + ['core_connections_connection_update_success', 'Connection was updated'], ['connections_connection_test', 'Test'], ['connections_connection_test_tooltip', 'Test connection'], ['connections_connection_test_fail', 'Connection test failed'], diff --git a/webapp/packages/core-connections/src/locales/fr.ts b/webapp/packages/core-connections/src/locales/fr.ts index be23efa59f..ede518f719 100644 --- a/webapp/packages/core-connections/src/locales/fr.ts +++ b/webapp/packages/core-connections/src/locales/fr.ts @@ -76,6 +76,8 @@ export default [ ['connections_connection_port', 'Port'], ['connections_connection_read_only', 'Read-only connection'], ['connections_connection_template', 'Modèle'], + ['core_connections_connection_create_success', 'Connection was created'], + ['core_connections_connection_update_success', 'Connection was updated'], ['connections_connection_test', 'Tester'], ['connections_connection_test_tooltip', 'Tester la connexion'], ['connections_connection_test_fail', 'Échec du test de connexion'], diff --git a/webapp/packages/core-connections/src/locales/it.ts b/webapp/packages/core-connections/src/locales/it.ts index 675cab0af8..39d2017903 100644 --- a/webapp/packages/core-connections/src/locales/it.ts +++ b/webapp/packages/core-connections/src/locales/it.ts @@ -64,6 +64,8 @@ export default [ ['connections_connection_host', 'Host'], ['connections_connection_port', 'Porta'], ['connections_connection_template', 'Template'], + ['core_connections_connection_create_success', 'Connection was created'], + ['core_connections_connection_update_success', 'Connection was updated'], ['connections_connection_test', 'Prova la connessione'], ['connections_connection_test_tooltip', 'Test connection'], ['connections_connection_test_fail', 'Prova di connessione fallita'], diff --git a/webapp/packages/core-connections/src/locales/ru.ts b/webapp/packages/core-connections/src/locales/ru.ts index 8af55497ce..00f2c92a5a 100644 --- a/webapp/packages/core-connections/src/locales/ru.ts +++ b/webapp/packages/core-connections/src/locales/ru.ts @@ -70,6 +70,8 @@ export default [ ['connections_connection_host', 'Хост'], ['connections_connection_port', 'Порт'], ['connections_connection_template', 'Шаблон'], + ['core_connections_connection_create_success', 'Подключение создано'], + ['core_connections_connection_update_success', 'Подключение обновлено'], ['connections_connection_test', 'Тест'], ['connections_connection_test_tooltip', 'Тестовое соединение'], ['connections_connection_test_fail', 'Не удалось выполнить подключение'], diff --git a/webapp/packages/core-connections/src/locales/zh.ts b/webapp/packages/core-connections/src/locales/zh.ts index 4428d50cbf..30ac74446a 100644 --- a/webapp/packages/core-connections/src/locales/zh.ts +++ b/webapp/packages/core-connections/src/locales/zh.ts @@ -59,6 +59,8 @@ export default [ ['connections_connection_host', '主机'], ['connections_connection_port', '端口'], ['connections_connection_template', '模板'], + ['core_connections_connection_create_success', 'Connection was created'], + ['core_connections_connection_update_success', 'Connection was updated'], ['connections_connection_test', '测试连接'], ['connections_connection_test_tooltip', '测试链接'], ['connections_connection_test_fail', '连接测试失败'], diff --git a/webapp/packages/core-connections/src/manifest.ts b/webapp/packages/core-connections/src/manifest.ts index 16c8bfde8f..981f91290f 100644 --- a/webapp/packages/core-connections/src/manifest.ts +++ b/webapp/packages/core-connections/src/manifest.ts @@ -20,6 +20,11 @@ export const manifest: PluginManifest = { () => import('./ConnectionInfoResource.js').then(m => m.ConnectionInfoResource), () => import('./ConnectionInfoOriginResource.js').then(m => m.ConnectionInfoOriginResource), () => import('./ConnectionInfoOriginDetailsResource.js').then(m => m.ConnectionInfoOriginDetailsResource), + () => import('./ConnectionInfoAuthPropertiesResource.js').then(m => m.ConnectionInfoAuthPropertiesResource), + () => import('./ConnectionInfoCustomOptionsResource.js').then(m => m.ConnectionInfoCustomOptionsResource), + () => import('./ConnectionInfoPropertiesResource.js').then(m => m.ConnectionInfoPropertiesResource), + () => import('./ConnectionInfoProviderPropertiesResource.js').then(m => m.ConnectionInfoProviderPropertiesResource), + () => import('./ConnectionInfoNetworkHandlersResource.js').then(m => m.ConnectionInfoNetworkHandlersResource), () => import('./ConnectionToolsResource.js').then(m => m.ConnectionToolsResource), () => import('./ContainerResource.js').then(m => m.ContainerResource), () => import('./ConnectionsLocaleService.js').then(m => m.ConnectionsLocaleService), diff --git a/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsAuthProperties.gql b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsAuthProperties.gql new file mode 100644 index 0000000000..0ea764324d --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsAuthProperties.gql @@ -0,0 +1,5 @@ +query getUserConnectionsAuthProperties($projectId: ID, $connectionId: ID, $projectIds: [ID!]) { + connections: userConnections(projectId: $projectId, id: $connectionId, projectIds: $projectIds) { + ...DatabaseConnectionAuthProperties + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsCustomOptions.gql b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsCustomOptions.gql new file mode 100644 index 0000000000..6cb8022c9b --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsCustomOptions.gql @@ -0,0 +1,5 @@ +query getUserConnectionsCustomOptions($projectId: ID, $connectionId: ID, $projectIds: [ID!]) { + connections: userConnections(projectId: $projectId, id: $connectionId, projectIds: $projectIds) { + ...DatabaseConnectionCustomOptions + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsNetworkHandlers.gql b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsNetworkHandlers.gql new file mode 100644 index 0000000000..d603134c90 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsNetworkHandlers.gql @@ -0,0 +1,5 @@ +query getUserConnectionsNetworkHandlers($projectId: ID, $connectionId: ID, $projectIds: [ID!]) { + connections: userConnections(projectId: $projectId, id: $connectionId, projectIds: $projectIds) { + ...DatabaseConnectionNetworkHandlers + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProperties.gql b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProperties.gql new file mode 100644 index 0000000000..4a0580d1f9 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProperties.gql @@ -0,0 +1,5 @@ +query getUserConnectionsProperties($projectId: ID, $connectionId: ID, $projectIds: [ID!]) { + connections: userConnections(projectId: $projectId, id: $connectionId, projectIds: $projectIds) { + ...DatabaseConnectionProperties + } +} diff --git a/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProviderProperties.gql b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProviderProperties.gql new file mode 100644 index 0000000000..d2291545ea --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/connections/getUserConnectionsProviderProperties.gql @@ -0,0 +1,5 @@ +query getUserConnectionsProviderProperties($projectId: ID, $connectionId: ID, $projectIds: [ID!]) { + connections: userConnections(projectId: $projectId, id: $connectionId, projectIds: $projectIds) { + ...DatabaseConnectionProviderProperties + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionAuthProperties.gql b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionAuthProperties.gql new file mode 100644 index 0000000000..dae97276dd --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionAuthProperties.gql @@ -0,0 +1,11 @@ +fragment DatabaseConnectionAuthProperties on ConnectionInfo { + id + projectId + credentialsSaved + authModel + sharedCredentials + requiredAuth + authProperties { + ...UserConnectionAuthProperties + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionCustomOptions.gql b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionCustomOptions.gql new file mode 100644 index 0000000000..c439ad10e3 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionCustomOptions.gql @@ -0,0 +1,20 @@ +fragment DatabaseConnectionCustomOptions on ConnectionInfo { + id + projectId + configurationType + useUrl + host + port + serverName + databaseName + url + name + description + folder + template + driverId + keepAliveInterval + autocommit + readOnly + mainPropertyValues +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionNetworkHandlers.gql b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionNetworkHandlers.gql new file mode 100644 index 0000000000..42bedc2e03 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionNetworkHandlers.gql @@ -0,0 +1,14 @@ +fragment DatabaseConnectionNetworkHandlers on ConnectionInfo { + id + projectId + networkHandlersConfig { + ...NetworkHandlerBasics + authType + userName + password + key + savePassword + properties + secureProperties + } +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProperties.gql b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProperties.gql new file mode 100644 index 0000000000..e5004e3860 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProperties.gql @@ -0,0 +1,5 @@ +fragment DatabaseConnectionProperties on ConnectionInfo { + id + projectId + properties +} diff --git a/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProviderProperties.gql b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProviderProperties.gql new file mode 100644 index 0000000000..74a70e7ccc --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/fragments/DatabaseConnectionProviderProperties.gql @@ -0,0 +1,5 @@ +fragment DatabaseConnectionProviderProperties on ConnectionInfo { + id + projectId + providerProperties +} diff --git a/webapp/packages/core-ui/src/Form/FormPart.ts b/webapp/packages/core-ui/src/Form/FormPart.ts index 088a69acc8..2ea4271ad1 100644 --- a/webapp/packages/core-ui/src/Form/FormPart.ts +++ b/webapp/packages/core-ui/src/Form/FormPart.ts @@ -12,6 +12,7 @@ import { isObjectsEqual } from '@cloudbeaver/core-utils'; import type { IFormPart } from './IFormPart.js'; import type { IFormState } from './IFormState.js'; +import { formSubmitContext } from './formSubmitContext.js'; export abstract class FormPart implements IFormPart { state: TPartState; @@ -39,7 +40,7 @@ export abstract class FormPart implements IFormPar this.loading = false; this.formState.submitTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.save.bind(this))); - this.formState.formatTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.format.bind(this))); + this.formState.formatTask.addHandler(executorHandlerFilter(() => this.isLoaded() && this.isChanged, this.format.bind(this))); this.formState.validationTask.addHandler(executorHandlerFilter(() => this.isLoaded(), this.handleValidation.bind(this))); makeObservable(this, { @@ -84,7 +85,7 @@ export abstract class FormPart implements IFormPar return !isObjectsEqual(this.initialState, this.state); } - async save(data: IFormState, contexts: IExecutionContextProvider>): Promise { + async save(data: IFormState, contexts: IExecutionContextProvider>): Promise { if (this.loading) { return; } @@ -93,8 +94,9 @@ export abstract class FormPart implements IFormPar try { await this.loader(); + const formSubmit = contexts.getContext(formSubmitContext); - if (!this.isChanged) { + if (!this.isChanged && !formSubmit.submitOnNoChanges) { return; } @@ -142,7 +144,7 @@ export abstract class FormPart implements IFormPar await this.load(); } - reset() { + reset(): void { this.setState(toJS(this.initialState)); } @@ -155,7 +157,7 @@ export abstract class FormPart implements IFormPar } } - protected setInitialState(initialState: TPartState) { + protected setInitialState(initialState: TPartState): void { const isChanged = this.isChanged; this.initialState = initialState; @@ -167,7 +169,7 @@ export abstract class FormPart implements IFormPar this.setState(toJS(this.initialState)); } - protected setState(state: TPartState) { + protected setState(state: TPartState): void { this.state = state; } diff --git a/webapp/packages/core-ui/src/Form/FormState.ts b/webapp/packages/core-ui/src/Form/FormState.ts index c223ec7f88..5250513a17 100644 --- a/webapp/packages/core-ui/src/Form/FormState.ts +++ b/webapp/packages/core-ui/src/Form/FormState.ts @@ -10,7 +10,7 @@ import { action, computed, makeObservable, observable } from 'mobx'; import { DataContext, dataContextAddDIProvider, type DataContextGetter, type IDataContext } from '@cloudbeaver/core-data-context'; import type { IServiceProvider } from '@cloudbeaver/core-di'; import type { ENotificationType } from '@cloudbeaver/core-events'; -import { Executor, ExecutorInterrupter, type IExecutionContextProvider, type IExecutor } from '@cloudbeaver/core-executor'; +import { Executor, ExecutorInterrupter, type IExecutionContext, type IExecutionContextProvider, type IExecutor } from '@cloudbeaver/core-executor'; import { isArraysEqual, isNotNullDefined, MetadataMap, uuid } from '@cloudbeaver/core-utils'; import { DATA_CONTEXT_LOADABLE_STATE, loadableStateContext } from '@cloudbeaver/core-view'; @@ -28,6 +28,7 @@ export class FormState implements IFormState { statusMessage: string | string[] | null; statusType: ENotificationType | null; + isReadOnly: boolean; savingPromise: Promise | null; @@ -61,14 +62,14 @@ export class FormState implements IFormState { this.statusMessage = null; this.statusType = null; - + this.isReadOnly = false; this.savingPromise = null; this.formStateTask = new Executor(state, () => true); this.formStateTask.addCollection(service.onState).addPostHandler(this.updateFormState.bind(this)); this.loadedTask = new Executor(this as IFormState, () => true); - this.loadedTask.addCollection(service.onLoaded).next(this.formStateTask).addPostHandler(this.onLoadedHandler.bind(this)); + this.loadedTask.addCollection(service.onLoaded).next(this.formStateTask); this.formatTask = new Executor(this as IFormState, () => true); this.formatTask.addCollection(service.onFormat); @@ -106,7 +107,7 @@ export class FormState implements IFormState { }); } - get partsValues() { + get partsValues(): IFormPart[] { return Array.from(this.parts.values()); } @@ -172,9 +173,9 @@ export class FormState implements IFormState { return this; } - async save(): Promise { + async save(providedContext?: IExecutionContext>): Promise { try { - this.savingPromise = this.submitTask.execute(this); + this.savingPromise = this.submitTask.execute(this, providedContext); const context = await this.savingPromise; if (ExecutorInterrupter.isInterrupted(context)) { @@ -190,15 +191,6 @@ export class FormState implements IFormState { return false; } - private onLoadedHandler(data: IFormState, contexts: IExecutionContextProvider>): void { - for (const part of this.parts.values()) { - if (!part.isLoaded()) { - ExecutorInterrupter.interrupt(contexts); - return; - } - } - } - private updateFormState(data: TState, contexts: IExecutionContextProvider): void { const context = contexts.getContext(formStateContext); @@ -208,6 +200,7 @@ export class FormState implements IFormState { this.statusMessage = context.statusMessage; this.statusType = context.statusType; + this.isReadOnly = context.readonly; } async dispose(): Promise { diff --git a/webapp/packages/core-ui/src/Form/IFormPart.ts b/webapp/packages/core-ui/src/Form/IFormPart.ts index e54e8bb9cd..dc70be0b76 100644 --- a/webapp/packages/core-ui/src/Form/IFormPart.ts +++ b/webapp/packages/core-ui/src/Form/IFormPart.ts @@ -16,6 +16,7 @@ export interface IFormPart extends ILoadableState { readonly isChanged: boolean; load(): Promise; + reload(): Promise; reset(): void; dispose(): void | Promise; } diff --git a/webapp/packages/core-ui/src/Form/IFormState.ts b/webapp/packages/core-ui/src/Form/IFormState.ts index f3fb9a36fe..884b6ba27c 100644 --- a/webapp/packages/core-ui/src/Form/IFormState.ts +++ b/webapp/packages/core-ui/src/Form/IFormState.ts @@ -7,7 +7,7 @@ */ import type { DataContextGetter, IDataContext } from '@cloudbeaver/core-data-context'; import type { ENotificationType } from '@cloudbeaver/core-events'; -import type { IExecutor } from '@cloudbeaver/core-executor'; +import type { IExecutionContext, IExecutor } from '@cloudbeaver/core-executor'; import type { MetadataMap } from '@cloudbeaver/core-utils'; import type { FormBaseService } from './FormBaseService.js'; @@ -45,8 +45,9 @@ export interface IFormState { isError: boolean; isCancelled: boolean; isChanged: boolean; + isReadOnly: boolean; - save(): Promise; + save(providedContext?: IExecutionContext>): Promise; reset(): void; cancel(): void; dispose(): void | Promise; diff --git a/webapp/packages/core-ui/src/Form/formSubmitContext.ts b/webapp/packages/core-ui/src/Form/formSubmitContext.ts new file mode 100644 index 0000000000..1ebdba78d8 --- /dev/null +++ b/webapp/packages/core-ui/src/Form/formSubmitContext.ts @@ -0,0 +1,27 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export interface IFormSubmitContext { + type: string; + submitOnNoChanges: boolean; + setType(type: string): void; + setSubmitOnNoChanges(submitOnNoChanges: boolean): void; +} + +export function formSubmitContext(): IFormSubmitContext { + return { + type: 'submit', + submitOnNoChanges: false, + setType(type) { + this.type = type; + }, + setSubmitOnNoChanges(submitOnNoChanges) { + this.submitOnNoChanges = submitOnNoChanges; + }, + }; +} diff --git a/webapp/packages/core-ui/src/Tabs/Tab/TabDefault.tsx b/webapp/packages/core-ui/src/Tabs/Tab/TabDefault.tsx index 50be2c8fcc..fdce2017e9 100644 --- a/webapp/packages/core-ui/src/Tabs/Tab/TabDefault.tsx +++ b/webapp/packages/core-ui/src/Tabs/Tab/TabDefault.tsx @@ -1,11 +1,11 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { useContext, useMemo } from 'react'; +import { Suspense, useContext, useMemo } from 'react'; import { Translate } from '@cloudbeaver/core-blocks'; @@ -47,15 +47,18 @@ export function TabDefault>({ const TabComponent = component; return ( - + + {/* TODO: do we want to fallback to default tab with provided name and icon? */} + + ); } diff --git a/webapp/packages/core-ui/src/index.ts b/webapp/packages/core-ui/src/index.ts index f00d8836f7..a625f1b390 100644 --- a/webapp/packages/core-ui/src/index.ts +++ b/webapp/packages/core-ui/src/index.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -33,6 +33,7 @@ export * from './Form/FormPart.js'; export * from './Form/formStateContext.js'; export * from './Form/formStatusContext.js'; export * from './Form/formValidationContext.js'; +export * from './Form/formSubmitContext.js'; export * from './Form/IFormPart.js'; export * from './Form/IFormProps.js'; export * from './Form/IFormState.js'; diff --git a/webapp/packages/core-utils/src/getSubjectDifferences.ts b/webapp/packages/core-utils/src/getSubjectDifferences.ts new file mode 100644 index 0000000000..efd9debb60 --- /dev/null +++ b/webapp/packages/core-utils/src/getSubjectDifferences.ts @@ -0,0 +1,14 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +export function getSubjectDifferences(current: string[], next: string[]): { subjectsToRevoke: string[]; subjectsToGrant: string[] } { + const subjectsToRevoke = current.filter(subjectId => !next.includes(subjectId)); + const subjectsToGrant = next.filter(subjectId => !current.includes(subjectId)); + + return { subjectsToRevoke, subjectsToGrant }; +} diff --git a/webapp/packages/core-utils/src/index.ts b/webapp/packages/core-utils/src/index.ts index 64608bd2a4..5e6c805333 100644 --- a/webapp/packages/core-utils/src/index.ts +++ b/webapp/packages/core-utils/src/index.ts @@ -88,3 +88,4 @@ export * from './types/UndefinedToNull.js'; export * from './bindFunctions.js'; export * from './getDomainFromUrl.js'; export * from './isNumber.js'; +export * from './getSubjectDifferences.js'; diff --git a/webapp/packages/eslint-plugin/noSyncComponentImport.js b/webapp/packages/eslint-plugin/noSyncComponentImport.js index a97f4b78e4..1341854765 100644 --- a/webapp/packages/eslint-plugin/noSyncComponentImport.js +++ b/webapp/packages/eslint-plugin/noSyncComponentImport.js @@ -8,7 +8,6 @@ import path from 'path'; import fs from 'fs'; -import pnpapi from 'pnpapi'; export default { meta: { diff --git a/webapp/packages/plugin-connection-search/src/Search/ConnectionSearchService.ts b/webapp/packages/plugin-connection-search/src/Search/ConnectionSearchService.ts index ab3ce752a7..c13f7dcd06 100644 --- a/webapp/packages/plugin-connection-search/src/Search/ConnectionSearchService.ts +++ b/webapp/packages/plugin-connection-search/src/Search/ConnectionSearchService.ts @@ -1,29 +1,24 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { makeObservable, observable } from 'mobx'; -import { ConfirmationDialog } from '@cloudbeaver/core-blocks'; -import { - ConnectionInfoOriginResource, - ConnectionInfoResource, - ConnectionsManagerService, - createConnectionParam, -} from '@cloudbeaver/core-connections'; -import { injectable } from '@cloudbeaver/core-di'; +import { action, makeObservable, observable } from 'mobx'; + +import { ConfirmationDialog, importLazyComponent } from '@cloudbeaver/core-blocks'; +import { ConnectionInfoResource, ConnectionsManagerService, createConnectionParam } from '@cloudbeaver/core-connections'; +import { injectable, IServiceProvider } from '@cloudbeaver/core-di'; import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; import { NotificationService } from '@cloudbeaver/core-events'; import { ExecutorInterrupter, type IExecutorHandler } from '@cloudbeaver/core-executor'; -import { ProjectInfoResource, ProjectsService } from '@cloudbeaver/core-projects'; import type { AdminConnectionSearchInfo } from '@cloudbeaver/core-sdk'; import { OptionsPanelService } from '@cloudbeaver/core-ui'; -import { ConnectionFormService, ConnectionFormState, type IConnectionFormState } from '@cloudbeaver/plugin-connections'; +import { ConnectionFormService, ConnectionFormState, getConnectionFormOptionsPart } from '@cloudbeaver/plugin-connections'; -import { SearchDatabase } from './SearchDatabase.js'; +const SearchDatabase = importLazyComponent(() => import('./SearchDatabase.js').then(module => module.SearchDatabase)); const formGetter = () => SearchDatabase; @@ -34,18 +29,16 @@ export class ConnectionSearchService { disabled = false; - formState: IConnectionFormState | null = null; + formState: ConnectionFormState | null = null; constructor( private readonly notificationService: NotificationService, private readonly connectionInfoResource: ConnectionInfoResource, - private readonly connectionFormService: ConnectionFormService, + private readonly serviceProvider: IServiceProvider, private readonly optionsPanelService: OptionsPanelService, + private readonly connectionFormService: ConnectionFormService, private readonly commonDialogService: CommonDialogService, - private readonly projectsService: ProjectsService, - private readonly projectInfoResource: ProjectInfoResource, private readonly connectionsManagerService: ConnectionsManagerService, - private readonly connectionInfoOriginResource: ConnectionInfoOriginResource, ) { this.optionsPanelService.closeTask.addHandler(this.closeHandler); @@ -59,6 +52,7 @@ export class ConnectionSearchService { databases: observable, disabled: observable, formState: observable.shallow, + select: action, }); } @@ -110,20 +104,22 @@ export class ConnectionSearchService { this.close(); }; + private get optionsPart() { + return this.formState ? getConnectionFormOptionsPart(this.formState) : null; + } + private async showUnsavedChangesDialog(): Promise { if ( !this.formState || !this.optionsPanelService.isOpen(formGetter) || - (this.formState.config.connectionId && - this.formState.projectId !== null && - !this.connectionInfoResource.has(createConnectionParam(this.formState.projectId, this.formState.config.connectionId))) + (this.optionsPart?.state.connectionId && + this.formState.state.projectId !== null && + !this.connectionInfoResource.has(createConnectionParam(this.formState.state.projectId, this.optionsPart.state.connectionId))) ) { return true; } - const state = await this.formState.checkFormState(); - - if (!state?.edited) { + if (!this.formState.isChanged) { return true; } @@ -140,15 +136,15 @@ export class ConnectionSearchService { this.hosts = hosts; } - saveConnection() { + saveConnection(): void { this.goBack(); } - goBack() { + goBack(): void { this.clearFormState(); } - select(database: AdminConnectionSearchInfo): void { + async select(database: AdminConnectionSearchInfo): Promise { const projects = this.connectionsManagerService.createConnectionProjects; if (projects.length === 0) { @@ -156,29 +152,23 @@ export class ConnectionSearchService { return; } - if (!this.formState) { - this.formState = new ConnectionFormState( - this.projectsService, - this.projectInfoResource, - this.connectionFormService, - this.connectionInfoResource, - this.connectionInfoOriginResource, - ); + this.formState?.dispose(); + this.formState = new ConnectionFormState(this.serviceProvider, this.connectionFormService, { + projectId: projects[0]!.id, + availableDrivers: database.possibleDrivers, + type: 'public', + requiredNetworkHandlersIds: [], + connectionId: undefined, + }); - this.formState.closeTask.addHandler(this.goBack.bind(this)); - } + await this.optionsPart?.load(); + await this.optionsPart?.setDriverId(database.defaultDriver); - this.formState - .setOptions('create', 'public') - .setConfig(projects[0]!.id, { - ...this.connectionInfoResource.getEmptyConfig(), - driverId: database.defaultDriver, - host: database.host, - port: `${database.port}`, - }) - .setAvailableDrivers(database.possibleDrivers); + this.optionsPart!.state.host = database.host; + this.optionsPart!.state.port = String(database.port); + this.optionsPart!.state.driverId = database.defaultDriver; - this.formState.load(); + this.formState.disposeTask.addHandler(this.goBack.bind(this)); } private clearFormState() { diff --git a/webapp/packages/plugin-connection-search/src/Search/SearchDatabase.tsx b/webapp/packages/plugin-connection-search/src/Search/SearchDatabase.tsx index e7381091ad..c5ed0ce3b6 100644 --- a/webapp/packages/plugin-connection-search/src/Search/SearchDatabase.tsx +++ b/webapp/packages/plugin-connection-search/src/Search/SearchDatabase.tsx @@ -34,7 +34,7 @@ export const SearchDatabase: React.FC = observer(function SearchDatabase() { return ( connectionSearchService.saveConnection()} onCancel={() => connectionSearchService.goBack()} /> diff --git a/webapp/packages/plugin-connections-administration/package.json b/webapp/packages/plugin-connections-administration/package.json index ed434d3f22..cfee15c69e 100644 --- a/webapp/packages/plugin-connections-administration/package.json +++ b/webapp/packages/plugin-connections-administration/package.json @@ -23,6 +23,7 @@ "@cloudbeaver/core-authentication": "workspace:*", "@cloudbeaver/core-blocks": "workspace:*", "@cloudbeaver/core-connections": "workspace:*", + "@cloudbeaver/core-data-context": "workspace:*", "@cloudbeaver/core-di": "workspace:*", "@cloudbeaver/core-dialogs": "workspace:*", "@cloudbeaver/core-events": "workspace:*", diff --git a/webapp/packages/plugin-connections-administration/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx b/webapp/packages/plugin-connections-administration/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx index 71c9ed2a0d..95424f947c 100644 --- a/webapp/packages/plugin-connections-administration/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx +++ b/webapp/packages/plugin-connections-administration/src/Administration/Connections/ConnectionsTable/ConnectionEdit.tsx @@ -1,48 +1,41 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { observer } from 'mobx-react-lite'; -import { useMemo } from 'react'; import { Loader, s, useS } from '@cloudbeaver/core-blocks'; -import { ConnectionInfoOriginResource, ConnectionInfoResource, type IConnectionInfoParams } from '@cloudbeaver/core-connections'; -import { useService } from '@cloudbeaver/core-di'; +import { type IConnectionInfoParams } from '@cloudbeaver/core-connections'; import { ConnectionFormLoader, useConnectionFormState } from '@cloudbeaver/plugin-connections'; import styles from './ConnectionEdit.module.css'; +import { FormMode } from '@cloudbeaver/core-ui'; +import { runInAction } from 'mobx'; interface Props { item: IConnectionInfoParams; } export const ConnectionEdit = observer(function ConnectionEditNew({ item }) { - const connectionInfoResource = useService(ConnectionInfoResource); - const connectionInfoOriginResource = useService(ConnectionInfoOriginResource); - // const tableContext = useContext(TableContext); - // const collapse = useCallback(() => tableContext?.setItemExpand(item, false), [tableContext, item]); + const data = useConnectionFormState(item, state => { + state.setMode(FormMode.Edit); - const data = useConnectionFormState(connectionInfoResource, connectionInfoOriginResource, state => state.setOptions('edit', 'admin')); + runInAction(() => { + state.state.connectionId = item.connectionId; + state.state.projectId = item.projectId; + state.state.type = 'admin'; + }); + }); const style = useS(styles); - const projectId = item.projectId; - const connectionId = item.connectionId; - - useMemo(() => { - data.setConfig(projectId, { connectionId }); - }, [data, projectId, connectionId]); - return (
- +
); diff --git a/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/CreateConnection.tsx b/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/CreateConnection.tsx index 0eb9a0cea9..acaacd7071 100644 --- a/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/CreateConnection.tsx +++ b/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/CreateConnection.tsx @@ -1,10 +1,11 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { observer } from 'mobx-react-lite'; import { @@ -25,7 +26,7 @@ import { import { DBDriverResource } from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { TabListStyles, TabPanelList, TabPanelStyles, TabsState, TabStyles } from '@cloudbeaver/core-ui'; -import { ConnectionFormLoader } from '@cloudbeaver/plugin-connections'; +import { ConnectionFormLoader, getConnectionFormOptionsPart } from '@cloudbeaver/plugin-connections'; import { CreateConnectionService } from '../CreateConnectionService.js'; import styles from './shared/CreateConnection.module.css'; @@ -48,7 +49,8 @@ export const CreateConnection = observer(function CreateConnection({ meth const style = useS(styles); const createConnectionService = useService(CreateConnectionService); const translate = useTranslate(); - const driver = useResource(CreateConnection, DBDriverResource, createConnectionService.data?.config.driverId || null); + const optionsPart = createConnectionService.data ? getConnectionFormOptionsPart(createConnectionService.data) : null; + const driver = useResource(CreateConnection, DBDriverResource, optionsPart?.state.driverId || null); if (createConnectionService.data) { return ( @@ -75,7 +77,7 @@ export const CreateConnection = observer(function CreateConnection({ meth diff --git a/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/Manual/ConnectionManualService.ts b/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/Manual/ConnectionManualService.ts index 8b51edd2f6..9d9af6f50c 100644 --- a/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/Manual/ConnectionManualService.ts +++ b/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnection/Manual/ConnectionManualService.ts @@ -1,11 +1,11 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; + import { injectable } from '@cloudbeaver/core-di'; import { CreateConnectionService } from '../../CreateConnectionService.js'; @@ -20,7 +20,7 @@ export class ConnectionManualService { this.createConnectionService.disabled = value; } - constructor(private readonly connectionInfoResource: ConnectionInfoResource, private readonly createConnectionService: CreateConnectionService) { + constructor(private readonly createConnectionService: CreateConnectionService) { this.select = this.select.bind(this); } @@ -28,7 +28,6 @@ export class ConnectionManualService { this.createConnectionService.setConnectionTemplate( projectId, { - ...this.connectionInfoResource.getEmptyConfig(), template: true, driverId, }, diff --git a/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnectionService.ts b/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnectionService.ts index d2f80db241..dda1809afa 100644 --- a/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnectionService.ts +++ b/webapp/packages/plugin-connections-administration/src/Administration/Connections/CreateConnectionService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,12 +8,10 @@ import { action, makeObservable, observable } from 'mobx'; import { AdministrationScreenService } from '@cloudbeaver/core-administration'; -import { ConnectionInfoOriginResource, ConnectionInfoResource } from '@cloudbeaver/core-connections'; -import { injectable } from '@cloudbeaver/core-di'; -import { ProjectInfoResource, ProjectsService } from '@cloudbeaver/core-projects'; +import { injectable, IServiceProvider } from '@cloudbeaver/core-di'; import type { ConnectionConfig } from '@cloudbeaver/core-sdk'; import { TabsContainer } from '@cloudbeaver/core-ui'; -import { ConnectionFormService, ConnectionFormState, type IConnectionFormState } from '@cloudbeaver/plugin-connections'; +import { ConnectionFormService, ConnectionFormState, getConnectionFormOptionsPart } from '@cloudbeaver/plugin-connections'; import { ConnectionsAdministrationNavService } from './ConnectionsAdministrationNavService.js'; @@ -27,18 +25,15 @@ export interface ICreateMethodOptions { @injectable() export class CreateConnectionService { disabled = false; - data: IConnectionFormState | null; + data: ConnectionFormState | null; readonly tabsContainer: TabsContainer; constructor( private readonly connectionsAdministrationNavService: ConnectionsAdministrationNavService, private readonly administrationScreenService: AdministrationScreenService, + private readonly serviceProvider: IServiceProvider, private readonly connectionFormService: ConnectionFormService, - private readonly connectionInfoResource: ConnectionInfoResource, - private readonly projectsService: ProjectsService, - private readonly projectInfoResource: ProjectInfoResource, - private readonly connectionInfoOriginResource: ConnectionInfoOriginResource, ) { this.data = null; this.tabsContainer = new TabsContainer('Connection Creation mode'); @@ -109,23 +104,26 @@ export class CreateConnectionService { this.activateMethod(defaultId); } - setConnectionTemplate(projectId: string, config: ConnectionConfig, availableDrivers: string[]): void { - this.data = new ConnectionFormState( - this.projectsService, - this.projectInfoResource, - this.connectionFormService, - this.connectionInfoResource, - this.connectionInfoOriginResource, - ); + private get optionsPart() { + return this.data ? getConnectionFormOptionsPart(this.data) : null; + } - this.data.closeTask.addHandler(this.cancelCreate.bind(this)); + async setConnectionTemplate(projectId: string, config: ConnectionConfig, availableDrivers: string[]): Promise { + this.clearConnectionTemplate(); + this.data = new ConnectionFormState(this.serviceProvider, this.connectionFormService, { + projectId, + availableDrivers, + type: 'admin', + requiredNetworkHandlersIds: [], + connectionId: config.connectionId, + }); - this.data - .setOptions('create', 'admin') - .setConfig(projectId, config) - .setAvailableDrivers(availableDrivers || []); + await this.optionsPart?.load(); + await this.optionsPart?.setDriverId(config.driverId); - this.data.load(); + Object.assign(this.optionsPart!.state, config); + + this.data.disposeTask.addHandler(this.cancelCreate.bind(this)); } clearConnectionTemplate(): void { diff --git a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx index 03677e20ac..661095144b 100644 --- a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx +++ b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccess.tsx @@ -1,13 +1,14 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { computed } from 'mobx'; + +import { action, computed, observable } from 'mobx'; import { observer } from 'mobx-react-lite'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { TeamsResource, UsersResource, UsersResourceFilterKey } from '@cloudbeaver/core-authentication'; import { @@ -19,29 +20,43 @@ import { s, TextPlaceholder, useAutoLoad, + useObservableRef, useResource, useS, useTranslate, } from '@cloudbeaver/core-blocks'; -import { isCloudConnection } from '@cloudbeaver/core-connections'; +import { ConnectionInfoOriginResource, ConnectionInfoResource, createConnectionParam, isCloudConnection } from '@cloudbeaver/core-connections'; import type { TLocalizationToken } from '@cloudbeaver/core-localization'; import { CachedMapAllKey, CachedResourceOffsetPageListKey } from '@cloudbeaver/core-resource'; -import { type TabContainerPanelComponent, useTab } from '@cloudbeaver/core-ui'; -import type { IConnectionFormProps } from '@cloudbeaver/plugin-connections'; - +import { FormMode, type TabContainerPanelComponent, useTab } from '@cloudbeaver/core-ui'; +import { getConnectionFormOptionsPart, type IConnectionFormProps } from '@cloudbeaver/plugin-connections'; import styles from './ConnectionAccess.module.css'; import { ConnectionAccessGrantedList } from './ConnectionAccessGrantedList.js'; import { ConnectionAccessList } from './ConnectionAccessList.js'; -import { useConnectionAccessState } from './useConnectionAccessState.js'; +import { getConnectionFormAccessPart } from './getConnectionFormAccessPart.js'; -export const ConnectionAccess: TabContainerPanelComponent = observer(function ConnectionAccess({ tabId, state: formState }) { - const state = useConnectionAccessState(formState.info); +export const ConnectionAccess: TabContainerPanelComponent = observer(function ConnectionAccess({ tabId, formState }) { const translate = useTranslate(); const style = useS(styles); + const state = useObservableRef( + () => ({ + editing: false, + toggleEdit() { + this.editing = !this.editing; + }, + }), + { + editing: observable.ref, + toggleEdit: action.bound, + }, + false, + ); const { selected } = useTab(tabId); + const accessPart = getConnectionFormAccessPart(formState); + const [initialFormMode] = useState(formState.mode); - useAutoLoad(ConnectionAccess, state, selected); + useAutoLoad(ConnectionAccess, accessPart, selected); const users = useResource(ConnectionAccess, UsersResource, CachedResourceOffsetPageListKey(0, 1000).setParent(UsersResourceFilterKey()), { active: selected, @@ -49,32 +64,51 @@ export const ConnectionAccess: TabContainerPanelComponent const teams = useResource(ConnectionAccess, TeamsResource, CachedMapAllKey, { active: selected }); const grantedUsers = useMemo( - () => computed(() => users.resource.values.filter(user => state.state.grantedSubjects.includes(user.userId))), - [state.state.grantedSubjects, users.resource], + () => computed(() => users.resource.values.filter(user => accessPart.state.includes(user.userId))), + [accessPart.state, users.resource], ); const grantedTeams = useMemo( - () => computed(() => teams.resource.values.filter(team => state.state.grantedSubjects.includes(team.teamId))), - [state.state.grantedSubjects, teams.resource], + () => computed(() => teams.resource.values.filter(team => accessPart.state.includes(team.teamId))), + [accessPart.state, teams.resource], ); - if (!selected) { - return null; - } - - const loading = users.isLoading() || teams.isLoading() || state.state.loading; - const cloud = formState.info && formState.originInfo?.origin ? isCloudConnection(formState.originInfo.origin) : false; - const disabled = loading || !state.state.loaded || formState.disabled || cloud; + const optionsPart = getConnectionFormOptionsPart(formState); + const connectionInfoResource = useResource( + ConnectionAccess, + ConnectionInfoResource, + createConnectionParam(formState.state.projectId, optionsPart.state.connectionId!), + { + active: selected && !!formState.state.projectId && !!optionsPart.state.connectionId, + }, + ); + const originInfoResource = useResource( + ConnectionAccess, + ConnectionInfoOriginResource, + createConnectionParam(formState.state.projectId, optionsPart.state.connectionId!), + { + active: selected && !!formState.state.projectId && !!optionsPart.state.connectionId, + }, + ); + const connectionInfo = connectionInfoResource.data; + const originInfo = originInfoResource.data; + const loading = users.isLoading() || teams.isLoading() || accessPart.isLoading(); + const cloud = connectionInfo && originInfo?.origin ? isCloudConnection(originInfo.origin) : false; + const disabled = loading || !accessPart.isLoaded() || formState.isDisabled || cloud; let info: TLocalizationToken | null = null; - if (formState.mode === 'edit' && state.changed) { + if (initialFormMode === FormMode.Edit && accessPart.isChanged) { info = 'ui_save_reminder'; } else if (cloud) { info = 'cloud_connections_access_placeholder'; } + if (!selected) { + return null; + } + return ( - + {() => ( {!users.resource.values.length && !teams.resource.values.length ? ( @@ -89,16 +123,16 @@ export const ConnectionAccess: TabContainerPanelComponent grantedUsers={grantedUsers.get()} grantedTeams={grantedTeams.get()} disabled={disabled} - onEdit={state.edit} - onRevoke={state.revoke} + onEdit={state.toggleEdit} + onRevoke={accessPart.revoke} /> - {state.state.editing && ( + {state.editing && ( )} diff --git a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts index 9ac0e447b0..40508a5c4b 100644 --- a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts +++ b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionAccessTabService.ts @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,22 +8,12 @@ import React from 'react'; import { AdministrationScreenService } from '@cloudbeaver/core-administration'; -import { ConnectionInfoResource, createConnectionParam, type IConnectionInfoParams } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import { executorHandlerFilter, type IExecutionContextProvider } from '@cloudbeaver/core-executor'; import { isGlobalProject, ProjectInfoResource } from '@cloudbeaver/core-projects'; -import { EAdminPermission, PermissionsService } from '@cloudbeaver/core-root'; -import { formStateContext } from '@cloudbeaver/core-ui'; -import type { MetadataValueGetter } from '@cloudbeaver/core-utils'; -import { - connectionConfigContext, - ConnectionFormService, - type IConnectionFormProps, - type IConnectionFormState, - type IConnectionFormSubmitData, -} from '@cloudbeaver/plugin-connections'; - -import type { IConnectionAccessTabState } from './IConnectionAccessTabState.js'; +import { EAdminPermission, PermissionsService, SessionPermissionsResource } from '@cloudbeaver/core-root'; +import { ConnectionFormService, getConnectionFormOptionsPart } from '@cloudbeaver/plugin-connections'; +import { getConnectionFormAccessPart } from './getConnectionFormAccessPart.js'; +import { getCachedDataResourceLoaderState, getCachedMapResourceLoaderState } from '@cloudbeaver/core-resource'; const ConnectionAccess = React.lazy(async () => { const { ConnectionAccess } = await import('./ConnectionAccess.js'); @@ -35,9 +25,9 @@ export class ConnectionAccessTabService extends Bootstrap { private readonly key: string; constructor( - private readonly connectionFormService: ConnectionFormService, + private readonly ConnectionFormService: ConnectionFormService, private readonly administrationScreenService: AdministrationScreenService, - private readonly connectionInfoResource: ConnectionInfoResource, + private readonly sessionPermissionsResource: SessionPermissionsResource, private readonly permissionsResource: PermissionsService, private readonly projectInfoResource: ProjectInfoResource, ) { @@ -46,112 +36,27 @@ export class ConnectionAccessTabService extends Bootstrap { } override register(): void { - this.connectionFormService.tabsContainer.add({ + this.ConnectionFormService.parts.add({ key: this.key, name: 'connections_connection_edit_access', title: 'connections_connection_edit_access', order: 4, - stateGetter: context => this.stateGetter(context), - isHidden: (_, context) => !context || !this.isAccessTabActive(context.state), - isDisabled: (tabId, props) => !props?.state.config.driverId || this.administrationScreenService.isConfigurationMode, + stateGetter: context => () => getConnectionFormAccessPart(context.formState), + getLoader: (_, context) => [ + getCachedMapResourceLoaderState(this.projectInfoResource, () => context?.formState.state.projectId ?? null), + getCachedDataResourceLoaderState(this.sessionPermissionsResource, () => undefined), + ], + isHidden: (_, context) => !context || !this.isAccessTabActive(context.formState.state.projectId), + isDisabled: (tabId, props) => { + const optionsPart = props?.formState ? getConnectionFormOptionsPart(props.formState) : null; + + return !optionsPart?.state.driverId || this.administrationScreenService.isConfigurationMode; + }, panel: () => ConnectionAccess, }); - - this.connectionFormService.formSubmittingTask.addHandler(executorHandlerFilter(data => this.isAccessTabActive(data.state), this.save.bind(this))); - - this.connectionFormService.formStateTask.addHandler(executorHandlerFilter(this.isAccessTabActive.bind(this), this.formState.bind(this))); } - private isAccessTabActive(state: IConnectionFormState): boolean { - return ( - state.projectId !== null && - isGlobalProject(this.projectInfoResource.get(state.projectId)) && - this.permissionsResource.has(EAdminPermission.admin) - ); - } - - private stateGetter(context: IConnectionFormProps): MetadataValueGetter { - return () => ({ - loading: false, - loaded: false, - editing: false, - grantedSubjects: [], - initialGrantedSubjects: [], - }); - } - - private async save(data: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - if (data.submitType === 'test' || !data.state.projectId) { - return; - } - const status = contexts.getContext(this.connectionFormService.connectionStatusContext); - - if (!status.saved) { - return; - } - - const config = contexts.getContext(connectionConfigContext); - const state = this.connectionFormService.tabsContainer.getTabState(data.state.partsState, this.key, { - state: data.state, - }); - - if (!config.connectionId || !state.loaded) { - return; - } - - const key = createConnectionParam(data.state.projectId, config.connectionId); - - const currentGrantedSubjects = await this.connectionInfoResource.loadAccessSubjects(key); - const currentGrantedSubjectIds = currentGrantedSubjects.map(subject => subject.subjectId); - - const { subjectsToRevoke, subjectsToGrant } = this.getSubjectDifferences(currentGrantedSubjectIds, state.grantedSubjects); - - if (subjectsToRevoke.length === 0 && subjectsToGrant.length === 0) { - return; - } - - if (subjectsToRevoke.length > 0) { - await this.connectionInfoResource.deleteConnectionsAccess(key, subjectsToRevoke); - } - - if (subjectsToGrant.length > 0) { - await this.connectionInfoResource.addConnectionsAccess(key, subjectsToGrant); - } - - state.initialGrantedSubjects = state.grantedSubjects.slice(); - } - - private async formState(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - const state = this.connectionFormService.tabsContainer.getTabState(data.partsState, this.key, { state: data }); - - if (!config.connectionId || !data.projectId || !state.loaded) { - return; - } - - const key = createConnectionParam(data.projectId, config.connectionId); - const changed = await this.isChanged(key, state.grantedSubjects); - - if (changed) { - const stateContext = contexts.getContext(formStateContext); - - stateContext.markEdited(); - } - } - - private async isChanged(connectionKey: IConnectionInfoParams, next: string[]): Promise { - const current = await this.connectionInfoResource.loadAccessSubjects(connectionKey); - if (current.length !== next.length) { - return true; - } - - return current.some(value => !next.some(subjectId => subjectId === value.subjectId)); - } - - private getSubjectDifferences(current: string[], next: string[]): { subjectsToRevoke: string[]; subjectsToGrant: string[] } { - const subjectsToRevoke = current.filter(subjectId => !next.includes(subjectId)); - const subjectsToGrant = next.filter(subjectId => !current.includes(subjectId)); - - return { subjectsToRevoke, subjectsToGrant }; + private isAccessTabActive(projectId: string | null): boolean { + return projectId !== null && isGlobalProject(this.projectInfoResource.get(projectId)) && this.permissionsResource.has(EAdminPermission.admin); } } diff --git a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionFormAccessPart.ts b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionFormAccessPart.ts new file mode 100644 index 0000000000..5fb4b09bdf --- /dev/null +++ b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/ConnectionFormAccessPart.ts @@ -0,0 +1,80 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { FormPart, formSubmitContext, type IFormState } from '@cloudbeaver/core-ui'; +import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import { type ConnectionInfoResource } from '@cloudbeaver/core-connections'; +import { action, makeObservable } from 'mobx'; +import { type IConnectionFormState, ConnectionFormOptionsPart } from '@cloudbeaver/plugin-connections'; +import { getSubjectDifferences } from '@cloudbeaver/core-utils'; + +function getDefaultState(): string[] { + return []; +} + +export class ConnectionFormAccessPart extends FormPart { + constructor( + formState: IFormState, + private readonly connectionInfoResource: ConnectionInfoResource, + private readonly optionsPart: ConnectionFormOptionsPart, + ) { + super(formState, getDefaultState()); + + makeObservable(this, { + revoke: action.bound, + grant: action.bound, + }); + } + + override isOutdated(): boolean { + return Boolean(this.optionsPart.connectionKey && this.connectionInfoResource.isOutdated(this.optionsPart.connectionKey)); + } + + protected override async loader(): Promise { + if (!this.optionsPart.connectionKey) { + this.setInitialState(getDefaultState()); + return; + } + + await this.connectionInfoResource.load(this.optionsPart.connectionKey); + const subjects = await this.connectionInfoResource.loadAccessSubjects(this.optionsPart.connectionKey); + + this.setInitialState(subjects.map(subject => subject.subjectId)); + } + + protected override async saveChanges( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise { + const submitInfo = contexts.getContext(formSubmitContext); + if (submitInfo.type === 'test' || !this.optionsPart.connectionKey) { + return; + } + + const { subjectsToRevoke, subjectsToGrant } = getSubjectDifferences(this.initialState, this.state); + + const promises = []; + + if (subjectsToRevoke.length > 0) { + promises.push(this.connectionInfoResource.deleteConnectionsAccess(this.optionsPart.connectionKey, subjectsToRevoke)); + } + + if (subjectsToGrant.length > 0) { + promises.push(this.connectionInfoResource.addConnectionsAccess(this.optionsPart.connectionKey, subjectsToGrant)); + } + + await Promise.all(promises); + } + + revoke(subjectIds: string[]) { + this.state = this.state.filter(subject => !subjectIds.includes(subject)); + } + + grant(subjectIds: string[]) { + this.state.push(...subjectIds); + } +} diff --git a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/getConnectionFormAccessPart.ts b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/getConnectionFormAccessPart.ts new file mode 100644 index 0000000000..18382930ff --- /dev/null +++ b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/getConnectionFormAccessPart.ts @@ -0,0 +1,24 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; +import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; +import { ConnectionFormAccessPart } from './ConnectionFormAccessPart.js'; +import { getConnectionFormOptionsPart, type IConnectionFormState } from '@cloudbeaver/plugin-connections'; + +const DATA_CONTEXT_CONNECTION_FORM_ACCESS_PART = createDataContext('Connection Form Access Part'); + +export function getConnectionFormAccessPart(formState: IFormState): ConnectionFormAccessPart { + return formState.getPart(DATA_CONTEXT_CONNECTION_FORM_ACCESS_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const connectionInfoResource = di.getService(ConnectionInfoResource); + const optionsPart = getConnectionFormOptionsPart(formState); + + return new ConnectionFormAccessPart(formState, connectionInfoResource, optionsPart); + }); +} diff --git a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts b/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts deleted file mode 100644 index 9e3202fbdc..0000000000 --- a/webapp/packages/plugin-connections-administration/src/ConnectionForm/ConnectionAccess/useConnectionAccessState.ts +++ /dev/null @@ -1,106 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { action, computed, observable } from 'mobx'; - -import { useObservableRef } from '@cloudbeaver/core-blocks'; -import { ConnectionInfoResource, createConnectionParam } from '@cloudbeaver/core-connections'; -import { useService } from '@cloudbeaver/core-di'; -import { NotificationService } from '@cloudbeaver/core-events'; -import type { DatabaseConnectionFragment } from '@cloudbeaver/core-sdk'; -import { useTabState } from '@cloudbeaver/core-ui'; -import { type ILoadableState, isArraysEqual, isContainsException } from '@cloudbeaver/core-utils'; - -import type { IConnectionAccessTabState } from './IConnectionAccessTabState.js'; - -interface State extends ILoadableState { - state: IConnectionAccessTabState; - changed: boolean; - edit: () => void; - revoke: (subjectIds: string[]) => void; - grant: (subjectIds: string[]) => void; - load: () => Promise; -} - -export function useConnectionAccessState(connection: DatabaseConnectionFragment | undefined): Readonly { - const resource = useService(ConnectionInfoResource); - const notificationService = useService(NotificationService); - const state = useTabState(); - - return useObservableRef( - () => ({ - exception: null as Error | null, - get changed() { - return !isArraysEqual(this.state.initialGrantedSubjects, this.state.grantedSubjects); - }, - isLoading() { - return this.state.loading; - }, - isError() { - return isContainsException(this.exception); - }, - isLoaded() { - return this.state.loaded; - }, - edit() { - this.state.editing = !this.state.editing; - }, - revoke(subjectIds: string[]) { - this.state.grantedSubjects = this.state.grantedSubjects.filter(subject => !subjectIds.includes(subject)); - }, - grant(subjectIds: string[]) { - this.state.grantedSubjects.push(...subjectIds); - }, - async load(reload = false) { - let loaded = this.exception || this.state.loaded; - - if (reload) { - loaded = false; - } - - if (loaded || this.state.loading) { - return; - } - - try { - this.state.loading = true; - - if (this.connection) { - const key = createConnectionParam(this.connection); - const grantedSubjects = await this.resource.loadAccessSubjects(key); - this.state.grantedSubjects = grantedSubjects.map(subject => subject.subjectId); - this.state.initialGrantedSubjects = this.state.grantedSubjects.slice(); - } - - this.state.loaded = true; - this.exception = null; - } catch (exception: any) { - this.notificationService.logException(exception, 'connections_connection_edit_access_load_failed'); - this.exception = exception; - } finally { - this.state.loading = false; - } - }, - async reload() { - this.load(true); - }, - }), - { - exception: observable.ref, - state: observable.ref, - changed: computed, - edit: action.bound, - isLoading: action.bound, - isLoaded: action.bound, - reload: action.bound, - revoke: action.bound, - grant: action.bound, - }, - { state, connection, resource, notificationService }, - ['load'], - ); -} diff --git a/webapp/packages/plugin-connections-administration/tsconfig.json b/webapp/packages/plugin-connections-administration/tsconfig.json index 991515d2cb..9aa814c303 100644 --- a/webapp/packages/plugin-connections-administration/tsconfig.json +++ b/webapp/packages/plugin-connections-administration/tsconfig.json @@ -22,6 +22,9 @@ { "path": "../core-connections" }, + { + "path": "../core-data-context" + }, { "path": "../core-di" }, diff --git a/webapp/packages/plugin-connections/package.json b/webapp/packages/plugin-connections/package.json index e12a4f4899..725df93dbd 100644 --- a/webapp/packages/plugin-connections/package.json +++ b/webapp/packages/plugin-connections/package.json @@ -41,6 +41,7 @@ "@cloudbeaver/plugin-navigation-tree": "workspace:*", "@cloudbeaver/plugin-projects": "workspace:*", "@cloudbeaver/plugin-top-app-bar": "workspace:*", + "@dbeaver/jdbc-uri-parser": "workspace:^", "mobx": "^6", "mobx-react-lite": "^4", "react": "^19", diff --git a/webapp/packages/plugin-connections/src/ConnectionAuthentication/ConnectionAuthenticationDialog.tsx b/webapp/packages/plugin-connections/src/ConnectionAuthentication/ConnectionAuthenticationDialog.tsx index 357a296ab9..6c6ebeff14 100644 --- a/webapp/packages/plugin-connections/src/ConnectionAuthentication/ConnectionAuthenticationDialog.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionAuthentication/ConnectionAuthenticationDialog.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectFormActionsContext.ts b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectFormActionsContext.ts index 8342f8120d..2ea807f272 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectFormActionsContext.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectFormActionsContext.ts @@ -7,11 +7,10 @@ */ import { createContext } from 'react'; -import type { IConnectionFormState } from './IConnectionFormProps.js'; - export interface IConnectionFormActionsContext { - save: IConnectionFormState['save']; - test: IConnectionFormState['test']; + save: () => Promise; + test: () => Promise; + onCancel?: () => void; } export const ConnectionFormActionsContext = createContext(null); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionForm.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionForm.tsx index 73c66567e3..b5370431a8 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionForm.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionForm.tsx @@ -1,98 +1,105 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ import { observer } from 'mobx-react-lite'; -import { useEffect } from 'react'; -import { ExceptionMessage, Form, Loader, Placeholder, s, StatusMessage, useExecutor, useForm, useObjectRef, useS } from '@cloudbeaver/core-blocks'; +import { Container, Form, Loader, Placeholder, s, StatusMessage, useForm, useObjectRef, useResource, useS } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; -import { ENotificationType } from '@cloudbeaver/core-events'; +import { ENotificationType, NotificationService } from '@cloudbeaver/core-events'; import type { ConnectionConfig } from '@cloudbeaver/core-sdk'; -import { TabList, TabPanelList, TabsState } from '@cloudbeaver/core-ui'; +import { formSubmitContext, TabList, TabPanelList, TabsState, type IFormState } from '@cloudbeaver/core-ui'; import { ConnectionFormActionsContext, type IConnectionFormActionsContext } from './ConnectFormActionsContext.js'; import style from './ConnectionForm.module.css'; +import type { ConnectionFormState } from './ConnectionFormState.js'; +import { getFirstException } from '@cloudbeaver/core-utils'; import { ConnectionFormService } from './ConnectionFormService.js'; -import { connectionConfigContext } from './Contexts/connectionConfigContext.js'; -import type { IConnectionFormState } from './IConnectionFormProps.js'; +import { ConnectionInfoResource } from '@cloudbeaver/core-connections'; +import { getConnectionFormOptionsPart } from './Options/getConnectionFormOptionsPart.js'; +import { ExecutionContext } from '@cloudbeaver/core-executor'; +import type { IConnectionFormState } from './IConnectionFormState.js'; export interface ConnectionFormProps { - state: IConnectionFormState; + formState: ConnectionFormState; onCancel?: () => void; onSave?: (config: ConnectionConfig) => void; className?: string; } -export const ConnectionForm = observer(function ConnectionForm({ state, onCancel, onSave = () => {}, className }) { - const props = useObjectRef({ onSave }); +export const ConnectionForm = observer(function ConnectionForm({ formState, onCancel, onSave = () => {}, className }) { const service = useService(ConnectionFormService); const styles = useS(style); + const notificationService = useService(NotificationService); + const optionsPart = getConnectionFormOptionsPart(formState); + const connectionInfoResource = useResource(ConnectionForm, ConnectionInfoResource, optionsPart.connectionKey, { + active: !!optionsPart.connectionKey, + }); + const exception = getFirstException(formState.exception); const form = useForm({ - onSubmit: event => { - if (event?.type === 'test') { - state.test(); + onSubmit: async event => { + const submitType = event?.type === 'test' ? 'test' : 'submit'; + const context = new ExecutionContext>(formState); + + const submitInfo = context.getContext(formSubmitContext); + submitInfo.setType(submitType); + if (submitType === 'test') { + submitInfo.setSubmitOnNoChanges(true); + } + const initialMode = formState.mode; + const saved = await formState.save(context); + + if (saved) { + if (submitType === 'submit') { + notificationService.notify( + { + title: initialMode === 'create' ? 'core_connections_connection_create_success' : 'core_connections_connection_update_success', + message: connectionInfoResource.data?.name, + }, + ENotificationType.Success, + ); + + onSave(optionsPart.state); + } } else { - state.save(); + const error = getFirstException(formState.exception); + + if (submitType === 'submit') { + notificationService.logException(error, 'connections_connection_create_fail'); + } } }, }); const actionsContext = useObjectRef(() => ({ - save: async () => form.submit(new SubmitEvent('submit')), - test: async () => form.submit(new SubmitEvent('test')), + save: () => form.submit(new SubmitEvent('submit')), + test: () => form.submit(new SubmitEvent('test')), + onCancel, })); - useExecutor({ - executor: state.submittingTask, - postHandlers: [ - function save(data, contexts) { - const validation = contexts.getContext(service.connectionValidationContext); - const state = contexts.getContext(service.connectionStatusContext); - const config = contexts.getContext(connectionConfigContext); - - if (validation.valid && state.saved && data.submitType === 'submit') { - props.onSave(config); - } - }, - ], - }); - - useEffect(() => { - state.loadConnectionInfo(); - }, [state]); - - if (state.initError) { - return state.loadConnectionInfo()} />; - } - - if (!state.configured) { - return ( -
- -
- ); - } - return (
- +
-
-
- -
- -
+ + + + + +
- +
diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormBaseActions.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormBaseActions.tsx index 718728d043..14c069789c 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormBaseActions.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormBaseActions.tsx @@ -1,25 +1,23 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { observer } from 'mobx-react-lite'; import { useContext } from 'react'; import { AUTH_PROVIDER_LOCAL_ID } from '@cloudbeaver/core-authentication'; import { Button, getComputed, type PlaceholderComponent, useResource, useTranslate, useAuthenticationAction } from '@cloudbeaver/core-blocks'; -import { DatabaseAuthModelsResource, DBDriverResource } from '@cloudbeaver/core-connections'; +import { ConnectionInfoResource, DatabaseAuthModelsResource, DBDriverResource } from '@cloudbeaver/core-connections'; import { ServerConfigResource } from '@cloudbeaver/core-root'; import { ConnectionFormActionsContext } from './ConnectFormActionsContext.js'; -import type { IConnectionFormProps } from './IConnectionFormProps.js'; - -export const ConnectionFormBaseActions: PlaceholderComponent = observer(function ConnectionFormBaseActions({ - state, - onCancel, -}) { +import type { IConnectionFormProps } from './IConnectionFormState.js'; +import { getConnectionFormOptionsPart } from './Options/getConnectionFormOptionsPart.js'; +export const ConnectionFormBaseActions: PlaceholderComponent = observer(function ConnectionFormBaseActions({ formState }) { const actions = useContext(ConnectionFormActionsContext); if (!actions) { @@ -27,36 +25,46 @@ export const ConnectionFormBaseActions: PlaceholderComponent state.config.authModelId || state.info?.authModel || driver?.defaultAuthModel || null), + getComputed(() => optionsPart.state.authModelId || connectionInfoResource.data?.authModel || driver?.defaultAuthModel || null), ); const authentication = useAuthenticationAction({ - providerId: authModel?.requiredAuth ?? state.info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID, + providerId: authModel?.requiredAuth ?? connectionInfoResource.data?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID, }); const authorized = authentication.providerId === AUTH_PROVIDER_LOCAL_ID || authentication.authorized; - const disableTest = serverConfigResource.data?.distributed && !!state.config.sharedCredentials; + const disableTest = serverConfigResource.data?.distributed && !!optionsPart.state.sharedCredentials; return ( <> - {onCancel && ( - )} {!disableTest && ( - )} - ); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormService.ts b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormService.ts index bc66406537..c10c46e94e 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormService.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormService.ts @@ -1,182 +1,24 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { observable, runInAction, toJS } from 'mobx'; - -import { PlaceholderContainer } from '@cloudbeaver/core-blocks'; import { injectable } from '@cloudbeaver/core-di'; -import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; -import { ENotificationType, NotificationService } from '@cloudbeaver/core-events'; -import { ExecutorHandlersCollection, ExecutorInterrupter, type IExecutorHandler, type IExecutorHandlersCollection } from '@cloudbeaver/core-executor'; +import { NotificationService } from '@cloudbeaver/core-events'; import { LocalizationService } from '@cloudbeaver/core-localization'; -import { TabsContainer } from '@cloudbeaver/core-ui'; +import { FormBaseService } from '@cloudbeaver/core-ui'; +import type { IConnectionFormProps, IConnectionFormState } from './IConnectionFormState.js'; +import { importLazyComponent } from '@cloudbeaver/core-blocks'; -import { ConnectionAuthenticationDialogLoader } from '../ConnectionAuthentication/ConnectionAuthenticationDialogLoader.js'; -import { ConnectionFormBaseActionsLoader } from './ConnectionFormBaseActionsLoader.js'; -import { connectionConfigContext } from './Contexts/connectionConfigContext.js'; -import { connectionCredentialsStateContext } from './Contexts/connectionCredentialsStateContext.js'; -import type { IConnectionFormFillConfigData, IConnectionFormProps, IConnectionFormState, IConnectionFormSubmitData } from './IConnectionFormProps.js'; - -export interface IConnectionFormValidation { - valid: boolean; - messages: string[]; - info: (message: string) => void; - error: (message: string) => void; -} - -export interface IConnectionFormStatus { - saved: boolean; - messages: string[]; - exception: Error | null; - info: (message: string) => void; - error: (message: string, exception?: Error) => void; -} +const ConnectionFormBaseActionsLoader = importLazyComponent(() => import('./ConnectionFormBaseActions.js').then(m => m.ConnectionFormBaseActions)); @injectable() -export class ConnectionFormService { - readonly tabsContainer: TabsContainer; - readonly actionsContainer: PlaceholderContainer; - - readonly configureTask: IExecutorHandlersCollection; - readonly fillConfigTask: IExecutorHandlersCollection; - readonly prepareConfigTask: IExecutorHandlersCollection; - readonly formValidationTask: IExecutorHandlersCollection; - readonly formSubmittingTask: IExecutorHandlersCollection; - readonly formStateTask: IExecutorHandlersCollection; - - constructor( - private readonly notificationService: NotificationService, - private readonly commonDialogService: CommonDialogService, - private readonly localizationService: LocalizationService, - ) { - this.tabsContainer = new TabsContainer('Connection settings'); - this.actionsContainer = new PlaceholderContainer(); - this.configureTask = new ExecutorHandlersCollection(); - this.fillConfigTask = new ExecutorHandlersCollection(); - this.prepareConfigTask = new ExecutorHandlersCollection(); - this.formSubmittingTask = new ExecutorHandlersCollection(); - this.formValidationTask = new ExecutorHandlersCollection(); - this.formStateTask = new ExecutorHandlersCollection(); - - this.formSubmittingTask.before(this.formValidationTask).before(this.prepareConfigTask); - - this.formStateTask.before(this.prepareConfigTask, state => ({ state, submitType: 'submit' })); - - this.prepareConfigTask.addPostHandler(this.askCredentials); - this.formSubmittingTask.addPostHandler(this.showSubmittingStatusMessage); - this.formValidationTask.addPostHandler(this.ensureValidation); +export class ConnectionFormService extends FormBaseService { + constructor(localizationService: LocalizationService, notificationService: NotificationService) { + super(localizationService, notificationService, 'Connection form'); this.actionsContainer.add(ConnectionFormBaseActionsLoader); } - - connectionValidationContext = (): IConnectionFormValidation => ({ - valid: true, - messages: [], - info(message: string) { - this.messages.push(message); - }, - error(message: string) { - this.messages.push(message); - this.valid = false; - }, - }); - - connectionStatusContext = (): IConnectionFormStatus => ({ - saved: true, - messages: [], - exception: null, - info(message: string) { - this.messages.push(message); - }, - error(message: string, exception: Error | null = null) { - this.messages.push(message); - this.saved = false; - this.exception = exception; - }, - }); - - private readonly showSubmittingStatusMessage: IExecutorHandler = (data, contexts) => { - const status = contexts.getContext(this.connectionStatusContext); - - if (!status.saved) { - ExecutorInterrupter.interrupt(contexts); - } - - if (status.messages.length > 0) { - if (status.exception) { - this.notificationService.logException(status.exception, status.messages[0], status.messages.slice(1).join('\n')); - } else { - this.notificationService.notify( - { - title: status.messages[0]!, - message: status.messages.slice(1).join('\n'), - }, - status.saved ? ENotificationType.Success : ENotificationType.Error, - ); - } - } - }; - - private readonly askCredentials: IExecutorHandler = async (data, contexts) => { - const credentialsState = contexts.getContext(connectionCredentialsStateContext); - - if (data.submitType !== 'test' || (!credentialsState.authModelId && !credentialsState.networkHandlers.length)) { - return; - } - - const config = contexts.getContext(connectionConfigContext); - - runInAction(() => { - if (credentialsState.authModelId) { - if (!config.credentials) { - config.credentials = { ...data.state.config.credentials }; - } - - config.credentials = observable(config.credentials); - } - - if (credentialsState.networkHandlers.length > 0) { - if (!config.networkHandlersConfig) { - config.networkHandlersConfig = toJS(data.state.config.networkHandlersConfig) || []; - } - - config.networkHandlersConfig = observable(config.networkHandlersConfig); - } - }); - - const result = await this.commonDialogService.open(ConnectionAuthenticationDialogLoader, { - config, - authModelId: credentialsState.authModelId, - networkHandlers: credentialsState.networkHandlers, - projectId: data.state.projectId, - }); - - if (result === DialogueStateResult.Rejected) { - ExecutorInterrupter.interrupt(contexts); - } - }; - - private readonly ensureValidation: IExecutorHandler = (data, contexts) => { - const validation = contexts.getContext(this.connectionValidationContext); - - if (!validation.valid) { - ExecutorInterrupter.interrupt(contexts); - } - - if (validation.messages.length > 0) { - const messages = validation.messages.map(message => this.localizationService.translate(message)); - this.notificationService.notify( - { - title: - data.state.mode === 'edit' ? 'connections_administration_connection_save_error' : 'connections_administration_connection_create_error', - message: messages.join('\n'), - }, - validation.valid ? ENotificationType.Info : ENotificationType.Error, - ); - } - }; } diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormState.ts index 3e4b8af8db..13d06e8d7f 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormState.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/ConnectionFormState.ts @@ -1,342 +1,18 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, computed, makeObservable, observable } from 'mobx'; +import type { IServiceProvider } from '@cloudbeaver/core-di'; +import { FormState } from '@cloudbeaver/core-ui'; -import { - ConnectionInfoOriginResource, - ConnectionInfoResource, - createConnectionParam, - type DatabaseConnection, - type IConnectionInfoParams, -} from '@cloudbeaver/core-connections'; -import { Executor, type IExecutionContextProvider, type IExecutor } from '@cloudbeaver/core-executor'; -import type { ProjectInfoResource, ProjectsService } from '@cloudbeaver/core-projects'; -import type { ResourceKeySimple } from '@cloudbeaver/core-resource'; -import type { ConnectionConfig } from '@cloudbeaver/core-sdk'; -import { formStateContext, type IFormStateInfo } from '@cloudbeaver/core-ui'; -import { MetadataMap, uuid } from '@cloudbeaver/core-utils'; +import { ConnectionFormService } from './ConnectionFormService.js'; +import type { IConnectionFormState } from './IConnectionFormState.js'; -import { connectionFormConfigureContext } from './connectionFormConfigureContext.js'; -import type { ConnectionFormService } from './ConnectionFormService.js'; -import type { ConnectionFormMode, ConnectionFormType, IConnectionFormState, IConnectionFormSubmitData } from './IConnectionFormProps.js'; - -export class ConnectionFormState implements IConnectionFormState { - mode: ConnectionFormMode; - type: ConnectionFormType; - projectId: string | null; - - config: ConnectionConfig; - - partsState: MetadataMap; - - statusMessage: string | string[] | null; - configured: boolean; - initError: Error | null; - - get loading(): boolean { - return this.loadConnectionTask.executing || this.submittingTask.executing; - } - - get disabled(): boolean { - return this.loading || !!this.stateInfo?.disabled || this.loadConnectionTask.executing; - } - - get availableDrivers(): string[] { - if (this._availableDrivers.length === 0 && this.config.driverId) { - return [this.config.driverId]; - } - - return this._availableDrivers; - } - - get info(): DatabaseConnection | undefined { - if (!this.config.connectionId || this.projectId === null) { - return undefined; - } - - return this.resource.get(createConnectionParam(this.projectId, this.config.connectionId)); - } - - get originInfo() { - if (!this.config.connectionId || this.projectId === null) { - return undefined; - } - - return this.originResource.get(createConnectionParam(this.projectId, this.config.connectionId)); - } - - get readonly(): boolean { - if (this.stateInfo?.readonly) { - return true; - } - - if (this.type === 'admin' || this.mode === 'create') { - return false; - } - - if (!this.info?.canEdit) { - return true; - } - - return false; - } - - get id(): string { - if (this.mode === 'create') { - return 'create'; - } - - return this.config.connectionId || this._id; - } - - readonly resource: ConnectionInfoResource; - readonly originResource: ConnectionInfoOriginResource; - readonly service: ConnectionFormService; - readonly submittingTask: IExecutor; - readonly closeTask: IExecutor; - - private readonly _id: string; - private stateInfo: IFormStateInfo | null; - private readonly loadConnectionTask: IExecutor; - private readonly formStateTask: IExecutor; - private _availableDrivers: string[]; - - constructor( - private readonly projectsService: ProjectsService, - private readonly projectInfoResource: ProjectInfoResource, - service: ConnectionFormService, - resource: ConnectionInfoResource, - originResource: ConnectionInfoOriginResource, - ) { - this._id = uuid(); - this.initError = null; - - this.resource = resource; - this.originResource = originResource; - this.projectId = null; - this.config = {}; - this._availableDrivers = []; - this.stateInfo = null; - this.partsState = new MetadataMap(); - this.service = service; - this.formStateTask = new Executor(this, () => true); - this.loadConnectionTask = new Executor(this, () => true); - this.submittingTask = new Executor(); - this.closeTask = new Executor(); - this.statusMessage = null; - this.configured = false; - this.mode = 'create'; - this.type = 'public'; - - this.syncProject = this.syncProject.bind(this); - this.syncInfo = this.syncInfo.bind(this); - this.test = this.test.bind(this); - this.save = this.save.bind(this); - this.checkFormState = this.checkFormState.bind(this); - this.loadInfo = this.loadInfo.bind(this); - this.updateFormState = this.updateFormState.bind(this); - - this.formStateTask.addCollection(service.formStateTask).addPostHandler(this.updateFormState); - - this.resource.onItemUpdate.addHandler(this.syncInfo); - - this.projectInfoResource.onDataUpdate.addHandler(this.syncProject); - - this.projectsService.onActiveProjectChange.addHandler(this.syncProject); - - this.submittingTask.addPostHandler(async (data, contexts) => { - const status = contexts.getContext(service.connectionStatusContext); - const validation = contexts.getContext(service.connectionValidationContext); - - if (data.submitType !== 'submit' || !status.saved || !validation.valid) { - return; - } - - this.reset(); - await this.load(); - }); - - this.loadConnectionTask - .before(service.configureTask) - .addPostHandler(this.loadInfo) - .next(service.fillConfigTask, (state, contexts) => { - const configuration = contexts.getContext(connectionFormConfigureContext); - - return { - state, - updated: state.info !== configuration.info || state.config.driverId !== configuration.driverId || !this.configured, - }; - }) - .next(this.formStateTask); - - makeObservable(this, { - projectId: observable, - mode: observable, - type: observable, - config: observable, - availableDrivers: computed, - _availableDrivers: observable, - info: computed, - originInfo: computed, - statusMessage: observable, - configured: observable, - readonly: computed, - stateInfo: observable, - initError: observable.ref, - id: computed, - reset: action, - setOptions: action, - setConfig: action, - setAvailableDrivers: action, - updateFormState: action, - }); - } - - async loadConnectionInfo(): Promise { - try { - this.initError = null; - await this.loadConnectionTask.execute(this); - - return this.info; - } catch (exception: any) { - this.initError = exception; - throw exception; - } - } - - async load(): Promise { - await this.loadConnectionInfo(); - } - - reset(): void { - this.configured = false; - this.partsState.clear(); - } - - setPartsState(state: MetadataMap): this { - this.partsState = state; - return this; - } - - setOptions(mode: ConnectionFormMode, type: ConnectionFormType): this { - this.mode = mode; - this.type = type; - return this; - } - - setConfig(projectId: string, config: ConnectionConfig): this { - this.setProject(projectId); - this.config = config; - this.reset(); - return this; - } - - setProject(projectId: string): this { - this.projectId = projectId; - return this; - } - - setAvailableDrivers(drivers: string[]): this { - this._availableDrivers = drivers; - this.reset(); - return this; - } - - async save(): Promise { - await this.submittingTask.executeScope( - { - state: this, - submitType: 'submit', - }, - this.service.formSubmittingTask, - ); - } - - async test(): Promise { - await this.submittingTask.executeScope( - { - state: this, - submitType: 'test', - }, - this.service.formSubmittingTask, - ); - } - - async checkFormState(): Promise { - await this.loadConnectionInfo(); - return this.stateInfo; - } - - dispose(): void { - this.resource.onItemUpdate.removeHandler(this.syncInfo); - this.projectInfoResource.onDataUpdate.removeHandler(this.syncProject); - this.projectsService.onActiveProjectChange.removeHandler(this.syncProject); - } - - async close(): Promise { - await this.closeTask.execute(); - } - - private updateFormState(data: IConnectionFormState, contexts: IExecutionContextProvider): void { - const context = contexts.getContext(formStateContext); - - if (this.mode === 'create') { - context.markEdited(); - } - - this.statusMessage = context.statusMessage; - - if (this.statusMessage === null && this.mode === 'edit') { - if (!this.info?.canEdit) { - this.statusMessage = 'connections_connection_edit_not_own_deny'; - } - } - - this.stateInfo = context; - this.configured = true; - } - - private syncInfo(key: ResourceKeySimple) { - if ( - !this.config.connectionId || - this.projectId === null || - !this.resource.isIntersect(key, createConnectionParam(this.projectId, this.config.connectionId)) - ) { - return; - } - - this.loadConnectionInfo(); - } - - private async syncProject() { - if (!this.projectId) { - return; - } - - const project = this.projectInfoResource.get(this.projectId); - if (!project?.canEditDataSources || !this.projectsService.activeProjects.includes(project)) { - await this.close(); - } - } - - private async loadInfo(data: IConnectionFormState, contexts: IExecutionContextProvider) { - if (!data.config.connectionId || data.projectId === null) { - return; - } - - const key = createConnectionParam(data.projectId, data.config.connectionId); - const configuration = contexts.getContext(connectionFormConfigureContext); - - if (!data.resource.has(key)) { - return; - } - - await data.resource.load(key, configuration.connectionIncludes); - await this.originResource.load(key); +export class ConnectionFormState extends FormState { + constructor(serviceProvider: IServiceProvider, service: ConnectionFormService, config: IConnectionFormState) { + super(serviceProvider, service, config); } } diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionConfigContext.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionConfigContext.ts deleted file mode 100644 index d225ff3ff4..0000000000 --- a/webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionConfigContext.ts +++ /dev/null @@ -1,12 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import type { ConnectionConfig } from '@cloudbeaver/core-sdk'; - -export function connectionConfigContext(): ConnectionConfig { - return {}; -} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionCredentialsStateContext.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionCredentialsStateContext.ts deleted file mode 100644 index 8884ee9241..0000000000 --- a/webapp/packages/plugin-connections/src/ConnectionForm/Contexts/connectionCredentialsStateContext.ts +++ /dev/null @@ -1,30 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ - -interface IConnectionCredentialsState { - authModelId: string | null; - networkHandlers: string[]; -} - -interface IConnectionCredentialsStateContext extends IConnectionCredentialsState { - requireAuthModel: (id: string) => void; - requireNetworkHandler: (id: string) => void; -} - -export function connectionCredentialsStateContext(): IConnectionCredentialsStateContext { - return { - authModelId: null, - networkHandlers: [], - requireAuthModel(id) { - this.authModelId = id; - }, - requireNetworkHandler(id) { - this.networkHandlers.push(id); - }, - }; -} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionDriverPropertiesTabService.ts b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionDriverPropertiesTabService.ts index dd932d3f43..ab327412de 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionDriverPropertiesTabService.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionDriverPropertiesTabService.ts @@ -1,122 +1,40 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, makeObservable } from 'mobx'; - -import { DBDriverResource } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import { isObjectPropertyInfoStateEqual } from '@cloudbeaver/core-sdk'; -import { formStateContext } from '@cloudbeaver/core-ui'; -import { connectionFormConfigureContext } from '../connectionFormConfigureContext.js'; import { ConnectionFormService } from '../ConnectionFormService.js'; -import { connectionConfigContext } from '../Contexts/connectionConfigContext.js'; -import type { IConnectionFormFillConfigData, IConnectionFormState, IConnectionFormSubmitData } from '../IConnectionFormProps.js'; -import { DriverPropertiesLoader } from './DriverPropertiesLoader.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; +import { importLazyComponent } from '@cloudbeaver/core-blocks'; + +const DriverProperties = importLazyComponent(() => import('./DriverProperties.js').then(m => m.DriverProperties)); @injectable() export class ConnectionDriverPropertiesTabService extends Bootstrap { - constructor( - private readonly connectionFormService: ConnectionFormService, - private readonly dbDriverResource: DBDriverResource, - ) { + constructor(private readonly connectionFormService: ConnectionFormService) { super(); - - makeObservable(this, { - fillConfig: action, - }); } override register(): void { - this.connectionFormService.tabsContainer.add({ + this.connectionFormService.parts.add({ key: 'driver_properties', name: 'plugin_connections_connection_form_part_properties', title: 'plugin_connections_connection_form_part_properties', order: 2, - panel: () => DriverPropertiesLoader, + panel: () => DriverProperties, isDisabled: (tabId, props) => { - if (props?.state.config.driverId) { - return !props.state.config.driverId; + const optionsPart = props?.formState ? getConnectionFormOptionsPart(props.formState) : null; + + if (optionsPart?.state.driverId) { + return false; } + return true; }, }); - - this.connectionFormService.prepareConfigTask.addHandler(this.prepareConfig.bind(this)); - - this.connectionFormService.formStateTask.addHandler(this.formState.bind(this)); - - this.connectionFormService.fillConfigTask.addHandler(this.fillConfig.bind(this)); - - this.connectionFormService.configureTask.addHandler(this.configure.bind(this)); - } - - private configure(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const configuration = contexts.getContext(connectionFormConfigureContext); - - configuration.include('includeProperties', 'includeProviderProperties'); - } - - private fillConfig({ state, updated }: IConnectionFormFillConfigData, contexts: IExecutionContextProvider) { - if (!updated) { - return; - } - if (!state.config.properties) { - state.config.properties = {}; - } - - if (!state.info) { - return; - } - - state.config.properties = { ...state.info.properties }; - } - - private prepareConfig({ state }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - - config.properties = { ...state.config.properties }; - - if (config.driverId) { - const driver = this.dbDriverResource.get(config.driverId); - const trimmedProperties: typeof config.properties = {}; - - const defaultDriverProperties = new Set(driver?.driverProperties?.map(property => property.id) ?? []); - - for (let key of Object.keys(config.properties)) { - const value = config.properties[key]; - if (!defaultDriverProperties?.has(key)) { - key = key.trim(); - } - - trimmedProperties[key] = typeof value === 'string' ? value.trim() : value; - } - - config.properties = trimmedProperties; - } - } - - private formState(data: IConnectionFormState, contexts: IExecutionContextProvider) { - if (!data.info || !data.config.driverId) { - return; - } - - const config = contexts.getContext(connectionConfigContext); - const driver = this.dbDriverResource.get(data.config.driverId); - - if (!driver?.driverProperties) { - return; - } - - if (!isObjectPropertyInfoStateEqual(driver.driverProperties, config.properties, data.info.properties)) { - const stateContext = contexts.getContext(formStateContext); - - stateContext.markEdited(); - } } } diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionFormDriverPropertiesPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionFormDriverPropertiesPart.ts new file mode 100644 index 0000000000..2ad87b8d30 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/ConnectionFormDriverPropertiesPart.ts @@ -0,0 +1,77 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { FormPart, type IFormState } from '@cloudbeaver/core-ui'; +import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import { ConnectionInfoPropertiesResource } from '@cloudbeaver/core-connections'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import type { IConnectionProperties } from '../Options/IConnectionConfig.js'; +import { observable, runInAction } from 'mobx'; +import type { ConnectionFormOptionsPart } from '../Options/ConnectionFormOptionsPart.js'; + +function getDefaultState(): IConnectionProperties { + return {}; +} + +export class ConnectionFormDriverPropertiesPart extends FormPart { + constructor( + formState: IFormState, + private readonly connectionInfoPropertiesResource: ConnectionInfoPropertiesResource, + private readonly optionsPart: ConnectionFormOptionsPart, + ) { + super(formState, getDefaultState()); + + this.optionsPart.onDriverIdChange.addHandler(this.onDriverIdChangeHandler.bind(this)); + } + + private onDriverIdChangeHandler(driverId: string | undefined) { + this.reset(); + } + + override isOutdated(): boolean { + if (!this.optionsPart.connectionKey) { + return false; + } + + return this.connectionInfoPropertiesResource.isOutdated(this.optionsPart.connectionKey); + } + + protected override async loader(): Promise { + if (!this.optionsPart.connectionKey) { + this.setInitialState(getDefaultState()); + return; + } + + const connection = await this.connectionInfoPropertiesResource.load(this.optionsPart.connectionKey); + + this.setInitialState({ ...connection.properties }); + } + + protected override async saveChanges( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise {} + + protected override format( + data: IFormState, + contexts: IExecutionContextProvider>, + ): void | Promise { + runInAction(() => { + for (const key of Object.keys(this.state!)) { + if (typeof this.state[key] === 'string') { + this.state[key] = this.state[key].trim(); + } + } + + if (!this.optionsPart.state.properties) { + this.optionsPart.state.properties = observable({}); + } + + Object.assign(this.optionsPart.state.properties, this.state); + }); + } +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/DriverProperties.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/DriverProperties.tsx index 957e7e2479..5538af5235 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/DriverProperties.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/DriverProperties.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -9,18 +9,21 @@ import { computed, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; import { useMemo, useState } from 'react'; -import { ColoredContainer, Group, type IProperty, PropertiesTable, s, useResource, useS } from '@cloudbeaver/core-blocks'; +import { ColoredContainer, Group, type IProperty, PropertiesTable, s, useAutoLoad, useResource, useS } from '@cloudbeaver/core-blocks'; import { DBDriverResource } from '@cloudbeaver/core-connections'; import { type TabContainerPanelComponent, useTab } from '@cloudbeaver/core-ui'; import { uuid } from '@cloudbeaver/core-utils'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import styles from './DriverProperties.module.css'; +import { getConnectionFormDriverPropertiesPart } from './getConnectionFormDriverPropertiesPart.js'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; -export const DriverProperties: TabContainerPanelComponent = observer(function DriverProperties({ tabId, state: formState }) { +export const DriverProperties: TabContainerPanelComponent = observer(function DriverProperties({ tabId, formState }) { const { selected } = useTab(tabId); const style = useS(styles); - + const driverPropertiesPart = getConnectionFormDriverPropertiesPart(formState); + const optionsPart = getConnectionFormOptionsPart(formState); const [state] = useState(() => { const propertiesList: IProperty[] = observable([]); @@ -42,18 +45,18 @@ export const DriverProperties: TabContainerPanelComponent }); const driver = useResource(DriverProperties, DBDriverResource, { - key: (selected && formState.config.driverId) || null, + key: (selected && optionsPart.state.driverId) || null, includes: ['includeDriverProperties'] as const, }); runInAction(() => { if (driver.data) { - for (const key of Object.keys(formState.config.properties)) { + for (const key of Object.keys(driverPropertiesPart.state)) { if (driver.data.driverProperties.some(property => property.id === key) || state.propertiesList.some(property => property.key === key)) { continue; } - state.add(key, formState.config.properties[key]); + state.add(key, driverPropertiesPart.state[key]); } } }); @@ -78,14 +81,16 @@ export const DriverProperties: TabContainerPanelComponent [driver.data], ); + useAutoLoad(DriverProperties, driverPropertiesPart, selected); + return ( { - const { DriverProperties } = await import('./DriverProperties.js'); - return { default: DriverProperties }; -}); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/getConnectionFormDriverPropertiesPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/getConnectionFormDriverPropertiesPart.ts new file mode 100644 index 0000000000..308aa4e6c4 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/DriverProperties/getConnectionFormDriverPropertiesPart.ts @@ -0,0 +1,27 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; +import { ConnectionFormDriverPropertiesPart } from './ConnectionFormDriverPropertiesPart.js'; +import { ConnectionInfoPropertiesResource } from '@cloudbeaver/core-connections'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; + +const DATA_CONTEXT_CONNECTION_FORM_DRIVER_PROPERTIES_PART = createDataContext( + 'Connection Form Driver Properties Part', +); + +export function getConnectionFormDriverPropertiesPart(formState: IFormState): ConnectionFormDriverPropertiesPart { + return formState.getPart(DATA_CONTEXT_CONNECTION_FORM_DRIVER_PROPERTIES_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const connectionInfoPropertiesResource = di.getService(ConnectionInfoPropertiesResource); + const optionsPart = getConnectionFormOptionsPart(formState); + + return new ConnectionFormDriverPropertiesPart(formState, connectionInfoPropertiesResource, optionsPart); + }); +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormProps.ts b/webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormProps.ts deleted file mode 100644 index 95993cc023..0000000000 --- a/webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormProps.ts +++ /dev/null @@ -1,70 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import type { ConnectionInfoOrigin, ConnectionInfoOriginResource, ConnectionInfoResource, DatabaseConnection } from '@cloudbeaver/core-connections'; -import type { IExecutor, IExecutorHandlersCollection } from '@cloudbeaver/core-executor'; -import type { ConnectionConfig } from '@cloudbeaver/core-sdk'; -import type { IFormStateInfo } from '@cloudbeaver/core-ui'; -import type { MetadataMap } from '@cloudbeaver/core-utils'; - -export type ConnectionFormMode = 'edit' | 'create'; -export type ConnectionFormType = 'admin' | 'public'; - -export interface IConnectionFormState { - mode: ConnectionFormMode; - type: ConnectionFormType; - projectId: string | null; - - config: ConnectionConfig; - - partsState: MetadataMap; - - readonly id: string; - readonly initError: Error | null; - readonly statusMessage: string | string[] | null; - readonly disabled: boolean; - readonly loading: boolean; - readonly configured: boolean; - - readonly availableDrivers: string[]; - readonly resource: ConnectionInfoResource; - readonly originResource: ConnectionInfoOriginResource; - readonly info: DatabaseConnection | undefined; - readonly originInfo: ConnectionInfoOrigin | undefined; - readonly readonly: boolean; - readonly submittingTask: IExecutorHandlersCollection; - readonly closeTask: IExecutor; - - readonly load: () => Promise; - readonly loadConnectionInfo: () => Promise; - readonly reset: () => void; - readonly setPartsState: (state: MetadataMap) => this; - readonly setOptions: (mode: ConnectionFormMode, type: ConnectionFormType) => this; - readonly setConfig: (projectId: string, config: ConnectionConfig) => this; - readonly setProject: (projectId: string) => this; - readonly setAvailableDrivers: (drivers: string[]) => this; - readonly save: () => Promise; - readonly test: () => Promise; - readonly checkFormState: () => Promise; - readonly close: () => Promise; - readonly dispose: () => void; -} - -export interface IConnectionFormProps { - state: IConnectionFormState; - onCancel?: () => void; -} - -export interface IConnectionFormFillConfigData { - updated: boolean; - state: IConnectionFormState; -} - -export interface IConnectionFormSubmitData { - submitType: 'submit' | 'test'; - state: IConnectionFormState; -} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormState.ts new file mode 100644 index 0000000000..fad53cc1dd --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/IConnectionFormState.ts @@ -0,0 +1,24 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { schema } from '@cloudbeaver/core-utils'; +import type { IFormProps } from '@cloudbeaver/core-ui'; + +export const CONNECTION_FORM_STATE_SCHEMA = schema + .object({ + projectId: schema.string(), + availableDrivers: schema.array(schema.string()), + requiredNetworkHandlersIds: schema.array(schema.string()), + connectionId: schema.string().or(schema.undefined()), + type: schema.enum(['admin', 'public']), + }) + .required() + .strict(); + +export type IConnectionFormState = schema.infer; +export type IConnectionFormProps = IFormProps; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionFormOptionsPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionFormOptionsPart.ts new file mode 100644 index 0000000000..4eff7c0cfa --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionFormOptionsPart.ts @@ -0,0 +1,565 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { FormMode, FormPart, formSubmitContext, formValidationContext, type IFormState } from '@cloudbeaver/core-ui'; +import { DriverConfigurationType, type ConnectionConfig, type ObjectPropertyInfo, type TestConnectionMutation } from '@cloudbeaver/core-sdk'; +import { Executor, ExecutorInterrupter, type IExecutionContextProvider, type IExecutor } from '@cloudbeaver/core-executor'; +import { + ConnectionInfoAuthPropertiesResource, + ConnectionInfoCustomOptionsResource, + ConnectionInfoProjectKey, + ConnectionInfoProviderPropertiesResource, + ConnectionInfoResource, + createConnectionParam, + DatabaseAuthModelsResource, + DBDriverResource, + type ConnectionInfoAuthProperties, + type DBDriver, +} from '@cloudbeaver/core-connections'; +import type { ProjectInfoResource } from '@cloudbeaver/core-projects'; +import { action, computed, makeObservable, observable, reaction, toJS } from 'mobx'; +import { getUniqueName, isNotNullDefined } from '@cloudbeaver/core-utils'; +import { getDefaultConfigurationType } from './getDefaultConfigurationType.js'; +import { getConnectionName } from './getConnectionName.js'; +import type { LocalizationService } from '@cloudbeaver/core-localization'; +import type { IConnectionFormOptionsState } from './IConnectionFormOptionsState.js'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; +import { ConnectionAuthenticationDialogLoader } from '../../ConnectionAuthentication/ConnectionAuthenticationDialogLoader.js'; +import type { NotificationService } from '@cloudbeaver/core-events'; +import { parseJdbcUri } from '@dbeaver/jdbc-uri-parser'; + +const MAIN_PROPERTY_DATABASE_KEY = 'database'; +const MAIN_PROPERTY_HOST_KEY = 'host'; +const MAIN_PROPERTY_PORT_KEY = 'port'; +const MAIN_PROPERTY_SERVER_KEY = 'server'; + +const defaultStateGetter = (connectionId?: string) => + ({ + connectionId, + configurationType: DriverConfigurationType.Manual, + keepAliveInterval: 0, + credentials: {}, + template: false, + mainPropertyValues: {}, + networkHandlersConfig: [], + properties: {}, + providerProperties: {}, + }) as IConnectionFormOptionsState; + +export class ConnectionFormOptionsPart extends FormPart { + private disposeReaction: () => void; + + readonly onDriverIdChange: IExecutor; + + constructor( + formState: IFormState, + private readonly dbDriverResource: DBDriverResource, + private readonly projectInfoResource: ProjectInfoResource, + private readonly databaseAuthModelsResource: DatabaseAuthModelsResource, + private readonly connectionInfoResource: ConnectionInfoResource, + private readonly connectionInfoAuthPropertiesResource: ConnectionInfoAuthPropertiesResource, + private readonly connectionInfoCustomOptionsResource: ConnectionInfoCustomOptionsResource, + private readonly connectionInfoProviderPropertiesResource: ConnectionInfoProviderPropertiesResource, + private readonly localizationService: LocalizationService, + private readonly commonDialogService: CommonDialogService, + private readonly notificationService: NotificationService, + ) { + super(formState, defaultStateGetter(formState.state.connectionId)); + + this.onDriverIdChange = new Executor(); + + this.disposeReaction = reaction( + () => this.getNameTemplate(), + (value, prev) => { + if (this.formState.mode === 'edit') { + return; + } + if (!this.state.name || prev === this.state.name) { + this.state.name = value; + } + }, + ); + + makeObservable(this, { + setAuthModelId: action.bound, + setDriverId: action.bound, + connectionKey: computed, + askCredentials: action.bound, + }); + } + + private async askCredentials(state: IConnectionFormOptionsState): Promise { + const driver = this.state.driverId ? this.dbDriverResource.get(this.state.driverId) : undefined; + const isCredentialsRequired = !state.saveCredentials || this.formState.state.requiredNetworkHandlersIds.length; + + if (!isCredentialsRequired || driver?.anonymousAccess) { + return state; + } + + const stateToRestore: IConnectionFormOptionsState = {}; + + // does not show credentials in dialog if they are already saved + if (state.saveCredentials) { + if (state.authModelId) { + stateToRestore.authModelId = state.authModelId; + delete state.authModelId; + } + + if (state.credentials) { + stateToRestore.credentials = toJS(state.credentials); + delete state.credentials; + } + } + + // does not show network handlers in dialog if they are not required + if (!this.formState.state.requiredNetworkHandlersIds.length && state.networkHandlersConfig) { + stateToRestore.networkHandlersConfig = toJS(state.networkHandlersConfig); + delete state.networkHandlersConfig; + } + + const result = await this.commonDialogService.open(ConnectionAuthenticationDialogLoader, { + config: state, + authModelId: state.authModelId ?? null, + networkHandlers: this.formState.state.requiredNetworkHandlersIds, + projectId: this.formState.state.projectId, + }); + + if (result === DialogueStateResult.Rejected) { + return null; + } + + for (const key of Object.keys(stateToRestore)) { + state[key as keyof IConnectionFormOptionsState] = observable(stateToRestore[key as keyof IConnectionFormOptionsState] as any); + } + + return state; + } + + get connectionKey() { + if (!this.initialState.connectionId || !this.formState.state.projectId) { + return null; + } + + return createConnectionParam(this.formState.state.projectId, this.initialState.connectionId); + } + + // do not check outdated of userInfoResource cause it synced with projectInfoResource which is handled in optionsPart outdated method + // otherwise you would get an infinite loading of the form + override isOutdated(): boolean { + if (!this.formState.state.projectId) { + return false; + } + + if (this.formState.mode === 'create') { + if (this.state.driverId && this.dbDriverResource.isOutdated(this.state.driverId)) { + return true; + } + } + + if (!this.connectionKey) { + return false; + } + + const isAuthPropertiesOutdated = this.connectionInfoAuthPropertiesResource.isOutdated(this.connectionKey); + const isCustomOptionsOutdated = this.connectionInfoCustomOptionsResource.isOutdated(this.connectionKey); + const isProviderPropertiesOutdated = this.connectionInfoProviderPropertiesResource.isOutdated(this.connectionKey); + + return isAuthPropertiesOutdated || isCustomOptionsOutdated || isProviderPropertiesOutdated; + } + + protected override async loader(): Promise { + if (this.formState.mode === 'create') { + this.setInitialState(defaultStateGetter(this.initialState.connectionId ?? this.formState.state.connectionId)); + + await this.setDriverId(this.state.driverId); + + return; + } + + if (!this.connectionKey) { + console.error('Connection connection key should be defined'); + return; + } + + const [authPropertiesInfo, customOptionsInfo, providerPropertiesInfo] = await Promise.all([ + this.connectionInfoAuthPropertiesResource.load(this.connectionKey), + this.connectionInfoCustomOptionsResource.load(this.connectionKey), + this.connectionInfoProviderPropertiesResource.load(this.connectionKey), + ]); + + const config: ConnectionConfig = defaultStateGetter(); + + config.connectionId = customOptionsInfo.id; + config.configurationType = customOptionsInfo.configurationType; + + config.name = customOptionsInfo.name; + config.description = customOptionsInfo.description; + config.template = customOptionsInfo.template; + config.driverId = customOptionsInfo.driverId; + + config.host = customOptionsInfo.host || customOptionsInfo.mainPropertyValues?.[MAIN_PROPERTY_HOST_KEY]; + config.port = customOptionsInfo.port || customOptionsInfo.mainPropertyValues?.[MAIN_PROPERTY_PORT_KEY]; + config.serverName = customOptionsInfo.serverName || customOptionsInfo.mainPropertyValues?.[MAIN_PROPERTY_SERVER_KEY]; + config.databaseName = customOptionsInfo.databaseName || customOptionsInfo.mainPropertyValues?.[MAIN_PROPERTY_DATABASE_KEY]; + + config.url = customOptionsInfo.url; + config.folder = customOptionsInfo.folder; + + config.authModelId = authPropertiesInfo.authModel; + config.saveCredentials = authPropertiesInfo.credentialsSaved; + config.sharedCredentials = authPropertiesInfo.sharedCredentials; + + config.keepAliveInterval = customOptionsInfo.keepAliveInterval; + config.autocommit = customOptionsInfo.autocommit; + config.readOnly = customOptionsInfo.readOnly; + + if (authPropertiesInfo.authProperties) { + for (const property of authPropertiesInfo.authProperties) { + if (!property.features.includes('password')) { + config.credentials[property.id!] = property.value; + } + } + } + + if (providerPropertiesInfo.providerProperties) { + config.providerProperties = { ...toJS(providerPropertiesInfo.providerProperties) }; + } + + if (customOptionsInfo.mainPropertyValues) { + config.mainPropertyValues = { ...toJS(customOptionsInfo.mainPropertyValues) }; + } + + this.formState.state.availableDrivers = [customOptionsInfo.driverId]; + + this.setInitialState(config); + } + + private getNameTemplate() { + const driver = this.state.driverId ? this.dbDriverResource.get(this.state.driverId) : undefined; + + if (this.state.configurationType === DriverConfigurationType.Url) { + const parsedJdbcUri = parseJdbcUri(this.state.url ?? ''); + return getConnectionName({ + driverName: driver?.name || 'New connection', + host: parsedJdbcUri.host, + port: parsedJdbcUri.port, + defaultPort: driver?.defaultPort, + }); + } + + return getConnectionName({ + driverName: driver?.name || 'New connection', + host: this.state.host, + port: this.state.port, + defaultPort: driver?.defaultPort, + }); + } + + async setDriverId(driverId: string | undefined): Promise { + if (this.formState.mode === 'edit') { + return; + } + + let driver: DBDriver | undefined; + let prevDriver: DBDriver | undefined; + const prevDriverId = this.state.driverId; + this.state.driverId = driverId; + + if (driverId) { + driver = await this.dbDriverResource.load(driverId, ['includeProviderProperties']); + } + + if (!driver) { + return; + } + + if (prevDriverId) { + prevDriver = await this.dbDriverResource.load(prevDriverId, ['includeProviderProperties']); + } + + if (!this.state.configurationType || !driver?.configurationTypes.includes(this.state.configurationType)) { + this.state.configurationType = getDefaultConfigurationType(driver); + } + + if ((!prevDriver && this.state.host === undefined) || this.state.host === prevDriver?.defaultServer) { + this.state.host = driver?.defaultServer || 'localhost'; + } + + if ((!prevDriver && this.state.port === undefined) || this.state.port === prevDriver?.defaultPort) { + this.state.port = driver?.defaultPort; + } + + if ((!prevDriver && this.state.databaseName === undefined) || this.state.databaseName === prevDriver?.defaultDatabase) { + this.state.databaseName = driver?.defaultDatabase; + } + + if ((!prevDriver && this.state.url === undefined) || this.state.url === prevDriver?.sampleURL) { + this.state.url = driver?.sampleURL; + } + + if (driver?.id !== prevDriver?.id) { + this.state.credentials = {}; + this.state.providerProperties = {}; + this.setAuthModelId(driver?.defaultAuthModel); + } + + await this.onDriverIdChange.execute(this.state.driverId); + } + + setAuthModelId(modelId: string | undefined): void { + if (modelId === this.initialState.authModelId) { + this.state.credentials = { ...this.initialState.credentials }; + } else if (modelId !== this.state.authModelId) { + this.state.credentials = {}; + } + + this.state.authModelId = modelId; + } + + protected override async format( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise { + if (!this.state.driverId || !this.formState.state.projectId) { + return; + } + + const driver = await this.dbDriverResource.load(this.state.driverId, ['includeProviderProperties', 'includeMainProperties']); + + this.formState.state.requiredNetworkHandlersIds = observable([]); + this.state.networkHandlersConfig = observable([]); + this.state.keepAliveInterval = this.state.keepAliveInterval ? Number(this.state.keepAliveInterval) : undefined; + + this.state.name = this.state.name?.trim(); + this.state.description = this.state.description?.trim(); + + if (this.state.template || !this.state.folder) { + delete this.state.folder; + } + + if (this.state.configurationType === DriverConfigurationType.Url) { + this.state.url = this.state.url?.trim(); + } else { + // if manual type configuration set, it helps to keep host, port, etc. properties (not saved on backend) + delete this.state.url; + } + + // databaseName, host, port, serverName only saves on backend like this + if (this.state.configurationType === DriverConfigurationType.Manual && !driver.useCustomPage) { + this.state.mainPropertyValues![MAIN_PROPERTY_DATABASE_KEY] = this.state.databaseName?.trim(); + + if (!driver.embedded) { + this.state.mainPropertyValues![MAIN_PROPERTY_HOST_KEY] = this.state.host?.trim(); + this.state.mainPropertyValues![MAIN_PROPERTY_PORT_KEY] = this.state.port?.trim(); + } + + if (driver.requiresServerName) { + this.state.mainPropertyValues![MAIN_PROPERTY_SERVER_KEY] = this.state.serverName?.trim(); + } + } + + if ((this.state.authModelId || driver.defaultAuthModel) && !driver.anonymousAccess) { + this.state.authModelId = this.state.authModelId || driver.defaultAuthModel; + this.state.saveCredentials = this.state.saveCredentials || this.state.sharedCredentials; + const authPropertiesInfo = this.connectionKey ? await this.connectionInfoAuthPropertiesResource.load(this.connectionKey) : undefined; + + const properties = await this.getConnectionAuthModelProperties(this.state.authModelId, authPropertiesInfo); + const passwordProperty = properties.find(property => property.features.includes('password')); + const isPasswordEmpty = + passwordProperty && + (this.state.credentials?.[passwordProperty.id!] === passwordProperty.defaultValue || !this.state.credentials?.[passwordProperty.id!]); + + if (isCredentialsChanged(properties, this.state.credentials!)) { + this.state.credentials = prepareDynamicProperties(properties, toJS(this.state.credentials!)); + } + + if (isPasswordEmpty) { + delete this.state.credentials?.[passwordProperty.id!]; + } + } + + if (driver.providerProperties.length > 0) { + this.state.providerProperties = prepareDynamicProperties( + driver.providerProperties, + toJS(this.state.providerProperties!), + this.state.configurationType, + ); + } + + if (driver.useCustomPage && driver.mainProperties.length > 0) { + this.state.mainPropertyValues = prepareDynamicProperties(driver.mainProperties, this.state.mainPropertyValues!, this.state.configurationType); + } + } + + private async getConnectionAuthModelProperties(authModelId: string, connectionInfo?: ConnectionInfoAuthProperties): Promise { + const authModel = await this.databaseAuthModelsResource.load(authModelId); + + let properties = authModel.properties; + + if (connectionInfo?.authProperties && connectionInfo.authProperties.length > 0) { + properties = connectionInfo.authProperties; + } + + return properties; + } + + private getTestMessageInfo(testContext: TestConnectionMutation['connection']) { + let message = ''; + + if (testContext.clientVersion) { + message += this.localizationService.translate('plugin_connections_connection_client_version', undefined, { + version: testContext.clientVersion, + }); + } + + if (testContext.serverVersion) { + message += this.localizationService.translate('plugin_connections_connection_server_version', undefined, { + version: testContext.serverVersion, + }); + } + + if (testContext.connectTime) { + message += this.localizationService.translate('plugin_connections_connection_connection_time', undefined, { + time: testContext.connectTime, + }); + } + + return message; + } + + protected override async validate( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise { + const validation = contexts.getContext(formValidationContext); + + if (this.state.configurationType === DriverConfigurationType.Manual && this.state.host?.length === 0 && this.state.driverId) { + const driver = await this.dbDriverResource.load(this.state.driverId); + if (!driver.embedded) { + validation.error('plugin_connections_connection_form_host_invalid'); + } + } + + if (!this.state.name?.length) { + validation.error('plugin_connections_connection_form_name_invalid'); + } + + if (this.state.driverId && this.state.configurationType) { + const driver = await this.dbDriverResource.load(this.state.driverId, ['includeProviderProperties']); + + if (!driver.configurationTypes.includes(this.state.configurationType)) { + validation.error('plugin_connections_connection_form_host_configuration_invalid'); + } + } + + if (this.formState.state.projectId !== null && this.formState.mode === 'create') { + const project = this.projectInfoResource.get(this.formState.state.projectId); + + if (!project?.canEditDataSources) { + validation.error('plugin_connections_connection_form_project_invalid'); + } + } + } + + protected override async saveChanges( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise { + if (!this.formState.state.projectId) { + return; + } + + const submitType = contexts.getContext(formSubmitContext); + if (submitType.type === 'submit') { + if (this.formState.mode === 'edit') { + await this.connectionInfoResource.update(this.connectionKey!, this.state); + } else { + const connections = await this.connectionInfoResource.load(ConnectionInfoProjectKey(this.formState.state.projectId)); + const connectionNames = connections.map(connection => connection.name); + + const uniqueName = getUniqueName(this.state.name || '', connectionNames); + const connection = await this.connectionInfoResource.create(this.formState.state.projectId, { ...this.state, name: uniqueName }); + this.state.connectionId = connection.id; + this.initialState.connectionId = connection.id; + this.formState.setMode(FormMode.Edit); + } + } else { + try { + const stateCopy = await this.askCredentials(observable(toJS(this.state))); + + if (!stateCopy) { + return; + } + + const info = await this.connectionInfoResource.test(this.formState.state.projectId, stateCopy); + + this.notificationService.logSuccess({ + title: 'plugin_connections_connection_established', + message: this.getTestMessageInfo(info), + }); + } catch (error) { + this.notificationService.logException(error as Error, 'connections_connection_test_fail'); + } finally { + // to prevent form from resetting the state after saving + ExecutorInterrupter.interrupt(contexts); + } + } + } + + override dispose(): void { + this.disposeReaction(); + } +} + +function prepareDynamicProperties( + propertiesInfo: ObjectPropertyInfo[], + properties: Record, + configurationType?: DriverConfigurationType, +) { + const result: Record = { ...properties }; + + for (const propertyInfo of propertiesInfo) { + if (!propertyInfo.id) { + continue; + } + + const supported = configurationType === undefined || propertyInfo.supportedConfigurationTypes?.some(type => type === configurationType); + + if (!supported) { + delete result[propertyInfo.id]; + } else { + const isDefault = isNotNullDefined(propertyInfo.defaultValue); + if (!(propertyInfo.id in result) && isDefault) { + result[propertyInfo.id] = propertyInfo.defaultValue; + } + } + } + + for (const key of Object.keys(result)) { + if (typeof result[key] === 'string') { + result[key] = result[key]?.trim(); + } + } + + return result; +} + +function isCredentialsChanged(authProperties: ObjectPropertyInfo[], credentials: Record) { + for (const property of authProperties) { + const value = credentials[property.id!]; + + if (property.features.includes('password')) { + if (value !== undefined) { + return property.features.includes('file') ? true : !!value; + } + } else if (value !== property.value) { + return true; + } + } + return false; +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionOptionsTabService.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionOptionsTabService.ts index ae99125547..59e11d781c 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionOptionsTabService.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/ConnectionOptionsTabService.ts @@ -5,459 +5,29 @@ * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, makeObservable, runInAction, toJS } from 'mobx'; import React from 'react'; -import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, UserInfoResource } from '@cloudbeaver/core-authentication'; -import { - ConnectionInfoProjectKey, - createConnectionParam, - DatabaseAuthModelsResource, - type DatabaseConnection, - DBDriverResource, - isJDBCConnection, -} from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import { LocalizationService } from '@cloudbeaver/core-localization'; -import { ProjectInfoResource } from '@cloudbeaver/core-projects'; -import { DriverConfigurationType, isObjectPropertyInfoStateEqual, type ObjectPropertyInfo } from '@cloudbeaver/core-sdk'; -import { formStateContext } from '@cloudbeaver/core-ui'; -import { getUniqueName, isNotNullDefined, isValuesEqual } from '@cloudbeaver/core-utils'; -import { connectionFormConfigureContext } from '../connectionFormConfigureContext.js'; import { ConnectionFormService } from '../ConnectionFormService.js'; -import { connectionConfigContext } from '../Contexts/connectionConfigContext.js'; -import { connectionCredentialsStateContext } from '../Contexts/connectionCredentialsStateContext.js'; -import type { IConnectionFormFillConfigData, IConnectionFormState, IConnectionFormSubmitData } from '../IConnectionFormProps.js'; -import { getConnectionName } from './getConnectionName.js'; -import { getDefaultConfigurationType } from './getDefaultConfigurationType.js'; export const Options = React.lazy(async () => { const { Options } = await import('./Options.js'); return { default: Options }; }); -const MAIN_PROPERTY_DATABASE_KEY = 'database'; -const MAIN_PROPERTY_HOST_KEY = 'host'; -const MAIN_PROPERTY_PORT_KEY = 'port'; -const MAIN_PROPERTY_SERVER_KEY = 'server'; - @injectable() export class ConnectionOptionsTabService extends Bootstrap { - constructor( - private readonly projectInfoResource: ProjectInfoResource, - private readonly connectionFormService: ConnectionFormService, - private readonly dbDriverResource: DBDriverResource, - private readonly userInfoResource: UserInfoResource, - private readonly localizationService: LocalizationService, - private readonly authProvidersResource: AuthProvidersResource, - private readonly databaseAuthModelsResource: DatabaseAuthModelsResource, - ) { + constructor(private readonly connectionFormService: ConnectionFormService) { super(); - - makeObservable(this, { - fillConfig: action, - }); } override register(): void { - this.connectionFormService.tabsContainer.add({ + this.connectionFormService.parts.add({ key: 'options', name: 'plugin_connections_connection_form_part_main', order: 1, panel: () => Options, }); - - this.connectionFormService.prepareConfigTask.addHandler(this.prepareConfig.bind(this)); - - this.connectionFormService.formValidationTask.addHandler(this.validate.bind(this)); - - this.connectionFormService.formSubmittingTask.addHandler(this.save.bind(this)); - - this.connectionFormService.formStateTask.addHandler(this.formState.bind(this)).addHandler(this.formAuthState.bind(this)); - - this.connectionFormService.configureTask.addHandler(this.configure.bind(this)); - - this.connectionFormService.fillConfigTask.addHandler(this.fillConfig.bind(this)); - } - - private async save({ state, submitType }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const status = contexts.getContext(this.connectionFormService.connectionStatusContext); - const config = contexts.getContext(connectionConfigContext); - - if (!state.projectId) { - status.error('connections_connection_create_fail'); - return; - } - - try { - if (submitType === 'submit') { - if (state.mode === 'edit') { - const connection = await state.resource.update(createConnectionParam(state.projectId, config.connectionId!), config); - status.info('Connection was updated'); - status.info(connection.name); - } else { - const connection = await state.resource.create(state.projectId, config); - config.connectionId = connection.id; - status.info('Connection was created'); - status.info(connection.name); - } - } else { - const info = await state.resource.test(state.projectId, config); - status.info('Connection is established'); - status.info('Client version: ' + info.clientVersion); - status.info('Server version: ' + info.serverVersion); - status.info('Connection time: ' + info.connectTime); - } - } catch (exception: any) { - if (submitType === 'submit') { - status.error('connections_connection_create_fail', exception); - } else { - status.error('connections_connection_test_fail', exception); - } - } - } - - private async validate({ state }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const validation = contexts.getContext(this.connectionFormService.connectionValidationContext); - - if (state.config.configurationType === DriverConfigurationType.Manual && state.config.host?.length === 0 && state.config.driverId) { - const driver = await this.dbDriverResource.load(state.config.driverId); - if (!driver.embedded) { - validation.error('plugin_connections_connection_form_host_invalid'); - } - } - - if (!state.config.name?.length) { - validation.error('plugin_connections_connection_form_name_invalid'); - } - - if (state.config.driverId && state.config.configurationType) { - const driver = await this.dbDriverResource.load(state.config.driverId, ['includeProviderProperties']); - - if (!driver.configurationTypes.includes(state.config.configurationType)) { - validation.error('plugin_connections_connection_form_host_configuration_invalid'); - } - } - - if (state.projectId !== null && state.mode === 'create') { - const project = this.projectInfoResource.get(state.projectId); - - if (!project?.canEditDataSources) { - validation.error('plugin_connections_connection_form_project_invalid'); - } - } - - // if (state.config.folder && !state.config.folder.match(CONNECTION_FOLDER_NAME_VALIDATION)) { - // validation.error('connections_connection_folder_validation'); - // } - } - - private async setDefaults(state: IConnectionFormState) { - if (!state.config.driverId) { - return; - } - - const driver = await this.dbDriverResource.load(state.config.driverId, ['includeProviderProperties']); - - state.config.authModelId = driver?.defaultAuthModel; - state.config.configurationType = getDefaultConfigurationType(driver); - - if (!state.config.host) { - state.config.host = driver?.defaultServer || 'localhost'; - } - - if (!state.config.port) { - state.config.port = driver?.defaultPort; - } - - state.config.databaseName = driver?.defaultDatabase; - state.config.url = driver?.sampleURL; - - if (isJDBCConnection(driver)) { - state.config.name = state.config.url; - } else { - state.config.name = getConnectionName(driver.name || '', state.config.host, state.config.port, driver.defaultPort); - } - } - - private async fillConfig({ state, updated }: IConnectionFormFillConfigData, contexts: IExecutionContextProvider) { - if (!updated) { - return; - } - - if (!state.config.credentials || updated) { - state.config.credentials = {}; - state.config.saveCredentials = false; - } - - if (!state.config.providerProperties || updated) { - state.config.providerProperties = {}; - } - - if (!state.config.mainPropertyValues || updated) { - state.config.mainPropertyValues = {}; - } - - if (!state.info) { - await this.setDefaults(state); - return; - } - - state.config.connectionId = state.info.id; - state.config.configurationType = state.info.configurationType; - - state.config.name = state.info.name; - state.config.description = state.info.description; - state.config.template = state.info.template; - state.config.driverId = state.info.driverId; - - state.config.host = state.info.mainPropertyValues[MAIN_PROPERTY_HOST_KEY]; - state.config.port = state.info.mainPropertyValues[MAIN_PROPERTY_PORT_KEY]; - state.config.serverName = state.info.mainPropertyValues[MAIN_PROPERTY_SERVER_KEY]; - state.config.databaseName = state.info.mainPropertyValues[MAIN_PROPERTY_DATABASE_KEY]; - - state.config.url = state.info.url; - state.config.folder = state.info.folder; - - state.config.authModelId = state.info.authModel; - state.config.saveCredentials = state.info.credentialsSaved; - state.config.sharedCredentials = state.info.sharedCredentials; - - state.config.keepAliveInterval = state.info.keepAliveInterval; - state.config.autocommit = state.info.autocommit; - state.config.readOnly = state.info.readOnly; - - if (state.info.authProperties) { - for (const property of state.info.authProperties) { - if (!property.features.includes('password')) { - state.config.credentials[property.id!] = property.value; - } - } - } - - if (state.info.providerProperties) { - state.config.providerProperties = { ...state.info.providerProperties }; - } - - if (state.info.mainPropertyValues) { - state.config.mainPropertyValues = { ...state.info.mainPropertyValues }; - } - } - - private configure(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const configuration = contexts.getContext(connectionFormConfigureContext); - - configuration.include('includeAuthProperties', 'includeCredentialsSaved', 'customIncludeOptions'); - } - - private async prepareConfig({ state }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - const credentialsState = contexts.getContext(connectionCredentialsStateContext); - - if (!state.config.driverId || !state.projectId) { - return; - } - - const driver = await this.dbDriverResource.load(state.config.driverId, ['includeProviderProperties', 'includeMainProperties']); - const tempConfig = toJS(config); - - if (state.mode === 'edit') { - tempConfig.connectionId = state.config.connectionId; - } - - tempConfig.configurationType = state.config.configurationType; - tempConfig.name = state.config.name?.trim(); - - if (tempConfig.name && state.mode === 'create') { - const connections = await state.resource.load(ConnectionInfoProjectKey(state.projectId)); - const connectionNames = connections.map(connection => connection.name); - - tempConfig.name = getUniqueName(tempConfig.name, connectionNames); - } - - tempConfig.description = state.config.description?.trim(); - tempConfig.template = state.config.template; - tempConfig.driverId = state.config.driverId; - tempConfig.keepAliveInterval = Number(state.config.keepAliveInterval); - tempConfig.autocommit = state.config.autocommit; - tempConfig.readOnly = state.config.readOnly; - - if (!state.config.template && state.config.folder) { - tempConfig.folder = state.config.folder; - } - - if (tempConfig.configurationType === DriverConfigurationType.Url) { - tempConfig.url = state.config.url?.trim(); - } - - tempConfig.mainPropertyValues = toJS(state.config.mainPropertyValues); - - if (tempConfig.configurationType === DriverConfigurationType.Manual && !driver.useCustomPage) { - tempConfig.mainPropertyValues[MAIN_PROPERTY_DATABASE_KEY] = state.config.databaseName?.trim(); - - if (!driver.embedded) { - tempConfig.mainPropertyValues[MAIN_PROPERTY_HOST_KEY] = state.config.host?.trim(); - tempConfig.mainPropertyValues[MAIN_PROPERTY_PORT_KEY] = state.config.port?.trim(); - } - - if (driver.requiresServerName) { - tempConfig.mainPropertyValues[MAIN_PROPERTY_SERVER_KEY] = state.config.serverName?.trim(); - } - } - - if ((state.config.authModelId || driver.defaultAuthModel) && !driver.anonymousAccess) { - tempConfig.authModelId = state.config.authModelId || driver.defaultAuthModel; - tempConfig.sharedCredentials = state.config.sharedCredentials; - tempConfig.saveCredentials = state.config.saveCredentials || tempConfig.sharedCredentials; - - const properties = await this.getConnectionAuthModelProperties(tempConfig.authModelId, state.info); - - if (this.isCredentialsChanged(properties, state.config.credentials)) { - tempConfig.credentials = this.prepareDynamicProperties(properties, toJS(state.config.credentials)); - } - - if (!tempConfig.saveCredentials) { - credentialsState.requireAuthModel(tempConfig.authModelId); - } - } - - if (driver.providerProperties.length > 0) { - tempConfig.providerProperties = this.prepareDynamicProperties( - driver.providerProperties, - toJS(state.config.providerProperties), - tempConfig.configurationType, - ); - } - - if (driver.useCustomPage && driver.mainProperties.length > 0) { - tempConfig.mainPropertyValues = this.prepareDynamicProperties( - driver.mainProperties, - tempConfig.mainPropertyValues, - tempConfig.configurationType, - ); - } - - runInAction(() => { - Object.assign(config, tempConfig); - }); - } - - private prepareDynamicProperties( - propertiesInfo: ObjectPropertyInfo[], - properties: Record, - configurationType?: DriverConfigurationType, - ) { - const result: Record = { ...properties }; - - for (const propertyInfo of propertiesInfo) { - if (!propertyInfo.id) { - continue; - } - - const supported = configurationType === undefined || propertyInfo.supportedConfigurationTypes?.some(type => type === configurationType); - - if (!supported) { - delete result[propertyInfo.id]; - } else { - const isDefault = isNotNullDefined(propertyInfo.defaultValue); - if (!(propertyInfo.id in result) && isDefault) { - result[propertyInfo.id] = propertyInfo.defaultValue; - } - } - } - - for (const key of Object.keys(result)) { - if (typeof result[key] === 'string') { - result[key] = result[key]?.trim(); - } - } - - return result; - } - - private async formAuthState(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - const stateContext = contexts.getContext(formStateContext); - - const driver = await this.dbDriverResource.load(config.driverId!, ['includeProviderProperties', 'includeMainProperties']); - const authModel = await this.databaseAuthModelsResource.load(config.authModelId ?? data.info?.authModel ?? driver.defaultAuthModel); - - const providerId = authModel.requiredAuth ?? data.info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID; - - await this.userInfoResource.load(); - - if (!this.userInfoResource.hasToken(providerId)) { - const provider = await this.authProvidersResource.load(providerId); - const message = this.localizationService.translate('plugin_connections_connection_cloud_auth_required', undefined, { - providerLabel: provider.label, - }); - stateContext.setInfo(message); - stateContext.readonly = data.mode === 'edit'; - } - } - - private async formState(data: IConnectionFormState, contexts: IExecutionContextProvider) { - if (!data.info) { - return; - } - - const config = contexts.getContext(connectionConfigContext); - const stateContext = contexts.getContext(formStateContext); - const driver = await this.dbDriverResource.load(data.config.driverId!, ['includeProviderProperties', 'includeMainProperties']); - - if ( - !isValuesEqual(config.name, data.info.name, '') || - !isValuesEqual(config.configurationType, data.info.configurationType, DriverConfigurationType.Manual) || - !isValuesEqual(config.description, data.info.description, '') || - !isValuesEqual(config.template, data.info.template, true) || - !isValuesEqual(config.folder, data.info.folder, undefined) || - !isValuesEqual(config.driverId, data.info.driverId, '') || - (config.url !== undefined && !isValuesEqual(config.url, data.info.url, '')) || - (config.host !== undefined && !isValuesEqual(config.host, data.info.host, '')) || - (config.port !== undefined && !isValuesEqual(config.port, data.info.port, '')) || - (config.serverName !== undefined && !isValuesEqual(config.serverName, data.info.serverName, '')) || - (config.databaseName !== undefined && !isValuesEqual(config.databaseName, data.info.databaseName, '')) || - config.credentials !== undefined || - (config.authModelId !== undefined && !isValuesEqual(config.authModelId, data.info.authModel, '')) || - (config.saveCredentials !== undefined && config.saveCredentials !== data.info.credentialsSaved) || - (config.sharedCredentials !== undefined && config.sharedCredentials !== data.info.sharedCredentials) || - (config.providerProperties !== undefined && - !isObjectPropertyInfoStateEqual(driver.providerProperties, config.providerProperties, data.info.providerProperties)) || - (config.mainPropertyValues !== undefined && - !isObjectPropertyInfoStateEqual(driver.mainProperties, config.mainPropertyValues, data.info.mainPropertyValues)) || - (config.keepAliveInterval !== undefined && !isValuesEqual(config.keepAliveInterval, data.info.keepAliveInterval)) || - (config.autocommit !== undefined && !isValuesEqual(config.autocommit, data.info.autocommit)) - ) { - stateContext.markEdited(); - } - } - - private isCredentialsChanged(authProperties: ObjectPropertyInfo[], credentials: Record) { - for (const property of authProperties) { - const value = credentials[property.id!]; - - if (property.features.includes('password')) { - if (value !== undefined) { - return property.features.includes('file') ? true : !!value; - } - } else if (value !== property.value) { - return true; - } - } - return false; - } - - private async getConnectionAuthModelProperties(authModelId: string, connectionInfo?: DatabaseConnection): Promise { - const authModel = await this.databaseAuthModelsResource.load(authModelId); - - let properties = authModel.properties; - - if (connectionInfo?.authProperties && connectionInfo.authProperties.length > 0) { - properties = connectionInfo.authProperties; - } - - return properties; } } diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionConfig.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionConfig.ts new file mode 100644 index 0000000000..9b7d7a2ad8 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionConfig.ts @@ -0,0 +1,45 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { DriverConfigurationType } from '@cloudbeaver/core-sdk'; +import { schema } from '@cloudbeaver/core-utils'; +import { CONNECTION_NETWORK_HANDLER_SCHEMA } from './IConnectionNetworkHanler.js'; + +export const CONNECTION_PROPERTIES_SCHEMA = schema.record(schema.any()); + +export const CONNECTION_CONFIG_SCHEMA = schema.object({ + authModelId: schema.string().optional(), + autocommit: schema.boolean().optional(), + configurationType: schema.enum([DriverConfigurationType.Manual, DriverConfigurationType.Url]).optional(), + connectionId: schema.string().optional(), + credentials: schema.record(schema.any()).optional(), + dataSourceId: schema.string().optional(), + databaseName: schema.string().optional(), + description: schema.string().optional(), + driverId: schema.string().optional(), + folder: schema.string().optional(), + host: schema.string().optional(), + keepAliveInterval: schema.number().optional(), + mainPropertyValues: schema.record(schema.any()).optional(), + name: schema.string().optional(), + networkHandlersConfig: schema.array(CONNECTION_NETWORK_HANDLER_SCHEMA).optional(), + port: schema.string().optional(), + properties: CONNECTION_PROPERTIES_SCHEMA.optional(), + providerProperties: schema.record(schema.any()).optional(), + readOnly: schema.boolean().optional(), + saveCredentials: schema.boolean().optional(), + selectedSecretId: schema.string().optional(), + serverName: schema.string().optional(), + sharedCredentials: schema.boolean().optional(), + template: schema.boolean().optional(), + templateId: schema.string().optional(), + url: schema.string().optional(), + userName: schema.string().optional(), + userPassword: schema.string().optional(), +}); + +export type IConnectionProperties = schema.infer; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionFormOptionsState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionFormOptionsState.ts new file mode 100644 index 0000000000..1283a2f4fb --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionFormOptionsState.ts @@ -0,0 +1,13 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { schema } from '@cloudbeaver/core-utils'; +import { CONNECTION_CONFIG_SCHEMA } from './IConnectionConfig.js'; + +export const CONNECTION_FORM_OPTIONS_SCHEMA = CONNECTION_CONFIG_SCHEMA.extend({}); + +export type IConnectionFormOptionsState = schema.infer; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionNetworkHanler.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionNetworkHanler.ts new file mode 100644 index 0000000000..8feb3892f1 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/IConnectionNetworkHanler.ts @@ -0,0 +1,23 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { NetworkHandlerAuthType } from '@cloudbeaver/core-sdk'; +import { schema } from '@cloudbeaver/core-utils'; + +export const CONNECTION_NETWORK_HANDLER_SCHEMA = schema.object({ + id: schema.string(), + authType: schema.nativeEnum(NetworkHandlerAuthType).optional(), + enabled: schema.boolean().optional(), + key: schema.string().optional(), + password: schema.string().optional(), + properties: schema.record(schema.any()).optional(), + savePassword: schema.boolean().optional(), + secureProperties: schema.record(schema.any()).optional(), + userName: schema.string().optional(), +}); + +export type INetworkHandlerConfig = schema.infer; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/Options.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/Options/Options.tsx index 7b9d597396..f6b7a9a5e0 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/Options/Options.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/Options.tsx @@ -34,26 +34,34 @@ import { useS, useTranslate, useAuthenticationAction, + useAutoLoad, } from '@cloudbeaver/core-blocks'; -import { DatabaseAuthModelsResource, type DBDriver, DBDriverResource, isLocalConnection } from '@cloudbeaver/core-connections'; +import { + ConnectionInfoAuthPropertiesResource, + ConnectionInfoOriginResource, + ConnectionInfoResource, + DatabaseAuthModelsResource, + type DBDriver, + DBDriverResource, + isLocalConnection, +} from '@cloudbeaver/core-connections'; import { useService } from '@cloudbeaver/core-di'; import { ProjectInfoResource } from '@cloudbeaver/core-projects'; import { EAdminPermission, ServerConfigResource } from '@cloudbeaver/core-root'; import { DriverConfigurationType } from '@cloudbeaver/core-sdk'; -import { type TabContainerPanelComponent, TabsContext } from '@cloudbeaver/core-ui'; +import { type TabContainerPanelComponent, TabsContext, useTab } from '@cloudbeaver/core-ui'; import { EMPTY_ARRAY } from '@cloudbeaver/core-utils'; import { ProjectSelect } from '@cloudbeaver/plugin-projects'; import { ConnectionAuthModelCredentialsForm } from '../ConnectionAuthModelCredentials/ConnectionAuthModelCredentialsForm.js'; import { ConnectionAuthModelSelector } from '../ConnectionAuthModelCredentials/ConnectionAuthModelSelector.js'; -import { ConnectionFormService } from '../ConnectionFormService.js'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import { CONNECTION_FORM_SHARED_CREDENTIALS_TAB_ID } from '../SharedCredentials/CONNECTION_FORM_SHARED_CREDENTIALS_TAB_ID.js'; import { AdvancedPropertiesForm } from './AdvancedPropertiesForm.js'; import styles from './Options.module.css'; import { ParametersForm } from './ParametersForm.js'; import { ProviderPropertiesForm } from './ProviderPropertiesForm.js'; -import { useOptions } from './useOptions.js'; +import { getConnectionFormOptionsPart } from './getConnectionFormOptionsPart.js'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; const PROFILE_AUTH_MODEL_ID = 'profile'; @@ -77,107 +85,112 @@ const driverConfiguration: IDriverConfiguration[] = [ isVisible: driver => driver.configurationTypes.includes(DriverConfigurationType.Url), }, ]; - -export const Options: TabContainerPanelComponent = observer(function Options({ state }) { +export const Options: TabContainerPanelComponent = observer(function Options({ formState, tabId }) { + const { selected } = useTab(tabId); const isAdmin = usePermission(EAdminPermission.admin); - const serverConfigResource = useResource(Options, ServerConfigResource, undefined); + const serverConfigResource = useResource(Options, ServerConfigResource, undefined, { + active: selected, + }); const projectInfoResource = useService(ProjectInfoResource); - const service = useService(ConnectionFormService); const formRef = useRef(null); const translate = useTranslate(); - const { info, originInfo, config, availableDrivers, submittingTask: submittingHandlers, disabled } = state; const style = useS(styles); const tabsState = useContext(TabsContext); - const isSharedProject = projectInfoResource.isProjectShared(state.projectId); + const isSharedProject = projectInfoResource.isProjectShared(formState.state.projectId); + const optionsPart = getConnectionFormOptionsPart(formState); + const connectionInfoResource = useResource(Options, ConnectionInfoResource, optionsPart.connectionKey, { + active: selected && !!optionsPart.connectionKey, + }); + const connectionInfoOriginResource = useResource(Options, ConnectionInfoOriginResource, optionsPart.connectionKey, { + active: selected && !!optionsPart.connectionKey, + }); + const connectionInfoAuthPropertiesResource = useResource(Options, ConnectionInfoAuthPropertiesResource, optionsPart.connectionKey, { + active: selected && !!optionsPart.connectionKey, + }); //@TODO it's here until the profile implementation in the CloudBeaver - const readonly = state.readonly || info?.authModel === PROFILE_AUTH_MODEL_ID; + const readonly = formState.isDisabled || formState.isReadOnly || connectionInfoResource.data?.authModel === PROFILE_AUTH_MODEL_ID; - useFormValidator(submittingHandlers.for(service.formValidationTask), formRef.current); - const optionsHook = useOptions(state); + useFormValidator(formState.validationTask, formRef.current); const { credentialsSavingEnabled } = useAdministrationSettings(); const driverMap = useResource( Options, DBDriverResource, - { key: config.driverId || null, includes: ['includeProviderProperties', 'includeMainProperties'] as const }, { - onData: data => { - optionsHook.setDefaults(data); - }, + key: optionsPart.state.driverId || null, + includes: ['includeProviderProperties', 'includeMainProperties', 'includeDriverProperties'] as const, + }, + { + active: selected, }, ); const driver = driverMap.data; const configurationTypes = driverConfiguration.filter(configuration => driver && configuration.isVisible(driver)); - function handleFormChange(value?: unknown, name?: string) { - if (name !== 'name' && optionsHook.isNameAutoFill()) { - optionsHook.updateNameTemplate(driver); - } - - if (config.template) { - config.folder = undefined; - } - } - const applicableAuthModels = driver?.applicableAuthModels ?? []; const authModelLoader = useResource( Options, DatabaseAuthModelsResource, - getComputed(() => config.authModelId || info?.authModel || driver?.defaultAuthModel || null), + getComputed(() => optionsPart.state.authModelId || connectionInfoResource.data?.authModel || driver?.defaultAuthModel || null), { - onData: data => optionsHook.setAuthModel(data), + active: selected, }, ); const authModel = authModelLoader.data; - async function handleAuthModelSelect(id: string | undefined) { - if (!id) { - return; - } - - const model = await authModelLoader.resource.load(id); - - if (!model) { - return; - } - - optionsHook.setAuthModel(model); + function handleAuthModelSelect(authModelId: string | undefined) { + optionsPart.setAuthModelId(authModelId); } const authentication = useAuthenticationAction({ - providerId: authModel?.requiredAuth ?? info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID, + providerId: authModel?.requiredAuth ?? connectionInfoResource.data?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID, }); - const edit = state.mode === 'edit'; - const originLocal = !info || (originInfo?.origin && isLocalConnection(originInfo.origin)); + const edit = formState.mode === 'edit'; + const originLocal = + !connectionInfoResource.data || (connectionInfoOriginResource.data?.origin && isLocalConnection(connectionInfoOriginResource.data.origin)); const drivers = driverMap.resource.enabledDrivers.filter(({ id, driverInstalled }) => { if (!edit && !isAdmin && !driverInstalled) { return false; } - return availableDrivers.includes(id); + return formState.state.availableDrivers.includes(id); }); + function setProject(projectId: string) { + formState.state.projectId = projectId; + } + let properties = authModel?.properties; - if (info?.authProperties && info.authProperties.length > 0 && config.authModelId === info.authModel) { - properties = info.authProperties; + if ( + connectionInfoAuthPropertiesResource.data?.authProperties && + connectionInfoAuthPropertiesResource.data.authProperties.length > 0 && + optionsPart.state.authModelId === connectionInfoResource.data?.authModel + ) { + properties = connectionInfoAuthPropertiesResource.data.authProperties; } - const sharedCredentials = config.sharedCredentials && serverConfigResource.data?.distributed; + const sharedCredentials = optionsPart.state.sharedCredentials && serverConfigResource.data?.distributed; function openCredentialsTab(event: React.MouseEvent) { event.preventDefault(); tabsState?.open(CONNECTION_FORM_SHARED_CREDENTIALS_TAB_ID); } + async function setDriverIdHandler(driverId: string | undefined) { + await optionsPart.setDriverId(driverId); + } + + useAutoLoad(Options, optionsPart, selected); + return ( - + @@ -191,7 +204,7 @@ export const Options: TabContainerPanelComponent = observe driver.id} valueSelector={driver => driver.name ?? ''} @@ -199,17 +212,18 @@ export const Options: TabContainerPanelComponent = observe iconSelector={driver => driver.icon} searchable={drivers.length > 10} readOnly={readonly || edit || drivers.length < 2} - disabled={disabled} + disabled={formState.isDisabled} loading={driverMap.isLoading()} tiny fill + onSelect={setDriverIdHandler} > {translate('connections_connection_driver')} {configurationTypes.length > 1 && ( - + {configurationTypes.map(conf => ( = observe )} - {config.configurationType === DriverConfigurationType.Url && ( + {optionsPart.state.configurationType === DriverConfigurationType.Url && ( {translate('plugin_connections_connection_form_part_main_url_jdbc')} )} - {config.configurationType === DriverConfigurationType.Manual && + {optionsPart.state.configurationType === DriverConfigurationType.Manual && (driver?.useCustomPage ? ( ) : ( @@ -262,24 +276,24 @@ export const Options: TabContainerPanelComponent = observe - + {translate('connections_connection_name')} - {!config.template && ( + {!optionsPart.state.template && ( state.setProject(projectId)} + onChange={setProject} /> )} - {!config.template && ( + {!optionsPart.state.template && ( = observe )} - @@ -300,32 +314,32 @@ export const Options: TabContainerPanelComponent = observe {translate('connections_connection_edit_authentication')} {serverConfigResource.resource.distributed && isSharedProject && ( {translate('connections_connection_share_credentials')} )} {!sharedCredentials ? ( <> {properties && ( )} @@ -337,12 +351,12 @@ export const Options: TabContainerPanelComponent = observe )} - {!sharedCredentials && authModel && credentialsSavingEnabled && !config.template && ( + {!sharedCredentials && authModel && credentialsSavingEnabled && !optionsPart.state.template && ( = observe )} {driver?.providerProperties && ( - + )} - + diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionFormOptionsPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionFormOptionsPart.ts new file mode 100644 index 0000000000..d57342623f --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionFormOptionsPart.ts @@ -0,0 +1,55 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; +import { ConnectionFormOptionsPart } from './ConnectionFormOptionsPart.js'; +import { + ConnectionInfoAuthPropertiesResource, + ConnectionInfoCustomOptionsResource, + ConnectionInfoProviderPropertiesResource, + ConnectionInfoResource, + DatabaseAuthModelsResource, + DBDriverResource, +} from '@cloudbeaver/core-connections'; +import { ProjectInfoResource } from '@cloudbeaver/core-projects'; +import { LocalizationService } from '@cloudbeaver/core-localization'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { CommonDialogService } from '@cloudbeaver/core-dialogs'; +import { NotificationService } from '@cloudbeaver/core-events'; + +const DATA_CONTEXT_CONNECTION_FORM_OPTIONS_PART = createDataContext('Connection Form Options Part'); + +export function getConnectionFormOptionsPart(formState: IFormState): ConnectionFormOptionsPart { + return formState.getPart(DATA_CONTEXT_CONNECTION_FORM_OPTIONS_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const dbDriverResource = di.getService(DBDriverResource); + const projectInfoResource = di.getService(ProjectInfoResource); + const databaseAuthModelsResource = di.getService(DatabaseAuthModelsResource); + const connectionInfoResource = di.getService(ConnectionInfoResource); + const localizationService = di.getService(LocalizationService); + const commonDialogService = di.getService(CommonDialogService); + const notificationService = di.getService(NotificationService); + const connectionInfoAuthPropertiesResource = di.getService(ConnectionInfoAuthPropertiesResource); + const connectionInfoCustomOptionsResource = di.getService(ConnectionInfoCustomOptionsResource); + const connectionInfoProviderPropertiesResource = di.getService(ConnectionInfoProviderPropertiesResource); + + return new ConnectionFormOptionsPart( + formState, + dbDriverResource, + projectInfoResource, + databaseAuthModelsResource, + connectionInfoResource, + connectionInfoAuthPropertiesResource, + connectionInfoCustomOptionsResource, + connectionInfoProviderPropertiesResource, + localizationService, + commonDialogService, + notificationService, + ); + }); +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionName.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionName.ts index b3593ee0f7..790c21cec3 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionName.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/Options/getConnectionName.ts @@ -1,13 +1,20 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ const MAX_HOST_LENGTH = 20; -export function getConnectionName(driverName: string, host?: string, port?: string, defaultPort?: string) { +interface IConnectionNameOptions { + driverName: string; + host?: string; + port?: string; + defaultPort?: string; +} + +export function getConnectionName({ driverName, host, port, defaultPort }: IConnectionNameOptions): string { let name = driverName; if (host) { diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/Options/useOptions.ts b/webapp/packages/plugin-connections/src/ConnectionForm/Options/useOptions.ts deleted file mode 100644 index 71838be53e..0000000000 --- a/webapp/packages/plugin-connections/src/ConnectionForm/Options/useOptions.ts +++ /dev/null @@ -1,136 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import { runInAction } from 'mobx'; - -import { useObjectRef } from '@cloudbeaver/core-blocks'; -import { type DBDriver, DBDriverResource, isJDBCConnection } from '@cloudbeaver/core-connections'; -import { useService } from '@cloudbeaver/core-di'; -import type { DatabaseAuthModel } from '@cloudbeaver/core-sdk'; - -import type { IConnectionFormState } from '../IConnectionFormProps.js'; -import { getConnectionName } from './getConnectionName.js'; -import { getDefaultConfigurationType } from './getDefaultConfigurationType.js'; - -export function useOptions(state: IConnectionFormState) { - const dbDriverResource = useService(DBDriverResource); - const refObject = useObjectRef( - () => ({ - prevName: null as string | null, - prevDriverId: null as string | null, - }), - { - state, - }, - ); - - return useObjectRef({ - updateNameTemplate(driver: DBDriver | undefined) { - runInAction(() => { - const { - state: { config, info }, - } = refObject; - - if (isJDBCConnection(driver, info)) { - refObject.prevName = config.url || ''; - config.name = config.url || ''; - return; - } - - if (!driver) { - config.name = 'New connection'; - return; - } - - const name = getConnectionName(driver.name || '', config.host, config.port, driver.defaultPort); - - refObject.prevName = name; - config.name = name; - }); - }, - setDefaults(driver: DBDriver | undefined) { - runInAction(() => { - const { - state: { config, info }, - prevDriverId, - } = refObject; - - if (info || driver?.id !== config.driverId) { - return; - } - - let prevDriver: DBDriver | undefined; - - if (prevDriverId) { - prevDriver = dbDriverResource.get(prevDriverId); - } - - refObject.prevDriverId = driver?.id || null; - - if (!config.configurationType || !driver?.configurationTypes.includes(config.configurationType)) { - state.config.configurationType = getDefaultConfigurationType(driver); - } - - if ((!prevDriver && config.host === undefined) || config.host === prevDriver?.defaultServer) { - config.host = driver?.defaultServer || 'localhost'; - } - - if ((!prevDriver && config.port === undefined) || config.port === prevDriver?.defaultPort) { - config.port = driver?.defaultPort; - } - - if ((!prevDriver && config.databaseName === undefined) || config.databaseName === prevDriver?.defaultDatabase) { - config.databaseName = driver?.defaultDatabase; - } - - if ((!prevDriver && config.url === undefined) || config.url === prevDriver?.sampleURL) { - config.url = driver?.sampleURL; - } - - if (this.isNameAutoFill()) { - this.updateNameTemplate(driver); - } - - if (driver?.id !== prevDriver?.id) { - config.credentials = {}; - config.providerProperties = {}; - config.authModelId = driver?.defaultAuthModel; - } - }); - }, - setAuthModel(model: DatabaseAuthModel) { - const { - state: { config, info }, - } = refObject; - - config.credentials = {}; - - if (model.id === info?.authModel) { - if (info.authProperties) { - for (const property of info.authProperties) { - if (!property.features.includes('password')) { - config.credentials[property.id!] = property.value; - } - } - } - } - - refObject.state.checkFormState(); - }, - - isNameAutoFill() { - const { - prevName, - state: { config, mode }, - } = refObject; - - const isAutoFill = config.name === prevName || prevName === null; - - return isAutoFill && mode === 'create'; - }, - }); -} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormAuthenticationAction.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormAuthenticationAction.tsx index 777afd2e16..60835d37a3 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormAuthenticationAction.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormAuthenticationAction.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -9,24 +9,35 @@ import { observer } from 'mobx-react-lite'; import { AUTH_PROVIDER_LOCAL_ID } from '@cloudbeaver/core-authentication'; import { Button, getComputed, type PlaceholderComponent, useResource, useTranslate, useAuthenticationAction } from '@cloudbeaver/core-blocks'; -import { DatabaseAuthModelsResource, DBDriverResource } from '@cloudbeaver/core-connections'; +import { ConnectionInfoResource, DatabaseAuthModelsResource, DBDriverResource } from '@cloudbeaver/core-connections'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; -export const AuthenticationButton: PlaceholderComponent = observer(function ConnectionFormAuthenticationAction({ state }) { +export const AuthenticationButton: PlaceholderComponent = observer(function ConnectionFormAuthenticationAction({ formState }) { const translate = useTranslate(); - const driverMap = useResource(ConnectionFormAuthenticationAction, DBDriverResource, state.config.driverId || null); - + const optionsPart = getConnectionFormOptionsPart(formState); + const driverMap = useResource(ConnectionFormAuthenticationAction, DBDriverResource, optionsPart.state.driverId || null); + const connectionInfoService = useResource(ConnectionFormAuthenticationAction, ConnectionInfoResource, optionsPart.connectionKey, { + active: !!optionsPart.connectionKey, + }); + const info = connectionInfoService.data; const driver = driverMap.data; const { data: authModel } = useResource( ConnectionFormAuthenticationAction, DatabaseAuthModelsResource, - getComputed(() => state.config.authModelId || state.info?.authModel || driver?.defaultAuthModel || null), + getComputed(() => optionsPart.state.authModelId || info?.authModel || driver?.defaultAuthModel || null), ); const authentication = useAuthenticationAction({ - providerId: authModel?.requiredAuth ?? state.info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID, - onAuthenticate: () => state.loadConnectionInfo(), + providerId: authModel?.requiredAuth ?? info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID, + onAuthenticate: () => { + if (!optionsPart.connectionKey) { + return; + } + + connectionInfoService.resource.markOutdated(optionsPart.connectionKey); + }, }); if (authentication.authorized) { @@ -34,27 +45,30 @@ export const AuthenticationButton: PlaceholderComponent = } return ( - ); }); export const ConnectionFormAuthenticationAction: PlaceholderComponent = observer(function ConnectionFormAuthenticationAction({ - state, + formState, }) { - const driverMap = useResource(ConnectionFormAuthenticationAction, DBDriverResource, state.config.driverId || null); - + const optionsPart = getConnectionFormOptionsPart(formState); + const driverMap = useResource(ConnectionFormAuthenticationAction, DBDriverResource, optionsPart.state.driverId || null); + const connectionInfoService = useResource(ConnectionFormAuthenticationAction, ConnectionInfoResource, optionsPart.connectionKey, { + active: !!optionsPart.connectionKey, + }); const driver = driverMap.data; const { data: authModel } = useResource( ConnectionFormAuthenticationAction, DatabaseAuthModelsResource, - getComputed(() => state.config.authModelId || state.info?.authModel || driver?.defaultAuthModel || null), + getComputed(() => optionsPart.state.authModelId || connectionInfoService.data?.authModel || driver?.defaultAuthModel || null), ); - if (!authModel?.requiredAuth && !state.info?.requiredAuth) { + if (!authModel?.requiredAuth && !connectionInfoService.data?.requiredAuth) { return null; } - return ; + return ; }); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormOriginInfoFormPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormOriginInfoFormPart.ts new file mode 100644 index 0000000000..b5627d49d1 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionFormOriginInfoFormPart.ts @@ -0,0 +1,167 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { injectable } from '@cloudbeaver/core-di'; +import { FormPart, formStateContext, type IFormState } from '@cloudbeaver/core-ui'; + +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import type { IConnectionFormOriginInfoState } from './IConnectionFormOriginInfoState.js'; +import { + ConnectionInfoAuthPropertiesResource, + DatabaseAuthModelsResource, + DBDriverResource, + type ConnectionInfoOriginDetailsResource, +} from '@cloudbeaver/core-connections'; +import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, type UserInfoResource } from '@cloudbeaver/core-authentication'; +import { computed, makeObservable } from 'mobx'; +import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import type { LocalizationService } from '@cloudbeaver/core-localization'; +import type { ConnectionFormOptionsPart } from '../Options/ConnectionFormOptionsPart.js'; + +const defaultStateGetter = () => ({}) as IConnectionFormOriginInfoState; + +@injectable() +export class ConnectionFormOriginInfoFormPart extends FormPart { + constructor( + formState: IFormState, + private readonly connectionInfoOriginDetailsResource: ConnectionInfoOriginDetailsResource, + private readonly userInfoResource: UserInfoResource, + private readonly databaseAuthModelsResource: DatabaseAuthModelsResource, + private readonly connectionInfoAuthPropertiesResource: ConnectionInfoAuthPropertiesResource, + private readonly dbDriverResource: DBDriverResource, + private readonly authProvidersResource: AuthProvidersResource, + private readonly localizationService: LocalizationService, + private readonly optionsPart: ConnectionFormOptionsPart, + ) { + super(formState, defaultStateGetter()); + + makeObservable(this, { + providerId: computed, + isAuthenticated: computed, + authModelId: computed, + }); + + this.formState.formStateTask.addHandler(this.formAuthState.bind(this)); + } + + override isOutdated(): boolean { + if (!this.optionsPart.connectionKey || !this.optionsPart.state.driverId) { + return false; + } + + if ( + this.dbDriverResource.isOutdated(this.optionsPart.state.driverId) || + this.connectionInfoAuthPropertiesResource.isOutdated(this.optionsPart.connectionKey) + ) { + return true; + } + + if (!this.authModelId) { + return false; + } + + if (this.databaseAuthModelsResource.isOutdated(this.authModelId) || this.userInfoResource.isOutdated()) { + return true; + } + + if (this.isAuthenticated && this.connectionInfoOriginDetailsResource.isOutdated(this.optionsPart.connectionKey)) { + return true; + } + + return false; + } + + get providerId(): string | null { + if (!this.formState.state.projectId || !this.optionsPart.state.driverId) { + return null; + } + + const driver = this.dbDriverResource.get(this.optionsPart.state.driverId); + + if (!driver) { + return null; + } + + const info = this.optionsPart.connectionKey ? this.connectionInfoAuthPropertiesResource.get(this.optionsPart.connectionKey) : null; + const authModel = this.databaseAuthModelsResource.get(this.optionsPart.state.authModelId ?? info?.authModel ?? driver?.defaultAuthModel ?? null); + + return authModel?.requiredAuth ?? info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID; + } + + get isAuthenticated(): boolean { + if (!this.providerId) { + return false; + } + + return this.userInfoResource.hasToken(this.providerId); + } + + get authModelId(): string | null { + const driver = this.dbDriverResource.get(this.optionsPart.state.driverId!)!; + const info = this.optionsPart.connectionKey ? this.connectionInfoAuthPropertiesResource.get(this.optionsPart.connectionKey) : null; + + return this.optionsPart.state.authModelId ?? info?.authModel ?? driver?.defaultAuthModel ?? null; + } + + private async formAuthState(data: IConnectionFormState, contexts: IExecutionContextProvider) { + const stateContext = contexts.getContext(formStateContext); + + const info = this.optionsPart.connectionKey ? this.connectionInfoAuthPropertiesResource.get(this.optionsPart.connectionKey) : null; + const driver = await this.dbDriverResource.load(this.optionsPart.state.driverId!, ['includeProviderProperties', 'includeMainProperties']); + const [authModel] = await Promise.all([this.databaseAuthModelsResource.load(driver.defaultAuthModel), this.userInfoResource.load()]); + const providerId = authModel.requiredAuth ?? info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID; + + if (!this.userInfoResource.hasToken(providerId)) { + const provider = await this.authProvidersResource.load(providerId); + const message = this.localizationService.translate('plugin_connections_connection_cloud_auth_required', undefined, { + providerLabel: provider.label, + }); + stateContext.setInfo(message); + stateContext.readonly = this.formState.mode === 'edit'; + } + } + + protected override async loader(): Promise { + const state = defaultStateGetter(); + + if (!this.optionsPart.connectionKey || !this.optionsPart.state.driverId) { + this.setInitialState(state); + return; + } + + await Promise.all([ + this.dbDriverResource.load(this.optionsPart.state.driverId), + this.connectionInfoAuthPropertiesResource.load(this.optionsPart.connectionKey), + ]); + + if (!this.authModelId) { + throw new Error('Auth model is not defined'); + } + + await Promise.all([this.databaseAuthModelsResource.load(this.authModelId), this.userInfoResource.load()]); + + if (!this.isAuthenticated) { + this.setInitialState(state); + return; + } + + const originInfo = await this.connectionInfoOriginDetailsResource.load(this.optionsPart.connectionKey); + + if (!originInfo.origin.details) { + this.setInitialState(state); + return; + } + + for (const property of originInfo.origin.details) { + state[property.id!] = property.value; + } + + this.setInitialState(state); + } + + protected override async saveChanges(data: IFormState): Promise {} +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionOriginInfoTabService.ts b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionOriginInfoTabService.ts index b30f900526..af40dc3080 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionOriginInfoTabService.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/ConnectionOriginInfoTabService.ts @@ -1,44 +1,52 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import React from 'react'; -import { isLocalConnection } from '@cloudbeaver/core-connections'; +import { ConnectionInfoOriginResource, isLocalConnection } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; import { ConnectionFormService } from '../ConnectionFormService.js'; +import { importLazyComponent } from '@cloudbeaver/core-blocks'; +import { CachedMapAllKey, getCachedMapResourceLoaderState } from '@cloudbeaver/core-resource'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; +export const ConnectionFormAuthenticationAction = importLazyComponent(() => + import('./ConnectionFormAuthenticationAction.js').then(m => m.ConnectionFormAuthenticationAction), +); -export const ConnectionFormAuthenticationAction = React.lazy(async () => { - const { ConnectionFormAuthenticationAction } = await import('./ConnectionFormAuthenticationAction.js'); - return { default: ConnectionFormAuthenticationAction }; -}); -export const OriginInfo = React.lazy(async () => { - const { OriginInfo } = await import('./OriginInfo.js'); - return { default: OriginInfo }; -}); -export const OriginInfoTab = React.lazy(async () => { - const { OriginInfoTab } = await import('./OriginInfoTab.js'); - return { default: OriginInfoTab }; -}); +const OriginInfo = importLazyComponent(() => import('./OriginInfo.js').then(m => m.OriginInfo)); +const OriginInfoTab = importLazyComponent(() => import('./OriginInfoTab.js').then(m => m.OriginInfoTab)); @injectable() export class ConnectionOriginInfoTabService extends Bootstrap { - constructor(private readonly connectionFormService: ConnectionFormService) { + constructor( + private readonly connectionFormService: ConnectionFormService, + private readonly connectionInfoOriginResource: ConnectionInfoOriginResource, + ) { super(); } override register(): void { - this.connectionFormService.tabsContainer.add({ + this.connectionFormService.parts.add({ key: 'origin', order: 3, tab: () => OriginInfoTab, panel: () => OriginInfo, - stateGetter: () => () => ({}), - isHidden: (tabId, props) => (props?.state.originInfo ? isLocalConnection(props.state.originInfo.origin) : true), + getLoader: () => getCachedMapResourceLoaderState(this.connectionInfoOriginResource, () => CachedMapAllKey), + isHidden: (tabId, props) => { + const optionsPart = props?.formState ? getConnectionFormOptionsPart(props.formState) : null; + const key = optionsPart?.connectionKey; + const originInfo = key ? this.connectionInfoOriginResource.get(key) : null; + + if (!originInfo) { + return true; + } + + return isLocalConnection(originInfo.origin); + }, }); this.connectionFormService.actionsContainer.add(ConnectionFormAuthenticationAction, 0); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/IConnectionFormOriginInfoState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/IConnectionFormOriginInfoState.ts new file mode 100644 index 0000000000..da5bfadc43 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/IConnectionFormOriginInfoState.ts @@ -0,0 +1,28 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { schema } from '@cloudbeaver/core-utils'; + +const CONNECTION_FORM_ORIGIN_INFO_SCHEMA = schema.record( + schema.string(), + schema.object({ + id: schema.string().optional(), + required: schema.boolean(), + displayName: schema.string().optional(), + description: schema.string().optional(), + category: schema.string().optional(), + dataType: schema.string().optional(), + defaultValue: schema.any().optional(), + validValues: schema.array(schema.any()).optional(), + value: schema.any().optional(), + length: schema.string(), + features: schema.array(schema.string()), + order: schema.number(), + }), +); + +export type IConnectionFormOriginInfoState = schema.infer; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfo.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfo.tsx index faa3a65aee..72ac8cca07 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfo.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfo.tsx @@ -1,80 +1,54 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { runInAction } from 'mobx'; import { observer } from 'mobx-react-lite'; -import { AUTH_PROVIDER_LOCAL_ID, AuthProvidersResource, UserInfoResource } from '@cloudbeaver/core-authentication'; +import { AuthProvidersResource } from '@cloudbeaver/core-authentication'; import { ColoredContainer, ExceptionMessage, + getComputed, Group, Loader, ObjectPropertyInfoForm, s, TextPlaceholder, + useAutoLoad, useResource, useS, useTranslate, } from '@cloudbeaver/core-blocks'; -import { - ConnectionInfoOriginDetailsResource, - createConnectionParam, - DatabaseAuthModelsResource, - DBDriverResource, -} from '@cloudbeaver/core-connections'; -import { type TabContainerPanelComponent, useTab, useTabState } from '@cloudbeaver/core-ui'; +import { ConnectionInfoOriginDetailsResource, ConnectionInfoResource } from '@cloudbeaver/core-connections'; +import { type TabContainerPanelComponent, useTab } from '@cloudbeaver/core-ui'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import styles from './OriginInfo.module.css'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; -export const OriginInfo: TabContainerPanelComponent = observer(function OriginInfo({ - tabId, - state: { info, resource, config }, -}) { +import { getConnectionFormOriginInfoFormPart } from './getConnectionFormOriginInfoFormPart.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; + +export const OriginInfo: TabContainerPanelComponent = observer(function OriginInfo({ tabId, formState }) { const tab = useTab(tabId); const translate = useTranslate(); - const userInfoLoader = useResource(OriginInfo, UserInfoResource, undefined); - const state = useTabState>(); + const originInfoPart = getConnectionFormOriginInfoFormPart(formState); const style = useS(styles); - const driverLoader = useResource(OriginInfo, DBDriverResource, config.driverId ?? null); - const authModeLoader = useResource( - OriginInfo, - DatabaseAuthModelsResource, - config.authModelId ?? info?.authModel ?? driverLoader.data?.defaultAuthModel ?? null, - ); - - const providerId = authModeLoader.data?.requiredAuth ?? info?.requiredAuth ?? AUTH_PROVIDER_LOCAL_ID; - const isAuthenticated = userInfoLoader.resource.hasToken(providerId); - const providerLoader = useResource(OriginInfo, AuthProvidersResource, providerId); - const connectionId = tab.selected && info ? createConnectionParam(info.projectId, info.id) : null; - - const connectionOriginDetailsResource = useResource(OriginInfo, ConnectionInfoOriginDetailsResource, connectionId, { - active: isAuthenticated, + const optionsPart = getConnectionFormOptionsPart(formState); + const providerLoader = useResource(OriginInfo, AuthProvidersResource, originInfoPart.providerId, { + active: tab.selected, }); - const connection = useResource(OriginInfo, resource, connectionId, { - active: isAuthenticated, - onData: connection => { - runInAction(() => { - if (!connectionOriginDetailsResource.data?.origin.details) { - return; - } - - for (const property of Object.keys(state)) { - // eslint-disable-next-line @typescript-eslint/no-dynamic-delete - delete state[property]; - } - - for (const property of connectionOriginDetailsResource.data.origin.details) { - state[property.id!] = property.value; - } - }); - }, + const isAuthenticated = getComputed(() => originInfoPart.isAuthenticated); + const connectionOriginDetailsResource = useResource(OriginInfo, ConnectionInfoOriginDetailsResource, optionsPart.connectionKey, { + active: tab.selected && isAuthenticated, }); + const connection = useResource(OriginInfo, ConnectionInfoResource, optionsPart.connectionKey, { + active: tab.selected && isAuthenticated, + }); + + useAutoLoad(OriginInfo, originInfoPart); if (connection.isLoading()) { return ( @@ -115,7 +89,13 @@ export const OriginInfo: TabContainerPanelComponent = obse return ( - + diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfoTab.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfoTab.tsx index aa82dbbf20..4d61a1b6a9 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfoTab.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/OriginInfoTab.tsx @@ -1,22 +1,28 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ import { observer } from 'mobx-react-lite'; -import { Translate } from '@cloudbeaver/core-blocks'; +import { Translate, useResource } from '@cloudbeaver/core-blocks'; import { Tab, type TabContainerTabComponent, TabTitle } from '@cloudbeaver/core-ui'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; +import { ConnectionInfoOriginResource } from '@cloudbeaver/core-connections'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; +export const OriginInfoTab: TabContainerTabComponent = observer(function OriginInfoTab({ formState, ...rest }) { + const optionsPart = getConnectionFormOptionsPart(formState); + const connectionInfoOriginResource = useResource(OriginInfoTab, ConnectionInfoOriginResource, optionsPart.connectionKey, { + active: !!optionsPart.connectionKey, + }); -export const OriginInfoTab: TabContainerTabComponent = observer(function OriginInfoTab({ state: { originInfo }, ...rest }) { return ( - + ); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/getConnectionFormOriginInfoFormPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/getConnectionFormOriginInfoFormPart.ts new file mode 100644 index 0000000000..678cdf9eb1 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/OriginInfo/getConnectionFormOriginInfoFormPart.ts @@ -0,0 +1,51 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { type IFormState } from '@cloudbeaver/core-ui'; + +import { + ConnectionInfoAuthPropertiesResource, + ConnectionInfoOriginDetailsResource, + DatabaseAuthModelsResource, + DBDriverResource, +} from '@cloudbeaver/core-connections'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { ConnectionFormOriginInfoFormPart } from './ConnectionFormOriginInfoFormPart.js'; +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import { AuthProvidersResource, UserInfoResource } from '@cloudbeaver/core-authentication'; +import { LocalizationService } from '@cloudbeaver/core-localization'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; + +const DATA_CONTEXT_CONNECTION_FORM_ORIGIN_INFO_FORM_PART = createDataContext( + 'Connection Form Origin Info Form Part', +); + +export function getConnectionFormOriginInfoFormPart(formState: IFormState): ConnectionFormOriginInfoFormPart { + return formState.getPart(DATA_CONTEXT_CONNECTION_FORM_ORIGIN_INFO_FORM_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const connectionInfoOriginDetailsResource = di.getService(ConnectionInfoOriginDetailsResource); + const userInfoResource = di.getService(UserInfoResource); + const databaseAuthModelsResource = di.getService(DatabaseAuthModelsResource); + const connectionInfoAuthPropertiesResource = di.getService(ConnectionInfoAuthPropertiesResource); + const dbDriverResource = di.getService(DBDriverResource); + const authProvidersResource = di.getService(AuthProvidersResource); + const localizationService = di.getService(LocalizationService); + const optionsPart = getConnectionFormOptionsPart(formState); + + return new ConnectionFormOriginInfoFormPart( + formState, + connectionInfoOriginDetailsResource, + userInfoResource, + databaseAuthModelsResource, + connectionInfoAuthPropertiesResource, + dbDriverResource, + authProvidersResource, + localizationService, + optionsPart, + ); + }); +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionFormSSHPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionFormSSHPart.ts new file mode 100644 index 0000000000..7c4148df60 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionFormSSHPart.ts @@ -0,0 +1,177 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { FormPart, formValidationContext, type IFormState } from '@cloudbeaver/core-ui'; + +import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import { DriverConfigurationType, NetworkHandlerAuthType, type NetworkHandlerConfigInput } from '@cloudbeaver/core-sdk'; +import { ConnectionInfoNetworkHandlersResource, SSH_TUNNEL_ID } from '@cloudbeaver/core-connections'; +import { toJS } from 'mobx'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import type { INetworkHandlerConfig } from '../Options/IConnectionNetworkHanler.js'; +import { ConnectionFormOptionsPart } from '../Options/ConnectionFormOptionsPart.js'; + +const getDefaultState = () => + ({ + id: SSH_TUNNEL_ID, + enabled: false, + authType: NetworkHandlerAuthType.Password, + // should initially undefined cause if it's empty string it counts as saved password + password: undefined, + savePassword: false, + userName: '', + // should initially undefined cause if it's empty string it counts as saved private key + key: undefined, + properties: { + port: 22, + host: '', + aliveInterval: '0', + sshConnectTimeout: '10000', + }, + }) as INetworkHandlerConfig; + +export class ConnectionFormSSHPart extends FormPart { + constructor( + formState: IFormState, + private readonly connectionInfoNetworkHandlersResource: ConnectionInfoNetworkHandlersResource, + private readonly optionsPart: ConnectionFormOptionsPart, + ) { + super(formState, getDefaultState()); + } + + override isOutdated(): boolean { + if (!this.optionsPart.connectionKey) { + return false; + } + + return this.connectionInfoNetworkHandlersResource.isOutdated(this.optionsPart.connectionKey); + } + + protected override async loader(): Promise { + if (!this.optionsPart.connectionKey) { + this.setInitialState(getDefaultState()); + return; + } + + const connection = await this.connectionInfoNetworkHandlersResource.load(this.optionsPart.connectionKey); + const sshHandler = connection?.networkHandlersConfig?.find(h => h.id === SSH_TUNNEL_ID); + + this.setInitialState(sshHandler ?? getDefaultState()); + } + + protected override async saveChanges( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise {} + + protected override format( + data: IFormState, + contexts: IExecutionContextProvider>, + ): void | Promise { + const urlType = this.optionsPart.state.configurationType === DriverConfigurationType.Url; + + if (urlType) { + return; + } + + const passwordChanged = isPasswordChanged(this.state, this.initialState); + const keyChanged = isKeyChanged(this.state, this.initialState); + + let handlerConfig: NetworkHandlerConfigInput = { + ...this.state, + savePassword: this.state.savePassword || this.optionsPart.state.sharedCredentials, + key: this.state.authType === NetworkHandlerAuthType.PublicKey && keyChanged ? this.state.key : undefined, + password: passwordChanged ? this.state.password : undefined, + }; + + delete handlerConfig.secureProperties; + + if (this.state.enabled && !this.state.savePassword) { + this.formState.state.requiredNetworkHandlersIds.push(this.state.id); + } else if (!this.state.enabled) { + this.formState.state.requiredNetworkHandlersIds = this.formState.state.requiredNetworkHandlersIds.filter(id => id !== this.state.id); + } + + if (handlerConfig) { + handlerConfig = getTrimmedSSHConfig(handlerConfig); + this.optionsPart.state.networkHandlersConfig!.push(handlerConfig); + } + } + + protected override validate( + data: IFormState, + contexts: IExecutionContextProvider>, + ): void | Promise { + const validation = contexts.getContext(formValidationContext); + + if (!this.isChanged || !this.state.enabled) { + return; + } + + if (this.state.savePassword && !this.state.userName?.length) { + validation.error("Field SSH 'User' can't be empty"); + } + + if (!this.state.properties?.['host']?.length) { + validation.error("Field SSH 'Host' can't be empty"); + } + + const port = Number(this.state.properties?.['port']); + if (Number.isNaN(port) || port < 1) { + validation.error("Field SSH 'Port' can't be empty"); + } + + const keyAuth = this.state.authType === NetworkHandlerAuthType.PublicKey; + const keySaved = this.initialState?.key === ''; + if (keyAuth && this.state.savePassword && !keySaved && !this.state.key?.length) { + validation.error("Field SSH 'Private key' can't be empty"); + } + + const passwordSaved = this.initialState?.password === '' && this.initialState?.authType === this.state.authType; + + if (!keyAuth && this.state.savePassword && !passwordSaved && !this.state.password?.length) { + validation.error("Field SSH 'Password' can't be empty"); + } + } +} + +function getTrimmedSSHConfig(input: NetworkHandlerConfigInput): NetworkHandlerConfigInput { + const trimmedInput = toJS(input); + const attributesToTrim = Object.keys(input) as (keyof NetworkHandlerConfigInput)[]; + + for (const key of attributesToTrim) { + if (typeof trimmedInput[key] === 'string') { + trimmedInput[key] = trimmedInput[key]?.trim(); + } + } + + for (const key in trimmedInput.properties) { + if (typeof trimmedInput.properties[key] === 'string') { + trimmedInput.properties[key] = trimmedInput.properties[key]?.trim(); + } + } + + return trimmedInput; +} + +function isPasswordChanged(handler: NetworkHandlerConfigInput, initial?: NetworkHandlerConfigInput) { + if (!initial && !handler.enabled) { + return false; + } + + return ( + (((initial?.password === null && handler.password !== null) || initial?.password === '') && handler.password !== '') || !!handler.password?.length + ); +} + +function isKeyChanged(handler: NetworkHandlerConfigInput, initial?: NetworkHandlerConfigInput) { + if (!initial && !handler.enabled) { + return false; + } + + return (((initial?.key === null && handler.key !== null) || initial?.key === '') && handler.key !== '') || !!handler.key?.length; +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionSSHTabService.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionSSHTabService.ts index 1aaab6c0e4..8c4f9c8627 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionSSHTabService.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/ConnectionSSHTabService.ts @@ -1,30 +1,25 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, makeObservable, toJS } from 'mobx'; import React from 'react'; import { DBDriverResource, SSH_TUNNEL_ID } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import { DriverConfigurationType, NetworkHandlerAuthType, type NetworkHandlerConfigInput } from '@cloudbeaver/core-sdk'; -import { formStateContext } from '@cloudbeaver/core-ui'; +import { DriverConfigurationType } from '@cloudbeaver/core-sdk'; -import { connectionFormConfigureContext } from '../connectionFormConfigureContext.js'; import { ConnectionFormService } from '../ConnectionFormService.js'; -import { connectionConfigContext } from '../Contexts/connectionConfigContext.js'; -import { connectionCredentialsStateContext } from '../Contexts/connectionCredentialsStateContext.js'; -import type { IConnectionFormFillConfigData, IConnectionFormState, IConnectionFormSubmitData } from '../IConnectionFormProps.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; +import { getCachedMapResourceLoaderState } from '@cloudbeaver/core-resource'; -export const SSHTab = React.lazy(async () => { +const SSHTab = React.lazy(async () => { const { SSHTab } = await import('./SSHTab.js'); return { default: SSHTab }; }); -export const SSHPanel = React.lazy(async () => { +const SSHPanel = React.lazy(async () => { const { SSHPanel } = await import('./SSHPanel.js'); return { default: SSHPanel }; }); @@ -32,28 +27,29 @@ export const SSHPanel = React.lazy(async () => { @injectable() export class ConnectionSSHTabService extends Bootstrap { constructor( - private readonly connectionFormService: ConnectionFormService, private readonly dbDriverResource: DBDriverResource, + private readonly connectionFormService: ConnectionFormService, ) { super(); - - makeObservable(this, { - fillConfig: action, - prepareConfig: action, - }); } override register(): void { - this.connectionFormService.tabsContainer.add({ + this.connectionFormService.parts.add({ key: 'ssh', name: 'plugin_connections_connection_form_part_main', order: 3, tab: () => SSHTab, panel: () => SSHPanel, + getLoader: (_, props) => { + const optionsPart = props?.formState ? getConnectionFormOptionsPart(props.formState) : null; + return [getCachedMapResourceLoaderState(this.dbDriverResource, () => optionsPart?.state.driverId ?? null)]; + }, isHidden: (tabId, props) => { - if (props?.state.config.driverId) { - const driver = this.dbDriverResource.get(props.state.config.driverId); - const urlType = props.state.config.configurationType === DriverConfigurationType.Url; + const optionsPart = props?.formState ? getConnectionFormOptionsPart(props.formState) : null; + + if (optionsPart?.state.driverId) { + const driver = this.dbDriverResource.get(optionsPart.state.driverId); + const urlType = optionsPart.state.configurationType === DriverConfigurationType.Url; return urlType || !driver?.applicableNetworkHandlers.includes(SSH_TUNNEL_ID); } @@ -61,212 +57,5 @@ export class ConnectionSSHTabService extends Bootstrap { return true; }, }); - - this.connectionFormService.prepareConfigTask.addHandler(this.prepareConfig.bind(this)); - - this.connectionFormService.formValidationTask.addHandler(this.validate.bind(this)); - - this.connectionFormService.formStateTask.addHandler(this.formState.bind(this)); - - this.connectionFormService.configureTask.addHandler(this.configure.bind(this)); - - this.connectionFormService.fillConfigTask.addHandler(this.fillConfig.bind(this)); - } - - private fillConfig({ state, updated }: IConnectionFormFillConfigData, contexts: IExecutionContextProvider) { - if (!updated) { - return; - } - const initialConfig = state.info?.networkHandlersConfig?.find(handler => handler.id === SSH_TUNNEL_ID); - - if (!state.config.networkHandlersConfig) { - state.config.networkHandlersConfig = []; - } - - if (!state.config.networkHandlersConfig.some(state => state.id === SSH_TUNNEL_ID)) { - state.config.networkHandlersConfig.push({ - id: SSH_TUNNEL_ID, - enabled: false, - authType: NetworkHandlerAuthType.Password, - password: '', - savePassword: false, - userName: '', - key: '', - ...initialConfig, - properties: { - port: 22, - host: '', - aliveInterval: '0', - sshConnectTimeout: '10000', - ...initialConfig?.properties, - }, - }); - } - } - - private configure(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const configuration = contexts.getContext(connectionFormConfigureContext); - - configuration.include('includeNetworkHandlersConfig'); - } - - private validate({ state: { config, info } }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const validation = contexts.getContext(this.connectionFormService.connectionValidationContext); - - if (!config.networkHandlersConfig) { - return; - } - - const handler = config.networkHandlersConfig.find(handler => handler.id === SSH_TUNNEL_ID); - - if (!handler) { - return; - } - - if (handler.enabled) { - const initial = info?.networkHandlersConfig?.find(h => h.id === handler.id); - if (this.isChanged(handler, initial)) { - if (handler.savePassword && !handler.userName?.length) { - validation.error("Field SSH 'User' can't be empty"); - } - - if (!handler.properties?.host?.length) { - validation.error("Field SSH 'Host' can't be empty"); - } - - const port = Number(handler.properties?.port); - if (Number.isNaN(port) || port < 1) { - validation.error("Field SSH 'Port' can't be empty"); - } - } - - const keyAuth = handler.authType === NetworkHandlerAuthType.PublicKey; - const keySaved = initial?.key === ''; - if (keyAuth && handler.savePassword && !keySaved && !handler.key?.length) { - validation.error("Field SSH 'Private key' can't be empty"); - } - - const passwordSaved = initial?.password === '' && initial.authType === handler.authType; - if (!keyAuth && handler.savePassword && !passwordSaved && !handler.password?.length) { - validation.error("Field SSH 'Password' can't be empty"); - } - } - } - - private prepareConfig({ state }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - const credentialsState = contexts.getContext(connectionCredentialsStateContext); - const urlType = state.config.configurationType === DriverConfigurationType.Url; - - if (urlType || !state.config.networkHandlersConfig || state.config.networkHandlersConfig.length === 0) { - return; - } - - let handlerConfig: NetworkHandlerConfigInput | undefined; - - const handler = state.config.networkHandlersConfig.find(handler => handler.id === SSH_TUNNEL_ID); - - if (!handler) { - return; - } - - const initial = state.info?.networkHandlersConfig?.find(h => h.id === handler.id); - const passwordChanged = this.isPasswordChanged(handler, initial); - const keyChanged = this.isKeyChanged(handler, initial); - - if (this.isChanged(handler, initial) || passwordChanged || keyChanged) { - handlerConfig = { - ...handler, - savePassword: handler.savePassword || config.sharedCredentials, - key: handler.authType === NetworkHandlerAuthType.PublicKey && keyChanged ? handler.key : undefined, - password: passwordChanged ? handler.password : undefined, - }; - - delete handlerConfig.secureProperties; - } - - if (handler.enabled && !handler.savePassword) { - credentialsState.requireNetworkHandler(handler.id); - } - - if (handlerConfig) { - if (!config.networkHandlersConfig) { - config.networkHandlersConfig = []; - } - - handlerConfig = this.getTrimmedSSHConfig(handlerConfig); - config.networkHandlersConfig.push(handlerConfig); - } - } - - private getTrimmedSSHConfig(input: NetworkHandlerConfigInput): NetworkHandlerConfigInput { - const trimmedInput = toJS(input); - const attributesToTrim = Object.keys(input) as (keyof NetworkHandlerConfigInput)[]; - - for (const key of attributesToTrim) { - if (typeof trimmedInput[key] === 'string') { - trimmedInput[key] = trimmedInput[key]?.trim(); - } - } - - for (const key in trimmedInput.properties) { - if (typeof trimmedInput.properties[key] === 'string') { - trimmedInput.properties[key] = trimmedInput.properties[key]?.trim(); - } - } - - return trimmedInput; - } - - private formState(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - if (config.networkHandlersConfig !== undefined) { - const stateContext = contexts.getContext(formStateContext); - - stateContext.markEdited(); - } - } - - private isChanged(handler: NetworkHandlerConfigInput, initial?: NetworkHandlerConfigInput) { - if (!initial && !handler.enabled) { - return false; - } - - const port = Number(initial?.properties?.port); - const formPort = Number(handler.properties?.port); - - if ( - handler.enabled !== initial?.enabled || - handler.authType !== initial?.authType || - handler.savePassword !== initial?.savePassword || - handler.userName !== initial?.userName || - handler.properties?.host !== initial?.properties?.host || - port !== formPort || - handler.properties?.aliveInterval !== initial?.properties?.aliveInterval || - handler.properties?.sshConnectTimeout !== initial?.properties?.sshConnectTimeout - ) { - return true; - } - - return false; - } - - private isPasswordChanged(handler: NetworkHandlerConfigInput, initial?: NetworkHandlerConfigInput) { - if (!initial && !handler.enabled) { - return false; - } - - return ( - (((initial?.password === null && handler.password !== null) || initial?.password === '') && handler.password !== '') || - !!handler.password?.length - ); - } - - private isKeyChanged(handler: NetworkHandlerConfigInput, initial?: NetworkHandlerConfigInput) { - if (!initial && !handler.enabled) { - return false; - } - - return (((initial?.key === null && handler.key !== null) || initial?.key === '') && handler.key !== '') || !!handler.key?.length; } } diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/IConnectionFromSSHState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/IConnectionFromSSHState.ts new file mode 100644 index 0000000000..4ffdd6b7cc --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/IConnectionFromSSHState.ts @@ -0,0 +1,13 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { schema } from '@cloudbeaver/core-utils'; +import { CONNECTION_NETWORK_HANDLER_SCHEMA } from '../Options/IConnectionNetworkHanler.js'; + +export const CONNECTION_FORM_SSH_SCHEMA = schema.object({}).extend(CONNECTION_NETWORK_HANDLER_SCHEMA.shape); + +export type IConnectionFromSSHState = schema.infer; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSH.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSH.tsx index 93ca791def..c429fbbafa 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSH.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSH.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -22,6 +22,7 @@ import { s, Switch, useAdministrationSettings, + useAutoLoad, useResource, useS, useTranslate, @@ -31,27 +32,31 @@ import { useService } from '@cloudbeaver/core-di'; import { ProjectInfoResource } from '@cloudbeaver/core-projects'; import { ServerConfigResource } from '@cloudbeaver/core-root'; import { NetworkHandlerAuthType, type NetworkHandlerConfigInput } from '@cloudbeaver/core-sdk'; -import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui'; +import { useTab, type IFormState, type TabContainerPanelComponent } from '@cloudbeaver/core-ui'; import { isSafari } from '@cloudbeaver/core-utils'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import { authTypes } from './authTypes.js'; import styles from './SSH.module.css'; import { SSHKeyUploader } from './SSHKeyUploader.js'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { getConnectionFormSSHPart } from './getConnectionFormSSHPart.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; -interface Props extends IConnectionFormProps { +interface Props { handlerState: NetworkHandlerConfigInput; + formState: IFormState; } -export const SSH: TabContainerPanelComponent = observer(function SSH({ state: formState, handlerState }) { - const { info, readonly, disabled: formDisabled } = formState; +export const SSH: TabContainerPanelComponent = observer(function SSH({ formState, handlerState, tabId }) { + const { selected } = useTab(tabId); const [loading, setLoading] = useState(false); const { credentialsSavingEnabled } = useAdministrationSettings(); - const serverConfigResource = useResource(SSH, ServerConfigResource, undefined); - - const initialConfig = info?.networkHandlersConfig?.find(handler => handler.id === SSH_TUNNEL_ID); + const serverConfigResource = useResource(SSH, ServerConfigResource, undefined, { + active: selected, + }); const resource = useResource(SSH, NetworkHandlerResource, SSH_TUNNEL_ID, { + active: selected, onData: handler => { if (Object.keys(handlerState).length === 0) { for (const property of handler.properties) { @@ -69,18 +74,20 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st setLoading(false); }; + const SSHPart = getConnectionFormSSHPart(formState); const style = useS(styles); const translate = useTranslate(); - const disabled = formDisabled || loading; + const disabled = formState.isDisabled || loading || formState.isReadOnly; const enabled = handlerState.enabled || false; const keyAuth = handlerState.authType === NetworkHandlerAuthType.PublicKey; - const passwordFilled = (initialConfig?.password === null && handlerState.password !== '') || !!handlerState.password?.length; + const passwordFilled = (SSHPart.initialState?.password === null && handlerState.password !== '') || !!handlerState.password?.length; const testAvailable = keyAuth ? !!handlerState.key?.length : passwordFilled; const passwordLabel = keyAuth ? 'Passphrase' : translate('connections_network_handler_ssh_tunnel_password'); - const passwordSaved = initialConfig?.password === '' && initialConfig.authType === handlerState.authType; - const keySaved = initialConfig?.key === ''; + const passwordSaved = SSHPart.initialState?.password === '' && SSHPart.initialState.authType === handlerState.authType; + const keySaved = SSHPart.initialState?.key === ''; const projectInfoResource = useService(ProjectInfoResource); - const isSharedProject = projectInfoResource.isProjectShared(formState.projectId); + const isSharedProject = projectInfoResource.isProjectShared(formState.state.projectId); + const optionsPart = getConnectionFormOptionsPart(formState); const aliveIntervalLabel = translate('connections_network_handler_ssh_tunnel_advanced_settings_alive_interval'); const connectTimeoutLabel = translate('connections_network_handler_ssh_tunnel_advanced_settings_connect_timeout'); @@ -89,11 +96,13 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st handlerState.password = ''; }, []); + useAutoLoad(SSH, [SSHPart, optionsPart], selected); + return (
- + {translate('connections_network_handler_ssh_tunnel_enable')} = observer(function SSH({ st items={authTypes} keySelector={value => value.key} valueSelector={value => value.label} - disabled={disabled || readonly || !enabled} + disabled={disabled || !enabled} tiny onSelect={authTypeChangeHandler} > {translate('connections_network_handler_ssh_tunnel_auth_type')} - + {translate('connections_network_handler_ssh_tunnel_host')} - + {translate('connections_network_handler_ssh_tunnel_port')} @@ -121,7 +130,7 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st type="text" name="userName" state={handlerState} - readOnly={readonly || disabled || !enabled} + readOnly={disabled || !enabled} required={handlerState.savePassword} tiny fill @@ -133,7 +142,7 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st name="password" autoComplete={isSafari ? 'section-connection-ssh-authentication section-ssh password' : 'new-password'} state={handlerState} - readOnly={readonly || disabled || !enabled} + readOnly={disabled || !enabled} required={!passwordSaved && !keyAuth && handlerState.savePassword} description={passwordSaved ? translate('ui_processing_saved') : undefined} tiny @@ -141,9 +150,9 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st > {passwordLabel} - {keyAuth && } + {keyAuth && } - {credentialsSavingEnabled && !formState.config.template && !formState.config.sharedCredentials && ( + {credentialsSavingEnabled && !optionsPart.state.template && !optionsPart.state.sharedCredentials && ( = observer(function SSH({ st )} name="savePassword" state={handlerState} - disabled={disabled || !enabled || readonly} + disabled={disabled || !enabled} > {translate( !isSharedProject || serverConfigResource.data?.distributed @@ -169,7 +178,7 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st type="number" name="aliveInterval" state={handlerState.properties} - readOnly={readonly || disabled || !enabled} + readOnly={disabled || !enabled} labelTooltip={aliveIntervalLabel} tiny > @@ -179,7 +188,7 @@ export const SSH: TabContainerPanelComponent = observer(function SSH({ st type="number" name="sshConnectTimeout" state={handlerState.properties} - readOnly={readonly || disabled || !enabled} + readOnly={disabled || !enabled} labelTooltip={connectTimeoutLabel} tiny > diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHPanel.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHPanel.tsx index e7937036ce..fddcacb926 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHPanel.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHPanel.tsx @@ -1,24 +1,27 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ import { observer } from 'mobx-react-lite'; -import { SSH_TUNNEL_ID } from '@cloudbeaver/core-connections'; import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import { SSH } from './SSH.js'; +import { useAutoLoad } from '@cloudbeaver/core-blocks'; +import { getConnectionFormSSHPart } from './getConnectionFormSSHPart.js'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; export const SSHPanel: TabContainerPanelComponent = observer(function SSHPanel(props) { - const state = props.state.config.networkHandlersConfig?.find(state => state.id === SSH_TUNNEL_ID); + const sshPart = getConnectionFormSSHPart(props.formState); - if (!state) { + useAutoLoad(SSHPanel, sshPart); + + if (!sshPart.state) { return null; } - return ; + return ; }); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHTab.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHTab.tsx index 92c7b13aa0..0300c6762c 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHTab.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/SSHTab.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -9,12 +9,14 @@ import { observer } from 'mobx-react-lite'; import { Translate, useResource } from '@cloudbeaver/core-blocks'; import { NetworkHandlerResource, SSH_TUNNEL_ID } from '@cloudbeaver/core-connections'; -import { Tab, type TabContainerTabComponent, TabTitle } from '@cloudbeaver/core-ui'; - -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; +import { Tab, type TabContainerTabComponent, TabTitle, useTab } from '@cloudbeaver/core-ui'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; export const SSHTab: TabContainerTabComponent = observer(function SSHTab(props) { - const handler = useResource(SSHTab, NetworkHandlerResource, SSH_TUNNEL_ID); + const { selected } = useTab(props.tabId); + const handler = useResource(SSHTab, NetworkHandlerResource, SSH_TUNNEL_ID, { + active: selected, + }); return ( diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSH/getConnectionFormSSHPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/getConnectionFormSSHPart.ts new file mode 100644 index 0000000000..6ad4e8a791 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSH/getConnectionFormSSHPart.ts @@ -0,0 +1,25 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; +import { ConnectionFormSSHPart } from './ConnectionFormSSHPart.js'; +import { ConnectionInfoNetworkHandlersResource } from '@cloudbeaver/core-connections'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; + +const DATA_CONTEXT_CONNECTION_FORM_OPTIONS_PART = createDataContext('Connection Form SSH Part'); + +export function getConnectionFormSSHPart(formState: IFormState): ConnectionFormSSHPart { + return formState.getPart(DATA_CONTEXT_CONNECTION_FORM_OPTIONS_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const connectionInfoNetworkHandlersResource = di.getService(ConnectionInfoNetworkHandlersResource); + const optionsPart = getConnectionFormOptionsPart(formState); + + return new ConnectionFormSSHPart(formState, connectionInfoNetworkHandlersResource, optionsPart); + }); +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionFormSSLPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionFormSSLPart.ts new file mode 100644 index 0000000000..6611c27c55 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionFormSSLPart.ts @@ -0,0 +1,192 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { FormPart, formSubmitContext, type IFormState } from '@cloudbeaver/core-ui'; + +import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { type NetworkHandlerConfigInput } from '@cloudbeaver/core-sdk'; +import { isNotNullDefined } from '@cloudbeaver/core-utils'; +import { getSSLDriverHandler } from './getSSLDriverHandler.js'; +import { ConnectionInfoNetworkHandlersResource, type DBDriverResource, type NetworkHandlerResource } from '@cloudbeaver/core-connections'; +import { CachedMapAllKey } from '@cloudbeaver/core-resource'; +import { makeObservable, observable, toJS } from 'mobx'; +import { PROPERTY_FEATURE_SECURED } from './PROPERTY_FEATURE_SECURED.js'; +import { SSL_CODE_NAME } from './SSL_CODE_NAME.js'; +import type { INetworkHandlerConfig } from '../Options/IConnectionNetworkHanler.js'; +import { getSSLDefaultConfig } from './getSSLDefaultConfig.js'; +import { ConnectionFormOptionsPart } from '../Options/ConnectionFormOptionsPart.js'; + +const getDefaultState = () => + ({ + id: SSL_CODE_NAME, + enabled: false, + properties: {}, + secureProperties: {}, + }) as INetworkHandlerConfig; + +export class ConnectionFormSSLPart extends FormPart { + private activeDriverId: string | undefined; + constructor( + formState: IFormState, + private readonly dbDriverResource: DBDriverResource, + private readonly networkHandlerResource: NetworkHandlerResource, + private readonly connectionInfoNetworkHandlersResource: ConnectionInfoNetworkHandlersResource, + private readonly optionsPart: ConnectionFormOptionsPart, + ) { + super(formState, getDefaultState()); + + this.activeDriverId = undefined; + + makeObservable(this, { + activeDriverId: observable, + }); + } + + override isLoaded(): boolean { + return super.isLoaded() && this.activeDriverId === this.optionsPart.state.driverId; + } + + override isOutdated(): boolean { + const isDriverOutdated = Boolean(this.optionsPart.state.driverId && this.dbDriverResource.isOutdated(this.optionsPart.state.driverId)); + const isNetworkHandlerOutdated = this.networkHandlerResource.isOutdated(CachedMapAllKey); + const isConnectionInfoNetworkHandlersOutdated = this.optionsPart.connectionKey + ? this.connectionInfoNetworkHandlersResource.isOutdated(this.optionsPart.connectionKey) + : false; + + return isDriverOutdated || isNetworkHandlerOutdated || isConnectionInfoNetworkHandlersOutdated; + } + + protected override async loader(): Promise { + this.activeDriverId = this.optionsPart.state.driverId; + + if (!this.optionsPart.state.driverId) { + this.setInitialState(getDefaultState()); + return; + } + + const [driver, handlers] = await Promise.all([ + this.dbDriverResource.load(this.optionsPart.state.driverId), + this.networkHandlerResource.load(CachedMapAllKey), + ]); + const handler = getSSLDriverHandler(handlers, driver?.applicableNetworkHandlers ?? []); + + if (!handler) { + this.setInitialState(getDefaultState()); + return; + } + + const info = this.optionsPart.connectionKey ? await this.connectionInfoNetworkHandlersResource.load(this.optionsPart.connectionKey) : null; + const initialConfig = info?.networkHandlersConfig?.find(h => h.id === handler.id); + + if (!this.optionsPart.state.networkHandlersConfig?.some(state => state.id === handler.id)) { + const config: NetworkHandlerConfigInput = initialConfig ? toJS(initialConfig) : getSSLDefaultConfig(handler.id); + + if (config.secureProperties) { + config.properties = { ...config.properties, ...config.secureProperties }; + } + + this.setInitialState(config); + return; + } + + this.setInitialState(initialConfig ?? getDefaultState()); + } + + protected override async format( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise { + if (!this.optionsPart.state.driverId) { + return; + } + + const handlers = await this.networkHandlerResource.load(CachedMapAllKey); + const descriptor = handlers.find(h => h.id === this.state?.id); + + const handlerConfig: NetworkHandlerConfigInput = toJS(this.state); + handlerConfig.savePassword = this.state.savePassword || this.optionsPart.state.sharedCredentials; + + if (this.isChanged && descriptor) { + for (const descriptorProperty of descriptor.properties) { + if (!descriptorProperty.id) { + continue; + } + + const key = descriptorProperty.id; + const isDefault = isNotNullDefined(descriptorProperty.defaultValue); + + if (!(key in handlerConfig.properties) && isDefault) { + handlerConfig.properties[key] = descriptorProperty.defaultValue; + } + + const secured = descriptorProperty.features.includes(PROPERTY_FEATURE_SECURED); + + if (secured) { + const value = handlerConfig.properties[key]; + const propertyChanged = this.initialState?.secureProperties?.[key] !== value; + + if (propertyChanged) { + handlerConfig.secureProperties[key] = toJS(value); + } else { + delete handlerConfig.secureProperties[key]; + } + + delete handlerConfig.properties[key]; + } + } + + const submitInfo = contexts.getContext(formSubmitContext); + if (submitInfo.type === 'submit') { + if (Object.keys(handlerConfig.secureProperties).length === 0) { + delete handlerConfig.secureProperties; + } + + if (Object.keys(handlerConfig.properties).length === 0) { + delete handlerConfig.properties; + } + } + } + + if (this.state.enabled && !this.state.savePassword) { + this.formState.state.requiredNetworkHandlersIds.push(this.state.id); + } else if (!this.state.enabled) { + this.formState.state.requiredNetworkHandlersIds = this.formState.state.requiredNetworkHandlersIds.filter(id => id !== this.state.id); + } + + if (handlerConfig) { + trimSSLConfig(handlerConfig); + + this.optionsPart.state.networkHandlersConfig!.push(handlerConfig); + } + } + + protected override async saveChanges( + data: IFormState, + contexts: IExecutionContextProvider>, + ): Promise {} +} + +function trimSSLConfig(input: INetworkHandlerConfig): INetworkHandlerConfig { + const { secureProperties } = input; + + if (!secureProperties) { + return input; + } + + if (!Object.keys(secureProperties).length) { + return input; + } + + for (const key in secureProperties) { + if (typeof secureProperties[key] === 'string') { + secureProperties[key] = secureProperties[key]?.trim(); + } + } + + return input; +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionSSLTabService.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionSSLTabService.ts index b62f948fd7..22f67e83ef 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionSSLTabService.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/ConnectionSSLTabService.ts @@ -1,36 +1,24 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { action, makeObservable, toJS } from 'mobx'; import React from 'react'; import { DBDriverResource, NetworkHandlerResource } from '@cloudbeaver/core-connections'; import { Bootstrap, injectable } from '@cloudbeaver/core-di'; -import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import { CachedMapAllKey } from '@cloudbeaver/core-resource'; -import type { NetworkHandlerConfigInput } from '@cloudbeaver/core-sdk'; -import { formStateContext } from '@cloudbeaver/core-ui'; -import { isNotNullDefined, isObjectsEqual } from '@cloudbeaver/core-utils'; - -import { connectionFormConfigureContext } from '../connectionFormConfigureContext.js'; -import { ConnectionFormService } from '../ConnectionFormService.js'; -import { connectionConfigContext } from '../Contexts/connectionConfigContext.js'; -import { connectionCredentialsStateContext } from '../Contexts/connectionCredentialsStateContext.js'; -import type { IConnectionFormFillConfigData, IConnectionFormState, IConnectionFormSubmitData } from '../IConnectionFormProps.js'; -import { getSSLDefaultConfig } from './getSSLDefaultConfig.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; import { getSSLDriverHandler } from './getSSLDriverHandler.js'; -import { PROPERTY_FEATURE_SECURED } from './PROPERTY_FEATURE_SECURED.js'; -import { SSL_CODE_NAME } from './SSL_CODE_NAME.js'; +import { ConnectionFormService } from '../ConnectionFormService.js'; +import { CachedMapAllKey, getCachedMapResourceLoaderState } from '@cloudbeaver/core-resource'; -export const SSLTab = React.lazy(async () => { +const SSLTab = React.lazy(async () => { const { SSLTab } = await import('./SSLTab.js'); return { default: SSLTab }; }); -export const SSLPanel = React.lazy(async () => { +const SSLPanel = React.lazy(async () => { const { SSLPanel } = await import('./SSLPanel.js'); return { default: SSLPanel }; }); @@ -38,27 +26,28 @@ export const SSLPanel = React.lazy(async () => { @injectable() export class ConnectionSSLTabService extends Bootstrap { constructor( - private readonly connectionFormService: ConnectionFormService, private readonly dbDriverResource: DBDriverResource, private readonly networkHandlerResource: NetworkHandlerResource, + private readonly connectionFormService: ConnectionFormService, ) { super(); - - makeObservable(this, { - fillConfig: action, - prepareConfig: action, - }); } override register(): void { - this.connectionFormService.tabsContainer.add({ + this.connectionFormService.parts.add({ key: 'ssl', order: 4, tab: () => SSLTab, panel: () => SSLPanel, + getLoader: () => [ + getCachedMapResourceLoaderState(this.dbDriverResource, () => CachedMapAllKey), + getCachedMapResourceLoaderState(this.networkHandlerResource, () => CachedMapAllKey), + ], isHidden: (_, props) => { - if (props?.state.config.driverId) { - const driver = this.dbDriverResource.get(props.state.config.driverId); + const optionsPart = props?.formState ? getConnectionFormOptionsPart(props.formState) : null; + + if (optionsPart?.state.driverId) { + const driver = this.dbDriverResource.get(optionsPart.state.driverId); const handler = getSSLDriverHandler(this.networkHandlerResource.values, driver?.applicableNetworkHandlers ?? []); return !handler; } @@ -66,174 +55,5 @@ export class ConnectionSSLTabService extends Bootstrap { return true; }, }); - - this.connectionFormService.prepareConfigTask.addHandler(this.prepareConfig.bind(this)); - - this.connectionFormService.formStateTask.addHandler(this.formState.bind(this)); - - this.connectionFormService.configureTask.addHandler(this.configure.bind(this)); - - this.connectionFormService.fillConfigTask.addHandler(this.fillConfig.bind(this)); - } - - private async fillConfig({ state, updated }: IConnectionFormFillConfigData, contexts: IExecutionContextProvider) { - if (!updated || !state.config.driverId) { - return; - } - - const driver = await this.dbDriverResource.load(state.config.driverId); - const handlers = await this.networkHandlerResource.load(CachedMapAllKey); - - const handler = getSSLDriverHandler(handlers, driver?.applicableNetworkHandlers ?? []); - - if (!handler) { - return; - } - - const initialConfig = state.info?.networkHandlersConfig?.find(h => h.id === handler.id); - - if (!state.config.networkHandlersConfig) { - state.config.networkHandlersConfig = []; - } - - if (!state.config.networkHandlersConfig.some(state => state.id === handler.id)) { - const config: NetworkHandlerConfigInput = initialConfig ? toJS(initialConfig) : getSSLDefaultConfig(handler.id); - - if (config.secureProperties) { - config.properties = { ...config.properties, ...config.secureProperties }; - } - - state.config.networkHandlersConfig.push(config); - } - } - - private configure(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const configuration = contexts.getContext(connectionFormConfigureContext); - - configuration.include('includeNetworkHandlersConfig'); - } - - private async prepareConfig({ state, submitType }: IConnectionFormSubmitData, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - const credentialsState = contexts.getContext(connectionCredentialsStateContext); - - if (!state.config.networkHandlersConfig || state.config.networkHandlersConfig.length === 0 || !state.config.driverId) { - return; - } - - const driver = await this.dbDriverResource.load(state.config.driverId); - const handlers = await this.networkHandlerResource.load(CachedMapAllKey); - const handler = state.config.networkHandlersConfig.find( - handler => driver?.applicableNetworkHandlers.includes(handler.id) && handlers.some(h => h.id === handler.id && h.codeName === SSL_CODE_NAME), - ); - const descriptor = handlers.find(h => h.id === handler?.id); - - if (!handler) { - return; - } - - const initial = state.info?.networkHandlersConfig?.find(h => h.id === handler.id); - const handlerConfig: NetworkHandlerConfigInput = toJS(handler); - handlerConfig.savePassword = handler.savePassword || config.sharedCredentials; - - const changed = this.isChanged(handlerConfig, initial); - - if (changed && descriptor) { - for (const descriptorProperty of descriptor.properties) { - if (!descriptorProperty.id) { - continue; - } - - const key = descriptorProperty.id; - const isDefault = isNotNullDefined(descriptorProperty.defaultValue); - - if (!(key in handlerConfig.properties) && isDefault) { - handlerConfig.properties[key] = descriptorProperty.defaultValue; - } - - const secured = descriptorProperty.features.includes(PROPERTY_FEATURE_SECURED); - - if (secured) { - const value = handlerConfig.properties[key]; - const propertyChanged = initial?.secureProperties?.[key] !== value; - - if (propertyChanged) { - handlerConfig.secureProperties[key] = toJS(value); - } else { - delete handlerConfig.secureProperties[key]; - } - - delete handlerConfig.properties[key]; - } - } - - if (submitType === 'submit') { - if (Object.keys(handlerConfig.secureProperties).length === 0) { - delete handlerConfig.secureProperties; - } - - if (Object.keys(handlerConfig.properties).length === 0) { - delete handlerConfig.properties; - } - } - } - - if (handler.enabled && !handler.savePassword) { - credentialsState.requireNetworkHandler(handler.id); - } - - if (changed) { - if (!config.networkHandlersConfig) { - config.networkHandlersConfig = []; - } - - this.trimSSLConfig(handlerConfig); - config.networkHandlersConfig.push(handlerConfig); - } - } - - private trimSSLConfig(input: NetworkHandlerConfigInput) { - const { secureProperties } = input; - - if (!secureProperties) { - return; - } - - if (!Object.keys(secureProperties).length) { - return; - } - - for (const key in secureProperties) { - if (typeof secureProperties[key] === 'string') { - secureProperties[key] = secureProperties[key]?.trim(); - } - } - } - - private formState(data: IConnectionFormState, contexts: IExecutionContextProvider) { - const config = contexts.getContext(connectionConfigContext); - if (config.networkHandlersConfig !== undefined) { - const stateContext = contexts.getContext(formStateContext); - - stateContext.markEdited(); - } - } - - private isChanged(handler: NetworkHandlerConfigInput, initial?: NetworkHandlerConfigInput) { - if (!initial && !handler.enabled) { - return false; - } - - const initialProperties = { ...(initial?.properties ?? {}), ...(initial?.secureProperties ?? {}) }; - - if ( - handler.enabled !== initial?.enabled || - handler.savePassword !== initial?.savePassword || - !isObjectsEqual(handler.properties, initialProperties) - ) { - return true; - } - - return false; } } diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/IConnectionFromSSLState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/IConnectionFromSSLState.ts new file mode 100644 index 0000000000..07ed0a5ae7 --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/IConnectionFromSSLState.ts @@ -0,0 +1,12 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { schema } from '@cloudbeaver/core-utils'; + +export const CONNECTION_FORM_SSL_SCHEMA = schema.object({}); + +export type IConnectionFromSSLState = schema.infer; diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSL.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSL.tsx index 65698f3066..4f162bd514 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSL.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSL.tsx @@ -1,10 +1,11 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { observer } from 'mobx-react-lite'; import React from 'react'; @@ -18,6 +19,7 @@ import { s, Switch, useAdministrationSettings, + useAutoLoad, useObjectPropertyCategories, useResource, useS, @@ -27,47 +29,49 @@ import { useService } from '@cloudbeaver/core-di'; import { ProjectInfoResource } from '@cloudbeaver/core-projects'; import { ServerConfigResource } from '@cloudbeaver/core-root'; import type { NetworkHandlerConfigInput, NetworkHandlerDescriptor } from '@cloudbeaver/core-sdk'; -import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui'; +import { type TabContainerPanelComponent, useTab } from '@cloudbeaver/core-ui'; import { isSafari } from '@cloudbeaver/core-utils'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import { SAVED_VALUE_INDICATOR } from './SAVED_VALUE_INDICATOR.js'; import styles from './SSL.module.css'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; +import { ConnectionInfoNetworkHandlersResource } from '@cloudbeaver/core-connections'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; interface Props extends IConnectionFormProps { handler: NetworkHandlerDescriptor; handlerState: NetworkHandlerConfigInput; } -export const SSL: TabContainerPanelComponent = observer(function SSL({ state: formState, handler, handlerState }) { - const { info, readonly, disabled: formDisabled, loading } = formState; - +export const SSL: TabContainerPanelComponent = observer(function SSL({ formState, handler, handlerState, tabId }) { const translate = useTranslate(); - + const { selected } = useTab(tabId); const style = useS(styles); const { credentialsSavingEnabled } = useAdministrationSettings(); const { categories, isUncategorizedExists } = useObjectPropertyCategories(handler.properties); - const serverConfigResource = useResource(SSL, ServerConfigResource, undefined); + const serverConfigResource = useResource(SSL, ServerConfigResource, undefined, { + active: selected, + }); - const disabled = formDisabled || loading; + const disabled = formState.isDisabled || formState.isReadOnly; const enabled = handlerState.enabled || false; - const initialHandler = info?.networkHandlersConfig?.find(h => h.id === handler.id); + const optionsPart = getConnectionFormOptionsPart(formState); + const connectionInfoNetworkHandlersService = useResource(SSL, ConnectionInfoNetworkHandlersResource, optionsPart.connectionKey, { + active: selected && !!optionsPart.connectionKey, + }); + const handlersInfo = connectionInfoNetworkHandlersService.data; + const initialHandler = handlersInfo?.networkHandlersConfig?.find(h => h.id === handler.id); const autofillToken = isSafari ? 'section-connection-authentication-ssl section-ssl' : 'new-password'; const projectInfoResource = useService(ProjectInfoResource); - const isSharedProject = projectInfoResource.isProjectShared(formState.projectId); + const isSharedProject = projectInfoResource.isProjectShared(formState.state.projectId); + + useAutoLoad(SSL, optionsPart, enabled); return ( - + {translate('plugin_connections_connection_ssl_enable')} {isUncategorizedExists && ( @@ -75,7 +79,7 @@ export const SSL: TabContainerPanelComponent = observer(function SSL({ st state={handlerState.properties} properties={handler.properties} category={null} - disabled={disabled || readonly || !enabled} + disabled={disabled || !enabled} isSaved={p => !!p.id && initialHandler?.secureProperties[p.id] === SAVED_VALUE_INDICATOR} autofillToken={autofillToken} hideEmptyPlaceholder @@ -91,7 +95,7 @@ export const SSL: TabContainerPanelComponent = observer(function SSL({ st state={handlerState.properties} properties={handler.properties} category={category} - disabled={disabled || readonly || !enabled} + disabled={disabled || !enabled} isSaved={p => !!p.id && initialHandler?.secureProperties[p.id] === SAVED_VALUE_INDICATOR} autofillToken={autofillToken} hideEmptyPlaceholder @@ -101,12 +105,12 @@ export const SSL: TabContainerPanelComponent = observer(function SSL({ st ))} - {credentialsSavingEnabled && !formState.config.template && !formState.config.sharedCredentials && ( + {credentialsSavingEnabled && !optionsPart.state.template && !optionsPart.state.sharedCredentials && ( = observer(function SSLPanel(props) { const networkHandlerResource = useResource(SSLPanel, NetworkHandlerResource, CachedMapAllKey); - const dbDriverResource = useResource(SSLPanel, DBDriverResource, props.state.config.driverId ?? null); + const optionsPart = getConnectionFormOptionsPart(props.formState); + const dbDriverResource = useResource(SSLPanel, DBDriverResource, optionsPart.state.driverId ?? null); const handler = getSSLDriverHandler(networkHandlerResource.resource.values, dbDriverResource.data?.applicableNetworkHandlers ?? []); + const sslPart = getConnectionFormSSLPart(props.formState); - if (props.state.configured && handler && !props.state.config.networkHandlersConfig?.some(state => state.id === handler?.id)) { - props.state.config.networkHandlersConfig?.push(getSSLDefaultConfig(handler.id)); - } + useAutoLoad(SSLPanel, sslPart); - const handlerState = props.state.config.networkHandlersConfig?.find(h => h.id === handler?.id); - - if (!handler || !handlerState) { + if (!handler || !sslPart.state) { return null; } - return ; + return ; }); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSLTab.tsx b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSLTab.tsx index bcde1ab239..5a3dbfba5b 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSLTab.tsx +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/SSLTab.tsx @@ -1,23 +1,29 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { observer } from 'mobx-react-lite'; import { Translate, useResource } from '@cloudbeaver/core-blocks'; import { DBDriverResource, NetworkHandlerResource } from '@cloudbeaver/core-connections'; import { CachedMapAllKey } from '@cloudbeaver/core-resource'; -import { Tab, type TabContainerTabComponent, TabTitle } from '@cloudbeaver/core-ui'; +import { Tab, type TabContainerTabComponent, TabTitle, useTab } from '@cloudbeaver/core-ui'; -import type { IConnectionFormProps } from '../IConnectionFormProps.js'; import { getSSLDriverHandler } from './getSSLDriverHandler.js'; +import type { IConnectionFormProps } from '../IConnectionFormState.js'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; export const SSLTab: TabContainerTabComponent = observer(function SSLTab(props) { - const networkHandlerResource = useResource(SSLTab, NetworkHandlerResource, CachedMapAllKey); - const dbDriverResource = useResource(SSLTab, DBDriverResource, props.state.config.driverId ?? null); + const { selected } = useTab(props.tabId); + const networkHandlerResource = useResource(SSLTab, NetworkHandlerResource, CachedMapAllKey, { + active: selected, + }); + const optionsPart = getConnectionFormOptionsPart(props.formState); + const dbDriverResource = useResource(SSLTab, DBDriverResource, optionsPart.state.driverId ?? null); const handler = getSSLDriverHandler(networkHandlerResource.resource.values, dbDriverResource.data?.applicableNetworkHandlers ?? []); diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/SSL/getConnectionFormSSLPart.ts b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/getConnectionFormSSLPart.ts new file mode 100644 index 0000000000..6c9aa82c8a --- /dev/null +++ b/webapp/packages/plugin-connections/src/ConnectionForm/SSL/getConnectionFormSSLPart.ts @@ -0,0 +1,27 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2025 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ +import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context'; +import type { IFormState } from '@cloudbeaver/core-ui'; +import { ConnectionFormSSLPart } from './ConnectionFormSSLPart.js'; +import type { IConnectionFormState } from '../IConnectionFormState.js'; +import { ConnectionInfoNetworkHandlersResource, DBDriverResource, NetworkHandlerResource } from '@cloudbeaver/core-connections'; +import { getConnectionFormOptionsPart } from '../Options/getConnectionFormOptionsPart.js'; + +const DATA_CONTEXT_CONNECTION_FORM_OPTIONS_PART = createDataContext('Connection Form SSL Part'); + +export function getConnectionFormSSLPart(formState: IFormState): ConnectionFormSSLPart { + return formState.getPart(DATA_CONTEXT_CONNECTION_FORM_OPTIONS_PART, context => { + const di = context.get(DATA_CONTEXT_DI_PROVIDER)!; + const dbDriverResource = di.getService(DBDriverResource); + const networkHandlerResource = di.getService(NetworkHandlerResource); + const connectionInfoNetworkHandlersResource = di.getService(ConnectionInfoNetworkHandlersResource); + const optionsPart = getConnectionFormOptionsPart(formState); + + return new ConnectionFormSSLPart(formState, dbDriverResource, networkHandlerResource, connectionInfoNetworkHandlersResource, optionsPart); + }); +} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/connectionFormConfigureContext.ts b/webapp/packages/plugin-connections/src/ConnectionForm/connectionFormConfigureContext.ts deleted file mode 100644 index d59940ffe3..0000000000 --- a/webapp/packages/plugin-connections/src/ConnectionForm/connectionFormConfigureContext.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others - * - * Licensed under the Apache License, Version 2.0. - * you may not use this file except in compliance with the License. - */ -import type { DatabaseConnection } from '@cloudbeaver/core-connections'; -import type { IExecutionContextProvider } from '@cloudbeaver/core-executor'; -import type { CachedResourceIncludeArgs } from '@cloudbeaver/core-resource'; -import type { GetUserConnectionsQueryVariables } from '@cloudbeaver/core-sdk'; - -import type { IConnectionFormState } from './IConnectionFormProps.js'; - -export type ConnectionFormInfoIncludes = CachedResourceIncludeArgs; - -export interface IConnectionFormConfigureContext { - readonly driverId: string | undefined; - readonly info: DatabaseConnection | undefined; - readonly connectionIncludes: ConnectionFormInfoIncludes; - - include: (...includes: ConnectionFormInfoIncludes) => any; -} - -export function connectionFormConfigureContext( - contexts: IExecutionContextProvider, - state: IConnectionFormState, -): IConnectionFormConfigureContext { - return { - info: state.info, - driverId: state.config.driverId, - connectionIncludes: [], - include(...includes) { - for (const include of includes) { - if (!this.connectionIncludes.includes(include as never)) { - this.connectionIncludes.push(include as never); - } - } - }, - }; -} diff --git a/webapp/packages/plugin-connections/src/ConnectionForm/useConnectionFormState.ts b/webapp/packages/plugin-connections/src/ConnectionForm/useConnectionFormState.ts index 111e597d22..7c163a1f54 100644 --- a/webapp/packages/plugin-connections/src/ConnectionForm/useConnectionFormState.ts +++ b/webapp/packages/plugin-connections/src/ConnectionForm/useConnectionFormState.ts @@ -1,38 +1,47 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ -import { useEffect, useState } from 'react'; - -import { ConnectionInfoOriginResource, ConnectionInfoResource } from '@cloudbeaver/core-connections'; -import { useService } from '@cloudbeaver/core-di'; -import { ProjectInfoResource, ProjectsService } from '@cloudbeaver/core-projects'; - -import { ConnectionFormService } from './ConnectionFormService.js'; +import { useEffect, useRef } from 'react'; +import { IServiceProvider, useService } from '@cloudbeaver/core-di'; import { ConnectionFormState } from './ConnectionFormState.js'; -import type { IConnectionFormState } from './IConnectionFormProps.js'; +import { ConnectionFormService } from './ConnectionFormService.js'; +import type { IConnectionFormState } from './IConnectionFormState.js'; +import type { IConnectionInfoParams } from '@cloudbeaver/core-connections'; -export function useConnectionFormState( - resource: ConnectionInfoResource, - originResource: ConnectionInfoOriginResource, - configure?: (state: IConnectionFormState) => any, -): IConnectionFormState { - const projectsService = useService(ProjectsService); - const projectInfoResource = useService(ProjectInfoResource); +const EMPTY_CONNECTION_INFO_PARAMS: IConnectionFormState = { + projectId: '', + availableDrivers: [], + type: 'admin', + requiredNetworkHandlersIds: [], + connectionId: undefined, +}; +export function useConnectionFormState(params: IConnectionInfoParams, configure?: (state: ConnectionFormState) => any): ConnectionFormState { + const serviceProvider = useService(IServiceProvider); const service = useService(ConnectionFormService); - const [state] = useState(() => { - const state = new ConnectionFormState(projectsService, projectInfoResource, service, resource, originResource); - configure?.(state); + const ref = useRef(null); - state.load(); - return state; - }); + if (ref.current?.state.connectionId !== params.connectionId || ref.current?.state.projectId !== params.projectId) { + ref.current?.dispose(); + ref.current = new ConnectionFormState(serviceProvider, service, { + ...EMPTY_CONNECTION_INFO_PARAMS, + projectId: params.projectId, + connectionId: params.connectionId, + }); - useEffect(() => () => state.dispose(), []); + configure?.(ref.current); + } - return state; + useEffect( + () => () => { + ref.current?.dispose(); + }, + [], + ); + + return ref.current; } diff --git a/webapp/packages/plugin-connections/src/DatabaseAuthDialog/DatabaseAuthDialog.tsx b/webapp/packages/plugin-connections/src/DatabaseAuthDialog/DatabaseAuthDialog.tsx index e0c5efff61..6db1246c76 100644 --- a/webapp/packages/plugin-connections/src/DatabaseAuthDialog/DatabaseAuthDialog.tsx +++ b/webapp/packages/plugin-connections/src/DatabaseAuthDialog/DatabaseAuthDialog.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. diff --git a/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionForm.tsx b/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionForm.tsx index e348a869da..3bb7e620ac 100644 --- a/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionForm.tsx +++ b/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionForm.tsx @@ -1,6 +1,6 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. @@ -8,7 +8,7 @@ import { observer } from 'mobx-react-lite'; import { useCallback } from 'react'; -import { Loader, s, useS } from '@cloudbeaver/core-blocks'; +import { ColoredContainer, Loader, s, useS } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { ConnectionFormLoader } from '../ConnectionForm/ConnectionFormLoader.js'; @@ -23,17 +23,10 @@ export const PublicConnectionForm: React.FC = observer(function PublicConnection const save = useCallback(() => service.save(), []); return ( - - {() => - service.formState && ( - - ) - } - + + + {service.formState && } + + ); }); diff --git a/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormService.ts b/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormService.ts index 5fbdcfc085..8cc6a03d2d 100644 --- a/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormService.ts +++ b/webapp/packages/plugin-connections/src/PublicConnectionForm/PublicConnectionFormService.ts @@ -1,34 +1,28 @@ /* * CloudBeaver - Cloud Database Manager - * Copyright (C) 2020-2024 DBeaver Corp and others + * Copyright (C) 2020-2025 DBeaver Corp and others * * Licensed under the Apache License, Version 2.0. * you may not use this file except in compliance with the License. */ + import { action, makeObservable, observable } from 'mobx'; import { UserInfoResource } from '@cloudbeaver/core-authentication'; import { ConfirmationDialog, importLazyComponent } from '@cloudbeaver/core-blocks'; -import { - ConnectionInfoOriginResource, - ConnectionInfoResource, - ConnectionsManagerService, - createConnectionParam, - type IConnectionInfoParams, -} from '@cloudbeaver/core-connections'; -import { injectable } from '@cloudbeaver/core-di'; +import { ConnectionInfoResource, ConnectionsManagerService, type IConnectionInfoParams } from '@cloudbeaver/core-connections'; +import { injectable, IServiceProvider } from '@cloudbeaver/core-di'; import { CommonDialogService, DialogueStateResult } from '@cloudbeaver/core-dialogs'; import { NotificationService } from '@cloudbeaver/core-events'; import { executorHandlerFilter, ExecutorInterrupter, type IExecutorHandler } from '@cloudbeaver/core-executor'; -import { ProjectInfoResource, ProjectsService } from '@cloudbeaver/core-projects'; import type { ResourceKey, ResourceKeySimple } from '@cloudbeaver/core-resource'; import type { ConnectionConfig } from '@cloudbeaver/core-sdk'; -import { OptionsPanelService } from '@cloudbeaver/core-ui'; +import { FormMode, OptionsPanelService } from '@cloudbeaver/core-ui'; import { AuthenticationService } from '@cloudbeaver/plugin-authentication'; -import { ConnectionFormService } from '../ConnectionForm/ConnectionFormService.js'; import { ConnectionFormState } from '../ConnectionForm/ConnectionFormState.js'; -import type { IConnectionFormState } from '../ConnectionForm/IConnectionFormProps.js'; +import { ConnectionFormService } from '../ConnectionForm/ConnectionFormService.js'; +import { getConnectionFormOptionsPart } from '../ConnectionForm/Options/getConnectionFormOptionsPart.js'; const PublicConnectionForm = importLazyComponent(() => import('./PublicConnectionForm.js').then(m => m.PublicConnectionForm)); @@ -36,20 +30,18 @@ const formGetter = () => PublicConnectionForm; @injectable() export class PublicConnectionFormService { - formState: IConnectionFormState | null; + formState: ConnectionFormState | null; constructor( private readonly commonDialogService: CommonDialogService, private readonly notificationService: NotificationService, private readonly optionsPanelService: OptionsPanelService, + private readonly serviceProvider: IServiceProvider, private readonly connectionFormService: ConnectionFormService, private readonly connectionInfoResource: ConnectionInfoResource, private readonly connectionsManagerService: ConnectionsManagerService, private readonly userInfoResource: UserInfoResource, private readonly authenticationService: AuthenticationService, - private readonly projectsService: ProjectsService, - private readonly projectInfoResource: ProjectInfoResource, - private readonly connectionInfoOriginResource: ConnectionInfoOriginResource, ) { this.formState = null; this.optionsPanelService.closeTask.addHandler(this.closeHandler); @@ -79,29 +71,25 @@ export class PublicConnectionFormService { }); } - change(projectId: string, config: ConnectionConfig, availableDrivers?: string[]): void { - // if (this.formState) { - // this.formState.dispose(); - // } + async change(projectId: string, config: ConnectionConfig, availableDrivers?: string[]): Promise { + this.formState?.dispose(); + this.formState = new ConnectionFormState(this.serviceProvider, this.connectionFormService, { + projectId, + availableDrivers: availableDrivers ?? [], + type: 'public', + requiredNetworkHandlersIds: [], + connectionId: config.connectionId, + }).setMode(config.connectionId ? FormMode.Edit : FormMode.Create); - if (!this.formState) { - this.formState = new ConnectionFormState( - this.projectsService, - this.projectInfoResource, - this.connectionFormService, - this.connectionInfoResource, - this.connectionInfoOriginResource, - ); + await this.optionsPart?.load(); - this.formState.closeTask.addHandler(this.close.bind(this, true)); + if (config.driverId) { + await this.optionsPart?.setDriverId(config.driverId); } - this.formState - .setOptions(config.connectionId ? 'edit' : 'create', 'public') - .setConfig(projectId, config) - .setAvailableDrivers(availableDrivers || []); + Object.assign(this.optionsPart!.state, config); - this.formState.load(); + this.formState.disposeTask.addHandler(this.close.bind(this, true)); } async open(projectId: string, config: ConnectionConfig, availableDrivers?: string[]): Promise { @@ -114,29 +102,20 @@ export class PublicConnectionFormService { return state; } - async close(saved?: boolean): Promise { + async close(saved?: boolean): Promise { if (!this.formState) { - return true; + return; } if (saved) { this.clearFormState(); } - const state = await this.optionsPanelService.close(); - - if (state) { - this.clearFormState(); - } - - return state; + await this.optionsPanelService.close(); } async save(): Promise { - const key = - this.formState && this.formState.config.connectionId && this.formState.projectId !== null - ? createConnectionParam(this.formState.projectId, this.formState.config.connectionId) - : null; + const key = this.optionsPart?.connectionKey; await this.close(true); @@ -145,22 +124,26 @@ export class PublicConnectionFormService { } } + private get optionsPart() { + return this.formState ? getConnectionFormOptionsPart(this.formState) : null; + } + private readonly closeRemoved: IExecutorHandler> = (data, contexts) => { - if (!this.formState || !this.formState.config.connectionId || this.formState.projectId === null) { + if (!this.formState || !this.optionsPart?.connectionKey) { return; } - if (!this.connectionInfoResource.has(createConnectionParam(this.formState.projectId, this.formState.config.connectionId))) { + if (!this.connectionInfoResource.has(this.optionsPart.connectionKey)) { this.close(true); } }; private readonly closeDeleted: IExecutorHandler> = (data, contexts) => { - if (!this.formState || !this.formState.config.connectionId || this.formState.projectId === null) { + if (!this.formState || !this.optionsPart?.connectionKey) { return; } - if (this.connectionInfoResource.isIntersect(data, createConnectionParam(this.formState.projectId, this.formState.config.connectionId))) { + if (this.connectionInfoResource.isIntersect(data, this.optionsPart.connectionKey)) { this.close(true); } }; @@ -170,23 +153,22 @@ export class PublicConnectionFormService { if (!confirmed) { ExecutorInterrupter.interrupt(contexts); + return; } + + this.clearFormState(); }; private async showUnsavedChangesDialog(): Promise { if ( !this.formState || !this.optionsPanelService.isOpen(formGetter) || - (this.formState.config.connectionId && - this.formState.projectId !== null && - !this.connectionInfoResource.has(createConnectionParam(this.formState.projectId, this.formState.config.connectionId))) + (this.optionsPart?.connectionKey && !this.connectionInfoResource.has(this.optionsPart.connectionKey)) ) { return true; } - const state = await this.formState.checkFormState(); - - if (!state?.edited) { + if (!this.formState.isChanged) { return true; } diff --git a/webapp/packages/plugin-connections/src/index.ts b/webapp/packages/plugin-connections/src/index.ts index f44f11e0a2..1950d6e103 100644 --- a/webapp/packages/plugin-connections/src/index.ts +++ b/webapp/packages/plugin-connections/src/index.ts @@ -13,15 +13,14 @@ export * from './ConnectionForm/Options/ConnectionOptionsTabService.js'; export * from './ConnectionForm/DriverProperties/ConnectionDriverPropertiesTabService.js'; export * from './ConnectionForm/SSH/ConnectionSSHTabService.js'; export * from './ConnectionForm/OriginInfo/ConnectionOriginInfoTabService.js'; -export * from './ConnectionForm/Contexts/connectionConfigContext.js'; -export * from './ConnectionForm/Contexts/connectionCredentialsStateContext.js'; export * from './ConnectionForm/ConnectionFormBaseActionsLoader.js'; -export * from './ConnectionForm/connectionFormConfigureContext.js'; export * from './ConnectionForm/ConnectionFormLoader.js'; -export * from './ConnectionForm/ConnectionFormService.js'; +export * from './ConnectionForm/IConnectionFormState.js'; export * from './ConnectionForm/ConnectionFormState.js'; -export * from './ConnectionForm/IConnectionFormProps.js'; export * from './ConnectionForm/useConnectionFormState.js'; +export * from './ConnectionForm/ConnectionFormService.js'; +export * from './ConnectionForm/Options/getConnectionFormOptionsPart.js'; +export * from './ConnectionForm/Options/ConnectionFormOptionsPart.js'; export * from './ConnectionForm/SharedCredentials/CONNECTION_FORM_SHARED_CREDENTIALS_TAB_ID.js'; export * from './ConnectionForm/ConnectionAuthModelCredentials/ConnectionAuthModelCredentialsForm.js'; export * from './ContextMenu/MENU_CONNECTION_VIEW.js'; diff --git a/webapp/packages/plugin-connections/src/locales/en.ts b/webapp/packages/plugin-connections/src/locales/en.ts index 3c4c0da1c1..9505456299 100644 --- a/webapp/packages/plugin-connections/src/locales/en.ts +++ b/webapp/packages/plugin-connections/src/locales/en.ts @@ -46,4 +46,8 @@ export default [ ], ['plugin_connections_connection_create_menu_title', 'New Connection'], ['plugin_connections_connection_driver_not_installed_message', 'Driver is not installed. You can install it in the "Administration" part.'], + ['plugin_connections_connection_established', 'Connection is established'], + ['plugin_connections_connection_client_version', 'Client version: {arg:version} \n'], + ['plugin_connections_connection_server_version', 'Server version: {arg:version} \n'], + ['plugin_connections_connection_connection_time', 'Connection time: {arg:time} \n'], ]; diff --git a/webapp/packages/plugin-connections/src/locales/fr.ts b/webapp/packages/plugin-connections/src/locales/fr.ts index 61a6f58029..f009c214ad 100644 --- a/webapp/packages/plugin-connections/src/locales/fr.ts +++ b/webapp/packages/plugin-connections/src/locales/fr.ts @@ -55,4 +55,8 @@ export default [ ['plugin_connections_connection_auth_secret_description', 'Veuillez sélectionner les identifiants fournis par une de vos équipes'], ['plugin_connections_connection_create_menu_title', 'New Connection'], ['plugin_connections_connection_driver_not_installed_message', 'Driver is not installed. You can install it in the "Administration" part.'], + ['plugin_connections_connection_established', 'Connection is established'], + ['plugin_connections_connection_client_version', 'Client version: {arg:version} \n'], + ['plugin_connections_connection_server_version', 'Server version: {arg:version} \n'], + ['plugin_connections_connection_connection_time', 'Connection time: {arg:time} \n'], ]; diff --git a/webapp/packages/plugin-connections/src/locales/it.ts b/webapp/packages/plugin-connections/src/locales/it.ts index 069732c5d5..17d9465dcc 100644 --- a/webapp/packages/plugin-connections/src/locales/it.ts +++ b/webapp/packages/plugin-connections/src/locales/it.ts @@ -48,4 +48,8 @@ export default [ ], ['plugin_connections_connection_create_menu_title', 'New Connection'], ['plugin_connections_connection_driver_not_installed_message', 'Driver is not installed. You can install it in the "Administration" part.'], + ['plugin_connections_connection_established', 'Connection is established'], + ['plugin_connections_connection_client_version', 'Client version: {arg:version} \n'], + ['plugin_connections_connection_server_version', 'Server version: {arg:version} \n'], + ['plugin_connections_connection_connection_time', 'Connection time: {arg:time} \n'], ]; diff --git a/webapp/packages/plugin-connections/src/locales/ru.ts b/webapp/packages/plugin-connections/src/locales/ru.ts index 77b3d7556a..45c22b03d2 100644 --- a/webapp/packages/plugin-connections/src/locales/ru.ts +++ b/webapp/packages/plugin-connections/src/locales/ru.ts @@ -46,4 +46,8 @@ export default [ ], ['plugin_connections_connection_create_menu_title', 'Новое Подключение'], ['plugin_connections_connection_driver_not_installed_message', 'Драйвер не установлен. Вы можете установить его в "Администрированой" части.'], + ['plugin_connections_connection_established', 'Подключение установлено'], + ['plugin_connections_connection_client_version', 'Версия клиента: {arg:version} \n'], + ['plugin_connections_connection_server_version', 'Версия сервера: {arg:version} \n'], + ['plugin_connections_connection_connection_time', 'Время подключения: {arg:time} \n'], ]; diff --git a/webapp/packages/plugin-connections/src/locales/zh.ts b/webapp/packages/plugin-connections/src/locales/zh.ts index 79a4182ed6..985bfec36c 100644 --- a/webapp/packages/plugin-connections/src/locales/zh.ts +++ b/webapp/packages/plugin-connections/src/locales/zh.ts @@ -42,4 +42,8 @@ export default [ ['plugin_connections_connection_auth_secret_description', '有多个凭证可用于身份验证.\n请选择您要使用的凭证。'], ['plugin_connections_connection_create_menu_title', 'New Connection'], ['plugin_connections_connection_driver_not_installed_message', 'Driver is not installed. You can install it in the "Administration" part.'], + ['plugin_connections_connection_established', 'Connection is established'], + ['plugin_connections_connection_client_version', 'Client version: {arg:version} \n'], + ['plugin_connections_connection_server_version', 'Server version: {arg:version} \n'], + ['plugin_connections_connection_connection_time', 'Connection time: {arg:time} \n'], ]; diff --git a/webapp/packages/plugin-connections/tsconfig.json b/webapp/packages/plugin-connections/tsconfig.json index 8e0041aebd..48dc9e429a 100644 --- a/webapp/packages/plugin-connections/tsconfig.json +++ b/webapp/packages/plugin-connections/tsconfig.json @@ -7,6 +7,9 @@ "composite": true }, "references": [ + { + "path": "../../../common-typescript/@dbeaver/jdbc-uri-parser" + }, { "path": "../core-authentication" }, diff --git a/webapp/yarn.lock b/webapp/yarn.lock index 51b9d525b6..e9d1c3d8d1 100644 --- a/webapp/yarn.lock +++ b/webapp/yarn.lock @@ -2698,6 +2698,7 @@ __metadata: "@cloudbeaver/core-blocks": "workspace:*" "@cloudbeaver/core-cli": "workspace:*" "@cloudbeaver/core-connections": "workspace:*" + "@cloudbeaver/core-data-context": "workspace:*" "@cloudbeaver/core-di": "workspace:*" "@cloudbeaver/core-dialogs": "workspace:*" "@cloudbeaver/core-events": "workspace:*" @@ -2750,6 +2751,7 @@ __metadata: "@cloudbeaver/plugin-projects": "workspace:*" "@cloudbeaver/plugin-top-app-bar": "workspace:*" "@cloudbeaver/tsconfig": "workspace:*" + "@dbeaver/jdbc-uri-parser": "workspace:^" "@types/react": "npm:^19" mobx: "npm:^6" mobx-react-lite: "npm:^4" @@ -4513,6 +4515,16 @@ __metadata: languageName: unknown linkType: soft +"@dbeaver/jdbc-uri-parser@workspace:../common-typescript/@dbeaver/jdbc-uri-parser, @dbeaver/jdbc-uri-parser@workspace:^": + version: 0.0.0-use.local + resolution: "@dbeaver/jdbc-uri-parser@workspace:../common-typescript/@dbeaver/jdbc-uri-parser" + dependencies: + "@dbeaver/tsconfig": "workspace:^" + rimraf: "npm:^6" + typescript: "npm:^5" + languageName: unknown + linkType: soft + "@dbeaver/prettier-config@workspace:*, @dbeaver/prettier-config@workspace:../common-typescript/@dbeaver/prettier-config, @dbeaver/prettier-config@workspace:^": version: 0.0.0-use.local resolution: "@dbeaver/prettier-config@workspace:../common-typescript/@dbeaver/prettier-config"