Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion chat2db-community-client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
"test:data-source-identity": "tsx src/utils/dataSourceIdentity.test.ts && tsx src/store/tree/dataSourceIdentity.test.ts && tsx src/store/tree/dataSourceList.test.ts && tsx src/store/tree/treeNodeLoadState.test.ts && tsx src/blocks/NewTree/dataSourceIdentity.test.ts && tsx src/blocks/NewTree/dataSourceColorMenu.test.ts && tsx src/blocks/NewTree/treeMenuDanger.test.ts && tsx src/blocks/NewTree/treeMenuGrouping.test.ts && tsx src/blocks/NewTree/dataSourceIdentityColorRequest.test.ts && tsx src/components/ConnectionEdit/identityColorUpdate.test.ts && tsx src/utils/editorDataSourceLifecycle.test.ts && tsx src/components/SelectBoundInfo/cascadeRequestGuard.test.ts && tsx src/components/SelectBoundInfo/dataSourceSelection.test.ts && tsx src/blocks/AI/dataSourceContext.test.ts",
"test:database-object-sorting": "tsx src/blocks/NewTree/utils/sortTreeNodes.test.ts",
"test:data-source-authorization": "tsx src/utils/dataSourceAuthorization.test.ts",
"test:data-source-mutation-refresh": "tsx src/store/tree/dataSourceMutationRefresh.test.ts",
"test:data-source-mutation-refresh": "tsx src/store/tree/dataSourceMutationRefresh.test.ts && tsx src/components/ConnectionEdit/submissionGuard.test.ts && tsx src/blocks/NewTree/treePositionMutation.test.ts",
"test:dashboard-store": "tsx src/store/dashboard/slices/common/refreshCurrentDashboard.test.ts",
"test:dynamic-database-registry": "tsx src/utils/dynamicDatabaseRegistry.test.ts",
"test:execution-console": "tsx src/blocks/SearchResult/components/ExecutionConsole/executionConsolePreferences.test.ts",
Expand Down
4 changes: 2 additions & 2 deletions chat2db-community-client/scripts/i18n-source-hashes.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980",
"sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9",
"stream.ts": "2e2fca0b1ba1ecd5a1501b767be8857cf0b72d800d69a4acf9cd62a61d5ceaf5",
"workspace.ts": "ee72537a2ca1ed7bd71558a051f23cdfe202b298a000f2f928316bff0b85fd2d"
"workspace.ts": "f59aa207c100881a66f0cc9d3a61308c9c7cfb02d2fc6ecf1c9f79f8571f5ac4"
},
"ko-KR": {
"ai.ts": "e7a2fcbfb3ed647bd14f63aca4b6c35c8fb402c49a6a1f36bb04f76f4e8415af",
Expand All @@ -42,7 +42,7 @@
"spaceSetting.ts": "defdf3138e5e1782f0282760bcef4ce1b8550cc80b03052fea468d97165cb980",
"sqlEditor.ts": "5c41e5f24d5ba78909795bcc681cabb8460cca075233b665dc4940afe82ce0c9",
"stream.ts": "2e2fca0b1ba1ecd5a1501b767be8857cf0b72d800d69a4acf9cd62a61d5ceaf5",
"workspace.ts": "ee72537a2ca1ed7bd71558a051f23cdfe202b298a000f2f928316bff0b85fd2d"
"workspace.ts": "f59aa207c100881a66f0cc9d3a61308c9c7cfb02d2fc6ecf1c9f79f8571f5ac4"
}
}
}
93 changes: 27 additions & 66 deletions chat2db-community-client/src/blocks/NewTree/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { memo, useCallback, useEffect, useLayoutEffect, useRef, useMemo, forwardRef, useState } from 'react';
import { Tree, TreeProps, ConfigProvider, TreeDataNode, Spin } from 'antd';
import { Tree, TreeProps, ConfigProvider, Spin } from 'antd';
import { useStyles } from './style';
import { useStyles as renderTitleUseStyles } from './renderTitleStyle';

Expand All @@ -24,6 +24,9 @@ import connectionService from '@/service/connection';
import { useSize } from 'ahooks';
import { decorateDataSourceIdentityTree } from './dataSourceIdentity';
import { measureTreeScrollWidth, resolveNextTreeScrollWidth } from './treeScrollWidth';
import { TreePositionMutationCoordinator, TreePositionRefreshError } from './treePositionMutation';
import i18n from '@/i18n';
import { staticMessage } from '@chat2db/ui';

