mirror of
https://github.com/bitwarden/browser
synced 2026-02-10 05:30:01 +00:00
Work on Basic Filter Handler
This commit is contained in:
@@ -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<string>[] | null;
|
||||
folders: ChipSelectOption<string>[] | null;
|
||||
collections: ChipSelectOption<string>[] | null;
|
||||
types: ChipSelectOption<string>[];
|
||||
fields: ChipSelectOption<string>[] | null;
|
||||
anyHaveAttachment: boolean;
|
||||
};
|
||||
|
||||
const setMap = <T, TResult>(
|
||||
set: Set<T>,
|
||||
type FilterModel = Partial<FilterData & { text: string }>;
|
||||
|
||||
// TODO: Include more details on basic so consumers can easily interact with it.
|
||||
export type Filter = { filter: string } & ({ type: "advanced" } | { type: "basic" });
|
||||
|
||||
const customMap = <T, TResult>(
|
||||
map: Map<T, unknown>,
|
||||
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 = <T, TResult>(
|
||||
@Component({
|
||||
selector: "app-filter-builder",
|
||||
template: `
|
||||
<ng-container *ngIf="filterData$ | async as filter">
|
||||
<form [formGroup]="form" *ngIf="filterData$ | async as filter">
|
||||
<bit-chip-multi-select
|
||||
placeholderText="Vault"
|
||||
placeholderIcon="bwi-vault"
|
||||
formControlName="vaults"
|
||||
[options]="filter.vaults"
|
||||
></bit-chip-multi-select>
|
||||
<bit-chip-multi-select
|
||||
placeholderText="Folders"
|
||||
placeholderIcon="bwi-folder"
|
||||
formControlName="folders"
|
||||
[options]="filter.folders"
|
||||
class="tw-pl-2"
|
||||
></bit-chip-multi-select>
|
||||
<bit-chip-multi-select
|
||||
placeholderText="Collections"
|
||||
placeholderIcon="bwi-collection"
|
||||
formControlName="collections"
|
||||
[options]="filter.collections"
|
||||
class="tw-pl-2"
|
||||
></bit-chip-multi-select>
|
||||
@for (selectedOtherOption of selectedOtherOptions$ | async; track selectedOtherOption) {
|
||||
@for (selectedOtherOption of selectedOptions(); track selectedOtherOption) {
|
||||
@switch (selectedOtherOption) {
|
||||
@case ("types") {
|
||||
<bit-chip-multi-select
|
||||
placeholderText="Types"
|
||||
placeholderIcon="bwi-sliders"
|
||||
formControlName="types"
|
||||
[options]="filter.types"
|
||||
class="tw-pl-2"
|
||||
></bit-chip-multi-select>
|
||||
@@ -80,14 +91,11 @@ const setMap = <T, TResult>(
|
||||
<bit-chip-multi-select
|
||||
placeholderText="Fields"
|
||||
placeholderIcon="bwi-filter"
|
||||
[loading]="filter.fields == null"
|
||||
formControlName="fields"
|
||||
[options]="filter.fields"
|
||||
class="tw-pl-2"
|
||||
></bit-chip-multi-select>
|
||||
}
|
||||
@default {
|
||||
<p>Invalid option {{ selectedOtherOption | json }}</p>
|
||||
}
|
||||
}
|
||||
}
|
||||
<ng-container *ngIf="otherOptions$ | async as otherOptions">
|
||||
@@ -95,16 +103,20 @@ const setMap = <T, TResult>(
|
||||
*ngIf="otherOptions.length !== 0"
|
||||
placeholderText="Other filters"
|
||||
placeholderIcon="bwi-sliders"
|
||||
formControlName="otherOptions"
|
||||
[options]="otherOptions"
|
||||
class="tw-pl-2"
|
||||
[(ngModel)]="otherOption"
|
||||
>
|
||||
</bit-chip-select>
|
||||
</ng-container>
|
||||
<span class="tw-border-l tw-border-0 tw-border-solid tw-border-secondary-300 tw-mx-2"></span>
|
||||
<button type="button" bitLink linkType="secondary" class="tw-text-sm">Reset</button>
|
||||
<button type="button" bitLink class="tw-ml-2 tw-text-sm">Save filter</button>
|
||||
</ng-container>
|
||||
<button type="button" bitLink linkType="secondary" class="tw-text-sm" (click)="resetFilter()">
|
||||
Reset
|
||||
</button>
|
||||
<button type="button" bitLink class="tw-ml-2 tw-text-sm" (click)="saveFilter()">
|
||||
Save filter
|
||||
</button>
|
||||
</form>
|
||||
`,
|
||||
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<string[]>([]),
|
||||
folders: this.formBuilder.control<string[]>([]),
|
||||
collections: this.formBuilder.control<string[]>([]),
|
||||
types: this.formBuilder.control<string[]>([]),
|
||||
fields: this.formBuilder.control<string[]>([]),
|
||||
otherOptions: this.formBuilder.control<string>(null),
|
||||
selectedOtherOptions: this.formBuilder.control<string[]>([]),
|
||||
});
|
||||
|
||||
filterData$: Observable<Filter>;
|
||||
private loadingFilter: FilterData;
|
||||
|
||||
protected filterData$: Observable<FilterData>;
|
||||
|
||||
private defaultOtherOptions: ChipSelectOption<string>[];
|
||||
|
||||
// TODO: Set these dynamically based on metadata
|
||||
private _otherOptions = new BehaviorSubject<ChipSelectOption<string>[]>([
|
||||
{ value: "types", label: "Types", icon: "bwi-sliders" },
|
||||
{ value: "fields", label: "Fields", icon: "bwi-filter" },
|
||||
]);
|
||||
private _otherOptions: BehaviorSubject<ChipSelectOption<string>[]>;
|
||||
|
||||
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<string[]>([]);
|
||||
|
||||
selectedOtherOptions$ = this._selectedOtherOptions.asObservable();
|
||||
protected otherOptions$: Observable<ChipSelectOption<string>[]>;
|
||||
|
||||
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<string>,
|
||||
),
|
||||
collections: setMap(
|
||||
collections: customMap(
|
||||
metadata.collections,
|
||||
(c, i) =>
|
||||
({
|
||||
@@ -229,7 +275,7 @@ export class FilterBuilderComponent implements OnInit {
|
||||
label: `Collection ${i}`,
|
||||
}) satisfies ChipSelectOption<string>,
|
||||
),
|
||||
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<string>,
|
||||
),
|
||||
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() {
|
||||
//
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,18 +34,32 @@ export default {
|
||||
collectMetadata: () => {
|
||||
return map<CipherView[], VaultFilterMetadata>((_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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -36,18 +36,32 @@ export default {
|
||||
collectMetadata: () => {
|
||||
return map<CipherView[], VaultFilterMetadata>((_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;
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
104
libs/common/src/vault/filtering/basic-vault-filter.handler.ts
Normal file
104
libs/common/src/vault/filtering/basic-vault-filter.handler.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,58 +4,68 @@ 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;
|
||||
vaults: Map<string | null, number>;
|
||||
fieldNames: Map<string, number>;
|
||||
itemTypes: Map<CipherType, number>;
|
||||
folders: Map<string, number>;
|
||||
collections: Map<string, number>;
|
||||
attachmentCount: number;
|
||||
};
|
||||
|
||||
export class VaultFilterMetadataService {
|
||||
collectMetadata() {
|
||||
const setOrIncrement = <T>(map: Map<T, number>, key: T) => {
|
||||
const entry = map.get(key);
|
||||
|
||||
if (entry == undefined) {
|
||||
map.set(key, 1);
|
||||
} else {
|
||||
map.set(key, entry + 1);
|
||||
}
|
||||
};
|
||||
|
||||
return map<CipherView[], VaultFilterMetadata>((ciphers) => {
|
||||
return ciphers.reduce<VaultFilterMetadata>(
|
||||
(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<string | null>(),
|
||||
fieldNames: new Set<string>(),
|
||||
itemTypes: new Set<CipherType>(),
|
||||
folders: new Set<string>(),
|
||||
collections: new Set<string>(),
|
||||
anyHaveAttachment: false,
|
||||
vaults: new Map<string | null, number>(),
|
||||
fieldNames: new Map<string, number>(),
|
||||
itemTypes: new Map<CipherType, number>(),
|
||||
folders: new Map<string, number>(),
|
||||
collections: new Map<string, number>(),
|
||||
attachmentCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user