From 6d09fbfb4ec115c97b7065ab9f8a5871580b5321 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Mon, 17 Mar 2025 16:17:23 -0400 Subject: [PATCH] Filter Builder Updates --- .../components/filter-builder.component.ts | 277 +++++++++--------- .../components/filter-builder.stories.ts | 17 +- .../search-filter-builder.stories.ts | 77 +++++ libs/components/src/index.ts | 1 + .../src/search/search.component.html | 10 + .../components/src/search/search.component.ts | 22 +- 6 files changed, 263 insertions(+), 141 deletions(-) create mode 100644 libs/angular/src/vault/components/search-filter-builder.stories.ts diff --git a/libs/angular/src/vault/components/filter-builder.component.ts b/libs/angular/src/vault/components/filter-builder.component.ts index 7921782d5a2..ca3211c9ee0 100644 --- a/libs/angular/src/vault/components/filter-builder.component.ts +++ b/libs/angular/src/vault/components/filter-builder.component.ts @@ -7,27 +7,29 @@ import { OnInit, Output, } from "@angular/core"; -import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; -import { map, Observable, startWith } from "rxjs"; +import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { BehaviorSubject, map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; import { VaultFilterMetadataService } from "@bitwarden/common/vault/filtering/vault-filter-metadata.service"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { - SelectItemView, FormFieldModule, ButtonModule, LinkModule, CheckboxModule, + ChipMultiSelectComponent, + ChipSelectOption, + ChipSelectComponent, } from "@bitwarden/components"; type Filter = { - vaults: SelectItemView[] | null; - folders: SelectItemView[] | null; - collections: SelectItemView[] | null; - types: SelectItemView[]; - fields: SelectItemView[] | null; + vaults: ChipSelectOption[] | null; + folders: ChipSelectOption[] | null; + collections: ChipSelectOption[] | null; + types: ChipSelectOption[]; + fields: ChipSelectOption[] | null; }; const setMap = ( @@ -46,118 +48,133 @@ const setMap = ( @Component({ selector: "app-filter-builder", template: ` -

Search within

-
- - Vaults - - - - Folders - - - - Collections - - - - Types - - - - Field - - -

Item includes

- - Words - - - - - Attachment - -
- -
- - -
-
-
+ + + + + @for (selectedOtherOption of selectedOtherOptions$ | async; track selectedOtherOption) { + @switch (selectedOtherOption) { + @case ("types") { + + } + @case ("fields") { + + } + @default { +

Invalid option {{ selectedOtherOption | json }}

+ } + } + } + + + + + + + +
`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ CommonModule, LinkModule, + ChipMultiSelectComponent, FormFieldModule, ButtonModule, + FormsModule, ReactiveFormsModule, CheckboxModule, AsyncPipe, + ChipSelectComponent, ], }) export class FilterBuilderComponent implements OnInit { - form = this.formBuilder.group({ - words: "", - hasAttachment: false, - types: this.formBuilder.control([]), - collections: this.formBuilder.control([]), - vaults: this.formBuilder.control([]), - folders: this.formBuilder.control([]), - fields: this.formBuilder.control([]), - }); - @Input({ required: true }) ciphers: Observable | undefined; @Output() searchFilter = new EventEmitter< Partial<{ words: string; - types: SelectItemView[]; - collections: SelectItemView[]; - vaults: SelectItemView[]; - folders: SelectItemView[]; - fields: SelectItemView[]; + types: ChipSelectOption[]; + collections: ChipSelectOption[]; + vaults: ChipSelectOption[]; + folders: ChipSelectOption[]; + fields: ChipSelectOption[]; }> >(); private loadingFilter: Filter; - filter$: Observable; + + filterData$: Observable; + + // TODO: Set these dynamically based on metadata + private _otherOptions = new BehaviorSubject[]>([ + { value: "types", label: "Types", icon: "bwi-sliders" }, + { value: "fields", label: "Fields", icon: "bwi-filter" }, + ]); + + otherOptions$ = this._otherOptions.asObservable(); + + get otherOption(): string { + return null; + } + + set otherOption(value: string) { + if (value == null) { + return; + } + const current = this._selectedOtherOptions.value; + this._selectedOtherOptions.next([...current, value]); + // TODO: Remove as option + const currentOptions = [...this._otherOptions.value]; + + const index = currentOptions.findIndex((o) => o.value === value); + + if (index === -1) { + throw new Error("Should be impossible."); + } + + currentOptions.splice(index, 1); + this._otherOptions.next(currentOptions); + } + + private _selectedOtherOptions = new BehaviorSubject([]); + + selectedOtherOptions$ = this._selectedOtherOptions.asObservable(); constructor( - private readonly formBuilder: FormBuilder, private readonly i18nService: I18nService, private readonly vaultFilterMetadataService: VaultFilterMetadataService, ) { @@ -166,17 +183,17 @@ export class FilterBuilderComponent implements OnInit { folders: null, collections: null, types: [ - { id: "login", listName: "Login", labelName: "Login", icon: "bwi-globe" }, - { id: "card", listName: "Card", labelName: "Card", icon: "bwi-credit-card" }, - { id: "identity", listName: "Identity", labelName: "Identity", icon: "bwi-id-card" }, - { id: "note", listName: "Secure Note", labelName: "Secure Note", icon: "bwi-sticky-note" }, + { value: "login", label: "Login", icon: "bwi-globe" }, + { value: "card", label: "Card", icon: "bwi-credit-card" }, + { value: "identity", label: "Identity", icon: "bwi-id-card" }, + { value: "note", label: "Secure Note", icon: "bwi-sticky-note" }, ], fields: null, }; } ngOnInit(): void { - this.filter$ = this.ciphers.pipe( + this.filterData$ = this.ciphers.pipe( this.vaultFilterMetadataService.collectMetadata(), map((metadata) => { // TODO: Combine with other info @@ -185,18 +202,14 @@ export class FilterBuilderComponent implements OnInit { if (v == null) { // Personal vault return { - id: "personal", - labelName: "My Vault", - listName: "My Vault", - icon: "bwi-vault", + value: "personal", + label: "My Vault", }; } else { // Get organization info return { - id: v, - labelName: `Organization ${i}`, - listName: `Organization ${i}`, - icon: "bwi-business", + value: v, + label: `Organization ${i}`, }; } }), @@ -204,47 +217,44 @@ export class FilterBuilderComponent implements OnInit { metadata.folders, (f, i) => ({ - id: f, - labelName: `Folder ${i}`, - listName: `Folder ${i}`, - icon: "bwi-folder", - }) satisfies SelectItemView, + value: f, + label: `Folder ${i}`, + }) satisfies ChipSelectOption, ), collections: setMap( metadata.collections, (c, i) => ({ - id: c, - labelName: `Collection ${i}`, - listName: `Collection ${i}`, - icon: "bwi-collection", - }) satisfies SelectItemView, + value: c, + label: `Collection ${i}`, + }) satisfies ChipSelectOption, ), types: setMap(metadata.itemTypes, (t) => { switch (t) { case CipherType.Login: - return { id: "login", listName: "Login", labelName: "Login", icon: "bwi-globe" }; + return { value: "login", label: "Login", icon: "bwi-globe" }; case CipherType.Card: - return { id: "card", listName: "Card", labelName: "Card", icon: "bwi-credit-card" }; + return { + value: "card", + label: "Card", + icon: "bwi-credit-card", + }; case CipherType.Identity: return { - id: "identity", - listName: "Identity", - labelName: "Identity", + value: "identity", + label: "Identity", icon: "bwi-id-card", }; case CipherType.SecureNote: return { - id: "note", - listName: "Secure Note", - labelName: "Secure Note", + value: "note", + label: "Secure Note", icon: "bwi-sticky-note", }; case CipherType.SshKey: return { - id: "sshkey", - listName: "SSH Key", - labelName: "SSH Key", + value: "sshkey", + label: "SSH Key", icon: "bwi-key", }; default: @@ -253,15 +263,12 @@ export class FilterBuilderComponent implements OnInit { }), fields: setMap( metadata.fieldNames, - (f, i) => ({ id: f, labelName: f, listName: f }) satisfies SelectItemView, + (f, i) => ({ value: f, label: f }) satisfies ChipSelectOption, ), - } satisfies Filter; + anyHaveAttachment: metadata.anyHaveAttachment, + } satisfies Filter & { anyHaveAttachment: boolean }; }), startWith(this.loadingFilter), ); } - - submit() { - this.searchFilter.emit(this.form.value); - } } diff --git a/libs/angular/src/vault/components/filter-builder.stories.ts b/libs/angular/src/vault/components/filter-builder.stories.ts index 92d9ef0d760..b8764ab574c 100644 --- a/libs/angular/src/vault/components/filter-builder.stories.ts +++ b/libs/angular/src/vault/components/filter-builder.stories.ts @@ -25,6 +25,7 @@ export default { multiSelectLoading: "Loading", multiSelectNotFound: "Not Found", multiSelectClearAll: "Clear All", + removeItem: "Remove Item", }), }, { @@ -33,11 +34,17 @@ export default { collectMetadata: () => { return map((_ciphers) => { return { - vaults: new Set([null, "1"]), - folders: new Set(["1"]), - collections: new Set(["1"]), - itemTypes: new Set([CipherType.Login]), - fieldNames: new Set(["one", "two"]), + vaults: new Set([null, "1", "2"]), + folders: new Set(["1", "2"]), + collections: new Set(["1", "2"]), + itemTypes: new Set([ + CipherType.Login, + CipherType.Card, + CipherType.Identity, + CipherType.SecureNote, + CipherType.SshKey, + ]), + fieldNames: new Set(["one", "two", "three"]), anyHaveAttachment: true, } satisfies VaultFilterMetadata; }); diff --git a/libs/angular/src/vault/components/search-filter-builder.stories.ts b/libs/angular/src/vault/components/search-filter-builder.stories.ts new file mode 100644 index 00000000000..d4e42a137b0 --- /dev/null +++ b/libs/angular/src/vault/components/search-filter-builder.stories.ts @@ -0,0 +1,77 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; +import { map, of } from "rxjs"; + +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CipherType } from "@bitwarden/common/vault/enums"; +import { + VaultFilterMetadata, + VaultFilterMetadataService, +} from "@bitwarden/common/vault/filtering/vault-filter-metadata.service"; +import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { I18nMockService } from "@bitwarden/components"; +// eslint-disable-next-line no-restricted-imports +import { SearchComponent } from "@bitwarden/components/src/search/search.component"; + +import { FilterBuilderComponent } from "./filter-builder.component"; + +export default { + title: "Filter/In Search", + component: SearchComponent, + decorators: [ + moduleMetadata({ + imports: [FilterBuilderComponent], + providers: [ + { + provide: I18nService, + useValue: new I18nMockService({ + search: "Search", + multiSelectLoading: "Loading", + multiSelectNotFound: "Not Found", + multiSelectClearAll: "Clear All", + }), + }, + { + provide: VaultFilterMetadataService, + useValue: { + collectMetadata: () => { + return map((_ciphers) => { + return { + vaults: new Set([null, "1", "2"]), + folders: new Set(["1", "2"]), + collections: new Set(["1", "2"]), + itemTypes: new Set([ + CipherType.Login, + CipherType.Card, + CipherType.Identity, + CipherType.SecureNote, + CipherType.SshKey, + ]), + fieldNames: new Set(["one", "two", "three"]), + anyHaveAttachment: true, + } satisfies VaultFilterMetadata; + }); + }, + } satisfies VaultFilterMetadataService, + }, + ], + }), + ], +} as Meta; + +export const Default: StoryObj = { + render: (args) => ({ + props: args, + template: /*html*/ ` + +
+ +
+
+

Other content below

+ `, + }), + args: { + ciphers: of([]), + history: ["One", "Two"], + }, +}; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index 319b60e6435..04b8da4a41c 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -11,6 +11,7 @@ export * from "./callout"; export * from "./card"; export * from "./checkbox"; export * from "./chip-select"; +export * from "./chip-multi-select"; export * from "./color-password"; export * from "./container"; export * from "./copy-click"; diff --git a/libs/components/src/search/search.component.html b/libs/components/src/search/search.component.html index 03ac0b423bb..609bf66e740 100644 --- a/libs/components/src/search/search.component.html +++ b/libs/components/src/search/search.component.html @@ -22,6 +22,13 @@ [disabled]="disabled" [attr.autocomplete]="autocomplete" /> +
@@ -40,4 +47,7 @@
+ + + diff --git a/libs/components/src/search/search.component.ts b/libs/components/src/search/search.component.ts index e28074f1aa6..c95e98a44eb 100644 --- a/libs/components/src/search/search.component.ts +++ b/libs/components/src/search/search.component.ts @@ -13,6 +13,7 @@ import { BehaviorSubject, map } from "rxjs"; import { isBrowserSafariApi } from "@bitwarden/platform"; import { I18nPipe } from "@bitwarden/ui-common"; +import { IconButtonModule } from "../icon-button"; import { InputModule } from "../input/input.module"; import { FocusableElement } from "../shared/focusable-element"; @@ -33,7 +34,14 @@ let nextId = 0; }, ], standalone: true, - imports: [InputModule, ReactiveFormsModule, FormsModule, I18nPipe, CommonModule], + imports: [ + InputModule, + ReactiveFormsModule, + FormsModule, + I18nPipe, + CommonModule, + IconButtonModule, + ], }) export class SearchComponent implements ControlValueAccessor, FocusableElement { private notifyOnChange: (v: string) => void; @@ -62,6 +70,10 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { // return this.textUpdated$.pipe(map((text) => this.history.filter((h) => h.startsWith(text)))); } + private _selectedContent = new BehaviorSubject(null); + + selectedContent$ = this._selectedContent.asObservable(); + onFocus() { this.focused = true; } @@ -100,4 +112,12 @@ export class SearchComponent implements ControlValueAccessor, FocusableElement { setDisabledState(isDisabled: boolean) { this.disabled = isDisabled; } + + filterToggled() { + this._selectedContent.next(this._selectedContent.value !== "filter" ? "filter" : null); + } + + filterShown() { + return this._selectedContent.value !== "filter"; + } }