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": { "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": {

View File

@@ -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."
}, },

View File

@@ -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"

View File

@@ -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 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 // 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();
});
}
} }

View File

@@ -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>

View File

@@ -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,