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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
144
libs/common/src/vault/search/saved-filters.service.ts
Normal file
144
libs/common/src/vault/search/saved-filters.service.ts
Normal 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;
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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: {},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user