1
0
mirror of https://github.com/bitwarden/browser.git synced 2024-11-22 11:45:59 +01:00

[PM-11201][CL-507] Add the ability to sort by Name, Group, and Permission within the collection and item tables (#11453)

* Added sorting to vault, name, permission and group

Added default sorting

* Fixed import

* reverted test on template

* Only add sorting functionality to admin console

* changed code order

* Fixed leftover test for sortingn

* Fixed reference

* sort permissions by ascending order

* Fixed bug where a collection had multiple groups and sorting alphbatically didn't happen correctly all the time

* Fixed bug whne creating a new cipher item

* Introduced fnFactory to create a sort function with direction provided

* Used new sort function to make collections always remain at the top and ciphers below

* extracted logic to always sort collections at the top

Added similar sorting to sortBygroup

* removed org vault check

* remove unused service

* Sort only collections

* Got rid of sortFn factory in favour of passing the direction as an optional parameter

* Removed tenary

* get cipher permissions

* Use all collections to filter collection ids

* Fixed ascending and descending issues

* Added functionality to default sort in descending order

* default sort permissions in descending order

* Refactored setActive to not pass direction as a paramater
This commit is contained in:
SmithThe4th 2024-11-07 10:10:15 -05:00 committed by GitHub
parent 872f36752f
commit faf7e3d315
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 245 additions and 14 deletions

View File

@ -16,13 +16,39 @@
"all" | i18n "all" | i18n
}}</label> }}</label>
</th> </th>
<th bitCell [class]="showExtraColumn ? 'lg:tw-w-3/5' : 'tw-w-full'">{{ "name" | i18n }}</th> <!-- Organization vault -->
<th
*ngIf="showAdminActions"
bitCell
bitSortable="name"
[fn]="sortByName"
[class]="showExtraColumn ? 'lg:tw-w-3/5' : 'tw-w-full'"
>
{{ "name" | i18n }}
</th>
<!-- Individual vault -->
<th
*ngIf="!showAdminActions"
bitCell
[class]="showExtraColumn ? 'lg:tw-w-3/5' : 'tw-w-full'"
>
{{ "name" | i18n }}
</th>
<th bitCell *ngIf="showOwner" class="tw-hidden tw-w-2/5 lg:tw-table-cell"> <th bitCell *ngIf="showOwner" class="tw-hidden tw-w-2/5 lg:tw-table-cell">
{{ "owner" | i18n }} {{ "owner" | i18n }}
</th> </th>
<th bitCell class="tw-w-2/5" *ngIf="showCollections">{{ "collections" | i18n }}</th> <th bitCell class="tw-w-2/5" *ngIf="showCollections">{{ "collections" | i18n }}</th>
<th bitCell class="tw-w-2/5" *ngIf="showGroups">{{ "groups" | i18n }}</th> <th bitCell bitSortable="groups" [fn]="sortByGroups" class="tw-w-2/5" *ngIf="showGroups">
<th bitCell class="tw-w-2/5" *ngIf="showPermissionsColumn"> {{ "groups" | i18n }}
</th>
<th
bitCell
bitSortable="permissions"
default="desc"
[fn]="sortByPermissions"
class="tw-w-2/5"
*ngIf="showPermissionsColumn"
>
{{ "permission" | i18n }} {{ "permission" | i18n }}
</th> </th>
<th bitCell class="tw-w-12 tw-text-right"> <th bitCell class="tw-w-12 tw-text-right">

View File

