diff --git a/apps/browser/src/background/main.background.ts b/apps/browser/src/background/main.background.ts index c6d68a9f047..16149ea0fb3 100644 --- a/apps/browser/src/background/main.background.ts +++ b/apps/browser/src/background/main.background.ts @@ -918,6 +918,8 @@ export default class MainBackground { this.policyService, this.stateProvider, this.accountService, + this.configService, + this.i18nService, ); this.vaultSettingsService = new VaultSettingsService(this.stateProvider); diff --git a/apps/browser/src/popup/services/services.module.ts b/apps/browser/src/popup/services/services.module.ts index 9f79cf42553..d70418137f8 100644 --- a/apps/browser/src/popup/services/services.module.ts +++ b/apps/browser/src/popup/services/services.module.ts @@ -404,6 +404,8 @@ const safeProviders: SafeProvider[] = [ PolicyService, StateProvider, AccountServiceAbstraction, + ConfigService, + I18nServiceAbstraction, ], }), safeProvider({ diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts index 1e56fd4d352..e530046a971 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.spec.ts @@ -5,12 +5,14 @@ import { BehaviorSubject, skipWhile } from "rxjs"; import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; +import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { mockAccountServiceWith } from "@bitwarden/common/spec"; @@ -31,6 +33,14 @@ import { VaultPopupListFiltersService, } from "./vault-popup-list-filters.service"; +const configService = { + getFeatureFlag$: jest.fn(() => new BehaviorSubject(true)), +} as unknown as ConfigService; + +jest.mock("@bitwarden/angular/vault/vault-filter/services/vault-filter.service", () => ({ + sortDefaultCollections: jest.fn(), +})); + describe("VaultPopupListFiltersService", () => { let service: VaultPopupListFiltersService; let _memberOrganizations$ = new BehaviorSubject([]); @@ -138,6 +148,10 @@ describe("VaultPopupListFiltersService", () => { provide: RestrictedItemTypesService, useValue: restrictedItemTypesService, }, + { + provide: ConfigService, + useValue: configService, + }, ], }); @@ -399,6 +413,29 @@ describe("VaultPopupListFiltersService", () => { done(); }); }); + + it("calls vaultFilterService.sortDefaultCollections", (done) => { + const collections = [ + { id: "1234", name: "Default Collection", organizationId: "org1" }, + { id: "5678", name: "Shared Collection", organizationId: "org2" }, + ] as CollectionView[]; + + const orgs = [ + { id: "org1", name: "Organization 1" }, + { id: "org2", name: "Organization 2" }, + ] as Organization[]; + + createSeededVaultPopupListFiltersService(orgs, collections, [], {}); + + service.collections$.subscribe(() => { + expect(vaultFilterSvc.sortDefaultCollections).toHaveBeenCalledWith( + collections, + orgs, + i18nService.collator, + ); + done(); + }); + }); }); describe("folders$", () => { @@ -573,6 +610,8 @@ describe("VaultPopupListFiltersService", () => { const seededOrganizations: Organization[] = [ { id: MY_VAULT_ID, name: "Test Org" } as Organization, + { id: "org1", name: "Default User Collection Org 1" } as Organization, + { id: "org2", name: "Default User Collection Org 2" } as Organization, ]; const seededCollections: CollectionView[] = [ { @@ -752,6 +791,7 @@ function createSeededVaultPopupListFiltersService( accountServiceMock, viewCacheServiceMock, restrictedItemTypesServiceMock, + configService, ); }); diff --git a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts index 12d0c445b4c..a936aaf86d9 100644 --- a/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts +++ b/apps/browser/src/vault/popup/services/vault-popup-list-filters.service.ts @@ -6,6 +6,7 @@ import { debounceTime, distinctUntilChanged, filter, + from, map, Observable, shareReplay, @@ -17,6 +18,7 @@ import { import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; import { ViewCacheService } from "@bitwarden/angular/platform/view-cache"; import { DynamicTreeNode } from "@bitwarden/angular/vault/vault-filter/models/dynamic-tree-node.model"; +import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -24,6 +26,8 @@ import { Organization } from "@bitwarden/common/admin-console/models/domain/orga import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { ProductTierType } from "@bitwarden/common/billing/enums"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { @@ -181,6 +185,7 @@ export class VaultPopupListFiltersService { private accountService: AccountService, private viewCacheService: ViewCacheService, private restrictedItemTypesService: RestrictedItemTypesService, + private configService: ConfigService, ) { this.filterForm.controls.organization.valueChanges .pipe(takeUntilDestroyed()) @@ -424,39 +429,47 @@ export class VaultPopupListFiltersService { /** * Collection array structured to be directly passed to `ChipSelectComponent` */ - collections$: Observable[]> = combineLatest([ - this.filters$.pipe( - distinctUntilChanged( - (previousFilter, currentFilter) => - // Only update the collections when the organizationId filter changes - previousFilter.organization?.id === currentFilter.organization?.id, + collections$: Observable[]> = + this.accountService.activeAccount$.pipe( + getUserId, + switchMap((userId) => + combineLatest([ + this.filters$.pipe( + distinctUntilChanged((prev, curr) => prev.organization?.id === curr.organization?.id), + ), + this.collectionService.decryptedCollections$, + this.organizationService.memberOrganizations$(userId), + this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), + ]), ), - ), - this.collectionService.decryptedCollections$, - ]).pipe( - map(([filters, allCollections]) => { - const organizationId = filters.organization?.id ?? null; - // When the organization filter is selected, filter out collections that do not belong to the selected organization - const collections = - organizationId === null - ? allCollections - : allCollections.filter((c) => c.organizationId === organizationId); + map(([filters, allCollections, orgs, defaultVaultEnabled]) => { + const orgFilterId = filters.organization?.id ?? null; + // When the organization filter is selected, filter out collections that do not belong to the selected organization + const filtered = orgFilterId + ? allCollections.filter((c) => c.organizationId === orgFilterId) + : allCollections; - return collections; - }), - switchMap(async (collections) => { - const nestedCollections = await this.collectionService.getAllNested(collections); - - return new DynamicTreeNode({ - fullList: collections, - nestedList: nestedCollections, - }); - }), - map((collections) => - collections.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection-shared")), - ), - shareReplay({ refCount: true, bufferSize: 1 }), - ); + if (!defaultVaultEnabled) { + return filtered; + } + return sortDefaultCollections(filtered, orgs, this.i18nService.collator); + }), + switchMap((collections) => { + return from(this.collectionService.getAllNested(collections)).pipe( + map( + (nested) => + new DynamicTreeNode({ + fullList: collections, + nestedList: nested, + }), + ), + ); + }), + map((tree) => + tree.nestedList.map((c) => this.convertToChipSelectOption(c, "bwi-collection-shared")), + ), + shareReplay({ bufferSize: 1, refCount: true }), + ); /** Organizations, collection, folders filters. */ allFilters$ = combineLatest([this.organizations$, this.collections$, this.folders$]); diff --git a/apps/browser/src/vault/services/vault-filter.service.ts b/apps/browser/src/vault/services/vault-filter.service.ts index f8b22f2f88f..f33e8e1c130 100644 --- a/apps/browser/src/vault/services/vault-filter.service.ts +++ b/apps/browser/src/vault/services/vault-filter.service.ts @@ -6,6 +6,8 @@ import { VaultFilterService as BaseVaultFilterService } from "@bitwarden/angular import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; @@ -25,6 +27,8 @@ export class VaultFilterService extends BaseVaultFilterService { policyService: PolicyService, stateProvider: StateProvider, accountService: AccountService, + configService: ConfigService, + i18nService: I18nService, ) { super( organizationService, @@ -34,6 +38,8 @@ export class VaultFilterService extends BaseVaultFilterService { policyService, stateProvider, accountService, + configService, + i18nService, ); this.vaultFilter.myVaultOnly = false; this.vaultFilter.selectedOrganizationId = null; diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts index f4b6f41fab6..dc05248d7ba 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.service.ts @@ -5,6 +5,7 @@ import { CollectionAdminView, CollectionService } from "@bitwarden/admin-console import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { StateProvider } from "@bitwarden/common/platform/state"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -34,6 +35,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest stateProvider: StateProvider, collectionService: CollectionService, accountService: AccountService, + configService: ConfigService, ) { super( organizationService, @@ -44,6 +46,7 @@ export class VaultFilterService extends BaseVaultFilterService implements OnDest stateProvider, collectionService, accountService, + configService, ); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts index 59aa169481e..2154ecff1b7 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.spec.ts @@ -5,13 +5,20 @@ import { import { FakeSingleUserState } from "@bitwarden/common/../spec/fake-state"; import { FakeStateProvider } from "@bitwarden/common/../spec/fake-state-provider"; import { mock, MockProxy } from "jest-mock-extended"; -import { firstValueFrom, ReplaySubject } from "rxjs"; +import { firstValueFrom, of, ReplaySubject } from "rxjs"; -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionService, + CollectionType, + CollectionTypes, + CollectionView, +} from "@bitwarden/admin-console/common"; +import * as vaultFilterSvc from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { UserId } from "@bitwarden/common/types/guid"; @@ -23,6 +30,10 @@ import { COLLAPSED_GROUPINGS } from "@bitwarden/common/vault/services/key-state/ import { VaultFilterService } from "./vault-filter.service"; +jest.mock("@bitwarden/angular/vault/vault-filter/services/vault-filter.service", () => ({ + sortDefaultCollections: jest.fn(() => []), +})); + describe("vault filter service", () => { let vaultFilterService: VaultFilterService; @@ -39,6 +50,7 @@ describe("vault filter service", () => { let organizationDataOwnershipPolicy: ReplaySubject; let singleOrgPolicy: ReplaySubject; let stateProvider: FakeStateProvider; + let configService: MockProxy; const mockUserId = Utils.newGuid() as UserId; let accountService: FakeAccountService; @@ -54,6 +66,7 @@ describe("vault filter service", () => { stateProvider = new FakeStateProvider(accountService); i18nService.collator = new Intl.Collator("en-US"); collectionService = mock(); + configService = mock(); organizations = new ReplaySubject(1); folderViews = new ReplaySubject(1); @@ -62,6 +75,7 @@ describe("vault filter service", () => { organizationDataOwnershipPolicy = new ReplaySubject(1); singleOrgPolicy = new ReplaySubject(1); + configService.getFeatureFlag$.mockReturnValue(of(true)); organizationService.memberOrganizations$.mockReturnValue(organizations); folderService.folderViews$.mockReturnValue(folderViews); collectionService.decryptedCollections$ = collectionViews; @@ -82,8 +96,10 @@ describe("vault filter service", () => { stateProvider, collectionService, accountService, + configService, ); collapsedGroupingsState = stateProvider.singleUser.getFake(mockUserId, COLLAPSED_GROUPINGS); + organizations.next([]); }); describe("collapsed filter nodes", () => { @@ -285,6 +301,40 @@ describe("vault filter service", () => { const c3 = c1.children[0]; expect(c3.parent.node.id).toEqual("id-1"); }); + + it.only("calls sortDefaultCollections with the correct args", async () => { + const storedOrgs = [ + createOrganization("id-defaultOrg1", "org1"), + createOrganization("id-defaultOrg2", "org2"), + ]; + organizations.next(storedOrgs); + + const storedCollections = [ + createCollectionView("id-2", "Collection 2", "org test id"), + createCollectionView("id-1", "Collection 1", "org test id"), + createCollectionView( + "id-3", + "Default User Collection - Org 2", + "id-defaultOrg2", + CollectionTypes.DefaultUserCollection, + ), + createCollectionView( + "id-4", + "Default User Collection - Org 1", + "id-defaultOrg1", + CollectionTypes.DefaultUserCollection, + ), + ]; + collectionViews.next(storedCollections); + + await firstValueFrom(vaultFilterService.collectionTree$); + + expect(vaultFilterSvc.sortDefaultCollections).toHaveBeenCalledWith( + storedCollections, + storedOrgs, + i18nService.collator, + ); + }); }); }); @@ -312,11 +362,17 @@ describe("vault filter service", () => { return folder; } - function createCollectionView(id: string, name: string, orgId: string): CollectionView { + function createCollectionView( + id: string, + name: string, + orgId: string, + type?: CollectionType, + ): CollectionView { const collection = new CollectionView(); collection.id = id; collection.name = name; collection.organizationId = orgId; + collection.type = type || CollectionTypes.SharedCollection; return collection; } }); diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts index b6548564ec9..f326034e806 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/services/vault-filter.service.ts @@ -18,12 +18,15 @@ import { CollectionService, CollectionView, } from "@bitwarden/admin-console/common"; +import { sortDefaultCollections } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; @@ -104,8 +107,14 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { }), ); - collectionTree$: Observable> = this.filteredCollections$.pipe( - map((collections) => this.buildCollectionTree(collections)), + collectionTree$: Observable> = combineLatest([ + this.filteredCollections$, + this.memberOrganizations$, + this.configService.getFeatureFlag$(FeatureFlag.CreateDefaultLocation), + ]).pipe( + map(([collections, organizations, defaultCollectionsFlagEnabled]) => + this.buildCollectionTree(collections, organizations, defaultCollectionsFlagEnabled), + ), ); cipherTypeTree$: Observable> = this.buildCipherTypeTree(); @@ -123,6 +132,7 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { protected stateProvider: StateProvider, protected collectionService: CollectionService, protected accountService: AccountService, + protected configService: ConfigService, ) {} async getCollectionNodeFromTree(id: string) { @@ -227,31 +237,39 @@ export class VaultFilterService implements VaultFilterServiceAbstraction { : storedCollections; } - protected buildCollectionTree(collections?: CollectionView[]): TreeNode { + protected buildCollectionTree( + collections?: CollectionView[], + orgs?: Organization[], + defaultCollectionsFlagEnabled?: boolean, + ): TreeNode { const headNode = this.getCollectionFilterHead(); if (!collections) { return headNode; } const nodes: TreeNode[] = []; - collections - .sort((a, b) => this.i18nService.collator.compare(a.name, b.name)) - .forEach((c) => { - const collectionCopy = new CollectionView() as CollectionFilter; - collectionCopy.id = c.id; - collectionCopy.organizationId = c.organizationId; - collectionCopy.icon = "bwi-collection-shared"; - if (c instanceof CollectionAdminView) { - collectionCopy.groups = c.groups; - collectionCopy.assigned = c.assigned; - } - const parts = - c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); - }); + + if (defaultCollectionsFlagEnabled) { + collections = sortDefaultCollections(collections, orgs, this.i18nService.collator); + } + + collections.forEach((c) => { + const collectionCopy = new CollectionView() as CollectionFilter; + collectionCopy.id = c.id; + collectionCopy.organizationId = c.organizationId; + collectionCopy.icon = "bwi-collection-shared"; + if (c instanceof CollectionAdminView) { + collectionCopy.groups = c.groups; + collectionCopy.assigned = c.assigned; + } + const parts = c.name != null ? c.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; + ServiceUtils.nestedTraverse(nodes, 0, parts, collectionCopy, null, NestingDelimiter); + }); + nodes.forEach((n) => { n.parent = headNode; headNode.children.push(n); }); + return headNode; } diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index ba5e4841e3d..bc2e49e85cd 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -864,6 +864,9 @@ "me": { "message": "Me" }, + "myItems": { + "message": "My items" + }, "myVault": { "message": "My vault" }, diff --git a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts index 3317f0c9002..fea57743055 100644 --- a/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts +++ b/libs/angular/src/vault/vault-filter/services/vault-filter.service.ts @@ -1,17 +1,22 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Injectable } from "@angular/core"; import { firstValueFrom, from, map, mergeMap, Observable, switchMap, take } from "rxjs"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports -import { CollectionService, CollectionView } from "@bitwarden/admin-console/common"; +import { + CollectionService, + CollectionTypes, + CollectionView, +} from "@bitwarden/admin-console/common"; import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; import { getUserId } from "@bitwarden/common/auth/services/account.service"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; +import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { SingleUserState, StateProvider } from "@bitwarden/common/platform/state"; import { UserId } from "@bitwarden/common/types/guid"; import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service"; @@ -40,6 +45,8 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti protected policyService: PolicyService, protected stateProvider: StateProvider, protected accountService: AccountService, + protected configService: ConfigService, + protected i18nService: I18nService, ) {} async storeCollapsedFilterNodes( @@ -103,12 +110,20 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti async buildCollections(organizationId?: string): Promise> { const storedCollections = await this.collectionService.getAllDecrypted(); - let collections: CollectionView[]; - if (organizationId != null) { - collections = storedCollections.filter((c) => c.organizationId === organizationId); - } else { - collections = storedCollections; + const orgs = await this.buildOrganizations(); + const defaulCollectionsFlagEnabled = await this.configService.getFeatureFlag( + FeatureFlag.CreateDefaultLocation, + ); + + let collections = + organizationId == null + ? storedCollections + : storedCollections.filter((c) => c.organizationId === organizationId); + + if (defaulCollectionsFlagEnabled) { + collections = sortDefaultCollections(collections, orgs, this.i18nService.collator); } + const nestedCollections = await this.collectionService.getAllNested(collections); return new DynamicTreeNode({ fullList: collections, @@ -145,7 +160,7 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti folderCopy.id = f.id; folderCopy.revisionDate = f.revisionDate; const parts = f.name != null ? f.name.replace(/^\/+|\/+$/g, "").split(NestingDelimiter) : []; - ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, null, NestingDelimiter); + ServiceUtils.nestedTraverse(nodes, 0, parts, folderCopy, undefined, NestingDelimiter); }); return nodes; } @@ -158,3 +173,28 @@ export class VaultFilterService implements DeprecatedVaultFilterServiceAbstracti return ServiceUtils.getTreeNodeObjectFromList(folders, id) as TreeNode; } } + +/** + * Sorts collections with default user collections at the top, sorted by organization name. + * Remaining collections are sorted by name. + * @param collections - The list of collections to sort. + * @param orgs - The list of organizations to use for sorting default user collections. + * @returns Sorted list of collections. + */ +export function sortDefaultCollections( + collections: CollectionView[], + orgs: Organization[] = [], + collator: Intl.Collator, +): CollectionView[] { + const sortedDefaultCollectionTypes = collections + .filter((c) => c.type === CollectionTypes.DefaultUserCollection) + .sort((a, b) => { + const aName = orgs.find((o) => o.id === a.organizationId)?.name ?? a.organizationId; + const bName = orgs.find((o) => o.id === b.organizationId)?.name ?? b.organizationId; + return collator.compare(aName, bName); + }); + return [ + ...sortedDefaultCollectionTypes, + ...collections.filter((c) => c.type !== CollectionTypes.DefaultUserCollection), + ]; +}