1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-31 00:33:33 +00:00

migrated vault filters using router params

This commit is contained in:
Leslie Xiong
2025-12-10 19:45:36 -05:00
parent f9635b55ec
commit 1c4deaab94
22 changed files with 573 additions and 910 deletions

View File

@@ -41,11 +41,19 @@ import {
NewDeviceVerificationComponent,
} from "@bitwarden/auth/angular";
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { AnonLayoutWrapperComponent, AnonLayoutWrapperData } from "@bitwarden/components";
import { LockComponent, ConfirmKeyConnectorDomainComponent } from "@bitwarden/key-management-ui";
import {
VaultFilterServiceAbstraction,
VaultFilterService,
RoutedVaultFilterBridgeService,
RoutedVaultFilterService,
} from "@bitwarden/vault";
import { maxAccountsGuardFn } from "../auth/guards/max-accounts.guard";
import { RemovePasswordComponent } from "../key-management/key-connector/remove-password.component";
import { DesktopPremiumUpgradePromptService } from "../services/desktop-premium-upgrade-prompt.service";
import { VaultV2Component } from "../vault/app/vault/vault-v2.component";
import { VaultComponent } from "../vault/app/vault-v3/vault.component";
@@ -337,6 +345,18 @@ const routes: Routes = [
path: "",
component: DesktopLayoutComponent,
canActivate: [authGuard],
providers: [
RoutedVaultFilterService,
RoutedVaultFilterBridgeService,
{
provide: VaultFilterServiceAbstraction,
useClass: VaultFilterService,
},
{
provide: PremiumUpgradePromptService,
useClass: DesktopPremiumUpgradePromptService,
},
],
children: [
{
path: "new-vault",

View File

@@ -2,7 +2,7 @@
<app-side-nav slot="side-nav">
<bit-nav-logo [openIcon]="logo" route="." [label]="'passwordManager' | i18n"></bit-nav-logo>
<app-vault-nav></app-vault-nav>
<app-vault-filter></app-vault-filter>
<bit-nav-item icon="bwi-send" [text]="'send' | i18n" route="new-sends"></bit-nav-item>
</app-side-nav>

View File

@@ -5,7 +5,7 @@ import { PasswordManagerLogo } from "@bitwarden/assets/svg";
import { LayoutComponent, NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultNavComponent } from "../../vault/app/vault-v3/nav/vault-nav.component";
import { VaultFilterComponent } from "../../vault/app/vault-v3/vault-filter/vault-filter.component";
import { DesktopSideNavComponent } from "./desktop-side-nav.component";
@@ -19,7 +19,7 @@ import { DesktopSideNavComponent } from "./desktop-side-nav.component";
LayoutComponent,
NavigationModule,
DesktopSideNavComponent,
VaultNavComponent,
VaultFilterComponent,
],
templateUrl: "./desktop-layout.component.html",
})

View File

@@ -1,108 +0,0 @@
import { Injectable } from "@angular/core";
import { Subject } from "rxjs";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { SearchBarService } from "../app/layout/search/search-bar.service";
/**
* Service to coordinate vault state, including filter state and folder actions,
* between the navigation component and the vault component.
*/
@Injectable({ providedIn: "root" })
export class VaultStateService {
private filterChangeSubject = new Subject<VaultFilter>();
private addFolderSubject = new Subject<void>();
private editFolderSubject = new Subject<string>();
/**
* The currently active vault filter.
*/
activeFilter: VaultFilter = new VaultFilter();
/**
* Observable stream of vault filter changes.
* Subscribe to this to react to filter changes from the navigation.
*/
readonly filterChange$ = this.filterChangeSubject.asObservable();
/**
* Observable stream of add folder requests.
* Subscribe to this to handle folder creation.
*/
readonly addFolder$ = this.addFolderSubject.asObservable();
/**
* Observable stream of edit folder requests.
* Subscribe to this to handle folder editing.
* Emits the folder ID to edit.
*/
readonly editFolder$ = this.editFolderSubject.asObservable();
constructor(
private i18nService: I18nService,
private searchBarService: SearchBarService,
) {}
/**
* Apply a new vault filter.
* This updates the search bar placeholder and notifies all subscribers.
*/
applyFilter(filter: VaultFilter): void {
// Store the active filter
this.activeFilter = filter;
// Update search bar placeholder text based on the filter
this.searchBarService.setPlaceholderText(
this.i18nService.t(this.calculateSearchBarLocalizationString(filter)),
);
// Emit the filter change to subscribers
this.filterChangeSubject.next(filter);
}
/**
* Request to add a new folder.
* This will notify subscribers to show the folder creation dialog.
*/
requestAddFolder(): void {
this.addFolderSubject.next();
}
/**
* Request to edit an existing folder.
* This will notify subscribers to show the folder edit dialog.
*/
requestEditFolder(folderId: string): void {
this.editFolderSubject.next(folderId);
}
/**
* Calculate the appropriate search bar localization string based on the active filter.
*/
private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string {
if (vaultFilter.status === "favorites") {
return "searchFavorites";
}
if (vaultFilter.status === "trash") {
return "searchTrash";
}
if (vaultFilter.cipherType != null) {
return "searchType";
}
if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") {
return "searchFolder";
}
if (vaultFilter.selectedCollectionId != null) {
return "searchCollection";
}
if (vaultFilter.selectedOrganizationId != null) {
return "searchOrganization";
}
if (vaultFilter.myVaultOnly) {
return "searchMyVault";
}
return "searchVault";
}
}

View File

@@ -1,9 +0,0 @@
<bit-nav-group icon="bwi-vault" [text]="'vault' | i18n" route="new-vault">
<app-vault-filter
class="vault-filters"
[activeFilter]="vaultStateService.activeFilter"
(onFilterChange)="vaultStateService.applyFilter($event)"
(onAddFolder)="vaultStateService.requestAddFolder()"
(onEditFolder)="vaultStateService.requestEditFolder($event.id)"
></app-vault-filter>
</bit-nav-group>

View File

