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": {
|
"dragToSort": {
|
||||||
"message": "Drag to sort"
|
"message": "Drag to sort"
|
||||||
},
|
},
|
||||||
|
"dragToReorder": {
|
||||||
|
"message": "Drag to reorder"
|
||||||
|
},
|
||||||
"cfTypeText": {
|
"cfTypeText": {
|
||||||
"message": "Text"
|
"message": "Text"
|
||||||
},
|
},
|
||||||
@@ -4706,6 +4709,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"reorderWebsiteUriButton": {
|
||||||
|
"message": "Reorder website URI. Use arrow key to move item up or down."
|
||||||
|
},
|
||||||
"reorderFieldUp": {
|
"reorderFieldUp": {
|
||||||
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
|
"message": "$LABEL$ moved up, position $INDEX$ of $LENGTH$",
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -449,6 +449,9 @@
|
|||||||
"dragToSort": {
|
"dragToSort": {
|
||||||
"message": "Drag to sort"
|
"message": "Drag to sort"
|
||||||
},
|
},
|
||||||
|
"dragToReorder": {
|
||||||
|
"message": "Drag to reorder"
|
||||||
|
},
|
||||||
"cfTypeText": {
|
"cfTypeText": {
|
||||||
"message": "Text"
|
"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": {
|
"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."
|
"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>
|
</h2>
|
||||||
</bit-section-header>
|
</bit-section-header>
|
||||||
|
|
||||||
<bit-card>
|
<bit-card cdkDropList (cdkDropListDropped)="onUriItemDrop($event)">
|
||||||
<ng-container formArrayName="uris">
|
<ng-container formArrayName="uris">
|
||||||
<vault-autofill-uri-option
|
<vault-autofill-uri-option
|
||||||
*ngFor="let uri of uriControls; let i = index"
|
*ngFor="let uri of uriControls; let i = index"
|
||||||
|
cdkDrag
|
||||||
[formControlName]="i"
|
[formControlName]="i"
|
||||||
(remove)="removeUri(i)"
|
(remove)="removeUri(i)"
|
||||||
|
(onKeydown)="onUriItemKeydown($event, i)"
|
||||||
|
[canReorder]="uriControls.length > 1"
|
||||||
[canRemove]="uriControls.length > 1"
|
[canRemove]="uriControls.length > 1"
|
||||||
[defaultMatchDetection]="defaultMatchDetection$ | async"
|
[defaultMatchDetection]="defaultMatchDetection$ | async"
|
||||||
[index]="i"
|
[index]="i"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
||||||
|
import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop";
|
||||||
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
import { ComponentFixture, fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||||
import { mock, MockProxy } from "jest-mock-extended";
|
import { mock, MockProxy } from "jest-mock-extended";
|
||||||
import { BehaviorSubject } from "rxjs";
|
import { BehaviorSubject } from "rxjs";
|
||||||
@@ -16,6 +17,14 @@ import { CipherFormContainer } from "../../cipher-form-container";
|
|||||||
|
|
||||||
import { AutofillOptionsComponent } from "./autofill-options.component";
|
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", () => {
|
describe("AutofillOptionsComponent", () => {
|
||||||
let component: AutofillOptionsComponent;
|
let component: AutofillOptionsComponent;
|
||||||
let fixture: ComponentFixture<AutofillOptionsComponent>;
|
let fixture: ComponentFixture<AutofillOptionsComponent>;
|
||||||
@@ -255,4 +264,111 @@ describe("AutofillOptionsComponent", () => {
|
|||||||
|
|
||||||
expect(component.autofillOptionsForm.value.uris.length).toEqual(1);
|
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
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
import { LiveAnnouncer } from "@angular/cdk/a11y";
|
||||||
|
import { CdkDragDrop, DragDropModule, moveItemInArray } from "@angular/cdk/drag-drop";
|
||||||
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
|
import { AsyncPipe, NgForOf, NgIf } from "@angular/common";
|
||||||
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
|
import { Component, OnInit, QueryList, ViewChildren } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
@@ -41,6 +42,7 @@ interface UriField {
|
|||||||
templateUrl: "./autofill-options.component.html",
|
templateUrl: "./autofill-options.component.html",
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
|
DragDropModule,
|
||||||
SectionComponent,
|
SectionComponent,
|
||||||
SectionHeaderComponent,
|
SectionHeaderComponent,
|
||||||
TypographyModule,
|
TypographyModule,
|
||||||
@@ -229,4 +231,58 @@ export class AutofillOptionsComponent implements OnInit {
|
|||||||
removeUri(i: number) {
|
removeUri(i: number) {
|
||||||
this.autofillOptionsForm.controls.uris.removeAt(i);
|
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,35 +1,50 @@
|
|||||||
<ng-container [formGroup]="uriForm">
|
<ng-container [formGroup]="uriForm">
|
||||||
<bit-form-field [class.!tw-mb-1]="showMatchDetection">
|
<div class="tw-mb-4 pt-1">
|
||||||
<bit-label>{{ uriLabel }}</bit-label>
|
<div class="tw-flex tw-pt-2" [class.!tw-mb-1]="showMatchDetection">
|
||||||
<input bitInput formControlName="uri" #uriInput />
|
<bit-form-field disableMargin class="tw-flex-1 !tw-pt-0">
|
||||||
<button
|
<bit-label>{{ uriLabel }}</bit-label>
|
||||||
type="button"
|
<input bitInput formControlName="uri" #uriInput />
|
||||||
bitIconButton="bwi-cog"
|
<button
|
||||||
bitSuffix
|
type="button"
|
||||||
[appA11yTitle]="toggleTitle"
|
bitIconButton="bwi-cog"
|
||||||
(click)="toggleMatchDetection()"
|
bitSuffix
|
||||||
data-testid="toggle-match-detection-button"
|
[appA11yTitle]="toggleTitle"
|
||||||
></button>
|
(click)="toggleMatchDetection()"
|
||||||
<button
|
data-testid="toggle-match-detection-button"
|
||||||
type="button"
|
></button>
|
||||||
bitIconButton="bwi-minus-circle"
|
<button
|
||||||
buttonType="danger"
|
type="button"
|
||||||
bitSuffix
|
bitIconButton="bwi-minus-circle"
|
||||||
[appA11yTitle]="'deleteWebsite' | i18n"
|
buttonType="danger"
|
||||||
*ngIf="canRemove"
|
bitSuffix
|
||||||
(click)="removeUri()"
|
[appA11yTitle]="'deleteWebsite' | i18n"
|
||||||
data-testid="remove-uri-button"
|
*ngIf="canRemove"
|
||||||
></button>
|
(click)="removeUri()"
|
||||||
</bit-form-field>
|
data-testid="remove-uri-button"
|
||||||
|
></button>
|
||||||
<bit-form-field *ngIf="showMatchDetection" class="!tw-mb-5">
|
</bit-form-field>
|
||||||
<bit-label>{{ "matchDetection" | i18n }}</bit-label>
|
<div class="tw-flex tw-items-center tw-ml-1.5">
|
||||||
<bit-select formControlName="matchDetection" #matchDetectionSelect>
|
<button
|
||||||
<bit-option
|
type="button"
|
||||||
*ngFor="let o of uriMatchOptions"
|
bitIconButton="bwi-hamburger"
|
||||||
[label]="o.label"
|
class="!tw-py-0 !tw-px-1"
|
||||||
[value]="o.value"
|
cdkDragHandle
|
||||||
></bit-option>
|
[appA11yTitle]="'reorderToggleButton' | i18n: uriLabel"
|
||||||
</bit-select>
|
(keydown)="handleKeydown($event)"
|
||||||
</bit-form-field>
|
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>
|
||||||
|
<bit-option
|
||||||
|
*ngFor="let o of uriMatchOptions"
|
||||||
|
[label]="o.label"
|
||||||
|
[value]="o.value"
|
||||||
|
></bit-option>
|
||||||
|
</bit-select>
|
||||||
|
</bit-form-field>
|
||||||
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
// FIXME: Update this file to be type safe and remove this and next line
|
// FIXME: Update this file to be type safe and remove this and next line
|
||||||
// @ts-strict-ignore
|
// @ts-strict-ignore
|
||||||
|
import { DragDropModule } from "@angular/cdk/drag-drop";
|
||||||
import { NgForOf, NgIf } from "@angular/common";
|
import { NgForOf, NgIf } from "@angular/common";
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
@@ -43,6 +44,7 @@ import {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
|
DragDropModule,
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
IconButtonModule,
|
IconButtonModule,
|
||||||
@@ -74,6 +76,12 @@ export class UriOptionComponent implements ControlValueAccessor {
|
|||||||
{ label: this.i18nService.t("never"), value: UriMatchStrategy.Never },
|
{ 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.
|
* 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;
|
@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.
|
* 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 onChange: any = () => {};
|
||||||
private onTouched: any = () => {};
|
private onTouched: any = () => {};
|
||||||
|
|
||||||
|
protected handleKeydown(event: KeyboardEvent) {
|
||||||
|
this.onKeydown.emit(event);
|
||||||
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private formBuilder: FormBuilder,
|
private formBuilder: FormBuilder,
|
||||||
private i18nService: I18nService,
|
private i18nService: I18nService,
|
||||||
|
|||||||
Reference in New Issue
Block a user