1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-27 10:03:23 +00:00

Remove filter component

This is a PoC commit, and would need significant clean up. We shouldn't be using the vault-filter component at all anymore, but it was the most expedient way to implement the new UI
This commit is contained in:
Matt Gibson
2025-03-19 16:34:11 -07:00
parent fd1ed3607e
commit 2fe8c491ed
9 changed files with 276 additions and 100 deletions

View File

@@ -1,40 +1,11 @@
<div class="tw-border tw-border-solid tw-border-secondary-300 tw-rounded" data-testid="filters">
<div class="tw-text-center tw-p-5" *ngIf="!isLoaded">
<i class="bwi bwi-spinner bwi-spin bwi-3x" aria-hidden="true"></i>
</div>
<div *ngIf="isLoaded">
<div
class="tw-bg-background-alt tw-border-0 tw-border-b tw-border-solid tw-border-secondary-100 tw-rounded-t tw-px-5 tw-py-2.5 tw-font-semibold tw-uppercase"
data-testid="filters-header"
>
{{ "filters" | i18n }}
<a
class="tw-float-right"
href="https://bitwarden.com/help/searching-vault/"
target="_blank"
rel="noreferrer"
appA11yTitle="{{ 'learnMoreAboutSearchingYourVault' | i18n }}"
>
<i class="bwi bwi-question-circle" aria-hidden="true"></i>
</a>
</div>
<div class="tw-p-5" data-testid="filters-body">
<div class="tw-mb-4">
<bit-search
id="search"
placeholder="{{ searchPlaceholder | i18n }}"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
[history]="searchHistory.history$ | async"
autocomplete="off"
appAutofocus
/>
</div>
<ng-container *ngFor="let f of filtersList">
<div class="filter">
<app-filter-section [activeFilter]="activeFilter" [section]="f"> </app-filter-section>
</div>
</ng-container>
</div>
</div>
<div class="tw-mb-4">
<bit-search
id="search"
placeholder="{{ searchPlaceholder | i18n }}"
[(ngModel)]="searchText"
(ngModelChange)="onSearchTextChanged($event)"
[history]="searchHistory.history$ | async"
autocomplete="off"
appAutofocus
/>
</div>

View File

@@ -22,67 +22,70 @@
>
</app-vault-onboarding>
<div class="tw-flex tw-flex-row -tw-mx-2.5">
<div class="tw-basis-1/4 tw-max-w-1/4 tw-px-2.5">
<app-vault-filter
#vaultFilter
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
[userId]="userId$ | async"
(searchTextChanged)="filterSearchText($event)"
(onEditFolder)="editFolder($event)"
></app-vault-filter>
</div>
<div class="tw-basis-3/4 tw-max-w-3/4 tw-px-2.5">
<div class="tw-flex tw-flex-row">
<div class="tw-w-full tw-px-2.5">
<bit-callout type="warning" *ngIf="activeFilter.isDeleted">
{{ trashCleanupWarning }}
</bit-callout>
<app-vault-items
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="allOrganizations"
[disabled]="refreshing"
[showOwner]="true"
[showCollections]="false"
[showGroups]="false"
[showPremiumFeatures]="canAccessPremium"
[showBulkMove]="showBulkMove"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false"
[showAdminActions]="false"
[showBulkAddToCollections]="true"
(onEvent)="onVaultItemsEvent($event)"
>
</app-vault-items>
<div
*ngIf="performingInitialLoad"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
>
<i
class="bwi bwi-spinner bwi-spin tw-text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div
*ngIf="isEmpty && !performingInitialLoad"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash'"
<bit-card>
<div class="tw-w-1/2">
<app-vault-filter
#vaultFilter
[activeFilter]="activeFilter"
[searchText]="currentSearchText$ | async"
[userId]="userId$ | async"
(searchTextChanged)="filterSearchText($event)"
(onEditFolder)="editFolder($event)"
></app-vault-filter>
</div>
<app-vault-items
[ciphers]="ciphers"
[collections]="collections"
[allCollections]="allCollections"
[allOrganizations]="allOrganizations"
[disabled]="refreshing"
[showOwner]="true"
[showCollections]="false"
[showGroups]="false"
[showPremiumFeatures]="canAccessPremium"
[showBulkMove]="showBulkMove"
[showBulkTrashOptions]="filter.type === 'trash'"
[useEvents]="false"
[showAdminActions]="false"
[showBulkAddToCollections]="true"
(onEvent)="onVaultItemsEvent($event)"
>
<i class="bwi bwi-plus-f bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}
</button>
</div>
</app-vault-items>
<div
*ngIf="performingInitialLoad"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
>
<i
class="bwi bwi-spinner bwi-spin text-muted"
title="{{ 'loading' | i18n }}"
aria-hidden="true"
></i>
<span class="tw-sr-only">{{ "loading" | i18n }}</span>
</div>
<div
*ngIf="isEmpty && !performingInitialLoad"
class="tw-mt-6 tw-flex tw-h-full tw-flex-col tw-items-center tw-justify-start"
>
<bit-icon [icon]="noItemIcon" aria-hidden="true"></bit-icon>
<p>{{ "noItemsInList" | i18n }}</p>
<button
type="button"
buttonType="primary"
bitButton
(click)="addCipher()"
*ngIf="filter.type !== 'trash'"
>
<i class="bwi bwi-plus-f bwi-fw" aria-hidden="true"></i>
{{ "newItem" | i18n }}
</button>
</div>
</bit-card>
</div>
</div>

