diff --git a/apps/web/src/app/vault/individual-vault/vault.component.ts b/apps/web/src/app/vault/individual-vault/vault.component.ts index 9a8972c9123..79c4d6c84e4 100644 --- a/apps/web/src/app/vault/individual-vault/vault.component.ts +++ b/apps/web/src/app/vault/individual-vault/vault.component.ts @@ -25,6 +25,7 @@ import { takeUntil, tap, catchError, + combineLatestWith, } from "rxjs/operators"; import { @@ -66,6 +67,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp 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 { SavedFiltersService } from "@bitwarden/common/vault/search/saved-filters.service"; import { ServiceUtils } from "@bitwarden/common/vault/service-utils"; import { CardComponent, DialogService, Icons, ToastService } from "@bitwarden/components"; import { @@ -274,6 +276,7 @@ export class VaultComponent implements OnInit, OnDestroy { private organizationBillingService: OrganizationBillingServiceAbstraction, private billingNotificationService: BillingNotificationService, private folderService: FolderService, + private savedFilterService: SavedFiltersService, ) {} async ngOnInit() { @@ -356,12 +359,22 @@ export class VaultComponent implements OnInit, OnDestroy { }); }), ); + const savedFilters$ = this.accountService.activeAccount$.pipe( + switchMap((account) => this.savedFilterService.filtersFor$(account.id)), + map((filters) => { + const savedFilters: [string, string][] = []; + for (const [key, value] of Object.entries(filters ?? {})) { + savedFilters.push([key, value]); + } + return savedFilters; + }), + ); const ciphers$ = combineLatest([this.currentSearchText$, context$]).pipe( - switchMap(([currentText, context]) => { - return this.filterService.filter(of([currentText, context])); - }), - // this.filterService.filter, + // switchMap(([currentText, context]) => { + // return this.filterService.filter(of([currentText, context])); + // }), + this.filterService.filter, switchMap((result) => { if (result.isError) { // return all ciphers @@ -371,6 +384,20 @@ export class VaultComponent implements OnInit, OnDestroy { return of(result.ciphers); } }), + combineLatestWith(context$), + map(([filtered, prevContext]) => { + prevContext.ciphers = filtered; + return prevContext; + }), + combineLatestWith(savedFilters$), + switchMap(([context, savedFilters]) => { + return this.filterService.tag(of([savedFilters, context])); + }), + // this.filterService.tag, + map((result) => { + // ignore tagging errors for now + return result.ciphers; + }), shareReplay({ refCount: true, bufferSize: 1 }), ); diff --git a/libs/common/src/vault/search/filter.service.ts b/libs/common/src/vault/search/filter.service.ts index 190e317efab..010f61a4cf8 100644 --- a/libs/common/src/vault/search/filter.service.ts +++ b/libs/common/src/vault/search/filter.service.ts @@ -9,14 +9,25 @@ import { CipherView } from "../models/view/cipher.view"; import { parseQuery } from "./parse"; import { FilterResult, + FilterTagged, ObservableSearchContextInput, ParseResult, SearchContext, + TagResult, } from "./query.types"; export abstract class FilterService { abstract readonly parse: OperatorFunction; abstract readonly filter: OperatorFunction<[string | ParseResult, SearchContext], FilterResult>; + /** + * Tags ciphers based on the provided queries. + * + * The queries are in the form of a record, where the key is the name of the tag and the value is either a string or a ParseResult query to perform. + */ + abstract readonly tag: OperatorFunction< + [[string, string][] | [string, ParseResult][], SearchContext], + TagResult + >; abstract context$(context: ObservableSearchContextInput): Observable; } @@ -60,25 +71,25 @@ export class DefaultFilterService implements FilterService { processInstructions: null, }); } else { - return query.pipe( - map((query: string) => { - try { - return { - isError: false as const, - processInstructions: parseQuery(query, this.logService), - }; - } catch { - return { - isError: true as const, - processInstructions: null, - }; - } - }), - ); + return query.pipe(map((query: string) => this.parseQueryString(query))); } }; } + private parseQueryString(query: string): ParseResult { + try { + return { + isError: false as const, + processInstructions: parseQuery(query, this.logService), + }; + } catch { + return { + isError: true as const, + processInstructions: null, + }; + } + } + get filter(): OperatorFunction<[string | ParseResult, SearchContext], FilterResult> { return pipe( switchMap(([queryOrParsedQuery, context]) => { @@ -106,6 +117,66 @@ export class DefaultFilterService implements FilterService { ); } + get tag(): OperatorFunction< + [[string, string][] | [string, ParseResult][], SearchContext], + TagResult + > { + return pipe( + switchMap(([queryOrParsedQueries, context]) => { + const parsedQueries: [string, ParseResult][] = []; + queryOrParsedQueries.forEach(([name, q]) => { + if (q == null || typeof q === "string") { + // it's a string that needs parsing + parsedQueries.push([name, this.parseQueryString(q as string)]); + } else { + // It's a parsed query + parsedQueries.push([name, q]); + } + }); + return combineLatest([of(parsedQueries), of(context)]); + }), + map(([parseResults, context]) => { + if (parseResults.length === 0) { + return { + ciphers: context.ciphers as FilterTagged[], + isError: false, + }; + } + // Reduce the parse results to a single result + return parseResults.reduce( + (acc, [name, parseResult]) => { + // cannot process if any parse result is error + if (parseResult.isError) { + return { + ciphers: acc.ciphers, + isError: true, + }; + } + // Identify the ciphers to tag for this query + const hitCipherIds = parseResult.processInstructions + .filter(context) + .ciphers.map((c) => c.id); + + return { + // tag ciphers for this query + ciphers: acc.ciphers.map((c) => { + const tagged = c as FilterTagged; + tagged.tags ??= []; + if (hitCipherIds.includes(c.id)) { + tagged.tags.push(name); + } + return tagged; + }), + isError: acc.isError, + }; + }, + // Initial value of the accumulator + { ciphers: context.ciphers, isError: false } as TagResult, + ); + }), + ); + } + private buildIndex(ciphers: CipherView[]) { const builder = new lunr.Builder(); builder.pipeline.add(this.normalizeAccentsPipelineFunction); diff --git a/libs/common/src/vault/search/query.types.ts b/libs/common/src/vault/search/query.types.ts index a98c539ab2c..0887b880d4c 100644 --- a/libs/common/src/vault/search/query.types.ts +++ b/libs/common/src/vault/search/query.types.ts @@ -31,6 +31,13 @@ export type FilterResult = isError: true; }; +export type TagResult = { + ciphers: FilterTagged[]; + isError: boolean; +}; + +export type FilterTagged = T & { tags: string[] }; + export type ProcessInstructions = { filter: (context: SearchContext) => SearchContext; ast: Search;