interface IProps extends TreeProps<TreeNodeData> {
className?: string;
Expand Down Expand Up @@ -69,12 +72,12 @@ const NewTree = (props: IProps, ref: React.ForwardedRef<NewTreeRef>) => {
const horizontalMeasureFrameRef = useRef<number>();
const horizontalMeasureResetRef = useRef(false);
const horizontalScrollHideTimerRef = useRef<number>();
const treePositionMutationRef = useRef(new TreePositionMutationCoordinator());
const [treeScrollWidth, setTreeScrollWidth] = useState<number>();
const [isHorizontalScrolling, setIsHorizontalScrolling] = useState(false);
const filteredTreeData = useTrimTreeData({ leafNodes, hiddenNoPermission, excludeNodes });
const {
editingTreeNode,
setTreeData,
selectedKeys,
setSelectedKeys,
setTreeRef,
Expand All @@ -83,9 +86,9 @@ const NewTree = (props: IProps, ref: React.ForwardedRef<NewTreeRef>) => {
setScrollTargetKey,
searchBarValue,
dataSourceList,
getTreeData,
} = useTreeStore((state) => ({
editingTreeNode: state.editingTreeNode,
setTreeData: state.setTreeData,
selectedKeys: state.selectedKeys,
setSelectedKeys: state.setSelectedKeys,
setTreeRef: state.setTreeRef,
Expand All @@ -94,6 +97,7 @@ const NewTree = (props: IProps, ref: React.ForwardedRef<NewTreeRef>) => {
setScrollTargetKey: state.setScrollTargetKey,
searchBarValue: state.searchBarValue,
dataSourceList: state.dataSourceList,
getTreeData: state.getTreeData,
}));
const identityTreeData = useMemo(
() => decorateDataSourceIdentityTree(filteredTreeData, dataSourceList),
Expand Down Expand Up @@ -202,75 +206,32 @@ const NewTree = (props: IProps, ref: React.ForwardedRef<NewTreeRef>) => {
};

const onDrop: TreeProps['onDrop'] = (info: any) => {
const dropKey = info.node.key;
const dragKey = info.dragNode.key;
const dropPos = info.node.pos.split('-');
const dropPosition = info.dropPosition - Number(dropPos[dropPos.length - 1]);
// the drop position relative to the drop node, inside 0, top -1, bottom 1

const loop = (
data: TreeDataNode[],
key: React.Key,
callback: (node: TreeDataNode, i: number, data: TreeDataNode[]) => void,
) => {
for (let i = 0; i < data.length; i++) {
if (data[i].key === key) {
return callback(data[i], i, data);
}
if (data[i].children) {
loop(data[i].children!, key, callback);
void treePositionMutationRef.current
.run(
dragKey,
() =>
connectionService.updatePosition({
dragNode: {
id: info.dragNode.id,
type: info.dragNode.treeNodeType === TreeNodeType.GROUP ? 'NAMESPACE' : 'DATA_SOURCE',
},
dropToNode: {
id: info.node.id,
type: info.node.treeNodeType === TreeNodeType.GROUP ? 'NAMESPACE' : 'DATA_SOURCE',
},
dropPosition: dropPosition as 0 | 1 | -1,
}),
() => getTreeData({ refresh: true, throwOnError: true }),
)
.catch((error) => {
if (error instanceof TreePositionRefreshError) {
staticMessage.error(i18n('workspace.tips.treePositionRefreshFailed'));
}
}
};

const data = [...(filteredTreeData || [])];

// Find dragObject
let dragObj: TreeDataNode;
loop(data, dragKey, (item, index, arr) => {
arr.splice(index, 1);
dragObj = item;
});

if (!info.dropToGap) {
// Drop on the content
loop(data, dropKey, (item) => {
item.children = item.children || [];
// where to insert. New item was inserted to the start of the array in this example, but can be anywhere
item.children.unshift(dragObj);
});
} else {
let ar: TreeDataNode[] = [];
let i: number;
loop(data, dropKey, (_item, index, arr) => {
ar = arr;
i = index;
});
if (dropPosition === -1) {
// Drop on the top of the drop node
ar.splice(i!, 0, dragObj!);
} else {
// Drop on the bottom of the drop node
ar.splice(i! + 1, 0, dragObj!);
}
}

connectionService
.updatePosition({
dragNode: {
id: info.dragNode.id,
type: info.dragNode.treeNodeType === TreeNodeType.GROUP ? 'NAMESPACE' : 'DATA_SOURCE',
// name: info.dragNode.originalTitle,
},
dropToNode: {
id: info.node.id,
type: info.node.treeNodeType === TreeNodeType.GROUP ? 'NAMESPACE' : 'DATA_SOURCE',
// name: info.node.originalTitle,
},
dropPosition: dropPosition as 0 | 1 | -1,
})
.then(() => {
setTreeData(data);
});
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import assert from 'node:assert/strict';
import { TreePositionMutationCoordinator, TreePositionRefreshError } from './treePositionMutation';

function deferred() {
let resolve!: () => void;
let reject!: (error: Error) => void;
const promise = new Promise<void>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
return { promise, resolve, reject };
}

async function testSuccessfulMoveRefreshesTree() {
const coordinator = new TreePositionMutationCoordinator();
let refreshCount = 0;

const committed = await coordinator.run(
'dataSource_1',
() => Promise.resolve(),
() => {
refreshCount += 1;
return Promise.resolve();
},
);

assert.equal(committed, true);
assert.equal(refreshCount, 1);
}

async function testFailedMoveDoesNotRefreshTree() {
const coordinator = new TreePositionMutationCoordinator();
let refreshCount = 0;

await assert.rejects(
coordinator.run(
'dataSource_1',
() => Promise.reject(new Error('move failed')),
() => {
refreshCount += 1;
return Promise.resolve();
},
),
/move failed/,
);

assert.equal(refreshCount, 0);
}

async function testConcurrentMoveForSameNodeIsRejected() {
const coordinator = new TreePositionMutationCoordinator();
const request = deferred();
let updateCount = 0;
const updatePosition = () => {
updateCount += 1;
return request.promise;
};

const first = coordinator.run('dataSource_1', updatePosition, () => Promise.resolve());
const second = coordinator.run('dataSource_1', updatePosition, () => Promise.resolve());

assert.equal(await second, false);
await Promise.resolve();
assert.equal(updateCount, 1);
request.resolve();
assert.equal(await first, true);
}

async function testMoveCanRetryAfterFailure() {
const coordinator = new TreePositionMutationCoordinator();
const request = deferred();
const first = coordinator.run('dataSource_1', () => request.promise, () => Promise.resolve());
request.reject(new Error('move failed'));
await assert.rejects(first, /move failed/);

assert.equal(
await coordinator.run('dataSource_1', () => Promise.resolve(), () => Promise.resolve()),
true,
);
}

async function testRefreshFailureIsDistinguishedAndCanRetry() {
const coordinator = new TreePositionMutationCoordinator();
let updateCount = 0;
const updatePosition = () => {
updateCount += 1;
return Promise.resolve();
};

await assert.rejects(
coordinator.run('dataSource_1', updatePosition, () => Promise.reject(new Error('refresh failed'))),
TreePositionRefreshError,
);

assert.equal(
await coordinator.run('dataSource_1', updatePosition, () => Promise.resolve()),
true,
);
assert.equal(updateCount, 2);
}

async function main() {
await testSuccessfulMoveRefreshesTree();
await testFailedMoveDoesNotRefreshTree();
await testConcurrentMoveForSameNodeIsRejected();
await testMoveCanRetryAfterFailure();
await testRefreshFailureIsDistinguishedAndCanRetry();
console.log('Tree position mutation tests passed');
}

main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { Key } from 'react';

export class TreePositionRefreshError extends Error {
constructor() {
super('The tree position was updated, but refreshing the tree failed');
this.name = 'TreePositionRefreshError';
}
}

export class TreePositionMutationCoordinator {
private readonly pendingKeys = new Set<Key>();

run(key: Key, updatePosition: () => Promise<void>, refreshTree: () => Promise<unknown>): Promise<boolean> {
if (this.pendingKeys.has(key)) {
return Promise.resolve(false);
}

this.pendingKeys.add(key);
return Promise.resolve()
.then(updatePosition)
.then(async () => {
try {
await refreshTree();
} catch {
throw new TreePositionRefreshError();
}
})
.then(() => true)
.finally(() => {
this.pendingKeys.delete(key);
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,14 @@ import { FolderOpenOutlined } from '@ant-design/icons';
import { Button, Checkbox, Collapse, Form, Input, Select, Table, Tooltip } from 'antd';
import classnames from 'classnames';
import { CircleHelp } from 'lucide-react';
import React, { ForwardedRef, Fragment, forwardRef, useEffect, useImperativeHandle, useMemo, useState } from 'react';
import React, { ForwardedRef, Fragment, forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
import Driver from './components/Driver';
import { dataSourceFormConfigs } from './config/dataSource';
import { InputType } from './config/enum';
import { IConnectionConfig, IFormItem, ILocalizedConnectionText, ISelect } from './config/types';
import { applyConnectionIdentityColorUpdate } from './identityColorUpdate';
import styles from './index.less';
import { SubmissionGuard } from './submissionGuard';
import { formatJdbcHostForUrl, normalizeJdbcHostFromUrl, shouldSyncJdbcUrlForField } from './utils/jdbcUrl';

// ----- store -----
Expand Down Expand Up @@ -398,6 +399,7 @@ const ConnectionEdit = forwardRef((props: IProps, ref: ForwardedRef<ICreateConne
testButton: false,
sshTestLoading: false,
});
const submissionGuardRef = useRef(new SubmissionGuard());
const { curOrg } = useOrgStore((s) => ({ curOrg: s.curOrg }));

const dataSourceFormConfigPropsMemo = useMemo<IConnectionConfig>(() => {
Expand Down Expand Up @@ -530,7 +532,11 @@ const ConnectionEdit = forwardRef((props: IProps, ref: ForwardedRef<ICreateConne
}));

if ((type === submitType.SAVE || type === submitType.UPDATE) && submit) {
Promise.resolve(submit(p, type))
const request = submissionGuardRef.current.run(() => submit(p, type));
if (!request) {
return;
}
request
.catch((error: any) => {
staticMessage.error(getConnectionErrorMessage(error));
})
Expand All @@ -543,7 +549,13 @@ const ConnectionEdit = forwardRef((props: IProps, ref: ForwardedRef<ICreateConne
return;
}

const api: any = connectionService[type](p);
const api: any =
type === submitType.SAVE || type === submitType.UPDATE
? submissionGuardRef.current.run(() => connectionService[type](p))
: connectionService[type](p);
if (!api) {
return;
}
if (type === submitType.TEST) {
api
.then((res: any) => {
Expand Down
Loading
Loading