import * as ko from "knockout"; import * as React from "react"; import { ReactAdapter } from "../../Bindings/ReactBindingHandler"; import { AccordionComponent, AccordionItemComponent } from "../Controls/Accordion/AccordionComponent"; import { TreeComponent, TreeNode, TreeNodeMenuItem } from "../Controls/TreeComponent/TreeComponent"; import * as ViewModels from "../../Contracts/ViewModels"; import { NotebookContentItem, NotebookContentItemType } from "../Notebook/NotebookContentItem"; import { ResourceTreeContextMenuButtonFactory } from "../ContextMenuButtonFactory"; import * as MostRecentActivity from "../MostRecentActivity/MostRecentActivity"; import { CosmosClient } from "../../Common/CosmosClient"; import CosmosDBIcon from "../../../images/Azure-Cosmos-DB.svg"; import CollectionIcon from "../../../images/tree-collection.svg"; import DeleteIcon from "../../../images/delete.svg"; import NotebookIcon from "../../../images/notebook/Notebook-resource.svg"; import RefreshIcon from "../../../images/refresh-cosmos.svg"; import NewNotebookIcon from "../../../images/notebook/Notebook-new.svg"; import FileIcon from "../../../images/notebook/file-cosmos.svg"; import { ArrayHashMap } from "../../Common/ArrayHashMap"; import { NotebookUtil } from "../Notebook/NotebookUtil"; import _ from "underscore"; import { StringUtils } from "../../Utils/StringUtils"; import { IPinnedRepo } from "../../Juno/JunoClient"; import TelemetryProcessor from "../../Shared/Telemetry/TelemetryProcessor"; import { Action, ActionModifiers } from "../../Shared/Telemetry/TelemetryConstants"; import { Areas } from "../../Common/Constants"; import * as GitHubUtils from "../../Utils/GitHubUtils"; import { SamplesRepo, SamplesBranch } from "../Notebook/NotebookSamples"; import GalleryIcon from "../../../images/GalleryIcon.svg"; import { Callout, Text, Link, DirectionalHint, Stack, ICalloutProps, ILinkProps } from "office-ui-fabric-react"; import { LocalStorageUtility, StorageKey } from "../../Shared/StorageUtility"; export class ResourceTreeAdapter implements ReactAdapter { private static readonly DataTitle = "DATA"; private static readonly NotebooksTitle = "NOTEBOOKS"; private static readonly PseudoDirPath = "PsuedoDir"; public parameters: ko.Observable; public galleryContentRoot: NotebookContentItem; public sampleNotebooksContentRoot: NotebookContentItem; public myNotebooksContentRoot: NotebookContentItem; public gitHubNotebooksContentRoot: NotebookContentItem; private koSubsDatabaseIdMap: ArrayHashMap; // database id -> ko subs private koSubsCollectionIdMap: ArrayHashMap; // collection id -> ko subs private databaseCollectionIdMap: ArrayHashMap; // database id -> collection ids public constructor(private container: ViewModels.Explorer) { this.parameters = ko.observable(Date.now()); this.container.selectedNode.subscribe((newValue: any) => this.triggerRender()); this.container.activeTab.subscribe((newValue: ViewModels.Tab) => this.triggerRender()); this.container.isNotebookEnabled.subscribe(newValue => this.triggerRender()); this.koSubsDatabaseIdMap = new ArrayHashMap(); this.koSubsCollectionIdMap = new ArrayHashMap(); this.databaseCollectionIdMap = new ArrayHashMap(); this.container.nonSystemDatabases.subscribe((databases: ViewModels.Database[]) => { // Clean up old databases this.cleanupDatabasesKoSubs(databases.map((database: ViewModels.Database) => database.id())); databases.forEach((database: ViewModels.Database) => this.watchDatabase(database)); this.triggerRender(); }); this.container.nonSystemDatabases().forEach((database: ViewModels.Database) => this.watchDatabase(database)); this.triggerRender(); } public renderComponent(): JSX.Element { const dataRootNode = this.buildDataTree(); const notebooksRootNode = this.buildNotebooksTrees(); if (this.container.isNotebookEnabled()) { return ( <> {this.galleryContentRoot && this.buildGalleryCallout()} ); } else { return ; } } public async initialize(): Promise { const refreshTasks: Promise[] = []; if (this.container.isGalleryEnabled()) { this.galleryContentRoot = { name: "Gallery", path: "Gallery", type: NotebookContentItemType.File }; this.sampleNotebooksContentRoot = undefined; } else { this.galleryContentRoot = undefined; this.sampleNotebooksContentRoot = { name: "Sample Notebooks (View Only)", path: GitHubUtils.toContentUri(SamplesRepo.owner, SamplesRepo.name, SamplesBranch.name, ""), type: NotebookContentItemType.Directory }; refreshTasks.push( this.container.refreshContentItem(this.sampleNotebooksContentRoot).then(() => this.triggerRender()) ); } this.myNotebooksContentRoot = { name: "My Notebooks", path: this.container.getNotebookBasePath(), type: NotebookContentItemType.Directory }; // Only if notebook server is available we can refresh if (this.container.notebookServerInfo().notebookServerEndpoint) { refreshTasks.push( this.container.refreshContentItem(this.myNotebooksContentRoot).then(() => this.triggerRender()) ); } if (this.container.notebookManager?.gitHubOAuthService.isLoggedIn()) { this.gitHubNotebooksContentRoot = { name: "GitHub repos", path: ResourceTreeAdapter.PseudoDirPath, type: NotebookContentItemType.Directory }; } else { this.gitHubNotebooksContentRoot = undefined; } return Promise.all(refreshTasks); } public initializeGitHubRepos(pinnedRepos: IPinnedRepo[]): void { if (this.gitHubNotebooksContentRoot) { this.gitHubNotebooksContentRoot.children = []; pinnedRepos?.forEach(pinnedRepo => { const repoFullName = GitHubUtils.toRepoFullName(pinnedRepo.owner, pinnedRepo.name); const repoTreeItem: NotebookContentItem = { name: repoFullName, path: ResourceTreeAdapter.PseudoDirPath, type: NotebookContentItemType.Directory, children: [] }; pinnedRepo.branches.forEach(branch => { repoTreeItem.children.push({ name: branch.name, path: GitHubUtils.toContentUri(pinnedRepo.owner, pinnedRepo.name, branch.name, ""), type: NotebookContentItemType.Directory }); }); this.gitHubNotebooksContentRoot.children.push(repoTreeItem); }); this.triggerRender(); } } private buildDataTree(): TreeNode { const databaseTreeNodes: TreeNode[] = this.container.nonSystemDatabases().map((database: ViewModels.Database) => { const databaseNode: TreeNode = { label: database.id(), iconSrc: CosmosDBIcon, isExpanded: false, className: "databaseHeader", children: [], isSelected: () => this.isDataNodeSelected(database.rid, "Database", undefined), contextMenu: ResourceTreeContextMenuButtonFactory.createDatabaseContextMenu(this.container, database), onClick: isExpanded => { // Rewritten version of expandCollapseDatabase(): if (!isExpanded) { database.expandDatabase(); database.loadCollections(); } else { database.collapseDatabase(); } database.selectDatabase(); this.container.onUpdateTabsButtons([]); database.refreshTabSelectedState(); }, onContextMenuOpen: () => this.container.selectedNode(database) }; if (database.isDatabaseShared()) { databaseNode.children.push({ label: "Scale", isSelected: () => this.isDataNodeSelected(database.rid, "Database", ViewModels.CollectionTabKind.DatabaseSettings), onClick: database.onSettingsClick.bind(database) }); } // Find collections database .collections() .forEach((collection: ViewModels.Collection) => databaseNode.children.push(this.buildCollectionNode(database, collection)) ); return databaseNode; }); return { label: undefined, isExpanded: true, children: databaseTreeNodes }; } /** * This is a rewrite of Collection.ts : showScriptsMenu, showStoredProcedures, showTriggers, showUserDefinedFunctions * @param container */ private static showScriptNodes(container: ViewModels.Explorer): boolean { return container.isPreferredApiDocumentDB() || container.isPreferredApiGraph(); } private buildCollectionNode(database: ViewModels.Database, collection: ViewModels.Collection): TreeNode { const children: TreeNode[] = []; children.push({ label: collection.getLabel(), onClick: () => { collection.openTab(); // push to most recent this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, { type: MostRecentActivity.Type.OpenCollection, title: collection.id(), description: "Data", data: collection.rid }); }, isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Documents), contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(this.container, collection) }); children.push({ label: database.isDatabaseShared() ? "Settings" : "Scale & Settings", onClick: collection.onSettingsClick.bind(collection), isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Settings) }); if (ResourceTreeAdapter.showScriptNodes(this.container)) { children.push(this.buildStoredProcedureNode(collection)); children.push(this.buildUserDefinedFunctionsNode(collection)); children.push(this.buildTriggerNode(collection)); } // This is a rewrite of showConflicts const showConflicts = this.container.databaseAccount && this.container.databaseAccount() && this.container.databaseAccount().properties && this.container.databaseAccount().properties.enableMultipleWriteLocations && collection.rawDataModel && !!collection.rawDataModel.conflictResolutionPolicy; if (showConflicts) { children.push({ label: "Conflicts", onClick: collection.onConflictsClick.bind(collection), isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Conflicts) }); } return { label: collection.id(), iconSrc: CollectionIcon, isExpanded: false, children: children, className: "collectionHeader", contextMenu: ResourceTreeContextMenuButtonFactory.createCollectionContextMenuButton(this.container, collection), onClick: () => { // Rewritten version of expandCollapseCollection this.container.selectedNode(collection); this.container.onUpdateTabsButtons([]); collection.refreshActiveTab(); }, onExpanded: () => { if (ResourceTreeAdapter.showScriptNodes(this.container)) { collection.loadStoredProcedures(); collection.loadUserDefinedFunctions(); collection.loadTriggers(); } }, isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", undefined), onContextMenuOpen: () => this.container.selectedNode(collection) }; } private buildStoredProcedureNode(collection: ViewModels.Collection): TreeNode { return { label: "Stored Procedures", children: collection.storedProcedures().map((sp: ViewModels.StoredProcedure) => ({ label: sp.id(), onClick: sp.open.bind(sp), isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.StoredProcedures), contextMenu: ResourceTreeContextMenuButtonFactory.createStoreProcedureContextMenuItems(this.container, sp) })), onClick: () => { collection.selectedSubnodeKind(ViewModels.CollectionTabKind.StoredProcedures); collection.refreshActiveTab(); } }; } private buildUserDefinedFunctionsNode(collection: ViewModels.Collection): TreeNode { return { label: "User Defined Functions", children: collection.userDefinedFunctions().map((udf: ViewModels.UserDefinedFunction) => ({ label: udf.id(), onClick: udf.open.bind(udf), isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.UserDefinedFunctions), contextMenu: ResourceTreeContextMenuButtonFactory.createUserDefinedFunctionContextMenuItems(this.container, udf) })), onClick: () => { collection.selectedSubnodeKind(ViewModels.CollectionTabKind.UserDefinedFunctions); collection.refreshActiveTab(); } }; } private buildTriggerNode(collection: ViewModels.Collection): TreeNode { return { label: "Triggers", children: collection.triggers().map((trigger: ViewModels.Trigger) => ({ label: trigger.id(), onClick: trigger.open.bind(trigger), isSelected: () => this.isDataNodeSelected(collection.rid, "Collection", ViewModels.CollectionTabKind.Triggers), contextMenu: ResourceTreeContextMenuButtonFactory.createTriggerContextMenuItems(this.container, trigger) })), onClick: () => { collection.selectedSubnodeKind(ViewModels.CollectionTabKind.Triggers); collection.refreshActiveTab(); } }; } private buildNotebooksTrees(): TreeNode { let notebooksTree: TreeNode = { label: undefined, isExpanded: true, children: [] }; if (this.galleryContentRoot) { notebooksTree.children.push(this.buildGalleryNotebooksTree()); } if (this.sampleNotebooksContentRoot) { notebooksTree.children.push(this.buildSampleNotebooksTree()); } if (this.myNotebooksContentRoot) { notebooksTree.children.push(this.buildMyNotebooksTree()); } if (this.gitHubNotebooksContentRoot) { // collapse all other notebook nodes notebooksTree.children.forEach(node => (node.isExpanded = false)); notebooksTree.children.push(this.buildGitHubNotebooksTree()); } return notebooksTree; } private buildGalleryCallout(): JSX.Element { if ( LocalStorageUtility.hasItem(StorageKey.GalleryCalloutDismissed) && LocalStorageUtility.getEntryBoolean(StorageKey.GalleryCalloutDismissed) ) { return undefined; } const calloutProps: ICalloutProps = { calloutMaxWidth: 350, ariaLabel: "New gallery", role: "alertdialog", gapSpace: 0, target: ".galleryHeader", directionalHint: DirectionalHint.leftTopEdge, onDismiss: () => { LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); this.triggerRender(); }, setInitialFocus: true }; const openGalleryProps: ILinkProps = { onClick: () => { LocalStorageUtility.setEntryBoolean(StorageKey.GalleryCalloutDismissed, true); this.container.openGallery(); this.triggerRender(); } }; return ( New gallery Sample notebooks are now combined in gallery. View and try out samples provided by Microsoft and other contributors. Open gallery ); } private buildGalleryNotebooksTree(): TreeNode { return { label: "Gallery", iconSrc: GalleryIcon, className: "notebookHeader galleryHeader", onClick: () => this.container.openGallery(), isSelected: () => { const activeTab = this.container.findActiveTab(); return activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.Gallery; } }; } private buildSampleNotebooksTree(): TreeNode { const sampleNotebooksTree: TreeNode = this.buildNotebookDirectoryNode( this.sampleNotebooksContentRoot, (item: NotebookContentItem) => { const databaseAccountName: string = this.container.databaseAccount() && this.container.databaseAccount().name; const defaultExperience: string = this.container.defaultExperience && this.container.defaultExperience(); const dataExplorerArea: string = Areas.ResourceTree; const startKey: number = TelemetryProcessor.traceStart(Action.OpenSampleNotebook, { databaseAccountName, defaultExperience, dataExplorerArea }); this.container.importAndOpen(item.path).then(hasOpened => { if (hasOpened) { this.pushItemToMostRecent(item); TelemetryProcessor.traceSuccess( Action.OpenSampleNotebook, { databaseAccountName, defaultExperience, dataExplorerArea }, startKey ); } else { TelemetryProcessor.traceFailure( Action.OpenSampleNotebook, { databaseAccountName, defaultExperience, dataExplorerArea }, startKey ); } }); }, false, false ); sampleNotebooksTree.isExpanded = true; // Remove children starting with "." sampleNotebooksTree.children = sampleNotebooksTree.children.filter( node => !StringUtils.startsWith(node.label, ".") ); return sampleNotebooksTree; } private buildMyNotebooksTree(): TreeNode { const myNotebooksTree: TreeNode = this.buildNotebookDirectoryNode( this.myNotebooksContentRoot, (item: NotebookContentItem) => { this.container.openNotebook(item).then(hasOpened => { if (hasOpened) { this.pushItemToMostRecent(item); } }); }, true, true ); myNotebooksTree.isExpanded = true; myNotebooksTree.isAlphaSorted = true; // Remove "Delete" menu item from context menu myNotebooksTree.contextMenu = myNotebooksTree.contextMenu.filter(menuItem => menuItem.label !== "Delete"); return myNotebooksTree; } private buildGitHubNotebooksTree(): TreeNode { const gitHubNotebooksTree: TreeNode = this.buildNotebookDirectoryNode( this.gitHubNotebooksContentRoot, (item: NotebookContentItem) => { this.container.openNotebook(item).then(hasOpened => { if (hasOpened) { this.pushItemToMostRecent(item); } }); }, true, true ); gitHubNotebooksTree.contextMenu = [ { label: "Manage GitHub settings", onClick: () => this.container.gitHubReposPane.open() }, { label: "Disconnect from GitHub", onClick: () => { TelemetryProcessor.trace(Action.NotebooksGitHubDisconnect, ActionModifiers.Mark, { databaseAccountName: this.container.databaseAccount() && this.container.databaseAccount().name, defaultExperience: this.container.defaultExperience && this.container.defaultExperience(), dataExplorerArea: Areas.Notebook }); this.container.notebookManager?.gitHubOAuthService.logout(); } } ]; gitHubNotebooksTree.isExpanded = true; gitHubNotebooksTree.isAlphaSorted = true; return gitHubNotebooksTree; } private pushItemToMostRecent(item: NotebookContentItem) { this.container.mostRecentActivity.addItem(CosmosClient.databaseAccount().id, { type: MostRecentActivity.Type.OpenNotebook, title: item.name, description: "Notebook", data: { name: item.name, path: item.path } }); } private buildChildNodes( item: NotebookContentItem, onFileClick: (item: NotebookContentItem) => void, createDirectoryContextMenu: boolean, createFileContextMenu: boolean ): TreeNode[] { if (!item || !item.children) { return []; } else { return item.children.map(item => { const result = item.type === NotebookContentItemType.Directory ? this.buildNotebookDirectoryNode(item, onFileClick, createDirectoryContextMenu, createFileContextMenu) : this.buildNotebookFileNode(item, onFileClick, createFileContextMenu); result.timestamp = item.timestamp; return result; }); } } private buildNotebookFileNode( item: NotebookContentItem, onFileClick: (item: NotebookContentItem) => void, createFileContextMenu: boolean ): TreeNode { return { label: item.name, iconSrc: NotebookUtil.isNotebookFile(item.path) ? NotebookIcon : FileIcon, className: "notebookHeader", onClick: () => onFileClick(item), isSelected: () => { const activeTab = this.container.findActiveTab(); return ( activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. */ (activeTab as any).notebookPath() === item.path ); }, contextMenu: createFileContextMenu ? [ { label: "Rename", iconSrc: NotebookIcon, onClick: () => this.container.renameNotebook(item) }, { label: "Delete", iconSrc: DeleteIcon, onClick: () => { this.container.showOkCancelModalDialog( "Confirm delete", `Are you sure you want to delete "${item.name}"`, "Delete", () => this.container.deleteNotebookFile(item).then(() => this.triggerRender()), "Cancel", undefined ); } }, { label: "Download", iconSrc: NotebookIcon, onClick: () => this.container.downloadFile(item) } ] : undefined, data: item }; } private createDirectoryContextMenu(item: NotebookContentItem): TreeNodeMenuItem[] { let items: TreeNodeMenuItem[] = [ { label: "Refresh", iconSrc: RefreshIcon, onClick: () => this.container.refreshContentItem(item).then(() => this.triggerRender()) }, { label: "Delete", iconSrc: DeleteIcon, onClick: () => { this.container.showOkCancelModalDialog( "Confirm delete", `Are you sure you want to delete "${item.name}?"`, "Delete", () => this.container.deleteNotebookFile(item).then(() => this.triggerRender()), "Cancel", undefined ); } }, { label: "Rename", iconSrc: NotebookIcon, onClick: () => this.container.renameNotebook(item).then(() => this.triggerRender()) }, { label: "New Directory", iconSrc: NewNotebookIcon, onClick: () => this.container.onCreateDirectory(item) }, { label: "New Notebook", iconSrc: NewNotebookIcon, onClick: () => this.container.onNewNotebookClicked(item) }, { label: "Upload File", iconSrc: NewNotebookIcon, onClick: () => this.container.onUploadToNotebookServerClicked(item) } ]; // For GitHub paths remove "Delete", "Rename", "New Directory", "Upload File" if (GitHubUtils.fromContentUri(item.path)) { items = items.filter( item => item.label !== "Delete" && item.label !== "Rename" && item.label !== "New Directory" && item.label !== "Upload File" ); } return items; } private buildNotebookDirectoryNode( item: NotebookContentItem, onFileClick: (item: NotebookContentItem) => void, createDirectoryContextMenu: boolean, createFileContextMenu: boolean ): TreeNode { return { label: item.name, iconSrc: undefined, className: "notebookHeader", isAlphaSorted: true, isLeavesParentsSeparate: true, onClick: () => { if (!item.children) { this.container.refreshContentItem(item).then(() => this.triggerRender()); } }, isSelected: () => { const activeTab = this.container.findActiveTab(); return ( activeTab && activeTab.tabKind === ViewModels.CollectionTabKind.NotebookV2 && /* TODO Redesign Tab interface so that resource tree doesn't need to know about NotebookV2Tab. NotebookV2Tab could be dynamically imported, but not worth it to just get this type right. */ (activeTab as any).notebookPath() === item.path ); }, contextMenu: createDirectoryContextMenu && item.path !== ResourceTreeAdapter.PseudoDirPath ? this.createDirectoryContextMenu(item) : undefined, data: item, children: this.buildChildNodes(item, onFileClick, createDirectoryContextMenu, createFileContextMenu) }; } public triggerRender() { window.requestAnimationFrame(() => this.parameters(Date.now())); } private getActiveTab(): ViewModels.Tab { const activeTabs: ViewModels.Tab[] = this.container.openedTabs().filter((tab: ViewModels.Tab) => tab.isActive()); if (activeTabs.length) { return activeTabs[0]; } return undefined; } private isDataNodeSelected(rid: string, nodeKind: string, subnodeKind: ViewModels.CollectionTabKind): boolean { if (!this.container.selectedNode || !this.container.selectedNode()) { return false; } const selectedNode = this.container.selectedNode(); if (subnodeKind === undefined) { return selectedNode.rid === rid && selectedNode.nodeKind === nodeKind; } else { const activeTab = this.getActiveTab(); let selectedSubnodeKind; if (nodeKind === "Database" && (selectedNode as ViewModels.Database).selectedSubnodeKind) { selectedSubnodeKind = (selectedNode as ViewModels.Database).selectedSubnodeKind(); } else if (nodeKind === "Collection" && (selectedNode as ViewModels.Collection).selectedSubnodeKind) { selectedSubnodeKind = (selectedNode as ViewModels.Collection).selectedSubnodeKind(); } return ( activeTab && activeTab.tabKind === subnodeKind && selectedNode.rid === rid && selectedSubnodeKind !== undefined && selectedSubnodeKind === subnodeKind ); } } // *************** watch all nested ko's inside database // TODO Simplify so we don't have to do this private watchCollection(databaseId: string, collection: ViewModels.Collection) { this.addKoSubToCollectionId( databaseId, collection.id(), collection.storedProcedures.subscribe(() => { this.triggerRender(); }) ); this.addKoSubToCollectionId( databaseId, collection.id(), collection.isCollectionExpanded.subscribe(() => { this.triggerRender(); }) ); this.addKoSubToCollectionId( databaseId, collection.id(), collection.isStoredProceduresExpanded.subscribe(() => { this.triggerRender(); }) ); } private watchDatabase(database: ViewModels.Database) { const databaseId = database.id(); const koSub = database.collections.subscribe((collections: ViewModels.Collection[]) => { this.cleanupCollectionsKoSubs( databaseId, collections.map((collection: ViewModels.Collection) => collection.id()) ); collections.forEach((collection: ViewModels.Collection) => this.watchCollection(databaseId, collection)); this.triggerRender(); }); this.addKoSubToDatabaseId(databaseId, koSub); database.collections().forEach((collection: ViewModels.Collection) => this.watchCollection(databaseId, collection)); } private addKoSubToDatabaseId(databaseId: string, sub: ko.Subscription): void { this.koSubsDatabaseIdMap.push(databaseId, sub); } private addKoSubToCollectionId(databaseId: string, collectionId: string, sub: ko.Subscription): void { this.databaseCollectionIdMap.push(databaseId, collectionId); this.koSubsCollectionIdMap.push(collectionId, sub); } private cleanupDatabasesKoSubs(existingDatabaseIds: string[]): void { const databaseIdsToRemove = this.databaseCollectionIdMap .keys() .filter((id: string) => existingDatabaseIds.indexOf(id) === -1); databaseIdsToRemove.forEach((databaseId: string) => { if (this.koSubsDatabaseIdMap.has(databaseId)) { this.koSubsDatabaseIdMap.get(databaseId).forEach((sub: ko.Subscription) => sub.dispose()); this.koSubsDatabaseIdMap.delete(databaseId); } if (this.databaseCollectionIdMap.has(databaseId)) { this.databaseCollectionIdMap .get(databaseId) .forEach((collectionId: string) => this.cleanupKoSubsForCollection(databaseId, collectionId)); } }); } private cleanupCollectionsKoSubs(databaseId: string, existingCollectionIds: string[]): void { if (!this.databaseCollectionIdMap.has(databaseId)) { return; } const collectionIdsToRemove = this.databaseCollectionIdMap .get(databaseId) .filter((id: string) => existingCollectionIds.indexOf(id) === -1); collectionIdsToRemove.forEach((id: string) => this.cleanupKoSubsForCollection(databaseId, id)); } private cleanupKoSubsForCollection(databaseId: string, collectionId: string) { if (!this.koSubsCollectionIdMap.has(collectionId)) { return; } this.koSubsCollectionIdMap.get(collectionId).forEach((sub: ko.Subscription) => sub.dispose()); this.koSubsCollectionIdMap.delete(collectionId); this.databaseCollectionIdMap.remove(databaseId, collectionId); } }