1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-14 15:23:33 +00:00

[PM-13991] - Edit login - reorder website URIs (#13595)

* WIP - sortable website uri

* add specs

* fix type errors in tests
This commit is contained in:
Jordan Aasen
2025-03-10 12:57:02 -07:00
committed by GitHub
parent a877450e0a
commit 992be1d054
7 changed files with 282 additions and 34 deletions

View File

@@ -1679,6 +1679,9 @@
"dragToSort": {
"message": "Drag to sort"
},
"dragToReorder": {
"message": "Drag to reorder"
},
"cfTypeText": {
"message": "Text"
},
@@ -4706,6 +4709,9 @@
}
}
},
"reorderWebsiteUriButton": {
"message": "Reorder website URI. Use arrow key to move item up or down."
},
"reorderFieldUp": {
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"placeholders": {

View File

@@ -449,6 +449,9 @@
"dragToSort": {
"message": "Drag to sort"
},
"dragToReorder": {
"message": "Drag to reorder"
},
"cfTypeText": {
"message": "Text"
},
@@ -4564,6 +4567,40 @@
}
}
},
"reorderFieldUp": {
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
},
"index": {
"content": "$2",
"example": "1"
},
"length": {
"content": "$3",
"example": "3"
}
}
},
"reorderFieldDown": {
"message": "$LABEL$ moved down, position $INDEX$ of $LENGTH$",
"placeholders": {
"label": {
"content": "$1",
"example": "Custom field"
},
"index": {
"content": "$2",
"example": "1"
},
"length": {
"content": "$3",
"example": "3"
}
}
},
"keyUpdateFoldersFailed": {
"message": "When updating your encryption key, your folders could not be decrypted. To continue with the update, your folders must be deleted. No vault items will be deleted if you proceed."
},

View File

@@ -5,12 +5,15 @@
</h2>
</bit-section-header>
<bit-card>
<bit-card cdkDropList (cdkDropListDropped)="onUriItemDrop($event)">
<ng-container formArrayName="uris">
<vault-autofill-uri-option
*ngFor="let uri of uriControls; let i = index"
cdkDrag
[formControlName]="i"
(remove)="removeUri(i)"
(onKeydown)="onUriItemKeydown($event, i)"
[canReorder]="uriControls.length > 1"
[canRemove]="uriControls.length > 1"
[defaultMatchDetection]="defaultMatchDetection$ | async"
[index]="i"

View File

@@ -1,4 +1,5 @@
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
@@ -16,6 +17,14 @@ import { CipherFormContainer } from "../../cipher-form-container";
import { AutofillOptionsComponent } from "./autofill-options.component";
jest.mock("@angular/cdk/drag-drop", () => {
const actual = jest.requireActual("@angular/cdk/drag-drop");
return {
...actual,
moveItemInArray: jest.fn(actual.moveItemInArray),
};
});
describe("AutofillOptionsComponent", () => {
let component: AutofillOptionsComponent;
let fixture: ComponentFixture<AutofillOptionsComponent>;
@@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => {
expect(component.autofillOptionsForm.value.uris.length).toEqual(1);
});
describe("Drag & Drop Functionality", () => {
beforeEach(() => {
// Prevent autoadding an empty URI by setting a nonnull initial value.
// This overrides the call to initNewCipher.
// Now clear any existing URIs (including the autoadded one)
component.autofillOptionsForm.controls.uris.clear();
// Add exactly three URIs that we want to test reordering on.
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
component.addUri({ uri: "https://third.com", matchDetection: null });
fixture.detectChanges();
});
it("should reorder URI inputs on drop event", () => {
// Simulate a drop event that moves the first URI (index 0) to the last position (index 2).
const dropEvent: CdkDragDrop<HTMLDivElement> = {
previousIndex: 0,
currentIndex: 2,
container: null,
previousContainer: null,
isPointerOverContainer: true,
item: null,
distance: { x: 0, y: 0 },
} as any;
component.onUriItemDrop(dropEvent);
fixture.detectChanges();
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
0,
2,
);
});
it("should reorder URI input via keyboard ArrowUp", async () => {
// Clear and add exactly two URIs.
component.autofillOptionsForm.controls.uris.clear();
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
fixture.detectChanges();
// Simulate pressing ArrowUp on the second URI (index 1)
const keyEvent = {
key: "ArrowUp",
preventDefault: jest.fn(),
target: document.createElement("button"),
} as unknown as KeyboardEvent;
// Force requestAnimationFrame to run synchronously
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(new Date().getTime());
return 0;
});
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
await component.onUriItemKeydown(keyEvent, 1);
fixture.detectChanges();
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
1,
0,
);
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
"reorderFieldUp websiteUri 1 2",
"assertive",
);
});
it("should reorder URI input via keyboard ArrowDown", async () => {
// Clear and add exactly three URIs.
component.autofillOptionsForm.controls.uris.clear();
component.addUri({ uri: "https://first.com", matchDetection: null });
component.addUri({ uri: "https://second.com", matchDetection: null });
component.addUri({ uri: "https://third.com", matchDetection: null });
fixture.detectChanges();
// Simulate pressing ArrowDown on the second URI (index 1)
const keyEvent = {
key: "ArrowDown",
preventDefault: jest.fn(),
target: document.createElement("button"),
} as unknown as KeyboardEvent;
jest.spyOn(window, "requestAnimationFrame").mockImplementation((cb: FrameRequestCallback) => {
cb(new Date().getTime());
return 0;
});
(liveAnnouncer.announce as jest.Mock).mockResolvedValue(null);
await component.onUriItemKeydown(keyEvent, 1);
expect(moveItemInArray).toHaveBeenCalledWith(
component.autofillOptionsForm.controls.uris.controls,
1,
2,
);
expect(liveAnnouncer.announce).toHaveBeenCalledWith(
"reorderFieldDown websiteUri 3 3",
"assertive",
);
});
});
});