@ -1,13 +1,17 @@
import { SelectionModel } from "@angular/cdk/collections"; import { SelectionModel } from "@angular/cdk/collections";
import { Component, EventEmitter, Input, Output } from "@angular/core"; import { Component, EventEmitter, Input, Output } from "@angular/core";
import { CollectionView, Unassigned } from "@bitwarden/admin-console/common"; import { CollectionView, Unassigned, CollectionAdminView } from "@bitwarden/admin-console/common";
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization"; import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
import { TableDataSource } from "@bitwarden/components"; import { SortDirection, TableDataSource } from "@bitwarden/components";
import { GroupView } from "../../../admin-console/organizations/core"; import { GroupView } from "../../../admin-console/organizations/core";
import {
CollectionPermission,
convertToPermission,
} from "./../../../admin-console/organizations/shared/components/access-selector/access-selector.models";
import { VaultItem } from "./vault-item"; import { VaultItem } from "./vault-item";
import { VaultItemEvent } from "./vault-item-event"; import { VaultItemEvent } from "./vault-item-event";
@ -17,6 +21,8 @@ export const RowHeightClass = `tw-h-[65px]`;
const MaxSelectionCount = 500; const MaxSelectionCount = 500;
type ItemPermission = CollectionPermission | "NoAccess";
@Component({ @Component({
selector: "app-vault-items", selector: "app-vault-items",
templateUrl: "vault-items.component.html", templateUrl: "vault-items.component.html",
@ -333,6 +339,119 @@ export class VaultItemsComponent {
return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected; return (canEditOrManageAllCiphers || this.allCiphersHaveEditAccess()) && collectionNotSelected;
} }
/**
* Sorts VaultItems, grouping collections before ciphers, and sorting each group alphabetically by name.
*/
protected sortByName = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) {
return collectionCompare;
}
return this.compareNames(a, b);
};
/**
* Sorts VaultItems based on group names
*/
protected sortByGroups = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
if (
!(a.collection instanceof CollectionAdminView) &&
!(b.collection instanceof CollectionAdminView)
) {
return 0;
}
const getFirstGroupName = (collection: CollectionAdminView): string => {
if (collection.groups.length > 0) {
return collection.groups.map((group) => this.getGroupName(group.id) || "").sort()[0];
}
return null;
};
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) {
return collectionCompare;
}
const aGroupName = getFirstGroupName(a.collection as CollectionAdminView);
const bGroupName = getFirstGroupName(b.collection as CollectionAdminView);
// Collections with groups come before collections without groups.
// If a collection has no groups, getFirstGroupName returns null.
if (aGroupName === null) {
return 1;
}
if (bGroupName === null) {
return -1;
}
return aGroupName.localeCompare(bGroupName);
};
/**
* Sorts VaultItems based on their permissions, with higher permissions taking precedence.
* If permissions are equal, it falls back to sorting by name.
*/
protected sortByPermissions = (a: VaultItem, b: VaultItem, direction: SortDirection) => {
const getPermissionPriority = (item: VaultItem): number => {
const permission = item.collection
? this.getCollectionPermission(item.collection)
: this.getCipherPermission(item.cipher);
const priorityMap = {
[CollectionPermission.Manage]: 5,
[CollectionPermission.Edit]: 4,
[CollectionPermission.EditExceptPass]: 3,
[CollectionPermission.View]: 2,
[CollectionPermission.ViewExceptPass]: 1,
NoAccess: 0,
};
return priorityMap[permission] ?? -1;
};
// Collections before ciphers
const collectionCompare = this.prioritizeCollections(a, b, direction);
if (collectionCompare !== 0) {
return collectionCompare;
}
const priorityA = getPermissionPriority(a);
const priorityB = getPermissionPriority(b);
// Higher priority first
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
return this.compareNames(a, b);
};
private compareNames(a: VaultItem, b: VaultItem): number {
const getName = (item: VaultItem) => item.collection?.name || item.cipher?.name;
return getName(a).localeCompare(getName(b));
}
/**
* Sorts VaultItems by prioritizing collections over ciphers.
* Collections are always placed before ciphers, regardless of the sorting direction.
*/
private prioritizeCollections(a: VaultItem, b: VaultItem, direction: SortDirection): number {
if (a.collection && !b.collection) {
return direction === "asc" ? -1 : 1;
}
if (!a.collection && b.collection) {
return direction === "asc" ? 1 : -1;
}
return 0;
}
private hasPersonalItems(): boolean { private hasPersonalItems(): boolean {
return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null); return this.selection.selected.some(({ cipher }) => cipher?.organizationId === null);
} }
@ -346,4 +465,58 @@ export class VaultItemsComponent {
private getUniqueOrganizationIds(): Set<string> { private getUniqueOrganizationIds(): Set<string> {
return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? [])); return new Set(this.selection.selected.flatMap((i) => i.cipher?.organizationId ?? []));
} }
private getGroupName(groupId: string): string | undefined {
return this.allGroups.find((g) => g.id === groupId)?.name;
}
private getCollectionPermission(collection: CollectionView): ItemPermission {
const organization = this.allOrganizations.find((o) => o.id === collection.organizationId);
if (collection.id == Unassigned && organization?.canEditUnassignedCiphers) {
return CollectionPermission.Edit;
}
if (collection.assigned) {
return convertToPermission(collection);
}
return "NoAccess";
}
private getCipherPermission(cipher: CipherView): ItemPermission {
if (!cipher.organizationId || cipher.collectionIds.length === 0) {
return CollectionPermission.Manage;
}
const filteredCollections = this.allCollections?.filter((collection) => {
if (collection.assigned) {
return cipher.collectionIds.find((id) => {
if (collection.id === id) {
return collection;
}
});
}
});
if (filteredCollections?.length === 1) {
return convertToPermission(filteredCollections[0]);
}
if (filteredCollections?.length > 0) {
const permissions = filteredCollections.map((collection) => convertToPermission(collection));
const orderedPermissions = [
CollectionPermission.Manage,
CollectionPermission.Edit,
CollectionPermission.EditExceptPass,
CollectionPermission.View,
CollectionPermission.ViewExceptPass,
];
return orderedPermissions.find((perm) => permissions.includes(perm));
}
return "NoAccess";
}
} }

