diff --git a/libs/angular/src/vault/components/filter-builder.component.ts b/libs/angular/src/vault/components/filter-builder.component.ts index bcee34ce6eb..d4c3486b654 100644 --- a/libs/angular/src/vault/components/filter-builder.component.ts +++ b/libs/angular/src/vault/components/filter-builder.component.ts @@ -9,7 +9,7 @@ import { } from "@angular/core"; import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; import { FormBuilder, FormsModule, ReactiveFormsModule } from "@angular/forms"; -import { BehaviorSubject, map, Observable, startWith } from "rxjs"; +import { BehaviorSubject, distinctUntilChanged, map, Observable, startWith } from "rxjs"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CipherType } from "@bitwarden/common/vault/enums"; @@ -23,8 +23,15 @@ import { ChipMultiSelectComponent, ChipSelectOption, ChipSelectComponent, + ToggleGroupModule, } from "@bitwarden/components"; +import { + BasicFilter, + BasicVaultFilterHandler, +} from "@bitwarden/common/vault/filtering/basic-vault-filter.handler"; +import { SearchComponent } from "@bitwarden/components/src/search/search.component"; + type FilterData = { vaults: ChipSelectOption[] | null; folders: ChipSelectOption[] | null; @@ -34,10 +41,19 @@ type FilterData = { anyHaveAttachment: boolean; }; -type FilterModel = Partial; +type FilterModel = { + text: string; + vaults: string[]; + folders: string[]; + collections: string[]; + types: string[]; + fields: string[]; +}; // TODO: Include more details on basic so consumers can easily interact with it. -export type Filter = { filter: string } & ({ type: "advanced" } | { type: "basic" }); +export type Filter = + | { type: "basic"; details: BasicFilter; raw: string } + | { type: "advanced"; raw: string }; const customMap = ( map: Map, @@ -56,66 +72,84 @@ const customMap = ( selector: "app-filter-builder", template: `
- - - - @for (selectedOtherOption of selectedOptions(); track selectedOtherOption) { - @switch (selectedOtherOption) { - @case ("types") { - - } - @case ("fields") { - +
+ +
+ @if (mode === "basic") { + + + + @for (selectedOtherOption of selectedOptions(); track selectedOtherOption) { + @switch (selectedOtherOption) { + @case ("types") { + + } + @case ("fields") { + + } } } - } - - + + + + + - + Reset + + + } + + + Basic + Advanced +
`, changeDetection: ChangeDetectionStrategy.OnPush, @@ -131,23 +165,23 @@ const customMap = ( CheckboxModule, AsyncPipe, ChipSelectComponent, + SearchComponent, + ToggleGroupModule, ], }) export class FilterBuilderComponent implements OnInit { + @Input({ required: true }) initialFilter: string; + @Input({ required: true }) ciphers: Observable | undefined; - @Output() searchFilter = new EventEmitter< - Partial<{ - words: string; - types: ChipSelectOption[]; - collections: ChipSelectOption[]; - vaults: ChipSelectOption[]; - folders: ChipSelectOption[]; - fields: ChipSelectOption[]; - }> - >(); + @Output() searchFilterEvent = new EventEmitter(); + + @Output() saveFilterEvent = new EventEmitter(); + + protected mode: string = "basic"; protected form = this.formBuilder.group({ + text: this.formBuilder.control(null), vaults: this.formBuilder.control([]), folders: this.formBuilder.control([]), collections: this.formBuilder.control([]), @@ -172,6 +206,7 @@ export class FilterBuilderComponent implements OnInit { private readonly i18nService: I18nService, private readonly formBuilder: FormBuilder, private readonly vaultFilterMetadataService: VaultFilterMetadataService, + private readonly basicVaultFilterHandler: BasicVaultFilterHandler, ) { // TODO: i18n this.loadingFilter = { @@ -227,18 +262,45 @@ export class FilterBuilderComponent implements OnInit { this.form.controls.otherOptions.setValue(null); }); - this.form.valueChanges.pipe(map((v) => v)); + this.form.valueChanges + .pipe( + // TODO: Debounce? + map((v) => this.convertFilter(v)), + distinctUntilChanged((previous, current) => { + return previous.raw === current.raw; + }), + takeUntilDestroyed(), + ) + .subscribe((f) => this.searchFilterEvent.emit(f)); } - private convertFilter(filter: FilterModel): Filter { + private convertFilter(filter: Partial): Filter { // TODO: Support advanced mode + if (this.mode === "advanced") { + return { type: "advanced", raw: filter.text }; + } + + const basic = this.convertToBasic(filter); + + return { type: "basic", details: basic, raw: this.basicVaultFilterHandler.toFilter(basic) }; + } + + private convertToBasic(filter: Partial): BasicFilter { return { - type: "basic", - filter: "", // TODO: Convert to string + terms: filter.text != null ? [filter.text] : [], + vaults: filter.vaults ?? [], + collections: filter.collections ?? [], + fields: filter.fields ?? [], + folders: filter.folders ?? [], + types: filter.types ?? [], }; } ngOnInit(): void { + if (this.initialFilter != null) { + // + } + this.filterData$ = this.ciphers.pipe( this.vaultFilterMetadataService.collectMetadata(), map((metadata) => { @@ -248,7 +310,7 @@ export class FilterBuilderComponent implements OnInit { if (v == null) { // Personal vault return { - value: "personal", + value: null, label: "My Vault", }; } else { @@ -312,7 +374,7 @@ export class FilterBuilderComponent implements OnInit { (f, i) => ({ value: f, label: f }) satisfies ChipSelectOption, ), anyHaveAttachment: metadata.attachmentCount !== 0, - } satisfies FilterModel; + } satisfies FilterData; }), startWith(this.loadingFilter), ); @@ -322,6 +384,50 @@ export class FilterBuilderComponent implements OnInit { return this.form.controls.selectedOtherOptions.value; } + private trySetBasicFilterElements(value: string) { + try { + const parseResult = this.basicVaultFilterHandler.tryParse(value); + + if (parseResult.success) { + if (parseResult.filter.terms.length >= 1) { + throw new Error("More than 1 term not actually supported in basic"); + } + + // This item can be displayed with basic, lets do that. + const selectedOtherOptions: string[] = []; + + if (parseResult.filter.types.length !== 0) { + selectedOtherOptions.push("types"); + } + + if (parseResult.filter.fields.length !== 0) { + selectedOtherOptions.push("fields"); + } + + console.log("Parse advanced query", value, parseResult.filter); + + this.form.setValue({ + text: parseResult.filter.terms.length === 1 ? parseResult.filter.terms[0] : null, + vaults: parseResult.filter.vaults, + folders: parseResult.filter.folders, + collections: parseResult.filter.collections, + fields: parseResult.filter.fields, + types: parseResult.filter.types, + otherOptions: null, + selectedOtherOptions: selectedOtherOptions, + }); + return true; + } else { + // set form to advanced mode and disable switching to basic + return false; + } + } catch (err) { + // How should I show off parse errors + console.log("Error", err); + return false; + } + } + protected resetFilter() { this._otherOptions.next(this.defaultOtherOptions); this.form.reset({ @@ -336,6 +442,23 @@ export class FilterBuilderComponent implements OnInit { } protected saveFilter() { - // + // TODO: Handle advanced mode and just emit the raw text + const currentFilter = this.convertFilter(this.form.value); + this.saveFilterEvent.emit(currentFilter.raw); + } + + protected modeChanged(newMode: string) { + this.mode = newMode; + if (newMode === "advanced") { + // Switching to advanced, place basic contents into text + this.form.controls.text.setValue( + this.basicVaultFilterHandler.toFilter(this.convertToBasic(this.form.value)), + ); + } else { + if (!this.trySetBasicFilterElements(this.form.controls.text.value)) { + console.log("Could not set filter back to basic, button should have been disabled."); + return; + } + } } } diff --git a/libs/angular/src/vault/components/filter-builder.stories.ts b/libs/angular/src/vault/components/filter-builder.stories.ts index 7d82a62315c..23e97638dba 100644 --- a/libs/angular/src/vault/components/filter-builder.stories.ts +++ b/libs/angular/src/vault/components/filter-builder.stories.ts @@ -11,6 +11,9 @@ import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { I18nMockService } from "@bitwarden/components"; import { FilterBuilderComponent } from "./filter-builder.component"; +import { BasicVaultFilterHandler } from "@bitwarden/common/vault/filtering/basic-vault-filter.handler"; +import { LogService } from "@bitwarden/common/platform/abstractions/log.service"; +import { ConsoleLogService } from "@bitwarden/common/platform/services/console-log.service"; export default { title: "Filter/Filter Builder", @@ -22,6 +25,7 @@ export default { { provide: I18nService, useValue: new I18nMockService({ + search: "Search", multiSelectLoading: "Loading", multiSelectNotFound: "Not Found", multiSelectClearAll: "Clear All", @@ -65,6 +69,15 @@ export default { }, } satisfies VaultFilterMetadataService, }, + { + provide: BasicVaultFilterHandler, + useClass: BasicVaultFilterHandler, + deps: [LogService], + }, + { + provide: LogService, + useValue: new ConsoleLogService(true), + }, ], }), ], @@ -76,13 +89,17 @@ export const Default: Story = { render: (args) => ({ props: args, template: /*html*/ ` - + + `, }), args: { ciphers: of([]), - searchFilter: (d: unknown) => { - alert(JSON.stringify(d)); + searchFilterEvent: (d: any) => { + console.log(d.raw); + }, + saveFilterEvent: (s: string) => { + alert(JSON.stringify(s)); }, }, }; 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 index 6339f7474c9..950bf129b57 100644 --- a/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts +++ b/libs/common/src/vault/filtering/basic-vault-filter.handler.spec.ts @@ -8,6 +8,7 @@ describe("BasicVaultFilterHandler", () => { const successfulCases: { basicFilter: BasicFilter; rawFilter: string }[] = [ { basicFilter: { + terms: [], vaults: [null, "org_vault"], collections: ["collection_one", "collection_two"], fields: ["field_one", "field_two"], @@ -19,6 +20,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [null, "org_one"], collections: [], fields: [], @@ -29,6 +31,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: ["collection_one", "Collection two"], fields: [], @@ -39,6 +42,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: [], fields: [], @@ -49,6 +53,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: [], fields: [], @@ -59,6 +64,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: [], fields: [], @@ -70,6 +76,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: [], fields: ["field_one", "Field two"], @@ -81,6 +88,7 @@ describe("BasicVaultFilterHandler", () => { { // Example of a filter that we could pretty basicFilter: { + terms: [], vaults: [null], collections: [], fields: [], @@ -111,6 +119,7 @@ describe("BasicVaultFilterHandler", () => { const extraAllowedSyntax: { basicFilter: BasicFilter; rawFilter: string }[] = [ { basicFilter: { + terms: [], vaults: [null], collections: [], fields: [], @@ -121,6 +130,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: ["my_collection"], fields: [], @@ -131,6 +141,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: [], fields: [], @@ -141,6 +152,7 @@ describe("BasicVaultFilterHandler", () => { }, { basicFilter: { + terms: [], vaults: [], collections: [], fields: [], diff --git a/libs/common/src/vault/filtering/basic-vault-filter.handler.ts b/libs/common/src/vault/filtering/basic-vault-filter.handler.ts index eefb3707f2b..13935743e05 100644 --- a/libs/common/src/vault/filtering/basic-vault-filter.handler.ts +++ b/libs/common/src/vault/filtering/basic-vault-filter.handler.ts @@ -15,6 +15,7 @@ import { import { parseQuery } from "../search/parse"; export type BasicFilter = { + terms: string[]; vaults: string[]; folders: string[]; collections: string[]; @@ -26,7 +27,6 @@ export class BasicVaultFilterHandler { constructor(private readonly logService: LogService) {} tryParse(rawFilter: string): { success: true; filter: BasicFilter } | { success: false } { - // TODO: Handle vaults const expectedBinaryOperator: Record = { vaults: "or", folders: "or", @@ -197,6 +197,7 @@ export class BasicVaultFilterHandler { return { success: true, filter: { + terms: basicFilter.terms ?? [], vaults: basicFilter.vaults ?? [], collections: basicFilter.collections ?? [], folders: basicFilter.folders ?? [], @@ -235,6 +236,17 @@ export class BasicVaultFilterHandler { addGroupAdvanced(items, (i) => `${preamble}:"${i}"`, binaryOp); }; + if (basicFilter.terms != null) { + if (basicFilter.terms.length === 2) { + throw new Error("Not currently supported and not currently possible through the UI."); + } + + if (basicFilter.terms.length === 1) { + // Add term + addGroup(basicFilter.terms, "term", "WHAT_TO_PUT_HERE_UNREACHABLE"); + } + } + addGroupAdvanced( basicFilter.vaults, (i) => (i == null ? "in:my_vault" : `in:org:"${i}"`),