View File

@@ -67,7 +67,7 @@ import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { FilterService } from "@bitwarden/common/vault/search/filter.service";
import { ServiceUtils } from "@bitwarden/common/vault/service-utils";
import { DialogService, Icons, ToastService } from "@bitwarden/components";
import { CardComponent, DialogService, Icons, ToastService } from "@bitwarden/components";
import {
AddEditFolderDialogComponent,
AddEditFolderDialogResult,
@@ -140,7 +140,7 @@ const SearchTextDebounceInterval = 200;
VaultFilterModule,
VaultItemsModule,
SharedModule,
DecryptionFailureDialogComponent,
CardComponent,
],
providers: [
RoutedVaultFilterService,

View File

@@ -0,0 +1,144 @@
import { Observable, combineLatestWith, map, mergeMap } from "rxjs";
import { Tagged } from "type-fest/source/opaque";
// eslint-disable-next-line no-restricted-imports --- TODO move this outside of common
import { KeyService } from "../../../../key-management/src/abstractions/key.service";
import { EncryptService } from "../../key-management/crypto/abstractions/encrypt.service";
import { EncString, EncryptedString } from "../../platform/models/domain/enc-string";
import { SymmetricCryptoKey } from "../../platform/models/domain/symmetric-crypto-key";
import {
SingleUserStateProvider,
UserKeyDefinition,
VAULT_SETTINGS_DISK,
} from "../../platform/state";
import { OrganizationId, UserId } from "../../types/guid";
export abstract class SavedFiltersService {
abstract filtersFor$(userId: UserId): Observable<Record<FilterName, FilterString>>;
abstract SaveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise<void>;
}
export type FilterName = string & Tagged<"FilterName">;
export type FilterString = string & Tagged<"FilterString">;
type UserSearchFilters = Record<EncryptedString, EncString>;
type DecryptedSearchFilters = Record<FilterName, FilterString>;
const SavedFiltersStateDefinition = new UserKeyDefinition<UserSearchFilters>(
VAULT_SETTINGS_DISK,
"SavedFilters",
{
deserializer: (value) => {
if (value == null) {
return {};
}
const result: Record<EncryptedString, EncString> = {};
for (const [k, v] of Object.entries(value)) {
const key = k as EncryptedString;
result[key] = EncString.fromJSON(v);
}
return result;
},
clearOn: ["logout"],
},
);
export class DefaultSavedFiltersService implements SavedFiltersService {
constructor(
private readonly stateProvider: SingleUserStateProvider,
private readonly encryptService: EncryptService,
private readonly keyService: KeyService,
) {}
filtersFor$(userId: UserId, orgId?: OrganizationId): Observable<DecryptedSearchFilters> {
const state = this.stateProvider.get(userId, SavedFiltersStateDefinition);
const decryptedState = state.state$.pipe(
combineLatestWith(this.keyService.userKey$(userId)),
mergeMap(async ([state, userKey]) => {
if (userKey == null || state == null) {
return {};
}
return await this.decryptFilters(state, userKey);
}),
);
return decryptedState;
}
async SaveFilter(userId: UserId, name: FilterName, filter: FilterString): Promise<void> {
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 ??= {};
oldState[name] = filter;
return [oldState, userKey] as const;
}),
mergeMap(async ([newState, userKey]) => {
return await this.encryptHistory(newState, userKey);
}),
),
shouldUpdate: (oldEncrypted, newEncrypted) => !recordsEqual(oldEncrypted, newEncrypted),
});
}
private async decryptFilters(
history: UserSearchFilters | null,
userKey: SymmetricCryptoKey | null,
): Promise<DecryptedSearchFilters> {
const decrypted: DecryptedSearchFilters = {};
if (history == null || userKey == null) {
return decrypted;
}
for (const [k, v] of Object.entries(history)) {
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;
}
return decrypted;
}
private async encryptHistory(
history: DecryptedSearchFilters | null,
userKey: SymmetricCryptoKey | null,
) {
if (history == null || userKey == null) {
return null;
}
const encrypted: UserSearchFilters = {};
for (const [k, v] of Object.entries(history)) {
const DecryptedKey = k as FilterName;
const key = (await this.encryptService.encrypt(DecryptedKey, userKey)).encryptedString!;
encrypted[key] = await this.encryptService.encrypt(v, userKey);
}
return encrypted;
}
}
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 (a[k].encryptedString !== b[k].encryptedString) {
return false;
}
}
return true;
}

