1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-10 05:30:01 +00:00

Search builder (#13823)

* Work on SearchBuilderComponent

* Get component to not throw errors

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>

* Rename to filter

* Align Buttons Correctly

* Filter Build Updates

* Add VaultFilterMetadataService

* Rename Directory

* Emit filter

---------

Co-authored-by: Matt Gibson <mgibson@bitwarden.com>
This commit is contained in:
Justin Baur
2025-03-13 13:34:41 -04:00
committed by Matt Gibson
parent 5922947474
commit ecb95bb471
6 changed files with 523 additions and 2 deletions

View File

@@ -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"),

View File

@@ -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 = <T, TResult>(
set: Set<T>,
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: `
<h4>Search within</h4>
<form [formGroup]="form" (ngSubmit)="submit()" *ngIf="filter$ | async as filter">
<bit-form-field>
<bit-label>Vaults</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="vaults"
placeholder="--Type to select--"
[loading]="filter.vaults == null"
[baseItems]="filter.vaults"
></bit-multi-select>
</bit-form-field>
<bit-form-field>
<bit-label>Folders</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="folders"
placeholder="--Type to select--"
[loading]="filter.folders == null"
[baseItems]="filter.folders"
></bit-multi-select>
</bit-form-field>
<bit-form-field>
<bit-label>Collections</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="collections"
placeholder="--Type to select--"
[loading]="filter.collections == null"
[baseItems]="filter.collections"
></bit-multi-select>
</bit-form-field>
<bit-form-field>
<bit-label>Types</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="types"
placeholder="--Type to select--"
[baseItems]="filter.types"
></bit-multi-select>
</bit-form-field>
<bit-form-field>
<bit-label>Field</bit-label>
<bit-multi-select
class="tw-w-full"
formControlName="fields"
placeholder="--Type to select--"
[loading]="filter.fields == null"
[baseItems]="filter.fields"
></bit-multi-select>
</bit-form-field>
<h3>Item includes</h3>
<bit-form-field>
<bit-label>Words</bit-label>
<input bitInput formControlName="words" />
</bit-form-field>
<bit-form-control *ngIf="filter.anyHaveAttachment">
<input type="checkbox" bitCheckbox formControlName="hasAttachment" />
<bit-label>Attachment</bit-label>
</bit-form-control>
<div>
<!-- <button class="tw-flex tw-justify-start" type="button" bitLink linkType="secondary">
Give feedback
</button> -->
<div class="tw-flex tw-justify-end">
<button type="button" bitLink linkType="primary" class="tw-mr-2">Cancel</button>
<button type="submit" bitButton buttonType="primary">Search</button>
</div>
</div>
</form>
`,
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<SelectItemView[]>([]),
collections: this.formBuilder.control<SelectItemView[]>([]),
vaults: this.formBuilder.control<SelectItemView[]>([]),
folders: this.formBuilder.control<SelectItemView[]>([]),
fields: this.formBuilder.control<SelectItemView[]>([]),
});
@Input({ required: true }) ciphers: Observable<CipherView[]> | undefined;
@Output() searchFilter = new EventEmitter<
Partial<{
words: string;
types: SelectItemView[];
collections: SelectItemView[];
vaults: SelectItemView[];
folders: SelectItemView[];
fields: SelectItemView[];
}>
>();
private loadingFilter: Filter;
filter$: Observable<Filter>;
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);
}
}

View File

@@ -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<CipherView[], VaultFilterMetadata>((_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<FilterBuilderComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-filter-builder [ciphers]="ciphers" (searchFilter)="searchFilter($event)"></app-filter-builder>
`,
}),
args: {
ciphers: of([]),
searchFilter: (d: unknown) => {
alert(JSON.stringify(d));
},
},
};

View File

@@ -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);
});
});
});

View File

@@ -0,0 +1,63 @@
import { map } from "rxjs";
import { CipherType } from "../enums";
import { CipherView } from "../models/view/cipher.view";
export type VaultFilterMetadata = {
vaults: Set<string | null>;
fieldNames: Set<string>;
itemTypes: Set<CipherType>;
folders: Set<string>;
collections: Set<string>;
anyHaveAttachment: boolean;
};
export class VaultFilterMetadataService {
collectMetadata() {
return map<CipherView[], VaultFilterMetadata>((ciphers) => {
return ciphers.reduce<VaultFilterMetadata>(
(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<string | null>(),
fieldNames: new Set<string>(),
itemTypes: new Set<CipherType>(),
folders: new Set<string>(),
collections: new Set<string>(),
anyHaveAttachment: false,
},
);
});
}
}

View File

@@ -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);
}