@@ -1,15 +0,0 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import { VaultStateService } from "../../../../services/vault-state.service";
import { VaultFilterModule } from "../vault-filter/vault-filter.module";
@Component({
selector: "app-vault-nav",
imports: [I18nPipe, NavigationModule, VaultFilterModule],
templateUrl: "./vault-nav.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class VaultNavComponent {
constructor(protected vaultStateService: VaultStateService) {}
}

View File

@@ -1,84 +1,23 @@
<ng-container *ngIf="show">
<div class="filter-heading">
<h2>
<button
type="button"
class="no-btn"
[attr.aria-expanded]="!isCollapsed(collectionsGrouping)"
aria-controls="collection-filters"
(click)="toggleCollapse(collectionsGrouping)"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed(collectionsGrouping),
'bwi-angle-down': !isCollapsed(collectionsGrouping),
}"
></i>
&nbsp;{{ collectionsGrouping.name | i18n }}
</button>
</h2>
</div>
<ul id="collection-filters" *ngIf="!isCollapsed(collectionsGrouping)" class="filter-options">
<ng-template #recursiveCollections let-collections>
<li
class="filter-option"
*ngFor="let c of collections"
[ngClass]="{ active: c.node.id === activeFilter.selectedCollectionId }"
>
<span class="filter-buttons">
<button
type="button"
*ngIf="c.children.length"
class="toggle-button"
[attr.aria-expanded]="!isCollapsed(c.node)"
[attr.aria-controls]="c.node.name + '_children'"
(click)="toggleCollapse(c.node)"
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ c.node.name }}"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed(c.node),
'bwi-angle-down': !isCollapsed(c.node),
}"
></i>
</button>
<button
type="button"
class="filter-button"
(click)="applyFilter(c.node)"
[attr.aria-pressed]="c.node.id === activeFilter.selectedCollectionId"
[title]="c.node.name"
>
<i
*ngIf="c.children.length === 0"
[class]="
'bwi bwi-fw ' +
(c.node.type === DefaultCollectionType ? 'bwi-user' : 'bwi-collection-shared')
"
aria-hidden="true"
></i>
&nbsp;{{ c.node.name }}
</button>
</span>
<ul
[id]="c.node.name + '_children'"
class="nested-filter-options"
*ngIf="c.children.length && !isCollapsed(c.node)"
>
<ng-container
*ngTemplateOutlet="recursiveCollections; context: { $implicit: c.children }"
>
</ng-container>
</ul>
</li>
</ng-template>
<ng-container
*ngTemplateOutlet="recursiveCollections; context: { $implicit: nestedCollections }"
>
</ng-container>
</ul>
</ng-container>
@if (collection().children.length) {
<bit-nav-group
[icon]="collection().node.icon"
[class.active]="isActive()"
[text]="displayName()"
[variant]="'tree'"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
>
@for (childCollection of collection().children; track childCollection.node.id) {
<app-collection-filter [collection]="childCollection" [activeFilter]="activeFilter()" />
}
</bit-nav-group>
} @else {
<bit-nav-item
[icon]="collection().node.icon"
[class.active]="isActive()"
[text]="collection().node.name"
[variant]="'tree'"
[appA11yTitle]="collection().node.name"
(click)="applyFilter($event)"
/>
}

View File

@@ -1,12 +1,40 @@
import { Component } from "@angular/core";
import { Component, input, computed } from "@angular/core";
import { CollectionFilterComponent as BaseCollectionFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/collection-filter.component";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilter, CollectionFilter } from "@bitwarden/vault";
import { VAULT_FILTER_IMPORTS } from "../shared-filter-imports";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-collection-filter",
templateUrl: "collection-filter.component.html",
standalone: false,
standalone: true,
imports: [...VAULT_FILTER_IMPORTS],
})
export class CollectionFilterComponent extends BaseCollectionFilterComponent {}
export class CollectionFilterComponent {
protected readonly collection = input<TreeNode<CollectionFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly displayName = computed<string>(() => {
return this.collection().node.name;
});
protected readonly isActive = computed<boolean>(() => {
return (
this.collection().node.id === this.activeFilter()?.collectionId &&
!!this.activeFilter()?.selectedCollectionNode
);
});
protected applyFilter(event: Event) {
event.stopPropagation();
const filter = this.activeFilter();
if (filter) {
filter.selectedCollectionNode = this.collection();
}
}
}

View File

@@ -1,94 +1,23 @@
<ng-container *ngIf="!hide">
<div class="filter-heading">
<h2>
<button
type="button"
class="toggle-button"
[attr.aria-expanded]="!isCollapsed(foldersGrouping)"
aria-controls="folder-filters"
(click)="toggleCollapse(foldersGrouping)"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed(foldersGrouping),
'bwi-angle-down': !isCollapsed(foldersGrouping),
}"
></i>
&nbsp;{{ foldersGrouping.name | i18n }}
</button>
</h2>
<button
type="button"
class="add-button"
(click)="addFolder()"
appA11yTitle="{{ 'addFolder' | i18n }}"
>
<i class="bwi bwi-plus bwi-fw" aria-hidden="true"></i>
</button>
</div>
<ul id="folder-filters" class="filter-options" *ngIf="!isCollapsed(foldersGrouping)">
<ng-template #recursiveFolders let-folders>
<li
*ngFor="let f of folders"
[ngClass]="{
active: f.node.id === activeFilter.selectedFolderId && activeFilter.selectedFolder,
}"
class="filter-option"
>
<span class="filter-buttons">
<button
type="button"
class="toggle-button"
*ngIf="f.children.length"
(click)="toggleCollapse(f.node)"
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ f.node.name }}"
[attr.aria-expanded]="!isCollapsed(f.node)"
[attr.aria-controls]="f.node.name + '_children'"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed(f.node),
'bwi-angle-down': !isCollapsed(f.node),
}"
></i>
</button>
<button
type="button"
class="filter-button"
(click)="applyFilter(f.node)"
[attr.aria-pressed]="
activeFilter.selectedFolder && f.node.id === activeFilter.selectedFolderId
"
>
<i *ngIf="f.children.length === 0" class="bwi bwi-fw bwi-folder" aria-hidden="true"></i>
&nbsp;{{ f.node.name }}
</button>
<button
type="button"
class="edit-button"
*ngIf="f.node.id"
(click)="editFolder(f.node)"
appA11yTitle="{{ 'editFolder' | i18n }}: {{ f.node.name }}"
>
<i class="bwi bwi-pencil bwi-fw" aria-hidden="true"></i>
</button>
</span>
<ul
[id]="f.node.name + '_children'"
class="nested-filter-options"
*ngIf="f.children.length && !isCollapsed(f.node)"
>
<ng-container *ngTemplateOutlet="recursiveFolders; context: { $implicit: f.children }">
</ng-container>
</ul>
</li>
</ng-template>
<ng-container
*ngTemplateOutlet="recursiveFolders; context: { $implicit: nestedFolders }"
></ng-container>
</ul>
</ng-container>
@if (folder().children.length) {
<bit-nav-group
[icon]="folder().node.icon"
[class.active]="isActive()"
[text]="displayName()"
[variant]="'tree'"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
>
@for (childFolder of folder().children; track childFolder.node.id) {
<app-folder-filter [folder]="childFolder" [activeFilter]="activeFilter()" />
}
</bit-nav-group>
} @else {
<bit-nav-item
[icon]="folder().node.icon"
[class.active]="isActive()"
[text]="displayName()"
[variant]="'tree'"
[appA11yTitle]="displayName()"
(click)="applyFilter($event)"
/>
}

View File

@@ -1,12 +1,39 @@
import { Component } from "@angular/core";
import { Component, input, computed } from "@angular/core";
import { FolderFilterComponent as BaseFolderFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/folder-filter.component";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilter, FolderFilter } from "@bitwarden/vault";
import { VAULT_FILTER_IMPORTS } from "../shared-filter-imports";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-folder-filter",
templateUrl: "folder-filter.component.html",
standalone: false,
standalone: true,
imports: [...VAULT_FILTER_IMPORTS],
})
export class FolderFilterComponent extends BaseFolderFilterComponent {}
export class FolderFilterComponent {
protected readonly folder = input<TreeNode<FolderFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly displayName = computed<string>(() => {
return this.folder().node.name;
});
protected readonly isActive = computed<boolean>(() => {
return (
this.folder().node.id === this.activeFilter()?.folderId &&
!!this.activeFilter()?.selectedFolderNode
);
});
protected applyFilter(event: Event) {
event.stopPropagation();
const filter = this.activeFilter();
if (filter) {
filter.selectedFolderNode = this.folder();
}
}
}

