diff --git a/libs/angular/src/vault/components/filter-builder.component.ts b/libs/angular/src/vault/components/filter-builder.component.ts index ca3211c9ee0..bcee34ce6eb 100644 --- a/libs/angular/src/vault/components/filter-builder.component.ts +++ b/libs/angular/src/vault/components/filter-builder.component.ts @@ -7,7 +7,8 @@ import { OnInit, Output, } from "@angular/core"; -import { FormsModule, ReactiveFormsModule } from "@angular/forms"; +import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; +import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; import { BehaviorSubject, map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; @@ -24,21 +25,27 @@ import { ChipSelectComponent, } from "@bitwarden/components"; -type Filter = { +type FilterData = { vaults: ChipSelectOption[] | null; folders: ChipSelectOption[] | null; collections: ChipSelectOption[] | null; types: ChipSelectOption[]; fields: ChipSelectOption[] | null; + anyHaveAttachment: boolean; }; -const setMap = ( - set: Set, +type FilterModel = Partial; + +// TODO: Include more details on basic so consumers can easily interact with it. +export type Filter = { filter: string } & ({ type: "advanced" } | { type: "basic" }); + +const customMap = ( + map: Map, selector: (element: T, index: number) => TResult, ): TResult[] => { let index = 0; const results: TResult[] = []; - for (const element of set) { + for (const element of map.keys()) { results.push(selector(element, index++)); } @@ -48,30 +55,34 @@ const setMap = ( @Component({ selector: "app-filter-builder", template: ` - +
- @for (selectedOtherOption of selectedOtherOptions$ | async; track selectedOtherOption) { + @for (selectedOtherOption of selectedOptions(); track selectedOtherOption) { @switch (selectedOtherOption) { @case ("types") { @@ -80,14 +91,11 @@ const setMap = ( } - @default { -

Invalid option {{ selectedOtherOption | json }}

- } } } @@ -95,16 +103,20 @@ const setMap = ( *ngIf="otherOptions.length !== 0" placeholderText="Other filters" placeholderIcon="bwi-sliders" + formControlName="otherOptions" [options]="otherOptions" class="tw-pl-2" - [(ngModel)]="otherOption" > - - - + + + `, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, @@ -135,49 +147,33 @@ export class FilterBuilderComponent implements OnInit { }> >(); - private loadingFilter: Filter; + protected form = this.formBuilder.group({ + vaults: this.formBuilder.control([]), + folders: this.formBuilder.control([]), + collections: this.formBuilder.control([]), + types: this.formBuilder.control([]), + fields: this.formBuilder.control([]), + otherOptions: this.formBuilder.control(null), + selectedOtherOptions: this.formBuilder.control([]), + }); - filterData$: Observable; + private loadingFilter: FilterData; + + protected filterData$: Observable; + + private defaultOtherOptions: ChipSelectOption[]; // 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" }, - ]); + private _otherOptions: BehaviorSubject[]>; - 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(); + protected otherOptions$: Observable[]>; constructor( private readonly i18nService: I18nService, + private readonly formBuilder: FormBuilder, private readonly vaultFilterMetadataService: VaultFilterMetadataService, ) { + // TODO: i18n this.loadingFilter = { vaults: null, folders: null, @@ -189,6 +185,56 @@ export class FilterBuilderComponent implements OnInit { { value: "note", label: "Secure Note", icon: "bwi-sticky-note" }, ], fields: null, + anyHaveAttachment: true, + }; + + // TODO: i18n + this.defaultOtherOptions = [ + { value: "types", label: "Types", icon: "bwi-sliders" }, + { value: "fields", label: "Fields", icon: "bwi-filter" }, + ]; + + this._otherOptions = new BehaviorSubject(this.defaultOtherOptions); + + this.otherOptions$ = this._otherOptions.asObservable(); + + this.defaultOtherOptions = [ + { value: "types", label: "Types", icon: "bwi-sliders" }, + { value: "fields", label: "Fields", icon: "bwi-filter" }, + ]; + + this.form.controls.otherOptions.valueChanges.pipe(takeUntilDestroyed()).subscribe((option) => { + if (option == null) { + return; + } + + // TODO: Do I need to ensure unique? + this.form.controls.selectedOtherOptions.setValue([ + ...this.form.controls.selectedOtherOptions.value, + option, + ]); + const existingOptions = [...this._otherOptions.value]; + + const index = existingOptions.findIndex((o) => o.value === option); + + if (index === -1) { + throw new Error("Should never happen."); + } + + existingOptions.splice(index, 1); + this._otherOptions.next(existingOptions); + + this.form.controls.otherOptions.setValue(null); + }); + + this.form.valueChanges.pipe(map((v) => v)); + } + + private convertFilter(filter: FilterModel): Filter { + // TODO: Support advanced mode + return { + type: "basic", + filter: "", // TODO: Convert to string }; } @@ -198,7 +244,7 @@ export class FilterBuilderComponent implements OnInit { map((metadata) => { // TODO: Combine with other info return { - vaults: setMap(metadata.vaults, (v, i) => { + vaults: customMap(metadata.vaults, (v, i) => { if (v == null) { // Personal vault return { @@ -213,7 +259,7 @@ export class FilterBuilderComponent implements OnInit { }; } }), - folders: setMap( + folders: customMap( metadata.folders, (f, i) => ({ @@ -221,7 +267,7 @@ export class FilterBuilderComponent implements OnInit { label: `Folder ${i}`, }) satisfies ChipSelectOption, ), - collections: setMap( + collections: customMap( metadata.collections, (c, i) => ({ @@ -229,7 +275,7 @@ export class FilterBuilderComponent implements OnInit { label: `Collection ${i}`, }) satisfies ChipSelectOption, ), - types: setMap(metadata.itemTypes, (t) => { + types: customMap(metadata.itemTypes, (t) => { switch (t) { case CipherType.Login: return { value: "login", label: "Login", icon: "bwi-globe" }; @@ -261,14 +307,35 @@ export class FilterBuilderComponent implements OnInit { throw new Error("Unreachable"); } }), - fields: setMap( + fields: customMap( metadata.fieldNames, (f, i) => ({ value: f, label: f }) satisfies ChipSelectOption, ), - anyHaveAttachment: metadata.anyHaveAttachment, - } satisfies Filter & { anyHaveAttachment: boolean }; + anyHaveAttachment: metadata.attachmentCount !== 0, + } satisfies FilterModel; }), startWith(this.loadingFilter), ); } + + protected selectedOptions() { + return this.form.controls.selectedOtherOptions.value; + } + + protected resetFilter() { + this._otherOptions.next(this.defaultOtherOptions); + this.form.reset({ + vaults: [], + folders: [], + types: [], + fields: [], + otherOptions: null, + collections: [], + selectedOtherOptions: [], + }); + } + + protected saveFilter() { + // + } } diff --git a/libs/angular/src/vault/components/filter-builder.stories.ts b/libs/angular/src/vault/components/filter-builder.stories.ts index b8764ab574c..7d82a62315c 100644 --- a/libs/angular/src/vault/components/filter-builder.stories.ts +++ b/libs/angular/src/vault/components/filter-builder.stories.ts @@ -34,18 +34,32 @@ export default { 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, + vaults: new Map([ + [null, 1], + ["1", 1], + ["2", 1], ]), - fieldNames: new Set(["one", "two", "three"]), - anyHaveAttachment: true, + folders: new Map([ + ["1", 1], + ["2", 1], + ]), + collections: new Map([ + ["1", 1], + ["2", 1], + ]), + itemTypes: new Map([ + [CipherType.Login, 1], + [CipherType.Card, 1], + [CipherType.Identity, 1], + [CipherType.SecureNote, 1], + [CipherType.SshKey, 1], + ]), + fieldNames: new Map([ + ["one", 1], + ["two", 1], + ["three", 1], + ]), + attachmentCount: 1, } 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 index d4e42a137b0..16f97e92331 100644 --- a/libs/angular/src/vault/components/search-filter-builder.stories.ts +++ b/libs/angular/src/vault/components/search-filter-builder.stories.ts @@ -36,18 +36,32 @@ export default { 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, + vaults: new Map([ + [null, 1], + ["1", 1], + ["2", 1], ]), - fieldNames: new Set(["one", "two", "three"]), - anyHaveAttachment: true, + folders: new Map([ + ["1", 1], + ["2", 1], + ]), + collections: new Map([ + ["1", 1], + ["2", 1], + ]), + itemTypes: new Map([ + [CipherType.Login, 1], + [CipherType.Card, 1], + [CipherType.Identity, 1], + [CipherType.SecureNote, 1], + [CipherType.SshKey, 1], + ]), + fieldNames: new Map([ + ["one", 1], + ["two", 1], + ["three", 1], + ]), + attachmentCount: 1, } satisfies VaultFilterMetadata; }); }, diff --git a/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts b/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts new file mode 100644 index 00000000000..4d2e8a49a74 --- /dev/null +++ b/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts @@ -0,0 +1,63 @@ +import { mock } from "jest-mock-extended"; + +import { BasicFilter, BasicVaultFilterHandler } from "./basic-vault-filter.handler"; + +describe("BasicVaultFilterHandler", () => { + const sut = new BasicVaultFilterHandler(mock()); + + describe("tryParse", () => { + it("success", () => { + const result = sut.tryParse( + '(in:collection:"My Collection" OR in:collection:"Other Collection")', + ); + + if (!result.success) { + fail("Result is expected to succeed"); + } + + expect(result.filter).toBe({}); + }); + }); + + describe("toFilter", () => { + const cases: { input: BasicFilter; output: string }[] = [ + { + input: { + vaults: [null, "org_vault"], + collections: ["collection_one", "collection_two"], + fields: ["one", "two"], + types: ["Login", "Card"], + folders: ["folder_one", "folder_two"], + }, + output: + '(in:my_vault OR in:org:"org_vault") AND (in:folder:"folder_one" OR in:folder:"folder_two") AND (in:collection:"collection_one" OR in:collection:"collection_two") AND (type:"Login" OR type:"Card") AND (field:"one" AND field:"two")', + }, + { + input: { + vaults: [null], + collections: [], + fields: [], + types: [], + folders: [], + }, + output: "(in:my_vault)", + }, + { + input: { + vaults: [], + collections: [], + fields: ["Banking"], + types: [], + folders: [], + }, + output: '(field:"Banking")', + }, + ]; + + it.each(cases)("translates basic filter to $output", ({ input, output }) => { + const actualOutput = sut.toFilter(input); + + expect(actualOutput).toEqual(output); + }); + }); +}); diff --git a/libs/common/src/vault/filtering/basic-vault-filter.handler.ts b/libs/common/src/vault/filtering/basic-vault-filter.handler.ts new file mode 100644 index 00000000000..1340c7b4a9a --- /dev/null +++ b/libs/common/src/vault/filtering/basic-vault-filter.handler.ts @@ -0,0 +1,104 @@ +import { LogService } from "../../platform/abstractions/log.service"; +import { parseQuery } from "../search/parse"; + +export type BasicFilter = { + vaults: string[]; + folders: string[]; + collections: string[]; + types: string[]; + fields: string[]; +}; + +export class BasicVaultFilterHandler { + constructor(private readonly logService: LogService) {} + + tryParse(rawFilter: string): { success: true; filter: BasicFilter } | { success: false } { + // Parse into AST + const ast = parseQuery(rawFilter, this.logService).ast; + + if (ast.type !== "search") { + return { success: false }; + } + + return { success: false }; + } + + toFilter(basicFilter: BasicFilter) { + const buildGroupAdvanced = ( + items: string[], + selector: (item: string) => string, + binaryOp: string, + ) => { + if (items == null || items.length === 0) { + return null; + } + + return `(${items.map(selector).join(` ${binaryOp} `)})`; + }; + + const buildGroup = (items: string[], preamble: string, binaryOp: string) => { + // TODO: Maybe only quote item when there is containing whitespace so we create as "pretty" of a filter as possible + return buildGroupAdvanced(items, (i) => `${preamble}:"${i}"`, binaryOp); + }; + + let filter = ""; + let addedItem = false; + + const vaultGroup = buildGroupAdvanced( + basicFilter.vaults, + (i) => { + if (i == null) { + return "in:my_vault"; + } + + return `in:org:"${i}"`; + }, + "OR", + ); + + if (vaultGroup != null) { + // vault is the first thing we might add, so no need to check if addedItem is already true + addedItem = true; + filter += vaultGroup; + } + + const foldersGroup = buildGroup(basicFilter.folders, "in:folder", "OR"); + + if (foldersGroup != null) { + if (addedItem) { + filter += " AND "; + } + addedItem = true; + filter += foldersGroup; + } + + const collectionsGroup = buildGroup(basicFilter.collections, "in:collection", "OR"); + if (collectionsGroup != null) { + if (addedItem) { + filter += " AND "; + } + addedItem = true; + filter += collectionsGroup; + } + + const typesGroup = buildGroup(basicFilter.types, "type", "OR"); + if (typesGroup != null) { + if (addedItem) { + filter += " AND "; + } + addedItem = true; + filter += typesGroup; + } + + const fieldsGroup = buildGroup(basicFilter.fields, "field", "AND"); + if (fieldsGroup != null) { + if (addedItem) { + filter += " AND "; + } + addedItem = true; + filter += fieldsGroup; + } + + return filter; + } +} 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 index ca4362f4139..de6671bb8fc 100644 --- a/libs/common/src/vault/filtering/vault-filter-metadata.service.spec.ts +++ b/libs/common/src/vault/filtering/vault-filter-metadata.service.spec.ts @@ -53,12 +53,12 @@ describe("VaultFilterMetadataService", () => { 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, + vaults: new Map([[null, 1]]), + fieldNames: new Map([]), + itemTypes: new Map([[CipherType.Card, 1]]), + folders: new Map([]), + collections: new Map([]), + attachmentCount: 0, }, }, { @@ -94,12 +94,24 @@ describe("VaultFilterMetadataService", () => { }), ], 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, + vaults: new Map([ + ["org-one", 2], + ["org-two", 2], + ]), + fieldNames: new Map([ + ["one", 7], + ["five", 1], + ]), + itemTypes: new Map([ + [CipherType.Login, 3], + [CipherType.Card, 1], + ]), + folders: new Map([]), + collections: new Map([ + ["one", 3], + ["three", 1], + ]), + attachmentCount: 8, }, }, ]; @@ -112,7 +124,7 @@ describe("VaultFilterMetadataService", () => { expect(actualMetadata.itemTypes).toEqual(output.itemTypes); expect(actualMetadata.folders).toEqual(output.folders); expect(actualMetadata.collections).toEqual(output.collections); - expect(actualMetadata.anyHaveAttachment).toBe(output.anyHaveAttachment); + expect(actualMetadata.attachmentCount).toBe(output.attachmentCount); }); }); }); diff --git a/libs/common/src/vault/filtering/vault-filter-metadata.service.ts b/libs/common/src/vault/filtering/vault-filter-metadata.service.ts index 410cb34edd6..15df07b7edb 100644 --- a/libs/common/src/vault/filtering/vault-filter-metadata.service.ts +++ b/libs/common/src/vault/filtering/vault-filter-metadata.service.ts @@ -4,58 +4,68 @@ 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; + vaults: Map; + fieldNames: Map; + itemTypes: Map; + folders: Map; + collections: Map; + attachmentCount: number; }; export class VaultFilterMetadataService { collectMetadata() { + const setOrIncrement = (map: Map, key: T) => { + const entry = map.get(key); + + if (entry == undefined) { + map.set(key, 1); + } else { + map.set(key, entry + 1); + } + }; + return map((ciphers) => { return ciphers.reduce( (metadata, cipher) => { // Track type - metadata.itemTypes.add(cipher.type); + setOrIncrement(metadata.itemTypes, cipher.type); // Track vault - metadata.vaults.add(cipher.organizationId ?? null); + setOrIncrement(metadata.vaults, cipher.organizationId ?? null); // Track all field names if (cipher.fields != null) { for (const field of cipher.fields) { - metadata.fieldNames.add(field.name); + setOrIncrement(metadata.fieldNames, field.name); } } // Track all folder ids if (cipher.folderId != null) { - metadata.folders.add(cipher.folderId); + setOrIncrement(metadata.folders, cipher.folderId); } // Track all collections if (cipher.collectionIds != null) { for (const collectionId of cipher.collectionIds) { - metadata.collections.add(collectionId); + setOrIncrement(metadata.collections, collectionId); } } // Track if any have an attachment if (cipher.attachments != null && cipher.attachments.length > 0) { - metadata.anyHaveAttachment = true; + metadata.attachmentCount = metadata.attachmentCount + cipher.attachments.length; } return metadata; }, { - vaults: new Set(), - fieldNames: new Set(), - itemTypes: new Set(), - folders: new Set(), - collections: new Set(), - anyHaveAttachment: false, + vaults: new Map(), + fieldNames: new Map(), + itemTypes: new Map(), + folders: new Map(), + collections: new Map(), + attachmentCount: 0, }, ); });