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:
@@ -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"),
|
||||
|
||||
267
libs/angular/src/vault/components/filter-builder.component.ts
Normal file
267
libs/angular/src/vault/components/filter-builder.component.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
67
libs/angular/src/vault/components/filter-builder.stories.ts
Normal file
67
libs/angular/src/vault/components/filter-builder.stories.ts
Normal 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));
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user