View File

@@ -1,140 +1,38 @@
<ng-container *ngIf="show">
<ng-container [ngSwitch]="displayMode">
<ng-container *ngSwitchCase="'organizationDataOwnershipPolicy'">
<div class="filter-heading" [ngClass]="{ active: !hasActiveFilter }">
<button
type="button"
class="toggle-button"
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ organizationGrouping.name | i18n }}"
(click)="toggleCollapse()"
[attr.aria-expanded]="!isCollapsed"
aria-controls="organization-filters"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed,
'bwi-angle-down': !isCollapsed,
}"
></i>
</button>
&nbsp;
<h2>
<button
type="button"
class="filter-button"
(click)="clearFilter()"
[attr.aria-pressed]="!hasActiveFilter"
appA11yTitle="{{ 'vault' | i18n }}: {{ organizationGrouping.name | i18n }}"
>
{{ organizationGrouping.name | i18n }}
</button>
</h2>
</div>
<ul id="organization-filters" *ngIf="!isCollapsed" class="filter-options no-margin">
<li
class="filter-option"
*ngFor="let organization of organizations"
[ngClass]="{ active: organization.id === activeFilter.selectedOrganizationId }"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyOrganizationFilter(organization)"
[attr.aria-pressed]="activeFilter.myVaultOnly"
appA11yTitle="{{ 'vault' | i18n }}: {{ organization.name }}"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
&nbsp;{{ organization.name }}
</button>
<span *ngIf="!organization.enabled" class="tw-ml-auto">
<i
class="bwi bwi-fw bwi-exclamation-triangle text-danger mr-auto"
attr.aria-label="{{ 'organizationIsDisabled' | i18n }}"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i>
</span>
</span>
</li>
</ul>
</ng-container>
<ng-container *ngSwitchDefault>
<div class="filter-heading" [ngClass]="{ active: !hasActiveFilter }">
<button
type="button"
appA11yTitle="{{ 'toggleCollapse' | i18n }} {{ organizationGrouping.name | i18n }}"
(click)="toggleCollapse()"
[attr.aria-expanded]="!isCollapsed"
aria-controls="organization-filters"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed,
'bwi-angle-down': !isCollapsed,
}"
></i>
</button>
&nbsp;
<h2>
<button
type="button"
class="filter-button"
(click)="clearFilter()"
[attr.aria-pressed]="!hasActiveFilter"
appA11yTitle="{{ 'vault' | i18n }}: {{ organizationGrouping.name | i18n }}"
>
{{ organizationGrouping.name | i18n }}
</button>
</h2>
</div>
<ul id="organization-filters" *ngIf="!isCollapsed" class="filter-options no-margin">
<li class="filter-option" [ngClass]="{ active: activeFilter.myVaultOnly }">
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyMyVaultFilter()"
[attr.aria-pressed]="activeFilter.myVaultOnly"
appA11yTitle="{{ 'vault' | i18n }}: {{ 'myVault' | i18n }}"
>
<i class="bwi bwi-fw bwi-user" aria-hidden="true"></i>
&nbsp;{{ "myVault" | i18n }}
</button>
</span>
</li>
<li
class="filter-option"
*ngFor="let organization of organizations"
[ngClass]="{ active: organization.id === activeFilter.selectedOrganizationId }"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyOrganizationFilter(organization)"
appA11yTitle="{{ 'vault' | i18n }}: {{ organization.name }} {{
organization.enabled ? '' : '(' + ('organizationIsDisabled' | i18n) + ')'
}}"
[attr.aria-pressed]="activeFilter.selectedOrganizationId === organization.id"
>
<i class="bwi bwi-fw bwi-business" aria-hidden="true"></i>
&nbsp;{{ organization.name }}
</button>
<span *ngIf="!organization.enabled" class="tw-ml-auto">
<i
class="bwi bwi-fw bwi-exclamation-triangle text-danger mr-auto"
attr.aria-label="{{ 'organizationIsDisabled' | i18n }}"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i>
</span>
</span>
</li>
</ul>
</ng-container>
</ng-container>
<hr />
</ng-container>
@if (show()) {
@for (organization of organizations().children ?? []; track organization.node.id) {
@if (getOrgCollections(organization.node.id)?.children?.length > 0) {
<bit-nav-group
[icon]="organization.node.icon"
[class.active]="organization.node.id === activeFilter()?.organizationId"
[text]="organization.node.name"
[variant]="'tree'"
[appA11yTitle]="organization.node.name"
(click)="applyFilter(organization)"
>
@if (!hideCollections() && collections() != null) {
@for (c of getOrgCollections(organization.node.id)?.children ?? []; track c.node.id) {
<app-collection-filter [activeFilter]="activeFilter()" [collection]="c" />
}
}
</bit-nav-group>
} @else {
<bit-nav-item
[icon]="organization.node.icon"
[class.active]="organization.node.id === activeFilter()?.organizationId"
[text]="organization.node.name"
[variant]="'tree'"
[appA11yTitle]="organization.node.name"
(click)="applyFilter(organization)"
/>
}
@if (!organization.node.enabled) {
<span class="tw-ml-auto">
<i
class="bwi bwi-fw bwi-exclamation-triangle text-danger mr-auto"
attr.aria-label="{{ 'organizationIsDisabled' | i18n }}"
appA11yTitle="{{ 'organizationIsDisabled' | i18n }}"
></i>
</span>
}
}
}

View File

