diff --git a/webapp/packages/core-blocks/src/Loader/Loader.tsx b/webapp/packages/core-blocks/src/Loader/Loader.tsx index 3f870fb2d7..f73593f2c6 100644 --- a/webapp/packages/core-blocks/src/Loader/Loader.tsx +++ b/webapp/packages/core-blocks/src/Loader/Loader.tsx @@ -72,7 +72,7 @@ export const Loader = observer(function Loader({ fullSize, className, loader, - loading = true, + loading, inlineException, state, style, @@ -87,30 +87,57 @@ export const Loader = observer(function Loader({ let exception: Error | null = null; let reload: (() => void) | undefined; + const loadingUndefined = loading === undefined; + + if (loadingUndefined) { + loading = true; + } + let loaded = !loading; + if (state) { state = Array.isArray(state) ? state : [state]; - for (const element of state) { + for (let i = 0; i < state.length; i++) { + const element = state[i]; + if ( 'isLoaded' in element && 'isLoading' in element ) { - loaded = element.isLoaded(); - loading = element.isLoading(); + + if (i === 0 && loadingUndefined) { + loaded = element.isLoaded(); + loading = element.isLoading(); + } else { + loaded &&= element.isLoaded(); + loading ||= element.isLoading(); + } if (loading) { if (element.cancel) { - onCancel = element.cancel; + onCancel = () => { + onCancel?.(); + element.cancel?.(); + }; } if (element.isCancelled) { - cancelDisabled = element.isCancelled(); + if (i == 0 && cancelDisabled === undefined) { + cancelDisabled = element.isCancelled(); + } else { + cancelDisabled ||= element.isCancelled(); + } } } } else { - loading = element.loading; - loaded = !loading; + if (i === 0 && loadingUndefined) { + loading = element.loading; + loaded = !loading; + } else { + loading ||= element.loading; + loaded &&= !loading; + } } if ('exception' in element && element.exception) { diff --git a/webapp/packages/core-blocks/src/useDataResource.ts b/webapp/packages/core-blocks/src/useDataResource.ts index 1e4a8052f8..3a23e64efc 100644 --- a/webapp/packages/core-blocks/src/useDataResource.ts +++ b/webapp/packages/core-blocks/src/useDataResource.ts @@ -20,10 +20,13 @@ interface KeyWithIncludes { includes: TIncludes; } -interface IActions { +interface IActions< + TResource, + TKeyArg extends CachedDataResourceParam, +> { active?: boolean; isActive?: (resource: TResource) => Promise | boolean; - onLoad?: (resource: TResource) => Promise | any; + onLoad?: (resource: TResource, key: TKeyArg | null) => Promise | any; onData?: ( data: CachedResourceData, resource: TResource, @@ -55,7 +58,7 @@ export function useDataResource< keyObj: TResource extends any ? TKeyArg | null | KeyWithIncludes : never, - actions?: IActions + actions?: IActions ): IMapResourceResult; export function useDataResource< @@ -71,7 +74,7 @@ export function useDataResource< keyObj: TResource extends any ? TKeyArg | null | KeyWithIncludes : never, - actions?: IActions + actions?: IActions ): IMapResourceResult { // eslint-disable-next-line react-hooks/rules-of-hooks const resource = ctor instanceof CachedDataResource ? ctor : useService(ctor); @@ -113,7 +116,7 @@ export function useDataResource< this.firstRender = false; this.loading = true; - await actions?.onLoad?.(resource); + await actions?.onLoad?.(resource, key); if (key === null) { return; diff --git a/webapp/packages/core-blocks/src/useMapResource.ts b/webapp/packages/core-blocks/src/useMapResource.ts index cf7032ba21..2a3c55b40d 100644 --- a/webapp/packages/core-blocks/src/useMapResource.ts +++ b/webapp/packages/core-blocks/src/useMapResource.ts @@ -24,7 +24,7 @@ interface IActions< > { active?: boolean; isActive?: (resource: TResource) => Promise | boolean; - onLoad?: (resource: TResource) => Promise | boolean | void; + onLoad?: (resource: TResource, key: TKeyArg | null) => Promise | boolean | void; onData?: ( data: CachedMapResourceLoader< TKeyArg, @@ -150,7 +150,7 @@ export function useMapResource< return true; } - if (this.loadedKey === null) { + if (this.loadedKey === null || this.key === null) { return false; } @@ -176,11 +176,15 @@ export function useMapResource< }); } - if (!untracked(() => resource.includes(key, keyRef.key))) { - untracked(() => { + untracked(() => { + if ( + key === null + || keyRef.key === null + || !resource.includes(key, keyRef.key) + ) { keyRef.key = key; - }); - } + } + }); const refObj = useObservableRef(() => ({ loading: false, @@ -227,7 +231,7 @@ export function useMapResource< keyRef.loadedKey = key; this.loading = true; - const prevent = await actions?.onLoad?.(resource); + const prevent = await actions?.onLoad?.(resource, key); if (key === null || prevent === true) { setException(null); @@ -269,6 +273,7 @@ export function useMapResource< } }, }), { + preloaded: computed, exception: observable.ref, loading: observable.ref, }, { diff --git a/webapp/packages/core-cli/tests/utils/getService.ts b/webapp/packages/core-cli/tests/utils/getService.ts new file mode 100644 index 0000000000..e5a192a3b6 --- /dev/null +++ b/webapp/packages/core-cli/tests/utils/getService.ts @@ -0,0 +1,15 @@ +/* + * 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 { IServiceConstructor } from '@cloudbeaver/core-di'; + +import type { IApplication } from './createApp'; + +export function getService(app: IApplication, ctor: IServiceConstructor): T { + return app.injector.getServiceByClass(ctor); +} \ No newline at end of file diff --git a/webapp/packages/core-navigation-tree/src/NodesManager/DBObjectResource.ts b/webapp/packages/core-navigation-tree/src/NodesManager/DBObjectResource.ts index b7fdee9e3d..1a1a5af15a 100644 --- a/webapp/packages/core-navigation-tree/src/NodesManager/DBObjectResource.ts +++ b/webapp/packages/core-navigation-tree/src/NodesManager/DBObjectResource.ts @@ -8,13 +8,19 @@ import { injectable } from '@cloudbeaver/core-di'; import { - GraphQLService, CachedMapResource, ResourceKey, isResourceKeyList, resourceKeyList, ResourceKeyUtils + GraphQLService, CachedMapResource, ResourceKey, isResourceKeyList, resourceKeyList, ResourceKeyUtils, ResourceKeyList } from '@cloudbeaver/core-sdk'; import type { DBObject } from './EntityTypes'; import { NavNodeInfoResource } from './NavNodeInfoResource'; import { NavTreeResource } from './NavTreeResource'; +const dbObjectParentKeySymbol = Symbol('@db-object/parent') as unknown as string; +export const DBObjectParentKey = (parentId: string) => resourceKeyList( + [dbObjectParentKeySymbol], + parentId +); + @injectable() export class DBObjectResource extends CachedMapResource { constructor( @@ -24,24 +30,25 @@ export class DBObjectResource extends CachedMapResource { ) { super(); + this.addAlias( + isDBObjectParentKey, + param => resourceKeyList(navTreeResource.get(param.mark) || []), + true + ); // this.preloadResource(this.navNodeInfoResource); this.navNodeInfoResource.outdateResource(this); this.navNodeInfoResource.deleteInResource(this); this.navNodeInfoResource.onDataOutdated.addHandler(this.outdateChildren.bind(this)); } - async loadChildren(parentId: string, key: ResourceKey): Promise> { - await this.performUpdate( - key, - [], - () => this.loadFromChildren(parentId, 0, this.navTreeResource.childrenLimit + 1), - () => this.isLoaded(key) && !this.isOutdated(key) - ); + protected async loader(originalKey: ResourceKey): Promise> { + const key = this.transformParam(originalKey); - return this.data; - } + if (isDBObjectParentKey(originalKey)) { + await this.loadFromChildren(originalKey.mark, 0, this.navTreeResource.childrenLimit + 1); + return this.data; + } - protected async loader(key: ResourceKey): Promise> { if (isResourceKeyList(key)) { const values: DBObject[] = []; for (const navNodeId of key.list) { @@ -87,3 +94,9 @@ export class DBObjectResource extends CachedMapResource { // } } } + +function isDBObjectParentKey( + param: ResourceKey +): param is ResourceKeyList { + return isResourceKeyList(param) && param.list.includes(dbObjectParentKeySymbol); +} diff --git a/webapp/packages/core-sdk/src/Resource/CachedMapResource.ts b/webapp/packages/core-sdk/src/Resource/CachedMapResource.ts index f40e64510d..4a38af4b34 100644 --- a/webapp/packages/core-sdk/src/Resource/CachedMapResource.ts +++ b/webapp/packages/core-sdk/src/Resource/CachedMapResource.ts @@ -374,6 +374,10 @@ export abstract class CachedMapResource< return this.isAliasEqual(param, key); } + if (ResourceKeyUtils.isEmpty(param) || ResourceKeyUtils.isEmpty(key)) { + return ResourceKeyUtils.isEmpty(param) && ResourceKeyUtils.isEmpty(key); + } + param = ResourceKeyUtils.mapKey(param, this.getKeyRef.bind(this)); key = ResourceKeyUtils.mapKey(key, this.getKeyRef.bind(this)); diff --git a/webapp/packages/core-sdk/src/Resource/ResourceKeyList.ts b/webapp/packages/core-sdk/src/Resource/ResourceKeyList.ts index 757aed34cd..44f8ca306f 100644 --- a/webapp/packages/core-sdk/src/Resource/ResourceKeyList.ts +++ b/webapp/packages/core-sdk/src/Resource/ResourceKeyList.ts @@ -41,6 +41,7 @@ interface MapFnc { } export interface ResourceKeyUtils { + isEmpty: (key: ResourceKey) => boolean; hasMark: (key: ResourceKey, mark: any) => boolean; count: (key: ResourceKey) => number; first: (key: ResourceKey) => TKey; @@ -65,6 +66,16 @@ export interface ResourceKeyUtils { // eslint-disable-next-line @typescript-eslint/no-redeclare export const ResourceKeyUtils: ResourceKeyUtils = { + isEmpty( + key: ResourceKey + ): boolean { + if (isResourceKeyList(key)) { + return key.list.length === 0; + } else { + return false; + } + }, + hasMark( key: ResourceKey, mark: any diff --git a/webapp/packages/plugin-navigation-tree/package.json b/webapp/packages/plugin-navigation-tree/package.json index f7c58bce6b..14d1ce3ba6 100644 --- a/webapp/packages/plugin-navigation-tree/package.json +++ b/webapp/packages/plugin-navigation-tree/package.json @@ -12,7 +12,8 @@ "build": "webpack --config ../../configs/webpack.plugin.config.js", "postbuild": "tsc", "lint": "eslint ./src/ --ext .ts,.tsx", - "lint-fix": "eslint ./src/ --ext .ts,.tsx --fix" + "lint-fix": "eslint ./src/ --ext .ts,.tsx --fix", + "test": "core-cli-test" }, "peerDependencies": { "@cloudbeaver/core-administration": "~0.1.0", diff --git a/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.test.ts b/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.test.ts new file mode 100644 index 0000000000..05d335eef5 --- /dev/null +++ b/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.test.ts @@ -0,0 +1,64 @@ +/* + * 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 '@testing-library/jest-dom'; + +import { createApp } from '@cloudbeaver/core-cli/tests/utils/createApp'; +import { getService } from '@cloudbeaver/core-cli/tests/utils/getService'; +import datasourceContextSwitchPlugin from '@cloudbeaver/plugin-datasource-context-switch'; +import navigationTabsPlugin from '@cloudbeaver/plugin-navigation-tabs'; +import topAppBarPlugin from '@cloudbeaver/plugin-top-app-bar'; + +import { navigationTreePlugin } from '../../manifest'; +import { NavNodeViewService } from './NavNodeViewService'; + +const app = createApp( + topAppBarPlugin, + navigationTabsPlugin, + datasourceContextSwitchPlugin, + navigationTreePlugin +); + +beforeAll(() => app.init()); + +describe('filterDuplicates', () => { + test('Filter duplicates', async () => { + const navNodeViewService = getService(app, NavNodeViewService); + + const { nodes, duplicates } = navNodeViewService.filterDuplicates(['0', '1', '2', '3', '0', '3']); + + expect(nodes).toEqual(['1', '2']); + expect(duplicates).toEqual(['0', '3']); + }); + + test('No duplicates', async () => { + const navNodeViewService = getService(app, NavNodeViewService); + const { nodes, duplicates } = navNodeViewService.filterDuplicates(['1', '2', '0']); + + expect(nodes).toEqual(['1', '2', '0']); + expect(duplicates).toEqual([]); + }); + + test('Empty list', async () => { + const navNodeViewService = getService(app, NavNodeViewService); + + const { nodes, duplicates } = navNodeViewService.filterDuplicates([]); + + expect(nodes).toEqual([]); + expect(duplicates).toEqual([]); + }); + + test('Only duplicates', async () => { + const navNodeViewService = getService(app, NavNodeViewService); + + const { nodes, duplicates } = navNodeViewService.filterDuplicates(['0', '1', '0', '1', '2', '3', '2', '3']); + + expect(nodes).toEqual([]); + expect(duplicates).toEqual(['0', '1', '2', '3']); + }); +}); \ No newline at end of file diff --git a/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.ts b/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.ts index 74f73f2c71..a153a2b6d3 100644 --- a/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.ts +++ b/webapp/packages/plugin-navigation-tree/src/NodesManager/NavNodeView/NavNodeViewService.ts @@ -113,8 +113,10 @@ export class NavNodeViewService { const duplicates: string[] = []; for (const child of nodes) { - if (nextChildren.includes(child)) { - if (!duplicates.includes(child)) { + const isDuplicate = duplicates.includes(child); + + if (nextChildren.includes(child) || isDuplicate) { + if (!isDuplicate) { duplicates.push(child); nextChildren.splice(nextChildren.indexOf(child), 1); } diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/NavNodeView/VirtualFolder/VirtualFolderPanel.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/NavNodeView/VirtualFolder/VirtualFolderPanel.tsx index ed90e4e978..e1c671f229 100644 --- a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/NavNodeView/VirtualFolder/VirtualFolderPanel.tsx +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/NavNodeView/VirtualFolder/VirtualFolderPanel.tsx @@ -6,14 +6,15 @@ * you may not use this file except in compliance with the License. */ +import { untracked } from 'mobx'; import { observer } from 'mobx-react-lite'; +import { useEffect } from 'react'; import styled, { css } from 'reshadow'; import { Loader, TextPlaceholder, useMapResource } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { useTranslate } from '@cloudbeaver/core-localization'; -import { NavNodeInfoResource, NavTreeResource, DBObjectResource, type DBObject } from '@cloudbeaver/core-navigation-tree'; -import { resourceKeyList } from '@cloudbeaver/core-sdk'; +import { NavNodeInfoResource, NavTreeResource, DBObjectResource, type DBObject, DBObjectParentKey } from '@cloudbeaver/core-navigation-tree'; import { type NavNodeTransformViewComponent, NavNodeViewService } from '@cloudbeaver/plugin-navigation-tree'; import { TableLoader } from '../../ObjectPropertyTable/Table/TableLoader'; @@ -42,24 +43,26 @@ export const VirtualFolderPanel: NavNodeTransformViewComponent = observer(functi onLoad: async resource => !(await resource.preloadNodeParents(parents, nodeId)), }); - const { nodes, truncated } = navNodeViewService.limit(tree.data || []); - const key = resourceKeyList(nodes); - const dbObject = useMapResource(VirtualFolderPanel, DBObjectResource, key, { - async onLoad(resource: DBObjectResource) { - const preloaded = await tree.resource.preloadNodeParents(parents, nodeId); + const limited = navNodeViewService.limit(tree.data || []); - if (!preloaded) { - return true; - } + const { nodes, duplicates } = navNodeViewService.filterDuplicates(limited.nodes); - await resource.loadChildren(nodeId, key); - return true; - }, + const dbObject = useMapResource(VirtualFolderPanel, DBObjectResource, DBObjectParentKey(nodeId), { preload: [tree], }); + useEffect(() => { + untracked(() => { + navNodeViewService.logDuplicates(nodeId, duplicates); + }); + }); + const objects = dbObject.data - .filter(object => object && navNodeInfoResource.get(object.id)?.nodeType === nodeType) as DBObject[]; + .filter(object => ( + object + && nodes.includes(object.id) + && navNodeInfoResource.get(object.id)?.nodeType === nodeType + )) as DBObject[]; return styled(style)( {() => styled(style)( @@ -68,7 +71,7 @@ export const VirtualFolderPanel: NavNodeTransformViewComponent = observer(functi {translate('plugin_object_viewer_table_no_items')} ) : ( - 0} /> + 0} /> )} diff --git a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx index 687c7eeb6f..d22f551e18 100644 --- a/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx +++ b/webapp/packages/plugin-object-viewer/src/ObjectPropertiesPage/ObjectPropertyTable/ObjectPropertyTable.tsx @@ -14,8 +14,7 @@ import styled, { css } from 'reshadow'; import { Loader, TextPlaceholder, useMapResource } from '@cloudbeaver/core-blocks'; import { useService } from '@cloudbeaver/core-di'; import { useTranslate } from '@cloudbeaver/core-localization'; -import { NavTreeResource, DBObjectResource, type DBObject } from '@cloudbeaver/core-navigation-tree'; -import { resourceKeyList } from '@cloudbeaver/core-sdk'; +import { NavTreeResource, DBObjectResource, type DBObject, DBObjectParentKey } from '@cloudbeaver/core-navigation-tree'; import { NavNodeViewService } from '@cloudbeaver/plugin-navigation-tree'; import { TableLoader } from './Table/TableLoader'; @@ -54,18 +53,7 @@ export const ObjectPropertyTable = observer(function O const { nodes, duplicates } = navNodeViewService.filterDuplicates(limited.nodes); - const key = resourceKeyList(nodes); - const dbObject = useMapResource(ObjectPropertyTable, DBObjectResource, key, { - async onLoad(resource: DBObjectResource) { - const preloaded = await tree.resource.preloadNodeParents(parents, objectId); - - if (!preloaded) { - return true; - } - - await resource.loadChildren(objectId, key); - return true; - }, + const dbObject = useMapResource(ObjectPropertyTable, DBObjectResource, DBObjectParentKey(objectId), { preload: [tree], }); @@ -75,20 +63,21 @@ export const ObjectPropertyTable = observer(function O }); }); - const objects = dbObject.data as DBObject[]; + const objects = dbObject.data.filter(object => nodes.includes(object?.id ?? '')) as DBObject[]; return styled(styles)( - {() => styled(styles)( - <> - {nodes.length === 0 ? ( - {translate('plugin_object_viewer_table_no_items')} - ) : ( -
- 0} /> -
- )} - - )} + + {() => styled(styles)( + <> + {nodes.length === 0 ? ( + {translate('plugin_object_viewer_table_no_items')} + ) : ( +
+ 0} /> +
+ )} + + )}
); });