Merge remote-tracking branch 'origin/devel' into CB-2386-cb-aws-the-changed-admin-in-the-easy-config-lost-the-admin-role-after-another-user-login

This commit is contained in:
Aleksandr Skoblikov
2022-08-31 18:51:29 +03:00
44 changed files with 445 additions and 69 deletions
@@ -56,7 +56,7 @@ public class VirtualProjectImpl extends BaseProjectImpl {
@NotNull
@Override
public String getName() {
return project.getName();
return project.getDisplayName();
}
@NotNull
@@ -438,7 +438,8 @@ public class LocalResourceController implements RMController {
public String setResourceContents(
@NotNull String projectId,
@NotNull String resourcePath,
@NotNull byte[] data) throws DBException
@NotNull byte[] data,
boolean forceOverwrite) throws DBException
{
validateResourcePath(resourcePath);
Number fileSizeLimit = WebAppUtils.getWebApplication()
@@ -452,6 +453,9 @@ public class LocalResourceController implements RMController {
data.length);
}
Path targetPath = getTargetPath(projectId, resourcePath);
if (!forceOverwrite && Files.exists(targetPath)) {
throw new DBException("Resource '" + resourcePath + "' exists");
}
if (!Files.exists(targetPath.getParent())) {
throw new DBException("Parent folder '" + targetPath.getParent().getFileName() + "' doesn't exist");
}
@@ -493,8 +493,8 @@ public class WebSession extends AbstractSessionPersistent implements SMSession,
return locale;
}
public void setLocale(String locale) {
this.locale = locale;
public void setLocale(@Nullable String locale) {
this.locale = locale != null ? locale : Locale.getDefault().getLanguage();
}
public DBNModel getNavigatorModel() {
@@ -22,7 +22,7 @@
<extension point="io.cloudbeaver.service">
<service id="core" label="Core service" description="Core services" class="io.cloudbeaver.service.core.WebServiceBindingCore">
<permission id="public" label="Public access" category="general" scope="subject"/>
<permission id="public" label="Public access" category="general" scope="subject" description="Provides access to the data management interface"/>
<permission id="access" label="Data source access" category="general" scope="datasource"/>
</service>
<service id="navigator" label="Database navigator" description="Database navigator services" class="io.cloudbeaver.service.navigator.WebServiceBindingNavigator">
@@ -5,7 +5,7 @@
<extension point="io.cloudbeaver.service">
<service id="admin" label="User administration" description="User administration services" class="io.cloudbeaver.service.admin.WebServiceBindingAdmin">
<permission id="admin" label="Admin Full Access" category="admin" scope="subject"/>
<permission id="admin" label="Admin Full Access" category="admin" scope="subject" description="Provides access to the CloudBeaver configuration"/>
</service>
</extension>
@@ -57,7 +57,8 @@ extend type Mutation {
rmWriteResourceStringContent(
projectId: String!,
resourcePath: String!,
data: String!): String!
data: String!,
forceOverwrite: Boolean!): String!
rmCreateProject(
projectName: String!,
@@ -80,7 +80,8 @@ public interface DBWServiceRM extends DBWService {
@NotNull WebSession webSession,
@NotNull @WebObjectId String projectId,
@NotNull String resourcePath,
@NotNull String data) throws DBException;
@NotNull String data,
boolean forceOverwrite) throws DBException;
@WebAction(requirePermissions = {DBWConstants.PERMISSION_ADMIN, RMConstants.PERMISSION_RM_ADMIN})
WebProjectInfo createProject(
@@ -64,7 +64,8 @@ public class WebServiceBindingRM extends WebServiceBindingBase<DBWServiceRM> {
env -> getService(env).writeResourceStringContent(getWebSession(env),
env.getArgument("projectId"),
env.getArgument("resourcePath"),
env.getArgument("data")))
env.getArgument("data"),
env.getArgument("forceOverwrite")))
.dataFetcher("rmCreateProject", env -> getService(env).createProject(
getWebSession(env),
@@ -89,11 +89,17 @@ public class WebServiceRM implements DBWServiceRM {
@NotNull
@Override
public String writeResourceStringContent(@NotNull WebSession webSession, @NotNull String projectId, @NotNull String resourcePath, @NotNull String data) throws DBException {
public String writeResourceStringContent(
@NotNull WebSession webSession,
@NotNull String projectId,
@NotNull String resourcePath,
@NotNull String data,
boolean forceOverwrite
) throws DBException {
checkIsRmEnabled(webSession);
try {
byte[] bytes = data.getBytes(StandardCharsets.UTF_8);
return getResourceController(webSession).setResourceContents(projectId, resourcePath, bytes);
return getResourceController(webSession).setResourceContents(projectId, resourcePath, bytes, forceOverwrite);
} catch (Exception e) {
throw new DBWebException("Error writing resource '" + resourcePath + "' data", e);
}
@@ -22,5 +22,6 @@ Require-Bundle: org.eclipse.core.runtime,
io.cloudbeaver.server,
io.cloudbeaver.resources.drivers.base,
io.cloudbeaver.product.ce,
io.cloudbeaver.service.auth
io.cloudbeaver.service.auth,
io.cloudbeaver.service.rm
Export-Package: io.cloudbeaver.test.platform
@@ -29,7 +29,7 @@ import java.net.http.HttpClient;
import java.nio.file.Path;
@RunWith(Suite.class)
@Suite.SuiteClasses({PlatformTest.class, AuthenticationTest.class })
@Suite.SuiteClasses({PlatformTest.class, AuthenticationTest.class, ResourceManagerTest.class })
public class CEServerTestSuite {
public static final String GQL_API_URL = "http://localhost:18978/api/gql";
@@ -0,0 +1,77 @@
/*
* DBeaver - Universal Database Manager
* Copyright (C) 2010-2022 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.cloudbeaver.test.platform;
import io.cloudbeaver.model.rm.local.LocalResourceController;
import io.cloudbeaver.server.CBApplication;
import io.cloudbeaver.utils.WebTestUtils;
import org.jkiss.dbeaver.model.auth.SMAuthStatus;
import org.jkiss.dbeaver.model.data.json.JSONUtils;
import org.jkiss.utils.CommonUtils;
import org.junit.Assert;
import org.junit.Test;
import java.net.CookieManager;
import java.net.http.HttpClient;
import java.util.Map;
public class ResourceManagerTest {
public static final String GQL_TEMPLATE_RM_WRITE_RESOURCE = "rmWriteResource.json";
public static final String GQL_TEMPLATE_RM_DELETE_RESOURCE = "navDeleteNode.json";
@Test
public void createDeleteResourceTest() throws Exception {
Assert.assertTrue(CBApplication.getInstance().getAppConfiguration().isResourceManagerEnabled());
HttpClient client = HttpClient.newBuilder()
.cookieHandler(new CookieManager())
.version(HttpClient.Version.HTTP_2)
.build();
Map<String, Object> authInfo = WebTestUtils.authenticateUser(
client, CEServerTestSuite.getScriptsPath(), CEServerTestSuite.GQL_API_URL);
Assert.assertEquals(SMAuthStatus.SUCCESS.name(), JSONUtils.getString(authInfo, "authStatus"));
Assert.assertTrue(createResource(client, false));
Assert.assertFalse(createResource(client, false));
Assert.assertTrue(createResource(client, true));
Assert.assertEquals(1, deleteResource(client));
}
private boolean createResource(HttpClient client, boolean forceOverwrite) throws Exception {
String input = WebTestUtils.readScriptTemplate(
GQL_TEMPLATE_RM_WRITE_RESOURCE, CEServerTestSuite.getScriptsPath()
).replaceAll("\\{forceOverwrite}", CommonUtils.toString(forceOverwrite));
Map<String, Object> map = WebTestUtils.doPost(CEServerTestSuite.GQL_API_URL, input, client);
Map<String, Object> data = JSONUtils.getObjectOrNull(map, "data");
if (data != null) {
return LocalResourceController.DEFAULT_CHANGE_ID.equals(JSONUtils.getString(data, "rmWriteResourceStringContent"));
}
return false;
}
private int deleteResource(HttpClient client) throws Exception {
String input = WebTestUtils.readScriptTemplate(GQL_TEMPLATE_RM_DELETE_RESOURCE, CEServerTestSuite.getScriptsPath());
Map<String, Object> map = WebTestUtils.doPost(CEServerTestSuite.GQL_API_URL, input, client);
Map<String, Object> data = JSONUtils.getObjectOrNull(map, "data");
if (data != null) {
return JSONUtils.getInteger(data, "navDeleteNodes");
}
return -1;
}
}
@@ -0,0 +1,9 @@
{
"query": "\n mutation navDeleteNodes($nodePaths: [ID!]!) {\n navDeleteNodes(nodePaths: $nodePaths)\n}\n ",
"variables": {
"nodePaths": [
"ext://resources/u_test/testScript.sql"
]
},
"operationName": "navDeleteNodes"
}
@@ -0,0 +1,10 @@
{
"query": "\n mutation writeContent($projectId: String!, $resourcePath: String!, $data: String!, $forceOverwrite: Boolean!) {\n rmWriteResourceStringContent(\n projectId: $projectId\n resourcePath: $resourcePath\n data: $data\n forceOverwrite: $forceOverwrite\n )\n}\n ",
"variables": {
"projectId": "u_test",
"resourcePath": "testScript.sql",
"data": "TEST SCRIPT;",
"forceOverwrite": {forceOverwrite}
},
"operationName": "writeContent"
}
@@ -11,7 +11,7 @@ export function isControlPresented(
): boolean {
if (state !== undefined && name !== undefined) {
if (name in state) {
return state[name] !== null;
return state[name] !== null && state[name] !== undefined;
}
return defaultValue !== undefined;
}
@@ -231,6 +231,7 @@ export const RenderField = observer<RenderFieldProps>(function RenderField({
</InputField>
);
}
return (
<InputField
type={password ? 'password' : 'text'}
@@ -39,6 +39,7 @@ export default [
['connections_connection_access_filter_placeholder', 'Search for user or role name'],
['connections_connection_access_admin_info', 'Administrators see all connections.'],
['connections_connection_description', 'Description'],
['connections_connection_project', 'Project'],
['connections_connection_driver', 'Driver'],
['connections_connection_configuration', 'Configuration'],
['connections_connection_host', 'Host'],
@@ -42,6 +42,7 @@ export default [
['connections_connection_access_admin_info', 'Gli amministratori vedono tutte le connessioni.'],
['connections_connection_description', 'Descrizione'],
['connections_connection_driver', 'Driver'],
['connections_connection_project', 'Project'],
['connections_connection_configuration', 'Configuration'],
['connections_connection_host', 'Host'],
['connections_connection_port', 'Porta'],
@@ -36,6 +36,7 @@ export default [
['connections_connection_access_filter_placeholder', 'Поиск по имени пользователя или роли'],
['connections_connection_access_admin_info', 'Администраторы видят все подключения.'],
['connections_connection_description', 'Описание'],
['connections_connection_project', 'Проект'],
['connections_connection_driver', 'Драйвер'],
['connections_connection_configuration', 'Конфигурация'],
['connections_connection_host', 'Хост'],
@@ -39,6 +39,7 @@ export default [
['connections_connection_access_filter_placeholder', '搜索用户或角色名称'],
['connections_connection_access_admin_info', '管理员查看所有连接。'],
['connections_connection_description', '描述'],
['connections_connection_project', 'Project'],
['connections_connection_driver', '驱动'],
['connections_connection_configuration', 'Configuration'],
['connections_connection_host', '主机'],
@@ -1,3 +1,13 @@
mutation writeResourceContent($projectId: String!, $resourcePath: String!, $data: String!) {
rmWriteResourceStringContent(projectId: $projectId, resourcePath: $resourcePath, data: $data)
mutation writeResourceContent(
$projectId: String!,
$resourcePath: String!,
$data: String!,
$forceOverwrite: Boolean!
) {
rmWriteResourceStringContent(
projectId: $projectId,
resourcePath: $resourcePath,
data: $data,
forceOverwrite: $forceOverwrite
)
}
+4 -1
View File
@@ -563,6 +563,7 @@ export interface MutationRmMoveResourceArgs {
export interface MutationRmWriteResourceStringContentArgs {
data: Scalars['String'];
forceOverwrite: Scalars['Boolean'];
projectId: Scalars['String'];
resourcePath: Scalars['String'];
}
@@ -2446,6 +2447,7 @@ export type WriteResourceContentMutationVariables = Exact<{
projectId: Scalars['String'];
resourcePath: Scalars['String'];
data: Scalars['String'];
forceOverwrite: Scalars['Boolean'];
}>;
@@ -3907,11 +3909,12 @@ export const ReadResourceDocument = `
}
`;
export const WriteResourceContentDocument = `
mutation writeResourceContent($projectId: String!, $resourcePath: String!, $data: String!) {
mutation writeResourceContent($projectId: String!, $resourcePath: String!, $data: String!, $forceOverwrite: Boolean!) {
rmWriteResourceStringContent(
projectId: $projectId
resourcePath: $resourcePath
data: $data
forceOverwrite: $forceOverwrite
)
}
`;
@@ -78,15 +78,22 @@ export const RoleOptions: TabContainerPanelComponent<IRoleFormProps> = observer(
<Group small gap>
<GroupTitle>{translate('administration_roles_role_permissions')}</GroupTitle>
{permissionsResource.resource.values.map(permission => {
let label = permission.id;
let caption: string | undefined;
const label = permission.label ?? permission.id;
let caption = '';
if (permission.description) {
caption = permission.description;
} else if (permission.label) {
caption = `(${permission.id})`;
}
let tooltip = `${permission.id}`;
if (permission.label) {
label = `${permission.label}`;
caption = permission.id;
tooltip = permission.label + ` (${permission.id})`;
}
const tooltip = `${label}${permission.description ? '\n' + permission.description : ''}`;
return (
<FieldCheckbox
key={permission.id}
@@ -98,7 +105,8 @@ export const RoleOptions: TabContainerPanelComponent<IRoleFormProps> = observer(
readOnly={state.readonly}
disabled={state.disabled}
>
{label} {caption && <caption>({caption})</caption>}
{label}
{caption ? <caption>{caption}</caption> : null}
</FieldCheckbox>
);
})}
@@ -23,11 +23,12 @@ import { AuthDialogService } from './Dialog/AuthDialogService';
import type { IAuthOptions } from './IAuthOptions';
import { isAutoLoginSessionAction } from './isAutoLoginSessionAction';
export type LogoutEventType = 'before' | 'after';
type AuthEventType = 'before' | 'after';
@injectable()
export class AuthenticationService extends Bootstrap {
readonly onLogout: Executor<LogoutEventType>;
readonly onLogout: Executor<AuthEventType>;
readonly onLogin: Executor<AuthEventType>;
configureAuthProvider: (() => void) | null;
configureIdentityProvider: (() => void) | null;
@@ -52,6 +53,7 @@ export class AuthenticationService extends Bootstrap {
super();
this.onLogout = new Executor();
this.onLogin = new Executor();
this.authPromise = null;
this.configureAuthProvider = null;
@@ -131,13 +133,24 @@ export class AuthenticationService extends Bootstrap {
}
private async auth(persistent: boolean, options: IAuthOptions) {
const contexts = await this.onLogin.execute('before');
if (ExecutorInterrupter.isInterrupted(contexts)) {
return;
}
if (this.authPromise) {
return this.authPromise;
await this.authPromise;
return;
}
options = observable(options);
this.authPromise = this.authDialogService.showLoginForm(persistent, options);
this.authPromise = this.authDialogService.showLoginForm(persistent, options)
.then(async state => {
await this.onLogin.execute('after');
return state;
});
if (this.serverConfigResource.redirectOnFederatedAuth) {
await this.authProvidersResource.loadAll();
@@ -158,7 +171,7 @@ export class AuthenticationService extends Bootstrap {
}
try {
return await this.authPromise;
await this.authPromise;
} finally {
this.authPromise = null;
}
@@ -13,13 +13,11 @@ import { DATA_CONTEXT_MENU, MenuBaseItem, MenuService } from '@cloudbeaver/core-
import { TOP_NAV_BAR_SETTINGS_MENU } from '@cloudbeaver/plugin-settings-menu';
import { AuthenticationService } from './AuthenticationService';
import { AuthDialogService } from './Dialog/AuthDialogService';
@injectable()
export class PluginBootstrap extends Bootstrap {
constructor(
private readonly serverConfigResource: ServerConfigResource,
private readonly authDialogService: AuthDialogService,
private readonly authenticationService: AuthenticationService,
private readonly authInfoService: AuthInfoService,
private readonly menuService: MenuService,
@@ -40,7 +38,7 @@ export class PluginBootstrap extends Bootstrap {
label: 'authentication_login',
tooltip: 'authentication_login',
},
{ onSelect: () => this.authDialogService.showLoginForm(false, { providerId: null, linkUser: false }) }
{ onSelect: () => this.authenticationService.authUser(null, false) }
),
];
}
@@ -54,7 +52,7 @@ export class PluginBootstrap extends Bootstrap {
label: 'authentication_logout',
tooltip: 'authentication_logout',
},
{ onSelect: this.authenticationService.logout.bind(this.authenticationService) }
{ onSelect: () => this.authenticationService.logout() }
),
];
}
@@ -152,6 +152,7 @@ export class ConnectionFormState implements IConnectionFormState {
.next(this.formStateTask);
makeObservable<IConnectionFormState, '_availableDrivers' | 'stateInfo' | 'updateFormState'>(this, {
projectId: observable,
mode: observable,
type: observable,
config: observable,
@@ -208,12 +209,17 @@ export class ConnectionFormState implements IConnectionFormState {
}
setConfig(projectId: string, config: ConnectionConfig): this {
this.projectId = projectId;
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();
@@ -46,6 +46,7 @@ export interface IConnectionFormState {
type: ConnectionFormType
) => this;
readonly setConfig: (projectId: string, config: ConnectionConfig) => this;
readonly setProject: (projectId: string) => this;
readonly setAvailableDrivers: (drivers: string[]) => this;
readonly save: () => Promise<void>;
readonly test: () => Promise<void>;
@@ -11,6 +11,7 @@ import { action, makeObservable, runInAction, toJS } from 'mobx';
import { createConnectionParam, DatabaseAuthModelsResource, DatabaseConnection, DBDriverResource } from '@cloudbeaver/core-connections';
import { Bootstrap, injectable } from '@cloudbeaver/core-di';
import type { IExecutionContextProvider } from '@cloudbeaver/core-executor';
import { ProjectsResource, PROJECT_GLOBAL_ID } from '@cloudbeaver/core-projects';
import { CachedMapAllKey, DriverConfigurationType, isObjectPropertyInfoStateEqual, ObjectPropertyInfo } from '@cloudbeaver/core-sdk';
import { getUniqueName, isValuesEqual } from '@cloudbeaver/core-utils';
@@ -25,6 +26,7 @@ import { Options } from './Options';
@injectable()
export class ConnectionOptionsTabService extends Bootstrap {
constructor(
private readonly projectsResource: ProjectsResource,
private readonly connectionFormService: ConnectionFormService,
private readonly dbDriverResource: DBDriverResource,
private readonly databaseAuthModelsResource: DatabaseAuthModelsResource,
@@ -120,14 +122,22 @@ export class ConnectionOptionsTabService extends Bootstrap {
const validation = contexts.getContext(this.connectionFormService.connectionValidationContext);
if (!state.config.name?.length) {
validation.error("Field 'name' can't be empty");
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('Configuration is not supported');
validation.error('plugin_connections_connection_form_host_configuration_invalid');
}
}
if (state.projectId !== null && state.mode === 'create') {
const project = this.projectsResource.get(state.projectId);
if (!project?.canCreateConnections) {
validation.error('plugin_connections_connection_form_project_invalid');
}
}
@@ -226,7 +236,11 @@ export class ConnectionOptionsTabService extends Bootstrap {
}
tempConfig.description = state.config.description;
tempConfig.template = state.config.template;
if (state.projectId === PROJECT_GLOBAL_ID) {
tempConfig.template = state.config.template;
}
tempConfig.driverId = state.config.driverId;
if (!state.config.template && state.config.folder) {
@@ -34,9 +34,9 @@ import {
import { DatabaseAuthModelsResource, DBDriverResource, isLocalConnection } from '@cloudbeaver/core-connections';
import { useService } from '@cloudbeaver/core-di';
import { useTranslate } from '@cloudbeaver/core-localization';
import { PROJECT_GLOBAL_ID } from '@cloudbeaver/core-projects';
import { Project, ProjectsResource, PROJECT_GLOBAL_ID } from '@cloudbeaver/core-projects';
import { usePermission } from '@cloudbeaver/core-root';
import { CachedMapEmptyKey, DriverConfigurationType, resourceKeyList } from '@cloudbeaver/core-sdk';
import { CachedMapAllKey, CachedMapEmptyKey, DriverConfigurationType, resourceKeyList } from '@cloudbeaver/core-sdk';
import { useStyles } from '@cloudbeaver/core-theming';
import type { TabContainerPanelComponent } from '@cloudbeaver/core-ui';
import { useAuthenticationAction } from '@cloudbeaver/core-ui';
@@ -116,6 +116,17 @@ export const Options: TabContainerPanelComponent<IConnectionFormProps> = observe
optionsHook.setAuthModel(model);
}, []);
const projectsLoader = useMapResource(Options, ProjectsResource, CachedMapAllKey);
const projects = projectsLoader.data as Project[];
function handleProjectSelect(projectId: string) {
const project = projectsLoader.resource.get(projectId);
if (project?.canCreateConnections) {
state.setProject(projectId);
}
}
const driverMap = useMapResource(
Options,
DBDriverResource,
@@ -293,6 +304,24 @@ export const Options: TabContainerPanelComponent<IConnectionFormProps> = observe
>
{translate('connections_connection_name')}
</InputField>
<Combobox
name='projectId'
value={state.projectId ?? PROJECT_GLOBAL_ID}
items={projects}
keySelector={project => project.id}
valueSelector={project => project.name}
titleSelector={project => project.description}
isDisabled={project => !project.canCreateConnections}
searchable={projects.length > 10}
readOnly={readonly || edit || projects.length < 2}
disabled={disabled}
loading={projectsLoader.isLoading()}
tiny
fill
onSelect={handleProjectSelect}
>
{translate('connections_connection_project')}
</Combobox>
{!config.template && (
<InputField
type="text"
@@ -301,6 +330,7 @@ export const Options: TabContainerPanelComponent<IConnectionFormProps> = observe
disabled={disabled}
autoComplete={`section-${config.driverId || 'driver'} section-folder`}
mod='surface'
autoHide
readOnly
tiny
fill
@@ -13,9 +13,9 @@ import { injectable } from '@cloudbeaver/core-di';
import { CommonDialogService, ConfirmationDialog, DialogueStateResult } from '@cloudbeaver/core-dialogs';
import { NotificationService } from '@cloudbeaver/core-events';
import { ExecutorInterrupter, IExecutorHandler } from '@cloudbeaver/core-executor';
import { SessionDataResource } from '@cloudbeaver/core-root';
import type { ConnectionConfig, ResourceKey } from '@cloudbeaver/core-sdk';
import { OptionsPanelService } from '@cloudbeaver/core-ui';
import { AuthenticationService } from '@cloudbeaver/plugin-authentication';
import { ConnectionAuthService } from '../ConnectionAuthService';
import { ConnectionFormService } from '../ConnectionForm/ConnectionFormService';
@@ -36,14 +36,29 @@ export class PublicConnectionFormService {
private readonly connectionFormService: ConnectionFormService,
private readonly connectionInfoResource: ConnectionInfoResource,
private readonly connectionAuthService: ConnectionAuthService,
private readonly sessionDataResource: SessionDataResource
private readonly authenticationService: AuthenticationService,
) {
this.formState = null;
this.optionsPanelService.closeTask.addHandler(this.closeHandler);
this.connectionInfoResource.onDataUpdate.addPostHandler(this.closeRemoved);
this.connectionInfoResource.onItemDelete.addPostHandler(this.closeDeleted);
this.sessionDataResource.onDataOutdated.addHandler(() => {
this.close(true);
this.authenticationService.onLogin.addHandler(async (event, context) => {
if (event === 'before') {
const confirmed = await this.showUnsavedChangesDialog();
if (!confirmed) {
ExecutorInterrupter.interrupt(context);
}
}
});
this.authenticationService.onLogout.addHandler(async (event, context) => {
if (event === 'before') {
const confirmed = await this.close(false);
if (!confirmed) {
ExecutorInterrupter.interrupt(context);
}
}
});
makeObservable(this, {
@@ -87,9 +102,9 @@ export class PublicConnectionFormService {
return state;
}
async close(saved?: boolean): Promise<void> {
async close(saved?: boolean): Promise<boolean> {
if (!this.formState) {
return;
return true;
}
if (saved) {
@@ -101,6 +116,8 @@ export class PublicConnectionFormService {
if (state) {
this.clearFormState();
}
return state;
}
async save(): Promise<void> {
@@ -147,6 +164,14 @@ export class PublicConnectionFormService {
};
private readonly closeHandler: IExecutorHandler<any> = async (data, contexts) => {
const confirmed = await this.showUnsavedChangesDialog();
if (!confirmed) {
ExecutorInterrupter.interrupt(contexts);
}
};
private async showUnsavedChangesDialog(): Promise<boolean> {
if (
!this.formState
|| !this.optionsPanelService.isOpen(formGetter)
@@ -159,13 +184,13 @@ export class PublicConnectionFormService {
))
)
) {
return;
return true;
}
const state = await this.formState.checkFormState();
if (!state?.edited) {
return;
return true;
}
const result = await this.commonDialogService.open(ConfirmationDialog, {
@@ -174,10 +199,8 @@ export class PublicConnectionFormService {
confirmActionText: 'ui_processing_ok',
});
if (result === DialogueStateResult.Rejected) {
ExecutorInterrupter.interrupt(contexts);
}
};
return result !== DialogueStateResult.Rejected;
}
private async tryReconnect(connectionKey: IConnectionInfoParams) {
const result = await this.commonDialogService.open(ConfirmationDialog, {
@@ -7,5 +7,8 @@ export default [
['connections_public_connection_edit_reconnect_failed', 'Failed to reconnect'],
['connections_public_connection_folder_move_failed', 'Failed to move to folder'],
['connections_public_connection_folder_move_duplication', 'Target folder or selected folders contains folder with the same name ({args.name})'],
['plugin_connections_connection_form_project_invalid', 'You have no access to create connections in selected project'],
['plugin_connections_connection_form_host_configuration_invalid', 'Host configuration is not supported'],
['plugin_connections_connection_form_name_invalid', 'Field \'name\' can\'t be empty'],
['connections_public_connection_folder_delete_confirmation', 'You\'re going to delete "{args.name}". Connections won\'t be deleted. Are you sure?'],
];
@@ -7,5 +7,8 @@ export default [
['connections_public_connection_edit_reconnect_failed', 'Failed to reconnect'],
['connections_public_connection_folder_move_failed', 'Failed to move to folder'],
['connections_public_connection_folder_move_duplication', 'Target folder or selected folders contains folder with the same name ({args.name})'],
['plugin_connections_connection_form_project_invalid', 'You have no access to create connections in selected project'],
['plugin_connections_connection_form_host_configuration_invalid', 'Host configuration is not supported'],
['plugin_connections_connection_form_name_invalid', 'Field \'name\' can\'t be empty'],
['connections_public_connection_folder_delete_confirmation', 'You\'re going to delete "{args.name}". Connections won\'t be deleted. Are you sure?'],
];
@@ -7,5 +7,8 @@ export default [
['connections_public_connection_edit_reconnect_failed', 'Не удалось переподключиться'],
['connections_public_connection_folder_move_failed', 'Ошибка перемещения в папку'],
['connections_public_connection_folder_move_duplication', 'Выбранные папки или папка назначения содержит папки с таким же названием ({args.name})'],
['plugin_connections_connection_form_project_invalid', 'У вас нет разрешения создавать коннекшены в выбранном проекте'],
['plugin_connections_connection_form_host_configuration_invalid', 'Конфигурация хоста не поддерживается'],
['plugin_connections_connection_form_name_invalid', 'Поле \'Название\' не может быть пустым'],
['connections_public_connection_folder_delete_confirmation', 'Вы удаляете "{args.name}". Подключения не будут удалены. Вы уверены?'],
];
@@ -7,5 +7,8 @@ export default [
['connections_public_connection_edit_reconnect_failed', '重新连接失败'],
['connections_public_connection_folder_move_failed', 'Failed to move to folder'],
['connections_public_connection_folder_move_duplication', 'Target folder or selected folders contains folder with the same name ({args.name})'],
['plugin_connections_connection_form_project_invalid', 'You have no access to create connections in selected project'],
['plugin_connections_connection_form_host_configuration_invalid', 'Host configuration is not supported'],
['plugin_connections_connection_form_name_invalid', 'Field \'name\' can\'t be empty'],
['connections_public_connection_folder_delete_confirmation', 'You\'re going to delete "{args.name}". Connections won\'t be deleted. Are you sure?'],
];
@@ -102,6 +102,7 @@ export const ElementsTree = observer<Props>(function ElementsTree({
navNodeFilterCompare,
filters = [],
renderers = [],
expandStateGetters,
style,
className,
getChildren,
@@ -161,7 +162,7 @@ export const ElementsTree = observer<Props>(function ElementsTree({
}, [folderExplorer]);
const children = useMapResource(ElementsTree, navTreeResource, root, {
useMapResource(ElementsTree, navTreeResource, root, {
onLoad: async resource => {
let fullPath = folderExplorer.state.fullPath;
const preload = await resource.preloadNodeParents(fullPath);
@@ -199,6 +200,7 @@ export const ElementsTree = observer<Props>(function ElementsTree({
localState,
filters: [nameFilter, ...filters, limitFilter],
renderers: [...renderers, elementsTreeLimitRenderer],
expandStateGetters,
getChildren,
loadChildren,
isGroup,
@@ -279,7 +281,8 @@ export const ElementsTree = observer<Props>(function ElementsTree({
}
});
const hasChildren = (children.data?.length || 0) > 0;
const children = tree.getNodeChildren(root);
const hasChildren = children.length > 0;
const loaderAvailable = !foldersTree || context.folderExplorer.root === root;
return styled(useStyles(TREE_NODE_STYLES, styles, style))(
@@ -290,7 +293,7 @@ export const ElementsTree = observer<Props>(function ElementsTree({
root={root}
context={context}
emptyPlaceholder={emptyPlaceholder}
childrenState={children}
childrenState={tree}
hasChildren={hasChildren}
>
<ElementsTreeContext.Provider value={context}>
@@ -104,9 +104,11 @@ export const NavigationNodeControl: NavTreeControlComponent = observer<NavTreeCo
treeNodeContext.select(event.ctrlKey || event.metaKey);
}
const expandable = getComputed(() => treeContext?.tree.isNodeExpandable(node.id) ?? true);
return styled(TREE_NODE_STYLES, nodeIconStyle, styles)(
<TreeNodeControl ref={ref} onClick={onClickHandler} {...use({ outdated, editing, dragging: dndElement })}>
<TreeNodeExpand filterActive={treeContext?.tree.filtering} />
{expandable && <TreeNodeExpand filterActive={treeContext?.tree.filtering} />}
<TreeNodeIcon icon={icon} style={nodeIconStyle} {...use({ connected })}>
<ConnectionMark connected={connected} />
</TreeNodeIcon>
@@ -9,7 +9,7 @@
import { action, computed, observable, runInAction } from 'mobx';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { IFolderExplorerContext, useExecutor, useObjectRef, useObservableRef, useUserData } from '@cloudbeaver/core-blocks';
import { IFolderExplorerContext, ILoadableState, useExecutor, useObjectRef, useObservableRef, useUserData } from '@cloudbeaver/core-blocks';
import { ConnectionInfoResource } from '@cloudbeaver/core-connections';
import { useService } from '@cloudbeaver/core-di';
import { NotificationService } from '@cloudbeaver/core-events';
@@ -23,6 +23,16 @@ import type { NavigationNodeRendererComponent } from './NavigationNodeComponent'
export type IElementsTreeCustomRenderer = (nodeId: string) => NavigationNodeRendererComponent | undefined;
export interface IElementsTreeNodeExpandedInfo {
expanded: boolean;
expandable?: boolean;
}
export type IElementsTreeNodeExpandInfoGetter = (
nodeId: string,
state: ITreeNodeState
) => IElementsTreeNodeExpandedInfo | null;
export type IElementsTreeFilter = (
filter: string,
node: NavNode,
@@ -56,6 +66,7 @@ export interface IElementsTreeOptions {
disabled?: boolean;
filters?: IElementsTreeFilter[];
renderers?: IElementsTreeCustomRenderer[];
expandStateGetters?: IElementsTreeNodeExpandInfoGetter[];
localState?: MetadataMap<string, ITreeNodeState>;
getChildren: (id: string) => string[] | undefined;
loadChildren: (id: string, manual: boolean) => Promise<boolean>;
@@ -74,7 +85,7 @@ interface IOptions extends IElementsTreeOptions {
folderExplorer: IFolderExplorerContext;
}
export interface IElementsTree {
export interface IElementsTree extends ILoadableState {
actions: ISyncExecutor<IElementsTreeAction>;
settings?: IElementsTreeSettings;
baseRoot: string;
@@ -89,6 +100,7 @@ export interface IElementsTree {
getNodeState: (nodeId: string) => ITreeNodeState;
isNodeExpanded: (nodeId: string, ignoreFilter?: boolean) => boolean;
isNodeExpandable: (nodeId: string) => boolean;
getExpanded: () => string[];
getSelected: () => string[];
isNodeSelected: (nodeId: string) => boolean;
@@ -294,6 +306,12 @@ export function useElementsTree(options: IOptions): IElementsTree {
get filtering() {
return this.filter !== '';
},
isLoading() {
return this.loading;
},
isLoaded() {
return true;
},
getNodeState(nodeId: string) {
return this.state.get(nodeId);
},
@@ -307,8 +325,34 @@ export function useElementsTree(options: IOptions): IElementsTree {
}
const nodeState = this.getNodeState(nodeId);
const expanded = nodeState.expanded || nodeState.showInFilter;
return nodeState.expanded || nodeState.showInFilter;
if (!expanded && options.expandStateGetters?.length) {
return options
.expandStateGetters
.map(getExpandState => getExpandState(nodeId, nodeState))
.filter(stateInfo => stateInfo !== null)
.some(stateInfo => stateInfo?.expanded);
}
return expanded;
},
isNodeExpandable(nodeId: string): boolean {
if (nodeId === this.root) {
return true;
}
if (options.expandStateGetters?.length) {
const nodeState = this.getNodeState(nodeId);
return options
.expandStateGetters
.map(getExpandState => getExpandState(nodeId, nodeState))
.filter(stateInfo => stateInfo !== null)
.every(stateInfo => stateInfo?.expandable !== false);
}
return true;
},
getExpanded(): string[] {
return Array.from(this.state).filter(([key, state]) => state.expanded).map(([key]) => key);
@@ -416,6 +460,10 @@ export function useElementsTree(options: IOptions): IElementsTree {
}
},
async expand(node: NavNode, state: boolean) {
if (!this.isNodeExpandable(node.id)) {
return;
}
const treeNodeState = this.state.get(node.id);
try {
@@ -499,7 +547,7 @@ export function useElementsTree(options: IOptions): IElementsTree {
baseRoot: options.baseRoot,
renderers,
userData,
});
}, ['isLoading', 'isLoaded']);
function exitNodeFolder(nodeId: string) {
runInAction(() => {
@@ -13,7 +13,7 @@ import styled, { css } from 'reshadow';
import { useUserData } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { Translate } from '@cloudbeaver/core-localization';
import { NavNodeInfoResource, ROOT_NODE_PATH } from '@cloudbeaver/core-navigation-tree';
import { NavNodeInfoResource, NavTreeResource, ROOT_NODE_PATH } from '@cloudbeaver/core-navigation-tree';
import { usePermission, EPermission } from '@cloudbeaver/core-root';
import { CaptureView } from '@cloudbeaver/core-view';
@@ -26,6 +26,8 @@ import type { IElementsTreeSettings } from './ElementsTree/useElementsTree';
import { getNavigationTreeUserSettingsId } from './getNavigationTreeUserSettingsId';
import { navigationTreeDuplicateFilter } from './navigationTreeDuplicateIdFilter';
import { NavigationTreeService } from './NavigationTreeService';
import { navigationTreeProjectFilter } from './ProjectsRenderer/navigationTreeProjectFilter';
import { navigationTreeProjectsExpandStateGetter } from './ProjectsRenderer/navigationTreeProjectsExpandStateGetter';
import { navigationTreeProjectsRendererRenderer } from './ProjectsRenderer/navigationTreeProjectsRendererRenderer';
import { useNavigationTree } from './useNavigationTree';
@@ -74,6 +76,7 @@ const elementsTreeStyles = css`
export const NavigationTree = observer(function NavigationTree() {
const navTreeService = useService(NavigationTreeService);
const navNodeInfoResource = useService(NavNodeInfoResource);
const navTreeResource = useService(NavTreeResource);
const navNodeViewService = useService(NavNodeViewService);
const root = ROOT_NODE_PATH;
@@ -94,7 +97,15 @@ export const NavigationTree = observer(function NavigationTree() {
const duplicateFilter = useMemo(() => navigationTreeDuplicateFilter(navNodeViewService), [navNodeViewService]);
const projectsRendererRenderer = useMemo(
() => navigationTreeProjectsRendererRenderer(navNodeInfoResource),
[navNodeViewService]
[navNodeInfoResource]
);
const projectsExpandStateGetter = useMemo(
() => navigationTreeProjectsExpandStateGetter(navNodeInfoResource),
[navNodeInfoResource]
);
const projectFilter = useMemo(
() => navigationTreeProjectFilter(navNodeInfoResource, navTreeResource),
[navNodeInfoResource, navTreeResource]
);
if (!isEnabled) {
@@ -106,8 +117,9 @@ export const NavigationTree = observer(function NavigationTree() {
<ElementsTree
root={root}
localState={navTreeService.treeState}
filters={[duplicateFilter, connectionGroupFilter]}
filters={[duplicateFilter, connectionGroupFilter, projectFilter]}
renderers={[projectsRendererRenderer, navigationTreeConnectionGroupRenderer]}
expandStateGetters={[projectsExpandStateGetter]}
emptyPlaceholder={() => styled(navigationTreeStyles)(
<center>
<message>
@@ -10,12 +10,11 @@ import { observer } from 'mobx-react-lite';
import React, { forwardRef, useContext, useState } from 'react';
import styled, { css, use } from 'reshadow';
import { getComputed, TreeNodeContext, TreeNodeControl, TreeNodeExpand, TreeNodeName, TREE_NODE_STYLES, useObjectRef } from '@cloudbeaver/core-blocks';
import { getComputed, TreeNodeContext, TreeNodeControl, TreeNodeName, TREE_NODE_STYLES, useObjectRef } from '@cloudbeaver/core-blocks';
import { useService } from '@cloudbeaver/core-di';
import { EventContext, EventStopPropagationFlag } from '@cloudbeaver/core-events';
import { NavNodeInfoResource, type INodeActions } from '@cloudbeaver/core-navigation-tree';
import { ElementsTreeContext } from '../ElementsTree/ElementsTreeContext';
import type { NavTreeControlComponent, NavTreeControlProps } from '../ElementsTree/NavigationNodeComponent';
import { NavigationNodeEditor } from '../ElementsTree/NavigationTreeNode/NavigationNode/NavigationNodeEditor';
import { TreeNodeMenu } from '../ElementsTree/NavigationTreeNode/TreeNodeMenu/TreeNodeMenu';
@@ -62,7 +61,6 @@ export const NavigationNodeProjectControl: NavTreeControlComponent = observer<Na
dndPlaceholder,
}, ref) {
const treeNodeContext = useContext(TreeNodeContext);
const treeContext = useContext(ElementsTreeContext);
const navNodeInfoResource = useService(NavNodeInfoResource);
const outdated = getComputed(() => navNodeInfoResource.isOutdated(node.id) && !treeNodeContext.loading);
const selected = treeNodeContext.selected;
@@ -86,7 +84,6 @@ export const NavigationNodeProjectControl: NavTreeControlComponent = observer<Na
return styled(TREE_NODE_STYLES, styles)(
<TreeNodeControl ref={ref} onClick={onClickHandler} {...use({ outdated, editing, dragging: dndElement })}>
<TreeNodeExpand filterActive={treeContext?.tree.filtering} />
<TreeNodeName title={node.name}>
{editing ? (
<NavigationNodeEditor node={node} onClose={() => setEditing(false)} />
@@ -0,0 +1,33 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2022 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 { NavNode, NavNodeInfoResource, NavTreeResource, ROOT_NODE_PATH } from '@cloudbeaver/core-navigation-tree';
import { NAV_NODE_TYPE_PROJECT } from '@cloudbeaver/core-projects';
import { resourceKeyList } from '@cloudbeaver/core-sdk';
import type { IElementsTreeFilter } from '../ElementsTree/useElementsTree';
export function navigationTreeProjectFilter(
navNodeInfoResource: NavNodeInfoResource,
navTreeResource: NavTreeResource,
): IElementsTreeFilter {
return (filter: string, node: NavNode, children: string[]) => {
if (node.id !== ROOT_NODE_PATH) {
return children;
}
const nodes = navNodeInfoResource
.get(resourceKeyList(children))
.filter<NavNode>((node => node !== undefined) as (node: NavNode | undefined) => node is NavNode)
.filter(node => node.nodeType !== NAV_NODE_TYPE_PROJECT || navTreeResource.get(node.id)?.length)
.map(node => node.id);
return nodes;
};
}
@@ -0,0 +1,31 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2022 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 { NavNodeInfoResource } from '@cloudbeaver/core-navigation-tree';
import { NAV_NODE_TYPE_PROJECT } from '@cloudbeaver/core-projects';
import type { IElementsTreeNodeExpandInfoGetter } from '../ElementsTree/useElementsTree';
export function navigationTreeProjectsExpandStateGetter(
navNodeInfoResource: NavNodeInfoResource
): IElementsTreeNodeExpandInfoGetter {
return nodeId => {
const node = navNodeInfoResource.get(nodeId);
if (node?.nodeType !== NAV_NODE_TYPE_PROJECT) {
return null;
}
return {
expanded: true,
expandable: false,
};
};
}
@@ -7,19 +7,29 @@
*/
import { observer } from 'mobx-react-lite';
import styled from 'reshadow';
import styled, { css } from 'reshadow';
import { TreeNodeNestedMessage, TREE_NODE_STYLES } from '@cloudbeaver/core-blocks';
import { Translate } from '@cloudbeaver/core-localization';
import type { NavNodeInfoResource } from '@cloudbeaver/core-navigation-tree';
import { NAV_NODE_TYPE_PROJECT } from '@cloudbeaver/core-projects';
import { useChildren } from '../../NodesManager/useChildren';
import { useNode } from '../../NodesManager/useNode';
import type { NavigationNodeRendererComponent } from '../ElementsTree/NavigationNodeComponent';
import { NavigationNodeRenderer } from '../ElementsTree/NavigationTreeNode/NavigationNodeRenderer';
import type { IElementsTreeCustomRenderer } from '../ElementsTree/useElementsTree';
import { NavigationNodeProjectControl } from './NavigationNodeProjectControl';
const nestedStyles = css`
TreeNode {
margin-top: 8px;
}
NavigationNodeNested {
padding-left: 8px !important;
}
`;
export function navigationTreeProjectsRendererRenderer(
navNodeInfoResource: NavNodeInfoResource
): IElementsTreeCustomRenderer {
@@ -61,6 +71,7 @@ const ProjectRenderer: NavigationNodeRendererComponent = observer(function Manag
dragging={dragging}
className={className}
control={NavigationNodeProjectControl}
style={nestedStyles}
component={component}
/>
);
@@ -40,7 +40,7 @@ export class NavResourceNodeService {
async saveScript(resourceData: IResourceData, name: string, script: string): Promise<string> {
const resourcePath = createPath(resourceData.resourcePath, name);
// await this.resourceManagerResource.createResource(resourceData.key.projectId, resourcePath, false);
await this.resourceManagerResource.writeResource(resourceData.key.projectId, resourcePath, script);
await this.resourceManagerResource.writeResource(resourceData.key.projectId, resourcePath, script, false);
await this.navTreeResource.refreshTree(resourceData.nodeId);
return createPath(RESOURCES_NODE_PATH, resourceData.key.projectId, resourcePath);
@@ -57,7 +57,12 @@ export class NavResourceNodeService {
}
async write(resourceData: IResourceData, value: string) {
await this.resourceManagerResource.writeResource(resourceData.key.projectId, resourceData.resourcePath, value);
await this.resourceManagerResource.writeResource(
resourceData.key.projectId,
resourceData.resourcePath,
value,
true
);
}
getResourceData(nodeId: string): IResourceData | undefined {
@@ -59,11 +59,12 @@ export class ResourceManagerResource extends CachedMapResource<IResourceManagerP
await this.load({ projectId, folder:folder ? resourcePath : this.getFolder(resourcePath) });
}
async writeResource(projectId: string, resourcePath: string, data: string) {
async writeResource(projectId: string, resourcePath: string, data: string, forceOverwrite: boolean) {
await this.graphQLService.sdk.writeResourceContent({
projectId,
resourcePath,
data,
forceOverwrite,
});
}