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:
@@ -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": {
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 auto‑adding an empty URI by setting a non‑null initial value.
|
||||
// This overrides the call to initNewCipher.
|
||||
|
||||
// Now clear any existing URIs (including the auto‑added 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user