@@ -1,53 +1,102 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component } from "@angular/core";
import { Component, computed, input, inject } from "@angular/core";
import { OrganizationFilterComponent as BaseOrganizationFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/organization-filter.component";
import { DisplayMode } from "@bitwarden/angular/vault/vault-filter/models/display-mode";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
import { OrganizationId } from "@bitwarden/common/types/guid";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { ToastService } from "@bitwarden/components";
import { OrganizationFilter, VaultFilter, CollectionFilter } from "@bitwarden/vault";
import { VAULT_FILTER_IMPORTS } from "../shared-filter-imports";
import { CollectionFilterComponent } from "./collection-filter.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-organization-filter",
templateUrl: "organization-filter.component.html",
standalone: false,
standalone: true,
imports: [...VAULT_FILTER_IMPORTS, CollectionFilterComponent],
})
export class OrganizationFilterComponent extends BaseOrganizationFilterComponent {
get show() {
export class OrganizationFilterComponent {
private toastService: ToastService = inject(ToastService);
private i18nService: I18nService = inject(I18nService);
protected readonly hide = input(false);
protected readonly organizations = input<TreeNode<OrganizationFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected readonly activeOrganizationDataOwnership = input<boolean>(false);
protected readonly activeSingleOrganizationPolicy = input<boolean>(false);
protected readonly hideCollections = input(false);
protected readonly collections = input<TreeNode<CollectionFilter>>();
protected readonly show = computed(() => {
const hiddenDisplayModes: DisplayMode[] = [
"singleOrganizationAndOrganizatonDataOwnershipPolicies",
];
return (
!this.hide &&
this.organizations.length > 0 &&
hiddenDisplayModes.indexOf(this.displayMode) === -1
!this.hide() &&
this.organizations()?.children.length > 0 &&
hiddenDisplayModes.indexOf(this.displayMode()) === -1
);
}
});
constructor(
private i18nService: I18nService,
private platformUtilsService: PlatformUtilsService,
private toastService: ToastService,
) {
super();
}
protected readonly displayMode = computed<DisplayMode>(() => {
let displayMode: DisplayMode = "organizationMember";
if (this.organizations() == null || this.organizations().children.length < 1) {
displayMode = "noOrganizations";
} else if (this.activeOrganizationDataOwnership() && !this.activeSingleOrganizationPolicy()) {
displayMode = "organizationDataOwnershipPolicy";
} else if (!this.activeOrganizationDataOwnership() && this.activeSingleOrganizationPolicy()) {
displayMode = "singleOrganizationPolicy";
} else if (this.activeOrganizationDataOwnership() && this.activeSingleOrganizationPolicy()) {
displayMode = "singleOrganizationAndOrganizatonDataOwnershipPolicies";
}
async applyOrganizationFilter(organization: Organization) {
if (organization.enabled) {
//proceed with default behaviour for enabled organizations
// FIXME: Verify that this floating promise is intentional. If it is, add an explanatory comment and ensure there is proper error handling.
// eslint-disable-next-line @typescript-eslint/no-floating-promises
super.applyOrganizationFilter(organization);
} else {
return displayMode;
});
protected applyFilter(organization: TreeNode<OrganizationFilter>) {
if (!organization.node.enabled) {
this.toastService.showToast({
variant: "error",
title: null,
message: this.i18nService.t("disabledOrganizationFilterError"),
});
return;
}
const filter = this.activeFilter();
if (filter) {
filter.selectedOrganizationNode = organization;
}
}
private readonly collectionsByOrganization = computed(() => {
const collections = this.collections();
const map = new Map<OrganizationId, TreeNode<CollectionFilter>>();
const orgs = this.organizations()?.children;
if (!collections || !orgs) {
return map;
}
for (const org of orgs) {
const filteredCollections = collections.children.filter(
(node) => node.node.organizationId === org.node.id,
);
const headNode = new TreeNode<CollectionFilter>(collections.node, null);
headNode.children = filteredCollections;
map.set(org.node.id, headNode);
}
return map;
});
protected getOrgCollections(organizationId: OrganizationId): TreeNode<CollectionFilter> {
return this.collectionsByOrganization().get(organizationId) ?? null;
}
}

View File

@@ -1,68 +1,25 @@
<ng-container *ngIf="show">
<h2 class="sr-only">{{ "filters" | i18n }}</h2>
<ul class="filter-options">
<li class="filter-option" [ngClass]="{ active: activeFilter.status === 'all' }">
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter('all')"
[attr.aria-pressed]="activeFilter.status === 'all'"
>
<i class="bwi bwi-fw bwi-filter" aria-hidden="true"></i>&nbsp;{{ "allItems" | i18n }}
</button>
</span>
</li>
<li
class="filter-option"
*ngIf="!hideFavorites"
[ngClass]="{ active: activeFilter.status === 'favorites' }"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter('favorites')"
[attr.aria-pressed]="activeFilter.status === 'favorites'"
>
<i class="bwi bwi-fw bwi-star" aria-hidden="true"></i>&nbsp;{{ "favorites" | i18n }}
</button>
</span>
</li>
<li
class="filter-option tw-flex tw-items-center tw-gap-2 [&>span]:tw-w-min"
[ngClass]="{ active: activeFilter.status === 'archive' }"
*ngIf="!hideArchive"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="handleArchiveFilter($event)"
[attr.aria-pressed]="activeFilter.status === 'archive'"
>
<i class="bwi bwi-fw bwi-archive" aria-hidden="true"></i>&nbsp;{{ "archiveNoun" | i18n }}
</button>
</span>
@if (!(canArchive$ | async)) {
<app-premium-badge></app-premium-badge>
}
</li>
<li
class="filter-option"
*ngIf="!hideTrash"
[ngClass]="{ active: activeFilter.status === 'trash' }"
>
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter('trash')"
[attr.aria-pressed]="activeFilter.status === 'trash'"
>
<i class="bwi bwi-fw bwi-trash" aria-hidden="true"></i>&nbsp;{{ "trash" | i18n }}
</button>
</span>
</li>
</ul>
</ng-container>
@if (show()) {
@if (!hideArchive()) {
<bit-nav-item
[icon]="archiveFilter.icon"
[class.active]="activeFilter()?.isArchived"
(click)="handleArchiveFilter($event)"
[text]="archiveFilter.name | i18n"
[attr.aria-pressed]="activeFilter()?.isArchived"
[appA11yTitle]="archiveFilter.name | i18n"
/>
@if (!(canArchive$ | async)) {
<app-premium-badge />
}
}
@if (!hideTrash()) {
<bit-nav-item
[icon]="trashFilter.icon"
[class.active]="activeFilter()?.isDeleted"
(click)="applyFilter('trash')"
[text]="trashFilter.name | i18n"
[attr.aria-pressed]="activeFilter()?.isDeleted"
[appA11yTitle]="trashFilter.name | i18n"
/>
}
}

View File

@@ -1,98 +0,0 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { Utils } from "@bitwarden/common/platform/misc/utils";
import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/spec";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { PremiumUpgradePromptService } from "@bitwarden/common/vault/abstractions/premium-upgrade-prompt.service";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { StatusFilterComponent } from "./status-filter.component";
describe("StatusFilterComponent", () => {
let component: StatusFilterComponent;
let fixture: ComponentFixture<StatusFilterComponent>;
let cipherArchiveService: jest.Mocked<CipherArchiveService>;
let accountService: FakeAccountService;
const mockUserId = Utils.newGuid() as UserId;
const event = new Event("click");
beforeEach(async () => {
accountService = mockAccountServiceWith(mockUserId);
cipherArchiveService = mock<CipherArchiveService>();
await TestBed.configureTestingModule({
declarations: [StatusFilterComponent],
providers: [
{ provide: AccountService, useValue: accountService },
{ provide: CipherArchiveService, useValue: cipherArchiveService },
{ provide: PremiumUpgradePromptService, useValue: mock<PremiumUpgradePromptService>() },
{
provide: BillingAccountProfileStateService,
useValue: mock<BillingAccountProfileStateService>(),
},
{ provide: I18nService, useValue: { t: (key: string) => key } },
],
imports: [JslibModule, PremiumBadgeComponent],
}).compileComponents();
fixture = TestBed.createComponent(StatusFilterComponent);
component = fixture.componentInstance;
component.activeFilter = new VaultFilter();
fixture.detectChanges();
});
describe("handleArchiveFilter", () => {
const applyFilter = jest.fn();
let promptForPremiumSpy: jest.SpyInstance;
beforeEach(() => {
applyFilter.mockClear();
component["applyFilter"] = applyFilter;
promptForPremiumSpy = jest.spyOn(component["premiumBadgeComponent"]()!, "promptForPremium");
});
it("should apply archive filter when userCanArchive returns true", async () => {
cipherArchiveService.userCanArchive$.mockReturnValue(of(true));
cipherArchiveService.archivedCiphers$.mockReturnValue(of([]));
await component["handleArchiveFilter"](event);
expect(applyFilter).toHaveBeenCalledWith("archive");
expect(promptForPremiumSpy).not.toHaveBeenCalled();
});
it("should apply archive filter when userCanArchive returns false but hasArchivedCiphers is true", async () => {
const mockCipher = new CipherView();
mockCipher.id = "test-id";
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
cipherArchiveService.archivedCiphers$.mockReturnValue(of([mockCipher]));
await component["handleArchiveFilter"](event);
expect(applyFilter).toHaveBeenCalledWith("archive");
expect(promptForPremiumSpy).not.toHaveBeenCalled();
});
it("should prompt for premium when userCanArchive returns false and hasArchivedCiphers is false", async () => {
cipherArchiveService.userCanArchive$.mockReturnValue(of(false));
cipherArchiveService.archivedCiphers$.mockReturnValue(of([]));
await component["handleArchiveFilter"](event);
expect(applyFilter).not.toHaveBeenCalled();
expect(promptForPremiumSpy).toHaveBeenCalled();
});
});
});

View File

@@ -1,20 +1,60 @@
import { Component, viewChild } from "@angular/core";
import { Component, viewChild, input, inject, computed } from "@angular/core";
import { combineLatest, firstValueFrom, map, switchMap } from "rxjs";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { StatusFilterComponent as BaseStatusFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/status-filter.component";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { VaultFilter, CipherStatus, CipherTypeFilter } from "@bitwarden/vault";
import { VAULT_FILTER_IMPORTS } from "../shared-filter-imports";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-status-filter",
templateUrl: "status-filter.component.html",
standalone: false,
standalone: true,
imports: [...VAULT_FILTER_IMPORTS, PremiumBadgeComponent],
})
export class StatusFilterComponent extends BaseStatusFilterComponent {
export class StatusFilterComponent {
private accountService: AccountService = inject(AccountService);
private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService);
protected readonly hideTrash = input(false);
protected readonly hideArchive = input(false);
protected readonly activeFilter = input<VaultFilter>();
protected readonly archiveFilter: CipherTypeFilter = {
id: "archive",
name: "archiveNoun",
type: "archive",
icon: "bwi-archive",
};
protected readonly trashFilter: CipherTypeFilter = {
id: "trash",
name: "trash",
type: "trash",
icon: "bwi-trash",
};
protected readonly show = computed(() => {
return !(this.hideTrash() && this.hideArchive());
});
protected applyFilter(filterType: CipherStatus) {
let filter: CipherTypeFilter = null;
if (filterType === "archive") {
filter = this.archiveFilter;
} else if (filterType === "trash") {
filter = this.trashFilter;
}
if (filter) {
this.activeFilter().selectedCipherTypeNode = new TreeNode<CipherTypeFilter>(filter, null);
}
}
private readonly premiumBadgeComponent = viewChild(PremiumBadgeComponent);
private userId$ = this.accountService.activeAccount$.pipe(getUserId);
@@ -28,13 +68,6 @@ export class StatusFilterComponent extends BaseStatusFilterComponent {
),
);
constructor(
private accountService: AccountService,
private cipherArchiveService: CipherArchiveService,
) {
super();
}
protected async handleArchiveFilter(event: Event) {
const [canArchive, hasArchivedCiphers] = await firstValueFrom(
combineLatest([this.canArchive$, this.hasArchivedCiphers$]),

View File

@@ -1,39 +1,10 @@
<div class="filter-heading">
<h2>
<button
type="button"
class="no-btn"
(click)="toggleCollapse()"
[attr.aria-expanded]="!isCollapsed"
aria-controls="type-filters"
>
<i
class="bwi bwi-fw"
aria-hidden="true"
[ngClass]="{
'bwi-angle-right': isCollapsed,
'bwi-angle-down': !isCollapsed,
}"
></i>
&nbsp;{{ typesNode.name | i18n }}
</button>
</h2>
</div>
<ul id="type-filters" *ngIf="!isCollapsed" class="filter-options">
@for (typeFilter of typeFilters$ | async; track typeFilter) {
<li class="filter-option" [ngClass]="{ active: activeFilter.cipherType === typeFilter.type }">
<span class="filter-buttons">
<button
type="button"
class="filter-button"
(click)="applyFilter(typeFilter.type)"
[attr.aria-pressed]="activeFilter.cipherType === typeFilter.type"
>
<i class="bwi bwi-fw {{ typeFilter.icon }}" aria-hidden="true"></i>&nbsp;{{
typeFilter.labelKey | i18n
}}
</button>
</span>
</li>
}
</ul>
@for (typeFilter of typeFilters$ | async; track typeFilter) {
<bit-nav-item
[icon]="typeFilter.node.icon"
[class.active]="activeFilter()?.cipherType === typeFilter.node.type"
(click)="applyFilter(typeFilter)"
[text]="typeFilter.node.name"
[attr.aria-pressed]="activeFilter()?.cipherType === typeFilter.node.type"
[appA11yTitle]="typeFilter.node.name"
/>
}

View File

@@ -1,34 +1,48 @@
import { Component } from "@angular/core";
import { Component, input, inject } from "@angular/core";
import { map, shareReplay } from "rxjs";
import { TypeFilterComponent as BaseTypeFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/type-filter.component";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CIPHER_MENU_ITEMS } from "@bitwarden/common/vault/types/cipher-menu-items";
import { VaultFilter, CipherTypeFilter } from "@bitwarden/vault";
import { VAULT_FILTER_IMPORTS } from "../shared-filter-imports";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-type-filter",
templateUrl: "type-filter.component.html",
standalone: false,
standalone: true,
imports: [...VAULT_FILTER_IMPORTS],
})
export class TypeFilterComponent extends BaseTypeFilterComponent {
export class TypeFilterComponent {
private restrictedItemTypesService: RestrictedItemTypesService = inject(
RestrictedItemTypesService,
);
protected readonly cipherTypes = input<TreeNode<CipherTypeFilter>>();
protected readonly activeFilter = input<VaultFilter>();
protected applyFilter(cipherType: TreeNode<CipherTypeFilter>) {
const filter = this.activeFilter();
if (filter) {
filter.selectedCipherTypeNode = cipherType;
}
}
protected typeFilters$ = this.restrictedItemTypesService.restricted$.pipe(
map((restrictedItemTypes) =>
// Filter out restricted item types from the typeFilters array
CIPHER_MENU_ITEMS.filter(
(typeFilter) =>
this.cipherTypes().children.filter(
(type) =>
!restrictedItemTypes.some(
(restrictedType) =>
restrictedType.allowViewOrgIds.length === 0 &&
restrictedType.cipherType === typeFilter.type,
restrictedType.cipherType === type.node.type,
),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
);
constructor(private restrictedItemTypesService: RestrictedItemTypesService) {
super();
}
}

View File

@@ -0,0 +1,9 @@
import { CommonModule } from "@angular/common";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { NavigationModule } from "@bitwarden/components";
/**
* Common imports shared across all vault filter components.
*/
export const VAULT_FILTER_IMPORTS = [CommonModule, JslibModule, NavigationModule] as const;

View File

@@ -1,51 +1,30 @@
<div class="container loading-spinner" *ngIf="!isLoaded">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<ng-container *ngIf="isLoaded">
<app-organization-filter
class="filter"
[hide]="hideOrganizations"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
[organizations]="organizations"
[activeOrganizationDataOwnership]="activeOrganizationDataOwnershipPolicy"
[activeSingleOrganizationPolicy]="activeSingleOrganizationPolicy"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
></app-organization-filter>
<app-status-filter
class="filter"
[hideFavorites]="hideFavorites"
[hideTrash]="hideTrash"
[hideArchive]="!showArchiveVaultFilter"
[activeFilter]="activeFilter"
(onFilterChange)="applyFilter($event)"
></app-status-filter>
<app-type-filter
class="filter"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
></app-type-filter>
<app-folder-filter
class="filter"
[hide]="hideFolders"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
[folderNodes]="folders$ | async"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
(onAddFolder)="addFolder()"
(onEditFolder)="editFolder($event)"
></app-folder-filter>
<app-collection-filter
class="filter"
[hide]="hideCollections"
[activeFilter]="activeFilter"
[collapsedFilterNodes]="collapsedFilterNodes"
[collectionNodes]="collections"
(onNodeCollapseStateChange)="toggleFilterNodeCollapseState($event)"
(onFilterChange)="applyFilter($event)"
></app-collection-filter>
</ng-container>
@if (!isLoaded) {
<div class="container loading-spinner">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
} @else {
<bit-nav-group icon="bwi-vault" [text]="'vault' | i18n" route="new-vault">
<app-organization-filter
[hide]="hideOrganizations()"
[activeFilter]="activeFilter"
[organizations]="organizations$ | async"
[activeOrganizationDataOwnership]="activeOrganizationDataOwnershipPolicy"
[activeSingleOrganizationPolicy]="activeSingleOrganizationPolicy"
[hideCollections]="hideCollections()"
[collections]="collections$ | async"
/>
<app-type-filter [activeFilter]="activeFilter" [cipherTypes]="cipherTypes$ | async" />
<app-status-filter
[hideTrash]="hideTrash()"
[hideArchive]="!showArchiveVaultFilter"
[activeFilter]="activeFilter"
/>
@if (!hideFolders()) {
<bit-nav-group [icon]="'bwi-folder'" [text]="'folders' | i18n" [variant]="'tree'">
@for (folder of (folders$ | async)?.children ?? []; track folder.node.id) {
<app-folder-filter [activeFilter]="activeFilter" [folder]="folder" />
}
</bit-nav-group>
}
</bit-nav-group>
}

View File

@@ -1,12 +1,109 @@
import { Component } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Component, inject, OnInit, input, output } from "@angular/core";
import { firstValueFrom, Observable, Subject, takeUntil } from "rxjs";
import { VaultFilterComponent as BaseVaultFilterComponent } from "@bitwarden/angular/vault/vault-filter/components/vault-filter.component";
import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction";
import { PolicyType } from "@bitwarden/common/admin-console/enums";
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { UserId } from "@bitwarden/common/types/guid";
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { NavigationModule } from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
OrganizationFilter,
CipherTypeFilter,
CollectionFilter,
FolderFilter,
VaultFilter,
VaultFilterServiceAbstraction as VaultFilterService,
RoutedVaultFilterBridgeService,
} from "@bitwarden/vault";
import { FolderFilterComponent } from "./filters/folder-filter.component";
import { OrganizationFilterComponent } from "./filters/organization-filter.component";
import { StatusFilterComponent } from "./filters/status-filter.component";
import { TypeFilterComponent } from "./filters/type-filter.component";
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
@Component({
selector: "app-vault-filter",
templateUrl: "vault-filter.component.html",
standalone: false,
standalone: true,
imports: [
I18nPipe,
NavigationModule,
CommonModule,
OrganizationFilterComponent,
StatusFilterComponent,
TypeFilterComponent,
FolderFilterComponent,
],
})
export class VaultFilterComponent extends BaseVaultFilterComponent {}
export class VaultFilterComponent implements OnInit {
private routedVaultFilterBridgeService = inject(RoutedVaultFilterBridgeService);
private vaultFilterService: VaultFilterService = inject(VaultFilterService);
private accountService: AccountService = inject(AccountService);
private cipherArchiveService: CipherArchiveService = inject(CipherArchiveService);
private policyService: PolicyService = inject(PolicyService);
private componentIsDestroyed$ = new Subject<boolean>();
protected activeFilter: VaultFilter;
protected readonly hideFolders = input(false);
protected readonly hideCollections = input(false);
protected readonly hideFavorites = input(false);
protected readonly hideTrash = input(false);
protected readonly hideOrganizations = input(false);
protected onFilterChange = output<VaultFilter>();
private activeUserId: UserId;
protected isLoaded = false;
protected showArchiveVaultFilter = false;
protected activeOrganizationDataOwnershipPolicy: boolean;
protected activeSingleOrganizationPolicy: boolean;
protected organizations$: Observable<TreeNode<OrganizationFilter>>;
protected collections$: Observable<TreeNode<CollectionFilter>>;
protected folders$: Observable<TreeNode<FolderFilter>>;
protected cipherTypes$: Observable<TreeNode<CipherTypeFilter>>;
private async setActivePolicies() {
this.activeOrganizationDataOwnershipPolicy = await firstValueFrom(
this.policyService.policyAppliesToUser$(
PolicyType.OrganizationDataOwnership,
this.activeUserId,
),
);
this.activeSingleOrganizationPolicy = await firstValueFrom(
this.policyService.policyAppliesToUser$(PolicyType.SingleOrg, this.activeUserId),
);
}
async ngOnInit(): Promise<void> {
this.activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId));
this.organizations$ = this.vaultFilterService.organizationTree$;
if (
this.organizations$ != null &&
(await firstValueFrom(this.organizations$)).children.length > 0
) {
await this.setActivePolicies();
}
this.cipherTypes$ = this.vaultFilterService.cipherTypeTree$;
this.folders$ = this.vaultFilterService.folderTree$;
this.collections$ = this.vaultFilterService.collectionTree$;
this.showArchiveVaultFilter = await firstValueFrom(
this.cipherArchiveService.hasArchiveFlagEnabled$(),
);
// Subscribe to the active filter from the bridge service
this.routedVaultFilterBridgeService.activeFilter$
.pipe(takeUntil(this.componentIsDestroyed$))
.subscribe((filter) => {
this.activeFilter = filter;
});
this.isLoaded = true;
}
}

View File

@@ -1,34 +0,0 @@
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { DeprecatedVaultFilterService as DeprecatedVaultFilterServiceAbstraction } from "@bitwarden/angular/vault/abstractions/deprecated-vault-filter.service";
import { VaultFilterService } from "@bitwarden/angular/vault/vault-filter/services/vault-filter.service";
import { CollectionFilterComponent } from "./filters/collection-filter.component";
import { FolderFilterComponent } from "./filters/folder-filter.component";
import { OrganizationFilterComponent } from "./filters/organization-filter.component";
import { StatusFilterComponent } from "./filters/status-filter.component";
import { TypeFilterComponent } from "./filters/type-filter.component";
import { VaultFilterComponent } from "./vault-filter.component";
@NgModule({
imports: [CommonModule, JslibModule, PremiumBadgeComponent],
declarations: [
VaultFilterComponent,
CollectionFilterComponent,
FolderFilterComponent,
OrganizationFilterComponent,
StatusFilterComponent,
TypeFilterComponent,
],
exports: [VaultFilterComponent],
providers: [
{
provide: DeprecatedVaultFilterServiceAbstraction,
useClass: VaultFilterService,
},
],
})
export class VaultFilterModule {}

View File

@@ -9,15 +9,21 @@ import {
ViewContainerRef,
} from "@angular/core";
import { ActivatedRoute, Router } from "@angular/router";
import { firstValueFrom, Subject, takeUntil, switchMap, lastValueFrom, Observable } from "rxjs";
import {
firstValueFrom,
Subject,
takeUntil,
switchMap,
lastValueFrom,
Observable,
from,
} from "rxjs";
import { filter, map, take } from "rxjs/operators";
import { CollectionService, CollectionView } from "@bitwarden/admin-console/common";
import { PremiumBadgeComponent } from "@bitwarden/angular/billing/components/premium-badge";
import { VaultViewPasswordHistoryService } from "@bitwarden/angular/services/view-password-history.service";
import { VaultFilter } from "@bitwarden/angular/vault/vault-filter/models/vault-filter.model";
import { AuthRequestServiceAbstraction } from "@bitwarden/auth/common";
import { ApiService } from "@bitwarden/common/abstractions/api.service";
import { EventCollectionService } from "@bitwarden/common/abstractions/event/event-collection.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";
@@ -28,7 +34,6 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service";
import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service";
import { EventType } from "@bitwarden/common/enums";
import { BroadcasterService } from "@bitwarden/common/platform/abstractions/broadcaster.service";
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { MessagingService } from "@bitwarden/common/platform/abstractions/messaging.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
@@ -60,8 +65,6 @@ import {
} from "@bitwarden/components";
import { I18nPipe } from "@bitwarden/ui-common";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
AttachmentDialogResult,
AttachmentsV2Component,
ChangeLoginPasswordService,
@@ -78,17 +81,17 @@ import {
PasswordRepromptService,
CipherFormComponent,
ArchiveCipherUtilitiesService,
VaultFilter,
RoutedVaultFilterBridgeService,
VaultFilterServiceAbstraction as VaultFilterService,
} from "@bitwarden/vault";
import { SearchBarService } from "../../../app/layout/search/search-bar.service";
import { DesktopCredentialGenerationService } from "../../../services/desktop-cipher-form-generator.service";
import { DesktopPremiumUpgradePromptService } from "../../../services/desktop-premium-upgrade-prompt.service";
import { VaultStateService } from "../../../services/vault-state.service";
import { invokeMenu, RendererMenuItem } from "../../../utils";
import { AssignCollectionsDesktopComponent } from "../vault/assign-collections";
import { ItemFooterComponent } from "../vault/item-footer.component";
import { VaultFilterComponent } from "../vault/vault-filter/vault-filter.component";
import { VaultFilterModule } from "../vault/vault-filter/vault-filter.module";
import { VaultItemsV2Component } from "../vault/vault-items-v2.component";
const BroadcasterSubscriptionId = "VaultComponent";
@@ -108,7 +111,6 @@ const BroadcasterSubscriptionId = "VaultComponent";
ItemModule,
ButtonModule,
PremiumBadgeComponent,
VaultFilterModule,
VaultItemsV2Component,
],
providers: [
@@ -135,17 +137,11 @@ const BroadcasterSubscriptionId = "VaultComponent";
},
],
})
export class VaultComponent<C extends CipherViewLike>
implements OnInit, OnDestroy, CopyClickListener
{
export class VaultComponent implements OnInit, OnDestroy, CopyClickListener {
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(VaultItemsV2Component, { static: true })
vaultItemsComponent: VaultItemsV2Component<C> | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild(VaultFilterComponent, { static: true })
vaultFilterComponent: VaultFilterComponent | null = null;
vaultItemsComponent: VaultItemsV2Component<CipherView> | null = null;
// FIXME(https://bitwarden.atlassian.net/browse/CL-903): Migrate to Signals
// eslint-disable-next-line @angular-eslint/prefer-signals
@ViewChild("folderAddEdit", { read: ViewContainerRef, static: true })
@@ -195,6 +191,7 @@ export class VaultComponent<C extends CipherViewLike>
private componentIsDestroyed$ = new Subject<boolean>();
private allOrganizations: Organization[] = [];
private allCollections: CollectionView[] = [];
private filteredCollections: CollectionView[] = [];
constructor(
private route: ActivatedRoute,
@@ -210,7 +207,6 @@ export class VaultComponent<C extends CipherViewLike>
private totpService: TotpService,
private passwordRepromptService: PasswordRepromptService,
private searchBarService: SearchBarService,
private apiService: ApiService,
private dialogService: DialogService,
private billingAccountProfileStateService: BillingAccountProfileStateService,
private toastService: ToastService,
@@ -221,12 +217,12 @@ export class VaultComponent<C extends CipherViewLike>
private collectionService: CollectionService,
private organizationService: OrganizationService,
private folderService: FolderService,
private configService: ConfigService,
private authRequestService: AuthRequestServiceAbstraction,
private cipherArchiveService: CipherArchiveService,
private policyService: PolicyService,
private archiveCipherUtilitiesService: ArchiveCipherUtilitiesService,
private vaultStateService: VaultStateService,
private routedVaultFilterBridgeService: RoutedVaultFilterBridgeService,
private vaultFilterService: VaultFilterService,
) {}
async ngOnInit() {
@@ -242,26 +238,10 @@ export class VaultComponent<C extends CipherViewLike>
this.userHasPremiumAccess = canAccessPremium;
});
// Subscribe to filter changes from VaultNavComponent
this.vaultStateService.filterChange$
// Subscribe to filter changes from router params via the bridge service
this.routedVaultFilterBridgeService.activeFilter$
.pipe(
switchMap((vaultFilter: VaultFilter) => this.applyVaultFilter(vaultFilter)),
takeUntil(this.componentIsDestroyed$),
)
.subscribe();
// Subscribe to add folder requests from VaultNavComponent
this.vaultStateService.addFolder$
.pipe(
switchMap(() => this.addFolder()),
takeUntil(this.componentIsDestroyed$),
)
.subscribe();
// Subscribe to edit folder requests from VaultNavComponent
this.vaultStateService.editFolder$
.pipe(
switchMap((folderId: string) => this.editFolder(folderId)),
switchMap((vaultFilter: VaultFilter) => from(this.applyVaultFilter(vaultFilter))),
takeUntil(this.componentIsDestroyed$),
)
.subscribe();
@@ -293,15 +273,12 @@ export class VaultComponent<C extends CipherViewLike>
break;
case "syncCompleted":
if (this.vaultItemsComponent) {
await this.vaultItemsComponent
.reload(this.activeFilter.buildFilter())
.catch(() => {});
}
if (this.vaultFilterComponent) {
await this.vaultFilterComponent
.reloadCollectionsAndFolders(this.activeFilter)
.catch(() => {});
await this.vaultFilterComponent.reloadOrganizations().catch(() => {});
// const filterFn = this.wrapFilterForCipherListView(
// this.activeFilter.buildFilter(),
// );
// await this.vaultItemsComponent.reload(filterFn).catch(() => {});
const filter = this.activeFilter.buildFilter();
await this.vaultItemsComponent.reload(filter).catch(() => {});
}
break;
case "modalShown":
@@ -403,6 +380,12 @@ export class VaultComponent<C extends CipherViewLike>
.subscribe((collections) => {
this.allCollections = collections;
});
this.vaultFilterService.filteredCollections$
.pipe(takeUntil(this.componentIsDestroyed$))
.subscribe((collections) => {
this.filteredCollections = collections;
});
}
ngOnDestroy() {
@@ -429,19 +412,6 @@ export class VaultComponent<C extends CipherViewLike>
this.addType = paramCipherAddType;
await this.addCipher(this.addType).catch(() => {});
}
const paramCipherType = toCipherType(params.type);
this.activeFilter = new VaultFilter({
status: params.deleted ? "trash" : params.favorites ? "favorites" : "all",
cipherType: params.action === "add" || paramCipherType == null ? undefined : paramCipherType,
selectedFolderId: params.folderId,
selectedCollectionId: params.selectedCollectionId,
selectedOrganizationId: params.selectedOrganizationId,
myVaultOnly: params.myVaultOnly ?? false,
});
if (this.vaultItemsComponent) {
await this.vaultItemsComponent.reload(this.activeFilter.buildFilter()).catch(() => {});
}
}
/**
@@ -465,9 +435,7 @@ export class VaultComponent<C extends CipherViewLike>
this.cipherId = cipher.id;
this.cipher = cipher;
this.collections =
this.vaultFilterComponent?.collections?.fullList.filter((c) =>
cipher.collectionIds.includes(c.id),
) ?? null;
this.filteredCollections?.filter((c) => cipher.collectionIds.includes(c.id)) ?? null;
this.action = "view";
await this.go().catch(() => {});
@@ -824,19 +792,45 @@ export class VaultComponent<C extends CipherViewLike>
await this.go().catch(() => {});
}
/**
* Wraps a filter function to handle CipherListView objects.
* CipherListView has a different type structure where type can be a string or object.
* This wrapper converts it to CipherView-compatible structure before filtering.
*/
private wrapFilterForCipherListView(
filterFn: (cipher: CipherView) => boolean,
): (cipher: CipherViewLike) => boolean {
return (cipher: CipherViewLike) => {
// For CipherListView, create a proxy object with the correct type property
if (CipherViewLikeUtils.isCipherListView(cipher)) {
const proxyCipher = {
...cipher,
type: CipherViewLikeUtils.getType(cipher),
// Normalize undefined organizationId to null for filter compatibility
organizationId: cipher.organizationId ?? null,
// Explicitly include isDeleted and isArchived since they might be getters
isDeleted: CipherViewLikeUtils.isDeleted(cipher),
isArchived: CipherViewLikeUtils.isArchived(cipher),
};
return filterFn(proxyCipher as any);
}
};
}
async applyVaultFilter(vaultFilter: VaultFilter) {
this.searchBarService.setPlaceholderText(
this.i18nService.t(this.calculateSearchBarLocalizationString(vaultFilter)),
);
this.activeFilter = vaultFilter;
await this.vaultItemsComponent
?.reload(
this.activeFilter.buildFilter(),
vaultFilter.status === "trash",
vaultFilter.status === "archive",
)
.catch(() => {});
await this.go().catch(() => {});
const originalFilterFn = this.activeFilter.buildFilter();
const wrappedFilterFn = this.wrapFilterForCipherListView(originalFilterFn);
await this.vaultItemsComponent?.reload(
wrappedFilterFn,
vaultFilter.isDeleted,
vaultFilter.isArchived,
);
}
private getAvailableCollections(cipher: CipherView): CollectionView[] {
@@ -850,25 +844,25 @@ export class VaultComponent<C extends CipherViewLike>
}
private calculateSearchBarLocalizationString(vaultFilter: VaultFilter): string {
if (vaultFilter.status === "favorites") {
if (vaultFilter.isFavorites) {
return "searchFavorites";
}
if (vaultFilter.status === "trash") {
if (vaultFilter.isDeleted) {
return "searchTrash";
}
if (vaultFilter.cipherType != null) {
return "searchType";
}
if (vaultFilter.selectedFolderId != null && vaultFilter.selectedFolderId !== "none") {
if (vaultFilter.folderId != null && vaultFilter.folderId !== "none") {
return "searchFolder";
}
if (vaultFilter.selectedCollectionId != null) {
if (vaultFilter.collectionId != null) {
return "searchCollection";
}
if (vaultFilter.selectedOrganizationId != null) {
if (vaultFilter.organizationId != null) {
return "searchOrganization";
}
if (vaultFilter.myVaultOnly) {
if (vaultFilter.isMyVaultSelected) {
return "searchMyVault";
}
return "searchVault";
@@ -889,23 +883,6 @@ export class VaultComponent<C extends CipherViewLike>
if (!folderView) {
return;
}
const dialogRef = AddEditFolderDialogComponent.open(this.dialogService, {
editFolderConfig: {
folder: {
...folderView,
},
},
});
const result = await lastValueFrom(dialogRef.closed);
if (
result === AddEditFolderDialogResult.Deleted ||
result === AddEditFolderDialogResult.Created
) {
await this.vaultFilterComponent?.reloadCollectionsAndFolders(this.activeFilter);
}
}
/** Refresh the current cipher object */
@@ -992,22 +969,22 @@ export class VaultComponent<C extends CipherViewLike>
}
private prefillCipherFromFilter() {
if (this.activeFilter.selectedCollectionId != null && this.vaultFilterComponent != null) {
const collections = this.vaultFilterComponent.collections?.fullList.filter(
(c) => c.id === this.activeFilter.selectedCollectionId,
if (this.activeFilter.collectionId != null) {
const collections = this.filteredCollections?.filter(
(c) => c.id === this.activeFilter.collectionId,
);
if (collections.length > 0) {
if (collections?.length > 0) {
this.addOrganizationId = collections[0].organizationId;
this.addCollectionIds = [this.activeFilter.selectedCollectionId];
this.addCollectionIds = [this.activeFilter.collectionId];
}
} else if (this.activeFilter.selectedOrganizationId) {
this.addOrganizationId = this.activeFilter.selectedOrganizationId;
} else if (this.activeFilter.organizationId) {
this.addOrganizationId = this.activeFilter.organizationId;
} else {
// clear out organizationId when the user switches to a personal vault filter
this.addOrganizationId = null;
}
if (this.activeFilter.selectedFolderId && this.activeFilter.selectedFolder) {
this.folderId = this.activeFilter.selectedFolderId;
if (this.activeFilter.folderId && this.activeFilter.selectedFolderNode) {
this.folderId = this.activeFilter.folderId;
}
if (this.config == null) {