From ecb95bb47124a95d8b38a77e234861aca1c2cd63 Mon Sep 17 00:00:00 2001 From: Justin Baur <19896123+justindbaur@users.noreply.github.com> Date: Thu, 13 Mar 2025 13:34:41 -0400 Subject: [PATCH] Search builder (#13823) * Work on SearchBuilderComponent * Get component to not throw errors Co-authored-by: Matt Gibson * Rename to filter * Align Buttons Correctly * Filter Build Updates * Add VaultFilterMetadataService * Rename Directory * Emit filter --------- Co-authored-by: Matt Gibson --- .storybook/main.ts | 2 + .../components/filter-builder.component.ts | 267 ++++++++++++++++++ .../components/filter-builder.stories.ts | 67 +++++ .../vault-filter-metadata.service.spec.ts | 118 ++++++++ .../vault-filter-metadata.service.ts | 63 +++++ .../components/src/utils/i18n-mock.service.ts | 8 +- 6 files changed, 523 insertions(+), 2 deletions(-) create mode 100644 libs/angular/src/vault/components/filter-builder.component.ts create mode 100644 libs/angular/src/vault/components/filter-builder.stories.ts create mode 100644 libs/common/src/vault/filtering/vault-filter-metadata.service.spec.ts create mode 100644 libs/common/src/vault/filtering/vault-filter-metadata.service.ts diff --git a/.storybook/main.ts b/.storybook/main.ts index 9583d1fc6f2..5029666ce13 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -22,6 +22,8 @@ const config: StorybookConfig = { "../bitwarden_license/bit-web/src/**/*.stories.@(js|jsx|ts|tsx)", "../libs/tools/card/src/**/*.mdx", "../libs/tools/card/src/**/*.stories.@(js|jsx|ts|tsx)", + "../libs/angular/src/**/*.mdx", + "../libs/angular/src/**/*.stories.@(js|jsx|ts|tsx)", ], addons: [ getAbsolutePath("@storybook/addon-links"), diff --git a/libs/angular/src/vault/components/filter-builder.component.ts b/libs/angular/src/vault/components/filter-builder.component.ts new file mode 100644 index 00000000000..7921782d5a2 --- /dev/null +++ b/libs/angular/src/vault/components/filter-builder.component.ts @@ -0,0 +1,267 @@ +import { AsyncPipe, CommonModule } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, +} from "@angular/core"; +import { FormBuilder, ReactiveFormsModule } from "@angular/forms"; +import { 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, +} from "@bitwarden/components"; + +type Filter = { + vaults: SelectItemView[] | null; + folders: SelectItemView[] | null; + collections: SelectItemView[] | null; + types: SelectItemView[]; + fields: SelectItemView[] | null; +}; + +const setMap = ( + set: Set, + selector: (element: T, index: number) => TResult, +): TResult[] => { + let index = 0; + const results: TResult[] = []; + for (const element of set) { + results.push(selector(element, index++)); + } + + return results; +}; + +@Component({ + selector: "app-filter-builder", + template: ` +

Search within

+
+ + Vaults + + + + Folders + + + + Collections + + + + Types + + + + Field + + +

Item includes

+ + Words + + + + + Attachment + +
+ +
+ + +
+
+
+ `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + LinkModule, + FormFieldModule, + ButtonModule, + ReactiveFormsModule, + CheckboxModule, + AsyncPipe, + ], +}) +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[]; + }> + >(); + + private loadingFilter: Filter; + filter$: Observable; + + constructor( + private readonly formBuilder: FormBuilder, + private readonly i18nService: I18nService, + private readonly vaultFilterMetadataService: VaultFilterMetadataService, + ) { + this.loadingFilter = { + vaults: null, + 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" }, + ], + fields: null, + }; + } + + ngOnInit(): void { + this.filter$ = this.ciphers.pipe( + this.vaultFilterMetadataService.collectMetadata(), + map((metadata) => { + // TODO: Combine with other info + return { + vaults: setMap(metadata.vaults, (v, i) => { + if (v == null) { + // Personal vault + return { + id: "personal", + labelName: "My Vault", + listName: "My Vault", + icon: "bwi-vault", + }; + } else { + // Get organization info + return { + id: v, + labelName: `Organization ${i}`, + listName: `Organization ${i}`, + icon: "bwi-business", + }; + } + }), + folders: setMap( + metadata.folders, + (f, i) => + ({ + id: f, + labelName: `Folder ${i}`, + listName: `Folder ${i}`, + icon: "bwi-folder", + }) satisfies SelectItemView, + ), + collections: setMap( + metadata.collections, + (c, i) => + ({ + id: c, + labelName: `Collection ${i}`, + listName: `Collection ${i}`, + icon: "bwi-collection", + }) satisfies SelectItemView, + ), + types: setMap(metadata.itemTypes, (t) => { + switch (t) { + case CipherType.Login: + return { id: "login", listName: "Login", labelName: "Login", icon: "bwi-globe" }; + case CipherType.Card: + return { id: "card", listName: "Card", labelName: "Card", icon: "bwi-credit-card" }; + case CipherType.Identity: + return { + id: "identity", + listName: "Identity", + labelName: "Identity", + icon: "bwi-id-card", + }; + case CipherType.SecureNote: + return { + id: "note", + listName: "Secure Note", + labelName: "Secure Note", + icon: "bwi-sticky-note", + }; + case CipherType.SshKey: + return { + id: "sshkey", + listName: "SSH Key", + labelName: "SSH Key", + icon: "bwi-key", + }; + default: + throw new Error("Unreachable"); + } + }), + fields: setMap( + metadata.fieldNames, + (f, i) => ({ id: f, labelName: f, listName: f }) satisfies SelectItemView, + ), + } satisfies Filter; + }), + 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 new file mode 100644 index 00000000000..92d9ef0d760 --- /dev/null +++ b/libs/angular/src/vault/components/filter-builder.stories.ts @@ -0,0 +1,67 @@ +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"; + +import { FilterBuilderComponent } from "./filter-builder.component"; + +export default { + title: "Filter/Filter Builder", + component: FilterBuilderComponent, + decorators: [ + moduleMetadata({ + imports: [], + providers: [ + { + provide: I18nService, + useValue: new I18nMockService({ + multiSelectLoading: "Loading", + multiSelectNotFound: "Not Found", + multiSelectClearAll: "Clear All", + }), + }, + { + provide: VaultFilterMetadataService, + useValue: { + 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"]), + anyHaveAttachment: true, + } satisfies VaultFilterMetadata; + }); + }, + } satisfies VaultFilterMetadataService, + }, + ], + }), + ], +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + `, + }), + args: { + ciphers: of([]), + searchFilter: (d: unknown) => { + alert(JSON.stringify(d)); + }, + }, +}; diff --git a/libs/common/src/vault/filtering/vault-filter-metadata.service.spec.ts b/libs/common/src/vault/filtering/vault-filter-metadata.service.spec.ts new file mode 100644 index 00000000000..ca4362f4139 --- /dev/null +++ b/libs/common/src/vault/filtering/vault-filter-metadata.service.spec.ts @@ -0,0 +1,118 @@ +import { firstValueFrom, of } from "rxjs"; + +import { CipherType } from "../enums"; +import { AttachmentView } from "../models/view/attachment.view"; +import { CipherView } from "../models/view/cipher.view"; +import { FieldView } from "../models/view/field.view"; + +import { + VaultFilterMetadata, + VaultFilterMetadataService as VaultFilterMetadataService, +} from "./vault-filter-metadata.service"; + +type TestCipher = { + organization?: string; + type: CipherType; + folderId?: string; + fields?: string[]; + collectionIds?: string[]; + attachments?: number; +}; +const createCipher = (data: TestCipher) => { + const cipher = new CipherView(); + cipher.organizationId = data.organization ?? null; + cipher.type = data.type; + cipher.fields = data.fields?.map((f) => { + const field = new FieldView(); + field.name = f; + return field; + }); + cipher.collectionIds = data.collectionIds; + + if (data.attachments != null) { + const attachments: AttachmentView[] = []; + for (let i = 0; i < data.attachments; i++) { + attachments.push(new AttachmentView()); + } + cipher.attachments = attachments; + } + + return cipher; +}; + +describe("VaultFilterMetadataService", () => { + const sut = new VaultFilterMetadataService(); + + describe("collectMetadata", () => { + const testData: { + name: string; + input: CipherView[]; + output: VaultFilterMetadata; + }[] = [ + { + name: "single personal vault cipher", + input: [createCipher({ type: CipherType.Card })], + output: { + vaults: new Set([null]), + fieldNames: new Set([]), + itemTypes: new Set([CipherType.Card]), + folders: new Set([]), + collections: new Set([]), + anyHaveAttachment: false, + }, + }, + { + name: "multiple different org ciphers", + input: [ + createCipher({ + organization: "org-one", + type: CipherType.Login, + attachments: 2, + collectionIds: ["one"], + fields: ["one", "one"], + }), + createCipher({ + organization: "org-one", + type: CipherType.Login, + attachments: 2, + collectionIds: ["one"], + fields: ["one", "one"], + }), + createCipher({ + organization: "org-two", + type: CipherType.Login, + attachments: 2, + collectionIds: ["one"], + fields: ["one", "one"], + }), + createCipher({ + organization: "org-two", + type: CipherType.Card, + attachments: 2, + collectionIds: ["three"], + fields: ["one", "five"], + }), + ], + output: { + vaults: new Set(["org-one", "org-two"]), + fieldNames: new Set(["one", "five"]), + itemTypes: new Set([CipherType.Login, CipherType.Card]), + folders: new Set([]), + collections: new Set(["one", "three"]), + anyHaveAttachment: true, + }, + }, + ]; + + it.each(testData)("$name", async ({ input, output }) => { + const actualMetadata = await firstValueFrom(of(input).pipe(sut.collectMetadata())); + + expect(actualMetadata.vaults).toEqual(output.vaults); + expect(actualMetadata.fieldNames).toEqual(output.fieldNames); + expect(actualMetadata.itemTypes).toEqual(output.itemTypes); + expect(actualMetadata.folders).toEqual(output.folders); + expect(actualMetadata.collections).toEqual(output.collections); + expect(actualMetadata.anyHaveAttachment).toBe(output.anyHaveAttachment); + }); + }); +}); diff --git a/libs/common/src/vault/filtering/vault-filter-metadata.service.ts b/libs/common/src/vault/filtering/vault-filter-metadata.service.ts new file mode 100644 index 00000000000..410cb34edd6 --- /dev/null +++ b/libs/common/src/vault/filtering/vault-filter-metadata.service.ts @@ -0,0 +1,63 @@ +import { map } from "rxjs"; + +import { CipherType } from "../enums"; +import { CipherView } from "../models/view/cipher.view"; + +export type VaultFilterMetadata = { + vaults: Set; + fieldNames: Set; + itemTypes: Set; + folders: Set; + collections: Set; + anyHaveAttachment: boolean; +}; + +export class VaultFilterMetadataService { + collectMetadata() { + return map((ciphers) => { + return ciphers.reduce( + (metadata, cipher) => { + // Track type + metadata.itemTypes.add(cipher.type); + + // Track vault + metadata.vaults.add(cipher.organizationId ?? null); + + // Track all field names + if (cipher.fields != null) { + for (const field of cipher.fields) { + metadata.fieldNames.add(field.name); + } + } + + // Track all folder ids + if (cipher.folderId != null) { + metadata.folders.add(cipher.folderId); + } + + // Track all collections + if (cipher.collectionIds != null) { + for (const collectionId of cipher.collectionIds) { + metadata.collections.add(collectionId); + } + } + + // Track if any have an attachment + if (cipher.attachments != null && cipher.attachments.length > 0) { + metadata.anyHaveAttachment = true; + } + + return metadata; + }, + { + vaults: new Set(), + fieldNames: new Set(), + itemTypes: new Set(), + folders: new Set(), + collections: new Set(), + anyHaveAttachment: false, + }, + ); + }); + } +} diff --git a/libs/components/src/utils/i18n-mock.service.ts b/libs/components/src/utils/i18n-mock.service.ts index 434da2255b3..0c40a292210 100644 --- a/libs/components/src/utils/i18n-mock.service.ts +++ b/libs/components/src/utils/i18n-mock.service.ts @@ -1,5 +1,3 @@ -// FIXME: Update this file to be type safe and remove this and next line -// @ts-strict-ignore import { Observable } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -16,6 +14,11 @@ export class I18nMockService implements I18nService { t(id: string, p1?: string, p2?: string, p3?: string) { let value = this.lookupTable[id]; + + if (value === undefined) { + throw new Error(`Nothing in lookup table for id '${id}'`); + } + if (typeof value == "string") { if (value !== "") { if (p1 != null) { @@ -31,6 +34,7 @@ export class I18nMockService implements I18nService { return value; } + return value(p1, p2, p3); }