From 5208206034bc8c50b8467fe576347563e38282e3 Mon Sep 17 00:00:00 2001 From: Matt Gibson Date: Wed, 19 Mar 2025 20:02:58 -0700 Subject: [PATCH] Very rudimentary saved filters interface --- .../vault-filter/vault-filter.component.ts | 3 + .../components/vault-filter.component.html | 5 +- .../components/vault-filter.component.ts | 31 +++++- .../individual-vault/vault.component.html | 2 +- .../src/services/jslib-services.module.ts | 9 ++ .../src/vault/search/saved-filters.service.ts | 42 ++++++-- .../src/search/search.component.html | 96 +++++++++++-------- .../components/src/search/search.component.ts | 20 +++- libs/components/src/search/search.module.ts | 4 +- 9 files changed, 157 insertions(+), 55 deletions(-) diff --git a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts index 613618c9d1e..28c222ba8e7 100644 --- a/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts +++ b/apps/web/src/app/admin-console/organizations/collections/vault-filter/vault-filter.component.ts @@ -10,6 +10,7 @@ import { ConfigService } from "@bitwarden/common/platform/abstractions/config/co import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { SavedFiltersService } from "@bitwarden/common/vault/search/saved-filters.service"; import { SearchHistoryService } from "@bitwarden/common/vault/search/search-history.service"; import { DialogService, ToastService } from "@bitwarden/components"; @@ -50,6 +51,7 @@ export class VaultFilterComponent protected dialogService: DialogService, protected configService: ConfigService, searchHistoryService: SearchHistoryService, + savedFiltersService: SavedFiltersService, ) { super( vaultFilterService, @@ -61,6 +63,7 @@ export class VaultFilterComponent dialogService, configService, searchHistoryService, + savedFiltersService, ); } diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html index 8dfc246c970..de2eda3813a 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.html @@ -1,10 +1,13 @@ -
+
diff --git a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts index fb1e5030c55..a9427f072e3 100644 --- a/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault-filter/components/vault-filter.component.ts @@ -2,7 +2,16 @@ // @ts-strict-ignore import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core"; import { Router } from "@angular/router"; -import { debounceTime, firstValueFrom, merge, mergeMap, Subject, switchMap, takeUntil } from "rxjs"; +import { + debounceTime, + firstValueFrom, + merge, + mergeMap, + Observable, + Subject, + switchMap, + takeUntil, +} from "rxjs"; import { PolicyService } from "@bitwarden/common/admin-console/abstractions/policy/policy.service.abstraction"; import { PolicyType } from "@bitwarden/common/admin-console/enums"; @@ -14,6 +23,11 @@ import { Utils } from "@bitwarden/common/platform/misc/utils"; import { OrganizationId, UserId } from "@bitwarden/common/types/guid"; import { CipherType } from "@bitwarden/common/vault/enums"; import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { + FilterName, + FilterString, + SavedFiltersService, +} from "@bitwarden/common/vault/search/saved-filters.service"; import { SearchHistory, SearchHistoryService, @@ -100,6 +114,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { private trialFlowService = inject(TrialFlowService); protected searchHistory: SearchHistory; + protected savedFilters$: Observable>; constructor( protected vaultFilterService: VaultFilterService, @@ -111,6 +126,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { protected dialogService: DialogService, protected configService: ConfigService, protected searchHistoryService: SearchHistoryService, + protected savedFiltersService: SavedFiltersService, ) {} async ngOnInit(): Promise { @@ -133,6 +149,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy { }); this.searchHistory = this.searchHistoryService.searchHistory(this.userId, this.organizationId); + this.savedFilters$ = this.savedFiltersService.filtersFor$(this.userId); this.searchTextChanged .asObservable() .pipe( @@ -200,6 +217,18 @@ export class VaultFilterComponent implements OnInit, OnDestroy { this.onEditFolder.emit(folder); }; + async onFilterSaved({ name, filter }: { name: string; filter: string }) { + await this.savedFiltersService.saveFilter( + this.userId, + name as FilterName, + filter as FilterString, + ); + } + + async onFilterDeleted({ name }: { name: string }) { + await this.savedFiltersService.deleteFilter(this.userId, name as FilterName); + } + async getDefaultFilter(): Promise> { return await firstValueFrom(this.filters?.typeFilter.data$); } diff --git a/apps/web/src/app/vault/individual-vault/vault.component.html b/apps/web/src/app/vault/individual-vault/vault.component.html index c66509b1f41..2a1e2c50348 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.html +++ b/apps/web/src/app/vault/individual-vault/vault.component.html @@ -28,7 +28,7 @@ {{ trashCleanupWarning }} -
+
>; - abstract SaveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise; + abstract saveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise; + abstract deleteFilter(userId: UserId, name: FilterName): Promise; } export type FilterName = string & Tagged<"FilterName">; @@ -66,7 +67,7 @@ export class DefaultSavedFiltersService implements SavedFiltersService { return decryptedState; } - async SaveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise { + async saveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise { const state = this.stateProvider.get(userId, SavedFiltersStateDefinition); await state.update((_, newState) => newState, { combineLatestWith: state.state$.pipe( @@ -80,7 +81,28 @@ export class DefaultSavedFiltersService implements SavedFiltersService { return [oldState, userKey] as const; }), mergeMap(async ([newState, userKey]) => { - return await this.encryptHistory(newState, userKey); + return await this.encryptFilters(newState, userKey); + }), + ), + shouldUpdate: (oldEncrypted, newEncrypted) => !recordsEqual(oldEncrypted, newEncrypted), + }); + } + + async deleteFilter(userId: UserId, name: FilterName): Promise { + const state = this.stateProvider.get(userId, SavedFiltersStateDefinition); + await state.update((_, newState) => newState, { + combineLatestWith: state.state$.pipe( + combineLatestWith(this.keyService.userKey$(userId)), + mergeMap(async ([encrypted, userKey]) => { + return [await this.decryptFilters(encrypted, userKey), userKey] as const; + }), + map(([oldState, userKey]) => { + oldState ??= {}; + delete oldState[name]; + return [oldState, userKey] as const; + }), + mergeMap(async ([newState, userKey]) => { + return await this.encryptFilters(newState, userKey); }), ), shouldUpdate: (oldEncrypted, newEncrypted) => !recordsEqual(oldEncrypted, newEncrypted), @@ -88,15 +110,15 @@ export class DefaultSavedFiltersService implements SavedFiltersService { } private async decryptFilters( - history: UserSearchFilters | null, + filters: UserSearchFilters | null, userKey: SymmetricCryptoKey | null, ): Promise { const decrypted: DecryptedSearchFilters = {}; - if (history == null || userKey == null) { + if (filters == null || userKey == null) { return decrypted; } - for (const [k, v] of Object.entries(history)) { + for (const [k, v] of Object.entries(filters)) { const encryptedKey = new EncString(k as EncryptedString); const key = (await encryptedKey.decryptWithKey(userKey, this.encryptService)) as FilterName; decrypted[key] = (await v.decryptWithKey(userKey, this.encryptService)) as FilterString; @@ -104,16 +126,16 @@ export class DefaultSavedFiltersService implements SavedFiltersService { return decrypted; } - private async encryptHistory( - history: DecryptedSearchFilters | null, + private async encryptFilters( + filters: DecryptedSearchFilters | null, userKey: SymmetricCryptoKey | null, ) { - if (history == null || userKey == null) { + if (filters == null || userKey == null) { return null; } const encrypted: UserSearchFilters = {}; - for (const [k, v] of Object.entries(history)) { + for (const [k, v] of Object.entries(filters)) { const DecryptedKey = k as FilterName; const key = (await this.encryptService.encrypt(DecryptedKey, userKey)).encryptedString!; encrypted[key] = await this.encryptService.encrypt(v, userKey); diff --git a/libs/components/src/search/search.component.html b/libs/components/src/search/search.component.html index 1af0e4b5678..68b1e5bdf42 100644 --- a/libs/components/src/search/search.component.html +++ b/libs/components/src/search/search.component.html @@ -1,42 +1,53 @@ -
-
- - - - +
+
+
+ + + + +
+ + +
@@ -50,7 +61,14 @@ (click)="writeValue(data.filter); onChange(data.filter); onTouch()" class="tw-pl-9 tw-pr-2 tw-block tw-text-ellipsis tw-text-nowrap tw-overflow-hidden hover:tw-bg-background-alt" > - {{ data.name }} +
+ + {{ data.name }} + +
+ +
+
diff --git a/libs/components/src/search/search.component.ts b/libs/components/src/search/search.component.ts index c44e5d7155e..db1d8782f7a 100644 --- a/libs/components/src/search/search.component.ts +++ b/libs/components/src/search/search.component.ts @@ -1,7 +1,7 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore import { CommonModule } from "@angular/common"; -import { Component, ElementRef, Input, ViewChild } from "@angular/core"; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from "@angular/core"; import { ControlValueAccessor, NG_VALUE_ACCESSOR, @@ -15,6 +15,7 @@ import { I18nPipe } from "@bitwarden/ui-common"; import { IconButtonModule } from "../icon-button"; import { InputModule } from "../input/input.module"; +import { LinkModule } from "../link"; import { FocusableElement } from "../shared/focusable-element"; let nextId = 0; @@ -41,6 +42,7 @@ let nextId = 0; I18nPipe, CommonModule, IconButtonModule, + LinkModule, ], }) export class SearchComponent implements ControlValueAccessor, FocusableElement { @@ -54,7 +56,7 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { // Use `type="text"` for Safari to improve rendering performance protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const); private focused = false; - private textUpdated$ = new BehaviorSubject(""); + protected textUpdated$ = new BehaviorSubject(""); protected showSavedFilters = false; @Input() disabled: boolean; @@ -62,6 +64,8 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { @Input() autocomplete: string; @Input() history: string[] | null; @Input() savedFilters: Record | null; + @Output() filterSaved = new EventEmitter<{ name: string; filter: string }>(); + @Output() filterDeleted = new EventEmitter<{ name: string; filter: string }>(); get savedFilterData() { if (this.savedFilters == null) { @@ -104,6 +108,7 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { if (this.notifyOnChange != undefined) { this.notifyOnChange(searchText); } + this.searchText = searchText; this.textUpdated$.next(searchText); } @@ -142,4 +147,15 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { filterShown() { return this._selectedContent.value !== "filter"; } + + saveFilter() { + this.filterSaved.emit({ + name: this.searchText, + filter: this.searchText, + }); + } + + deleteFilter(toDelete: { name: string; filter: string }) { + this.filterDeleted.emit(toDelete); + } } diff --git a/libs/components/src/search/search.module.ts b/libs/components/src/search/search.module.ts index cb9761eae6b..fa7b59be762 100644 --- a/libs/components/src/search/search.module.ts +++ b/libs/components/src/search/search.module.ts @@ -1,9 +1,11 @@ import { NgModule } from "@angular/core"; +import { ButtonModule } from "../button"; + import { SearchComponent } from "./search.component"; @NgModule({ - imports: [SearchComponent], + imports: [SearchComponent, ButtonModule], exports: [SearchComponent], }) export class SearchModule {}