View File

@@ -7,7 +7,7 @@ import { ChangeDetectionStrategy, Component } from "@angular/core";
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class:
"tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 [&:not(bit-layout_*)]:tw-rounded-lg [&:not(bit-layout_*)]:tw-border-b-shadow tw-py-4 bit-compact:tw-py-3 tw-px-3 bit-compact:tw-px-2",
"tw-box-border tw-block tw-bg-background tw-text-main tw-border-solid tw-border-b tw-border-0 tw-border-b-secondary-300 tw-rounded-lg tw-border-b-shadow tw-py-4 bit-compact:tw-py-3 tw-px-3 bit-compact:tw-px-2",
},
})
export class CardComponent {}

View File

@@ -18,7 +18,7 @@
<main
[id]="mainContentId"
tabindex="-1"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ml-0 tw-ml-16"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background-alt tw-p-6 md:tw-ml-0 tw-ml-16"
>
<ng-content></ng-content>

View File

@@ -22,6 +22,14 @@
[disabled]="disabled"
[attr.autocomplete]="autocomplete"
/>
<button
bitSuffix
type="button"
bitIconButton="bwi-arrow-circle-down"
(click)="toggleSavedFilters()"
[buttonType]=""
*ngIf="savedFilterData?.length > 0"
></button>
<button
bitSuffix
type="button"
@@ -30,6 +38,24 @@
[buttonType]=""
></button>
</div>
<ng-container *ngIf="showSavedFilters">
<div class="tw-size-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 tw-overflow-hidden tw-rounded-xl tw-border tw-border-solid tw-border-secondary-300"
aria-labelledby="dropdownMenuButton1"
data-twe-dropdown-menu-ref
>
<li
*ngFor="let data of savedFilterData"
(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 }}
</li>
</ul>
</div>
</ng-container>
<ng-container *ngIf="showHistory">
<div class="tw-size-full">
<ul

View File

@@ -55,15 +55,33 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
protected inputType = isBrowserSafariApi() ? ("text" as const) : ("search" as const);
private focused = false;
private textUpdated$ = new BehaviorSubject<string>("");
protected showSavedFilters = false;
@Input() disabled: boolean;
@Input() placeholder: string;
@Input() autocomplete: string;
@Input() history: string[] | null;
@Input() savedFilters: Record<string, string> | null;
get savedFilterData() {
if (this.savedFilters == null) {
return [];
}
return Object.entries(this.savedFilters).map(([name, filter]) => {
return {
name,
filter,
};
});
}
get showHistory() {
// turn off history for now
return false;
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));
@@ -113,6 +131,10 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement {
this.disabled = isDisabled;
}
toggleSavedFilters() {
this.showSavedFilters = !this.showSavedFilters;
}
filterToggled() {
this._selectedContent.next(this._selectedContent.value !== "filter" ? "filter" : null);
}

View File

@@ -50,3 +50,13 @@ export const WithHistory: Story = {
}),
args: {},
};
export const WithSavedFilters: Story = {
render: (args) => ({
props: args,
template: `
<bit-search [(ngModel)]="searchText" [placeholder]="placeholder" [disabled]="disabled" [savedFilters]="{'test': 'test', 'or': 'test OR toast', 'and': 'test AND toast'}"></bit-search>
`,
}),
args: {},
};