1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Introduce search history

This is a first pass that needs a lot of UX and accessibility cleanup
This commit is contained in:
Matt Gibson
2025-03-13 15:08:07 -07:00
parent 9353cfb6fb
commit 98a3e85d13
12 changed files with 351 additions and 33 deletions

View File

@@ -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 { SearchHistoryService } from "@bitwarden/common/vault/search/search-history.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { VaultFilterComponent as BaseVaultFilterComponent } from "../../../../vault/individual-vault/vault-filter/components/vault-filter.component";
@@ -48,6 +49,7 @@ export class VaultFilterComponent
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
searchHistoryService: SearchHistoryService,
) {
super(
vaultFilterService,
@@ -58,6 +60,7 @@ export class VaultFilterComponent
billingApiService,
dialogService,
configService,
searchHistoryService,
);
}

View File

@@ -51,6 +51,8 @@
[organization]="organization"
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
[userId]="userId$ | async"
[organizationId]="organizationId$ | async"
(searchTextChanged)="filterSearchText($event)"
></app-organization-vault-filter>
</div>

View File

@@ -181,6 +181,8 @@ export class VaultComponent implements OnInit, OnDestroy {
protected resellerWarning$: Observable<ResellerWarning | null>;
protected prevCipherId: string | null = null;
protected userId: UserId;
protected organizationId$: Observable<OrganizationId>;
protected userId$: Observable<UserId>;
/**
* A list of collections that the user can assign items to and edit those items within.
* @protected
@@ -262,7 +264,8 @@ export class VaultComponent implements OnInit, OnDestroy {
) {}
async ngOnInit() {
this.userId = await firstValueFrom(getUserId(this.accountService.activeAccount$));
this.userId$ = getUserId(this.accountService.activeAccount$);
this.userId = await firstValueFrom(this.userId$);
this.resellerManagedOrgAlert = await this.configService.getFeatureFlag(
FeatureFlag.ResellerManagedOrgAlert,
@@ -275,8 +278,8 @@ export class VaultComponent implements OnInit, OnDestroy {
);
const filter$ = this.routedVaultFilterService.filter$;
const organizationId$ = filter$.pipe(
map((filter) => filter.organizationId),
this.organizationId$ = filter$.pipe(
map((filter) => filter.organizationId as OrganizationId),
filter((filter) => filter !== undefined),
distinctUntilChanged(),
);
@@ -284,7 +287,7 @@ export class VaultComponent implements OnInit, OnDestroy {
const organization$ = this.accountService.activeAccount$.pipe(
map((account) => account?.id),
switchMap((id) =>
organizationId$.pipe(
this.organizationId$.pipe(
switchMap((organizationId) =>
this.organizationService
.organizations$(id)
@@ -349,7 +352,7 @@ export class VaultComponent implements OnInit, OnDestroy {
this.currentSearchText$ = this.route.queryParams.pipe(map((queryParams) => queryParams.search));
this.allCollectionsWithoutUnassigned$ = this.refresh$.pipe(
switchMap(() => organizationId$),
switchMap(() => this.organizationId$),
switchMap((orgId) => this.collectionAdminService.getAll(orgId)),
shareReplay({ refCount: false, bufferSize: 1 }),
);
@@ -367,7 +370,7 @@ export class VaultComponent implements OnInit, OnDestroy {
);
const allCollections$ = combineLatest([
organizationId$,
this.organizationId$,
this.allCollectionsWithoutUnassigned$,
]).pipe(
map(([organizationId, allCollections]) => {
@@ -379,7 +382,7 @@ export class VaultComponent implements OnInit, OnDestroy {
}),
);
const allGroups$ = organizationId$.pipe(
const allGroups$ = this.organizationId$.pipe(
switchMap((organizationId) => this.groupService.getAll(organizationId)),
shareReplay({ refCount: true, bufferSize: 1 }),
);

View File

@@ -25,6 +25,7 @@
placeholder="{{ searchPlaceholder | i18n }}"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
[history]="searchHistory.history$ | async"
autocomplete="off"
appAutofocus
/>

View File

@@ -2,7 +2,7 @@
// @ts-strict-ignore
import { Component, EventEmitter, inject, Input, OnDestroy, OnInit, Output } from "@angular/core";
import { Router } from "@angular/router";
import { firstValueFrom, merge, Subject, switchMap, takeUntil } from "rxjs";
import { debounceTime, firstValueFrom, merge, mergeMap, 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";
@@ -10,8 +10,14 @@ import { BillingApiServiceAbstraction } from "@bitwarden/common/billing/abstract
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { PlatformUtilsService } from "@bitwarden/common/platform/abstractions/platform-utils.service";
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 {
SearchHistory,
SearchHistoryService,
} from "@bitwarden/common/vault/search/search-history.service";
import { DialogService, ToastService } from "@bitwarden/components";
import { TrialFlowService } from "../../../../billing/services/trial-flow.service";
@@ -43,6 +49,8 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
@Input() searchText = "";
@Output() searchTextChanged = new EventEmitter<string>();
@Input({ required: true }) userId!: UserId;
@Input() organizationId: OrganizationId | undefined;
isLoaded = false;
@@ -91,6 +99,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
}
private trialFlowService = inject(TrialFlowService);
protected searchHistory: SearchHistory;
constructor(
protected vaultFilterService: VaultFilterService,
@@ -101,6 +110,7 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
protected billingApiService: BillingApiServiceAbstraction,
protected dialogService: DialogService,
protected configService: ConfigService,
protected searchHistoryService: SearchHistoryService,
) {}
async ngOnInit(): Promise<void> {
@@ -121,6 +131,21 @@ export class VaultFilterComponent implements OnInit, OnDestroy {
.subscribe((orgFilters) => {
this.filters.organizationFilter = orgFilters;
});
this.searchHistory = this.searchHistoryService.searchHistory(this.userId, this.organizationId);
this.searchTextChanged
.asObservable()
.pipe(
debounceTime(1000),
mergeMap(async (searchText) => {
if (Utils.isNullOrEmpty(searchText)) {
return;
}
await this.searchHistory.push(searchText);
}),
takeUntil(this.destroy$),
)
.subscribe();
}
ngOnDestroy() {

View File

@@ -28,6 +28,7 @@
#vaultFilter
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
[userId]="userId$ | async"
(searchTextChanged)="filterSearchText($event)"
(onEditFolder)="editFolder($event)"
></app-vault-filter>

View File

@@ -174,11 +174,12 @@ export class VaultComponent implements OnInit, OnDestroy {
private refresh$ = new BehaviorSubject<void>(null);
private destroy$ = new Subject<void>();
private hasSubscription$ = new BehaviorSubject<boolean>(false);
protected userId$ = this.accountService.activeAccount$.pipe(map((a) => a?.id));
private vaultItemDialogRef?: DialogRef<VaultItemDialogResult> | undefined;
private organizations$ = this.accountService.activeAccount$
.pipe(map((a) => a?.id))
.pipe(switchMap((id) => this.organizationService.organizations$(id)));
private organizations$ = this.userId$.pipe(
switchMap((id) => this.organizationService.organizations$(id)),
);
private readonly unpaidSubscriptionDialog$ = this.organizations$.pipe(
filter((organizations) => organizations.length === 1),

View File

@@ -270,6 +270,10 @@ import {
import { TotpService as TotpServiceAbstraction } from "@bitwarden/common/vault/abstractions/totp.service";
import { VaultSettingsService as VaultSettingsServiceAbstraction } from "@bitwarden/common/vault/abstractions/vault-settings/vault-settings.service";
import { DefaultFilterService, FilterService } from "@bitwarden/common/vault/search/filter.service";
import {
SearchHistoryService,
DefaultSearchHistoryService,
} from "@bitwarden/common/vault/search/search-history.service";
import {
CipherAuthorizationService,
DefaultCipherAuthorizationService,
@@ -1471,6 +1475,11 @@ const safeProviders: SafeProvider[] = [
useClass: DefaultFilterService,
deps: [],
}),
safeProvider({
provide: SearchHistoryService,
useClass: DefaultSearchHistoryService,
deps: [SingleUserStateProvider, EncryptService, KeyService],
}),
];
@NgModule({

View File

@@ -0,0 +1,222 @@
import { Observable, combineLatestWith, map, mergeMap } from "rxjs";
// eslint-disable-next-line no-restricted-imports -- TODO this will need to move
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import {
SingleUserState,
SingleUserStateProvider,
UserKeyDefinition,
VAULT_SETTINGS_DISK,
} from "../../platform/state";
import { OrganizationId, UserId } from "../../types/guid";
export abstract class SearchHistoryService {
abstract searchHistory(userId: UserId, orgId?: OrganizationId | undefined): SearchHistory;
}
export class SearchHistory {
history$: Observable<string[]>;
private readonly userHistoryIndexer: UserId | OrganizationId;
constructor(
readonly userId: UserId,
readonly orgId: OrganizationId | undefined,
readonly historyLength: number,
private readonly userHistory$: Observable<DecryptedSearchHistory>,
private readonly updateCallback: (newSearch: string) => Promise<void>,
) {
this.userHistoryIndexer = orgId ?? userId;
this.history$ = userHistory$.pipe(map((h) => h?.[this.userHistoryIndexer] ?? []));
}
async push(newSearch: string) {
await this.updateCallback(newSearch);
}
}
type UserSearchHistory = Record<UserId | OrganizationId, EncString[]>;
type DecryptedSearchHistory = Record<UserId | OrganizationId, string[]>;
const SearchHistoryStateDefinition = new UserKeyDefinition<UserSearchHistory>(
VAULT_SETTINGS_DISK,
"searchHistory",
{
deserializer: (value) => {
if (value == null) {
return {};
}
const result: Record<UserId | OrganizationId, EncString[]> = {};
for (const [k, v] of Object.entries(value)) {
const key = k as UserId | OrganizationId;
const value = v as string[];
result[key] = value?.map((v) => new EncString(v)) ?? [];
}
return result;
},
clearOn: ["logout"],
},
);
export class DefaultSearchHistoryService implements SearchHistoryService {
private readonly historyLength = 3;
constructor(
private readonly stateProvider: SingleUserStateProvider,
private readonly encryptService: EncryptService,
private readonly keyService: KeyService,
) {}
searchHistory(userId: UserId, orgId?: OrganizationId): SearchHistory {
const state = this.stateProvider.get(userId, SearchHistoryStateDefinition);
const decryptedState = state.state$.pipe(
combineLatestWith(this.keyService.userKey$(userId)),
mergeMap(async ([state, userKey]) => {
if (userKey == null || state == null) {
return {};
}
return await this.decryptHistory(state, userKey);
}),
);
return new SearchHistory(userId, orgId, this.historyLength, decryptedState, (newSearch) =>
this.updateHistory(userId, orgId, newSearch, state),
);
}
private async decryptHistory(
history: UserSearchHistory | null,
userKey: SymmetricCryptoKey | null,
): Promise<DecryptedSearchHistory> {
const decrypted: DecryptedSearchHistory = {};
if (history == null || userKey == null) {
return decrypted;
}
for (const [k, v] of Object.entries(history)) {
const key = k as UserId | OrganizationId;
decrypted[key] = [];
for (const item of v) {
decrypted[key].push(await this.encryptService.decryptToUtf8(item, userKey));
}
}
return decrypted;
}
private async encryptHistory(
history: DecryptedSearchHistory | null,
userKey: SymmetricCryptoKey | null,
) {
if (history == null || userKey == null) {
return null;
}
const encrypted: UserSearchHistory = {};
for (const [k, v] of Object.entries(history)) {
const key = k as UserId | OrganizationId;
encrypted[key] = [];
for (const item of v) {
encrypted[key].push(await this.encryptService.encrypt(item, userKey));
}
}
return encrypted;
}
private async updateHistory(
userId: UserId,
orgId: OrganizationId | undefined,
newSearch: string,
state: SingleUserState<UserSearchHistory>,
): Promise<void> {
const userHistoryIndexer = orgId ?? userId;
await state.update((_, newState) => newState, {
combineLatestWith: state.state$.pipe(
combineLatestWith(this.keyService.userKey$(userId)),
mergeMap(async ([encrypted, userKey]) => {
return [await this.decryptHistory(encrypted, userKey), userKey] as const;
}),
map(([userHistory, userKey]) => {
userHistory ??= {};
const history = userHistory[userHistoryIndexer] ?? [];
// Use combineLatestWith to update to the latest state
if (newSearch == null || newSearch === "") {
userHistory[userHistoryIndexer] = history;
return [userHistory, userKey] as const;
}
if (history == null) {
userHistory[userHistoryIndexer] = [newSearch];
return [userHistory, userKey] as const;
}
const existing = history.findIndex((prev) => prev === newSearch);
const newHistory = [...history];
if (existing > -1) {
newHistory.splice(existing, 1);
newHistory.splice(0, 0, newSearch);
} else {
newHistory.splice(0, 0, newSearch);
if (newHistory.length > this.historyLength) {
newHistory.splice(this.historyLength, newHistory.length - this.historyLength);
}
}
userHistory[userHistoryIndexer] = newHistory;
return [userHistory, userKey] as const;
}),
mergeMap(async ([decrypted, userKey]) => {
return await this.encryptHistory(decrypted, userKey);
}),
),
shouldUpdate: (oldHistory, newHistory) => !recordsEqual(oldHistory, newHistory),
});
}
}
function stringArraysEqual(
a: (string | undefined)[] | null,
b: (string | undefined)[] | null,
): boolean {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
if (a.length !== b.length) {
return false;
}
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) {
return false;
}
}
return true;
}
function recordsEqual(
a: Record<string, EncString[]> | null,
b: Record<string, EncString[]> | null,
): boolean {
if (a == null && b == null) {
return true;
}
if (a == null || b == null) {
return false;
}
if (Object.keys(a).length !== Object.keys(b).length) {
return false;
}
for (const k of Object.keys(a)) {
if (
!stringArraysEqual(
a[k].map((e) => e.encryptedString),
b[k].map((e) => e.encryptedString),
)
) {
return false;
}
}
return true;
}

View File

@@ -1,23 +1,43 @@
<label class="tw-sr-only" [for]="id">{{ "search" | i18n }}</label>
<div class="tw-relative tw-flex tw-items-center">
<label
[for]="id"
aria-hidden="true"
class="tw-absolute tw-left-2 tw-z-20 !tw-mb-0 tw-cursor-text"
>
<i class="bwi bwi-search bwi-fw tw-text-muted"></i>
</label>
<input
#input
bitInput
[type]="inputType"
[id]="id"
[placeholder]="placeholder ?? ('search' | i18n)"
class="tw-pl-9"
[ngModel]="searchText"
(ngModelChange)="onChange($event)"
(blur)="onTouch()"
[disabled]="disabled"
[attr.autocomplete]="autocomplete"
/>
<div class="tw-relative">
<div class="tw-flex tw-flex-grow tw-items-center">
<label
[for]="id"
aria-hidden="true"
class="tw-absolute tw-left-2 tw-z-20 !tw-mb-0 tw-cursor-text"
>
<i class="bwi bwi-search bwi-fw tw-text-muted"></i>
</label>
<input
#input
bitInput
[type]="inputType"
[id]="id"
[placeholder]="placeholder ?? ('search' | i18n)"
class="tw-pl-9"
[ngModel]="searchText"
(ngModelChange)="onChange($event)"
(focus)="onFocus()"
(blur)="onTouch()"
[disabled]="disabled"
[attr.autocomplete]="autocomplete"
/>
</div>
<ng-container *ngIf="showHistory">
<div class="tw-w-full">
<ul
class="tw-absolute tw-w-full tw-z-[1000] tw-float-left tw-m-0 tw-pl-0 tw-list-none tw-bg-background"
aria-labelledby="dropdownMenuButton1"
data-twe-dropdown-menu-ref
>
<li
*ngFor="let search of filteredHistory$ | async"
(click)="writeValue(search); onChange(search); onTouch()"
class="tw-pl-9 tw-pr-2 tw-block tw-text-ellipsis tw-text-nowrap tw-overflow-hidden hover:tw-bg-background-alt"
>
{{ search }}
</li>
</ul>
</div>
</ng-container>
</div>

View File

@@ -1,5 +1,6 @@
// 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 {
ControlValueAccessor,
@@ -7,6 +8,7 @@ import {
ReactiveFormsModule,
FormsModule,
} from "@angular/forms";
import { BehaviorSubject, map } from "rxjs";
import { isBrowserSafariApi } from "@bitwarden/platform";
import { I18nPipe } from "@bitwarden/ui-common";
@@ -31,7 +33,7 @@ let nextId = 0;
},
],
standalone: true,
imports: [InputModule, ReactiveFormsModule, FormsModule, I18nPipe],
imports: [InputModule, ReactiveFormsModule, FormsModule, I18nPipe, CommonModule],
})
export class SearchComponent implements ControlValueAccessor, FocusableElement {
private notifyOnChange: (v: string) => void;
@@ -43,10 +45,26 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
protected searchText: string;
// 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<string>("");
@Input() disabled: boolean;
@Input() placeholder: string;
@Input() autocomplete: string;
@Input() history: string[] | null;
get showHistory() {
return this.history != null && this.focused;
}
get filteredHistory$() {
// TODO: Not clear if filtering is better or worse
return this.textUpdated$.pipe(map((text) => this.history));
// return this.textUpdated$.pipe(map((text) => this.history.filter((h) => h.startsWith(text))));
}
onFocus() {
this.focused = true;
}
getFocusTarget() {
return this.input.nativeElement;
@@ -56,9 +74,12 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
if (this.notifyOnChange != undefined) {
this.notifyOnChange(searchText);
}
this.textUpdated$.next(searchText);
}
onTouch() {
// Need to provide enough time for a history option to be selected, it if it's clicked
setTimeout(() => (this.focused = false), 100);
if (this.notifyOnTouch != undefined) {
this.notifyOnTouch();
}

View File

@@ -40,3 +40,13 @@ export const Default: Story = {
}),
args: {},
};
export const WithHistory: Story = {
render: (args) => ({
props: args,
template: `
<bit-search [(ngModel)]="searchText" [placeholder]="placeholder" [disabled]="disabled" [history]="['1 something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long something very long ','2','3','4','5','6','7','8','9','10']"></bit-search>
`,
}),
args: {},
};