View File

@ -1,7 +1,7 @@
import { coerceBooleanProperty } from "@angular/cdk/coercion"; import { coerceBooleanProperty } from "@angular/cdk/coercion";
import { Component, HostBinding, Input, OnInit } from "@angular/core"; import { Component, HostBinding, Input, OnInit } from "@angular/core";
import type { SortFn } from "./table-data-source"; import type { SortDirection, SortFn } from "./table-data-source";
import { TableComponent } from "./table.component"; import { TableComponent } from "./table.component";
@Component({ @Component({
@ -19,12 +19,16 @@ export class SortableComponent implements OnInit {
*/ */
@Input() bitSortable: string; @Input() bitSortable: string;
private _default: boolean; private _default: SortDirection | boolean = false;
/** /**
* Mark the column as the default sort column * Mark the column as the default sort column
*/ */
@Input() set default(value: boolean | "") { @Input() set default(value: SortDirection | boolean | "") {
this._default = coerceBooleanProperty(value); if (value === "desc" || value === "asc") {
this._default = value;
} else {
this._default = coerceBooleanProperty(value) ? "asc" : false;
}
} }
/** /**
@ -32,6 +36,11 @@ export class SortableComponent implements OnInit {
* *
* @example * @example
* fn = (a, b) => a.name.localeCompare(b.name) * fn = (a, b) => a.name.localeCompare(b.name)
*
* fn = (a, b, direction) => {
* const result = a.name.localeCompare(b.name)
* return direction === 'asc' ? result : -result;
* }
*/ */
@Input() fn: SortFn; @Input() fn: SortFn;
@ -52,8 +61,18 @@ export class SortableComponent implements OnInit {
protected setActive() { protected setActive() {
if (this.table.dataSource) { if (this.table.dataSource) {
const direction = this.isActive && this.direction === "asc" ? "desc" : "asc"; const defaultDirection = this._default === "desc" ? "desc" : "asc";
this.table.dataSource.sort = { column: this.bitSortable, direction: direction, fn: this.fn }; const direction = this.isActive
? this.direction === "asc"
? "desc"
: "asc"
: defaultDirection;
this.table.dataSource.sort = {
column: this.bitSortable,
direction: direction,
fn: this.fn,
};
} }
} }

View File

@ -3,7 +3,7 @@ import { DataSource } from "@angular/cdk/collections";
import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs"; import { BehaviorSubject, combineLatest, map, Observable, Subscription } from "rxjs";
export type SortDirection = "asc" | "desc"; export type SortDirection = "asc" | "desc";
export type SortFn = (a: any, b: any) => number; export type SortFn = (a: any, b: any, direction?: SortDirection) => number;
export type Sort = { export type Sort = {
column?: string; column?: string;
direction: SortDirection; direction: SortDirection;
@ -166,7 +166,7 @@ export class TableDataSource<T> extends DataSource<T> {
return data.sort((a, b) => { return data.sort((a, b) => {
// If a custom sort function is provided, use it instead of the default. // If a custom sort function is provided, use it instead of the default.
if (sort.fn) { if (sort.fn) {
return sort.fn(a, b) * directionModifier; return sort.fn(a, b, sort.direction) * directionModifier;
} }
let valueA = this.sortingDataAccessor(a, column); let valueA = this.sortingDataAccessor(a, column);

View File

@ -105,7 +105,7 @@ within the `ng-template`which provides access to the rows using `let-rows$`.
We provide a simple component for displaying sortable column headers. The `bitSortable` component We provide a simple component for displaying sortable column headers. The `bitSortable` component
wires up to the `TableDataSource` and will automatically sort the data when clicked and display an wires up to the `TableDataSource` and will automatically sort the data when clicked and display an
indicator for which column is currently sorted. The dafault sorting can be specified by setting the indicator for which column is currently sorted. The default sorting can be specified by setting the
`default`. `default`.
```html ```html
@ -113,10 +113,23 @@ indicator for which column is currently sorted. The dafault sorting can be speci
<th bitCell bitSortable="name" default>Name</th> <th bitCell bitSortable="name" default>Name</th>
``` ```
For default sorting in descending order, set default="desc"
```html
<th bitCell bitSortable="name" default="desc">Name</th>
```
It's also possible to define a custom sorting function by setting the `fn` input. It's also possible to define a custom sorting function by setting the `fn` input.
```ts ```ts
// Basic sort function
const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1); const sortFn = (a: T, b: T) => (a.id > b.id ? 1 : -1);
// Direction aware sort function
const sortByName = (a: T, b: T, direction?: SortDirection) => {
const result = a.name.localeCompare(b.name);
return direction === "asc" ? result : -result;
};
``` ```
### Filtering ### Filtering