View File

@@ -1,6 +1,7 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { LiveAnnouncer } from "@angular/cdk/a11y";
import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop";
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
@@ -41,6 +42,7 @@ interface UriField {
templateUrl: "./autofill-options.component.html",
standalone: true,
imports: [
DragDropModule,
SectionComponent,
SectionHeaderComponent,
TypographyModule,
@@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit {
removeUri(i: number) {
this.autofillOptionsForm.controls.uris.removeAt(i);
}
/** Create a new list of LoginUriViews from the form objects and update the cipher */
private updateUriFields() {
this.cipherFormContainer.patchCipher((cipher) => {
cipher.login.uris = this.uriControls.map(
(control) =>
Object.assign(new LoginUriView(), {
uri: control.value.uri,
matchDetection: control.value.matchDetection ?? null,
}) as LoginUriView,
);
return cipher;
});
}
/** Reorder the controls to match the new order after a "drop" event */
onUriItemDrop(event: CdkDragDrop<HTMLDivElement>) {
moveItemInArray(this.uriControls, event.previousIndex, event.currentIndex);
this.updateUriFields();
}
/** Handles a uri item keyboard up or down event */
async onUriItemKeydown(event: KeyboardEvent, index: number) {
if (event.key === "ArrowUp" && index !== 0) {
await this.reorderUriItems(event, index, "Up");
}
if (event.key === "ArrowDown" && index !== this.uriControls.length - 1) {
await this.reorderUriItems(event, index, "Down");
}
}
/** Reorders the uri items from a keyboard up or down event */
async reorderUriItems(event: KeyboardEvent, previousIndex: number, direction: "Up" | "Down") {
const currentIndex = previousIndex + (direction === "Up" ? -1 : 1);
event.preventDefault();
await this.liveAnnouncer.announce(
this.i18nService.t(
`reorderField${direction}`,
this.i18nService.t("websiteUri"),
currentIndex + 1,
this.uriControls.length,
),
"assertive",
);
moveItemInArray(this.uriControls, previousIndex, currentIndex);
this.updateUriFields();
// Refocus the button after the reorder
// Angular re-renders the list when moving an item up which causes the focus to be lost
// Wait for the next tick to ensure the button is rendered before focusing
requestAnimationFrame(() => {
(event.target as HTMLButtonElement).focus();
});
}
}

View File

@@ -1,5 +1,7 @@
<ng-container [formGroup]="uriForm">
<bit-form-field [class.!tw-mb-1]="showMatchDetection">
<div class="tw-mb-4 pt-1">
<div class="tw-flex tw-pt-2" [class.!tw-mb-1]="showMatchDetection">
<bit-form-field disableMargin class="tw-flex-1 !tw-pt-0">
<bit-label>{{ uriLabel }}</bit-label>
<input bitInput formControlName="uri" #uriInput />
<button
@@ -21,7 +23,19 @@
data-testid="remove-uri-button"
></button>
</bit-form-field>
<div class="tw-flex tw-items-center tw-ml-1.5">
<button
type="button"
bitIconButton="bwi-hamburger"
class="!tw-py-0 !tw-px-1"
cdkDragHandle
[appA11yTitle]="'reorderToggleButton' | i18n: uriLabel"
(keydown)="handleKeydown($event)"
data-testid="reorder-toggle-button"
*ngIf="canReorder"
></button>
</div>
</div>
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
<bit-select formControlName="matchDetection" #matchDetectionSelect>
@@ -32,4 +46,5 @@
></bit-option>
</bit-select>
</bit-form-field>
</div>
</ng-container>

View File

@@ -1,5 +1,6 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { DragDropModule } from "@angular/cdk/drag-drop";
import { NgForOf, NgIf } from "@angular/common";
import {
Component,
@@ -43,6 +44,7 @@ import {
},
],
imports: [
DragDropModule,
FormFieldModule,
ReactiveFormsModule,
IconButtonModule,
@@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor {
{ label: this.i18nService.t("never"), value: UriMatchStrategy.Never },
];
/**
* Whether the option can be reordered. If false, the reorder button will be hidden.
*/
@Input({ required: true })
canReorder: boolean;
/**
* Whether the URI can be removed from the form. If false, the remove button will be hidden.
*/
@@ -101,6 +109,9 @@ export class UriOptionComponent implements ControlValueAccessor {
*/
@Input({ required: true }) index: number;
@Output()
onKeydown = new EventEmitter<KeyboardEvent>();
/**
* Emits when the remove button is clicked and URI should be removed from the form.
*/
@@ -132,6 +143,10 @@ export class UriOptionComponent implements ControlValueAccessor {
private onChange: any = () => {};
private onTouched: any = () => {};
protected handleKeydown(event: KeyboardEvent) {
this.onKeydown.emit(event);
}
constructor(
private formBuilder: FormBuilder,
private i18nService: I18nService,