mirror of
https://github.com/bitwarden/browser
synced 2025-12-14 07:13:32 +00:00
[PM-13388] Extension: Persist Scroll from Vault (#12325)
* add service to track scroll position of the vault tab in the popup * add data attribute to individual vault items - Allows query selector to focus on the specific element * stop scroll service when a cipher is deleted * start scroll listener when the vault page is initialized * fix strict linting errors * remove focus reset when navigating back to the vault screen * skip recording the first scroll from the automatic scroll * combine filters into a single observable * do not start the scroll service until filters have loaded in * refactor allFilters to come from the vault popup list filters service * use assertion on scroll position * hide virtual scrolling element while scrolling is restored * update comments * fix failing tests to use different matcher * remove visibility trick for restoring scroll position after chatting with design --------- Co-authored-by: bnagawiecki <107435978+bnagawiecki@users.noreply.github.com>
This commit is contained in:
@@ -2,8 +2,9 @@
|
|||||||
<form
|
<form
|
||||||
[formGroup]="filterForm"
|
[formGroup]="filterForm"
|
||||||
class="tw-gap-2 tw-mt-2 tw-grid tw-grid-cols-2 sm:tw-grid-cols-3 lg:tw-grid-cols-4"
|
class="tw-gap-2 tw-mt-2 tw-grid tw-grid-cols-2 sm:tw-grid-cols-3 lg:tw-grid-cols-4"
|
||||||
|
*ngIf="allFilters$ | async as allFilters"
|
||||||
>
|
>
|
||||||
<ng-container *ngIf="organizations$ | async as organizations">
|
<ng-container *ngIf="allFilters.organizations as organizations">
|
||||||
<bit-chip-select
|
<bit-chip-select
|
||||||
*ngIf="organizations.length"
|
*ngIf="organizations.length"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -14,7 +15,7 @@
|
|||||||
>
|
>
|
||||||
</bit-chip-select>
|
</bit-chip-select>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="collections$ | async as collections">
|
<ng-container *ngIf="allFilters.collections as collections">
|
||||||
<bit-chip-select
|
<bit-chip-select
|
||||||
*ngIf="collections.length"
|
*ngIf="collections.length"
|
||||||
fullWidth
|
fullWidth
|
||||||
@@ -25,7 +26,7 @@
|
|||||||
>
|
>
|
||||||
</bit-chip-select>
|
</bit-chip-select>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container *ngIf="folders$ | async as folders">
|
<ng-container *ngIf="allFilters.folders as folders">
|
||||||
<bit-chip-select
|
<bit-chip-select
|
||||||
*ngIf="folders.length"
|
*ngIf="folders.length"
|
||||||
fullWidth
|
fullWidth
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component } from "@angular/core";
|
import { Component } from "@angular/core";
|
||||||
import { ReactiveFormsModule } from "@angular/forms";
|
import { ReactiveFormsModule } from "@angular/forms";
|
||||||
|
import { combineLatest, map } from "rxjs";
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { ChipSelectComponent } from "@bitwarden/components";
|
import { ChipSelectComponent } from "@bitwarden/components";
|
||||||
@@ -20,5 +21,20 @@ export class VaultListFiltersComponent {
|
|||||||
protected folders$ = this.vaultPopupListFiltersService.folders$;
|
protected folders$ = this.vaultPopupListFiltersService.folders$;
|
||||||
protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes;
|
protected cipherTypes = this.vaultPopupListFiltersService.cipherTypes;
|
||||||
|
|
||||||
|
// Combine all filters into a single observable to eliminate the filters from loading separately in the UI.
|
||||||
|
protected allFilters$ = combineLatest([
|
||||||
|
this.organizations$,
|
||||||
|
this.collections$,
|
||||||
|
this.folders$,
|
||||||
|
]).pipe(
|
||||||
|
map(([organizations, collections, folders]) => {
|
||||||
|
return {
|
||||||
|
organizations,
|
||||||
|
collections,
|
||||||
|
folders,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
constructor(private vaultPopupListFiltersService: VaultPopupListFiltersService) {}
|
constructor(private vaultPopupListFiltersService: VaultPopupListFiltersService) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { ScrollingModule } from "@angular/cdk/scrolling";
|
import { CdkVirtualScrollableElement, ScrollingModule } from "@angular/cdk/scrolling";
|
||||||
import { CommonModule } from "@angular/common";
|
import { CommonModule } from "@angular/common";
|
||||||
import { Component, DestroyRef, OnDestroy, OnInit } from "@angular/core";
|
import { AfterViewInit, Component, DestroyRef, OnDestroy, OnInit, ViewChild } from "@angular/core";
|
||||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
import { RouterLink } from "@angular/router";
|
import { RouterLink } from "@angular/router";
|
||||||
import { combineLatest, Observable, shareReplay, switchMap } from "rxjs";
|
import { combineLatest, filter, map, Observable, shareReplay, switchMap, take } from "rxjs";
|
||||||
import { filter, map, take } from "rxjs/operators";
|
|
||||||
|
|
||||||
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
import { JslibModule } from "@bitwarden/angular/jslib.module";
|
||||||
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
import { CipherId, CollectionId, OrganizationId } from "@bitwarden/common/types/guid";
|
||||||
@@ -19,6 +18,7 @@ import { PopupHeaderComponent } from "../../../../platform/popup/layout/popup-he
|
|||||||
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
import { PopupPageComponent } from "../../../../platform/popup/layout/popup-page.component";
|
||||||
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
import { VaultPopupItemsService } from "../../services/vault-popup-items.service";
|
||||||
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
|
import { VaultPopupListFiltersService } from "../../services/vault-popup-list-filters.service";
|
||||||
|
import { VaultPopupScrollPositionService } from "../../services/vault-popup-scroll-position.service";
|
||||||
|
|
||||||
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
|
import { BlockedInjectionBanner } from "./blocked-injection-banner/blocked-injection-banner.component";
|
||||||
import {
|
import {
|
||||||
@@ -58,7 +58,9 @@ enum VaultState {
|
|||||||
DecryptionFailureDialogComponent,
|
DecryptionFailureDialogComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class VaultV2Component implements OnInit, OnDestroy {
|
export class VaultV2Component implements OnInit, AfterViewInit, OnDestroy {
|
||||||
|
@ViewChild(CdkVirtualScrollableElement) virtualScrollElement?: CdkVirtualScrollableElement;
|
||||||
|
|
||||||
cipherType = CipherType;
|
cipherType = CipherType;
|
||||||
|
|
||||||
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
|
protected favoriteCiphers$ = this.vaultPopupItemsService.favoriteCiphers$;
|
||||||
@@ -88,9 +90,12 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
protected VaultStateEnum = VaultState;
|
protected VaultStateEnum = VaultState;
|
||||||
|
|
||||||
|
private allFilters$ = this.vaultPopupListFiltersService.allFilters$;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private vaultPopupItemsService: VaultPopupItemsService,
|
private vaultPopupItemsService: VaultPopupItemsService,
|
||||||
private vaultPopupListFiltersService: VaultPopupListFiltersService,
|
private vaultPopupListFiltersService: VaultPopupListFiltersService,
|
||||||
|
private vaultScrollPositionService: VaultPopupScrollPositionService,
|
||||||
private destroyRef: DestroyRef,
|
private destroyRef: DestroyRef,
|
||||||
private cipherService: CipherService,
|
private cipherService: CipherService,
|
||||||
private dialogService: DialogService,
|
private dialogService: DialogService,
|
||||||
@@ -119,6 +124,17 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ngAfterViewInit(): void {
|
||||||
|
if (this.virtualScrollElement) {
|
||||||
|
// The filters component can cause the size of the virtual scroll element to change,
|
||||||
|
// which can cause the scroll position to be land in the wrong spot. To fix this,
|
||||||
|
// wait until all filters are populated before restoring the scroll position.
|
||||||
|
this.allFilters$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe(() => {
|
||||||
|
this.vaultScrollPositionService.start(this.virtualScrollElement!);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.cipherService.failedToDecryptCiphers$
|
this.cipherService.failedToDecryptCiphers$
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -134,5 +150,7 @@ export class VaultV2Component implements OnInit, OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {}
|
ngOnDestroy(): void {
|
||||||
|
this.vaultScrollPositionService.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,12 +21,15 @@ import { FakeAccountService, mockAccountServiceWith } from "@bitwarden/common/sp
|
|||||||
import { UserId } from "@bitwarden/common/types/guid";
|
import { UserId } from "@bitwarden/common/types/guid";
|
||||||
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.service";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
import { CipherAuthorizationService } from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||||
|
import { DialogService, ToastService } from "@bitwarden/components";
|
||||||
import { CopyCipherFieldService } from "@bitwarden/vault";
|
import { CopyCipherFieldService } from "@bitwarden/vault";
|
||||||
|
|
||||||
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
import { BrowserApi } from "../../../../../platform/browser/browser-api";
|
||||||
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
import BrowserPopupUtils from "../../../../../platform/popup/browser-popup-utils";
|
||||||
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
||||||
|
import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service";
|
||||||
|
|
||||||
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
|
import { VaultPopupAutofillService } from "./../../../services/vault-popup-autofill.service";
|
||||||
import { ViewV2Component } from "./view-v2.component";
|
import { ViewV2Component } from "./view-v2.component";
|
||||||
@@ -44,6 +47,10 @@ describe("ViewV2Component", () => {
|
|||||||
const collect = jest.fn().mockResolvedValue(null);
|
const collect = jest.fn().mockResolvedValue(null);
|
||||||
const doAutofill = jest.fn().mockResolvedValue(true);
|
const doAutofill = jest.fn().mockResolvedValue(true);
|
||||||
const copy = jest.fn().mockResolvedValue(true);
|
const copy = jest.fn().mockResolvedValue(true);
|
||||||
|
const back = jest.fn().mockResolvedValue(null);
|
||||||
|
const openSimpleDialog = jest.fn().mockResolvedValue(true);
|
||||||
|
const stop = jest.fn();
|
||||||
|
const showToast = jest.fn();
|
||||||
|
|
||||||
const mockCipher = {
|
const mockCipher = {
|
||||||
id: "122-333-444",
|
id: "122-333-444",
|
||||||
@@ -54,7 +61,7 @@ describe("ViewV2Component", () => {
|
|||||||
password: "test-password",
|
password: "test-password",
|
||||||
totp: "123",
|
totp: "123",
|
||||||
},
|
},
|
||||||
};
|
} as unknown as CipherView;
|
||||||
|
|
||||||
const mockVaultPopupAutofillService = {
|
const mockVaultPopupAutofillService = {
|
||||||
doAutofill,
|
doAutofill,
|
||||||
@@ -68,13 +75,21 @@ describe("ViewV2Component", () => {
|
|||||||
const mockCipherService = {
|
const mockCipherService = {
|
||||||
get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }),
|
get: jest.fn().mockResolvedValue({ decrypt: jest.fn().mockResolvedValue(mockCipher) }),
|
||||||
getKeyForCipherKeyDecryption: jest.fn().mockResolvedValue({}),
|
getKeyForCipherKeyDecryption: jest.fn().mockResolvedValue({}),
|
||||||
|
deleteWithServer: jest.fn().mockResolvedValue(undefined),
|
||||||
|
softDeleteWithServer: jest.fn().mockResolvedValue(undefined),
|
||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
|
mockCipherService.deleteWithServer.mockClear();
|
||||||
|
mockCipherService.softDeleteWithServer.mockClear();
|
||||||
mockNavigate.mockClear();
|
mockNavigate.mockClear();
|
||||||
collect.mockClear();
|
collect.mockClear();
|
||||||
doAutofill.mockClear();
|
doAutofill.mockClear();
|
||||||
copy.mockClear();
|
copy.mockClear();
|
||||||
|
stop.mockClear();
|
||||||
|
openSimpleDialog.mockClear();
|
||||||
|
back.mockClear();
|
||||||
|
showToast.mockClear();
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ViewV2Component],
|
imports: [ViewV2Component],
|
||||||
@@ -84,9 +99,12 @@ describe("ViewV2Component", () => {
|
|||||||
{ provide: LogService, useValue: mock<LogService>() },
|
{ provide: LogService, useValue: mock<LogService>() },
|
||||||
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
{ provide: PlatformUtilsService, useValue: mock<PlatformUtilsService>() },
|
||||||
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
{ provide: ConfigService, useValue: mock<ConfigService>() },
|
||||||
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>() },
|
{ provide: PopupRouterCacheService, useValue: mock<PopupRouterCacheService>({ back }) },
|
||||||
{ provide: ActivatedRoute, useValue: { queryParams: params$ } },
|
{ provide: ActivatedRoute, useValue: { queryParams: params$ } },
|
||||||
{ provide: EventCollectionService, useValue: { collect } },
|
{ provide: EventCollectionService, useValue: { collect } },
|
||||||
|
{ provide: VaultPopupScrollPositionService, useValue: { stop } },
|
||||||
|
{ provide: VaultPopupAutofillService, useValue: mockVaultPopupAutofillService },
|
||||||
|
{ provide: ToastService, useValue: { showToast } },
|
||||||
{
|
{
|
||||||
provide: I18nService,
|
provide: I18nService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -98,7 +116,6 @@ describe("ViewV2Component", () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{ provide: VaultPopupAutofillService, useValue: mockVaultPopupAutofillService },
|
|
||||||
{
|
{
|
||||||
provide: AccountService,
|
provide: AccountService,
|
||||||
useValue: accountService,
|
useValue: accountService,
|
||||||
@@ -114,7 +131,13 @@ describe("ViewV2Component", () => {
|
|||||||
useValue: mockCopyCipherFieldService,
|
useValue: mockCopyCipherFieldService,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}).compileComponents();
|
})
|
||||||
|
.overrideProvider(DialogService, {
|
||||||
|
useValue: {
|
||||||
|
openSimpleDialog,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ViewV2Component);
|
fixture = TestBed.createComponent(ViewV2Component);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
@@ -223,4 +246,130 @@ describe("ViewV2Component", () => {
|
|||||||
expect(closeSpy).toHaveBeenCalledTimes(1);
|
expect(closeSpy).toHaveBeenCalledTimes(1);
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("delete", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
component.cipher = mockCipher;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens confirmation modal", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates back", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(back).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops scroll position service", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
|
expect(stop).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deny confirmation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
openSimpleDialog.mockResolvedValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not delete the cipher", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(mockCipherService.deleteWithServer).not.toHaveBeenCalled();
|
||||||
|
expect(mockCipherService.softDeleteWithServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not interact with side effects", () => {
|
||||||
|
expect(back).not.toHaveBeenCalled();
|
||||||
|
expect(stop).not.toHaveBeenCalled();
|
||||||
|
expect(showToast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("accept confirmation", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
openSimpleDialog.mockResolvedValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("soft delete", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockCipher as any).isDeleted = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens confirmation dialog", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
content: {
|
||||||
|
key: "deleteItemConfirmation",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
key: "deleteItem",
|
||||||
|
},
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls soft delete", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(mockCipherService.softDeleteWithServer).toHaveBeenCalled();
|
||||||
|
expect(mockCipherService.deleteWithServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: "deletedItem",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("hard delete", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(mockCipher as any).isDeleted = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens confirmation dialog", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(openSimpleDialog).toHaveBeenCalledTimes(1);
|
||||||
|
expect(openSimpleDialog).toHaveBeenCalledWith({
|
||||||
|
content: {
|
||||||
|
key: "permanentlyDeleteItemConfirmation",
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
key: "deleteItem",
|
||||||
|
},
|
||||||
|
type: "warning",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("calls soft delete", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(mockCipherService.deleteWithServer).toHaveBeenCalled();
|
||||||
|
expect(mockCipherService.softDeleteWithServer).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows toast", async () => {
|
||||||
|
await component.delete();
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith({
|
||||||
|
variant: "success",
|
||||||
|
title: null,
|
||||||
|
message: "permanentlyDeletedItem",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import { PopOutComponent } from "../../../../../platform/popup/components/pop-ou
|
|||||||
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
import { PopupRouterCacheService } from "../../../../../platform/popup/view-cache/popup-router-cache.service";
|
||||||
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
|
import { BrowserPremiumUpgradePromptService } from "../../../services/browser-premium-upgrade-prompt.service";
|
||||||
import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service";
|
import { BrowserViewPasswordHistoryService } from "../../../services/browser-view-password-history.service";
|
||||||
|
import { VaultPopupScrollPositionService } from "../../../services/vault-popup-scroll-position.service";
|
||||||
import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window";
|
import { closeViewVaultItemPopout, VaultPopoutType } from "../../../utils/vault-popout-window";
|
||||||
|
|
||||||
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
|
import { PopupFooterComponent } from "./../../../../../platform/popup/layout/popup-footer.component";
|
||||||
@@ -113,6 +114,7 @@ export class ViewV2Component {
|
|||||||
private popupRouterCacheService: PopupRouterCacheService,
|
private popupRouterCacheService: PopupRouterCacheService,
|
||||||
protected cipherAuthorizationService: CipherAuthorizationService,
|
protected cipherAuthorizationService: CipherAuthorizationService,
|
||||||
private copyCipherFieldService: CopyCipherFieldService,
|
private copyCipherFieldService: CopyCipherFieldService,
|
||||||
|
private popupScrollPositionService: VaultPopupScrollPositionService,
|
||||||
) {
|
) {
|
||||||
this.subscribeToParams();
|
this.subscribeToParams();
|
||||||
}
|
}
|
||||||
@@ -202,6 +204,7 @@ export class ViewV2Component {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.popupScrollPositionService.stop(true);
|
||||||
await this.popupRouterCacheService.back();
|
await this.popupRouterCacheService.back();
|
||||||
|
|
||||||
this.toastService.showToast({
|
this.toastService.showToast({
|
||||||
|
|||||||
@@ -370,6 +370,9 @@ export class VaultPopupListFiltersService {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Organizations, collection, folders filters. */
|
||||||
|
allFilters$ = combineLatest([this.organizations$, this.collections$, this.folders$]);
|
||||||
|
|
||||||
/** Updates the stored state for filter visibility. */
|
/** Updates the stored state for filter visibility. */
|
||||||
async updateFilterVisibility(isVisible: boolean): Promise<void> {
|
async updateFilterVisibility(isVisible: boolean): Promise<void> {
|
||||||
await this.filterVisibilityState.update(() => isVisible);
|
await this.filterVisibilityState.update(() => isVisible);
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
|
||||||
|
import { fakeAsync, TestBed, tick } from "@angular/core/testing";
|
||||||
|
import { NavigationEnd, Router } from "@angular/router";
|
||||||
|
import { Subject, Subscription } from "rxjs";
|
||||||
|
|
||||||
|
import { VaultPopupScrollPositionService } from "./vault-popup-scroll-position.service";
|
||||||
|
|
||||||
|
describe("VaultPopupScrollPositionService", () => {
|
||||||
|
let service: VaultPopupScrollPositionService;
|
||||||
|
const events$ = new Subject();
|
||||||
|
const unsubscribe = jest.fn();
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
unsubscribe.mockClear();
|
||||||
|
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
VaultPopupScrollPositionService,
|
||||||
|
{ provide: Router, useValue: { events: events$ } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
service = TestBed.inject(VaultPopupScrollPositionService);
|
||||||
|
|
||||||
|
// set up dummy values
|
||||||
|
service["scrollPosition"] = 234;
|
||||||
|
service["scrollSubscription"] = { unsubscribe } as unknown as Subscription;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("router events", () => {
|
||||||
|
it("does not reset service when navigating to `/tabs/vault`", fakeAsync(() => {
|
||||||
|
const event = new NavigationEnd(22, "/tabs/vault", "");
|
||||||
|
events$.next(event);
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(service["scrollPosition"]).toBe(234);
|
||||||
|
expect(service["scrollSubscription"]).not.toBeNull();
|
||||||
|
}));
|
||||||
|
|
||||||
|
it("resets values when navigating to other tab pages", fakeAsync(() => {
|
||||||
|
const event = new NavigationEnd(23, "/tabs/generator", "");
|
||||||
|
events$.next(event);
|
||||||
|
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(service["scrollPosition"]).toBeNull();
|
||||||
|
expect(unsubscribe).toHaveBeenCalled();
|
||||||
|
expect(service["scrollSubscription"]).toBeNull();
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("stop", () => {
|
||||||
|
it("removes scroll listener", () => {
|
||||||
|
service.stop();
|
||||||
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalledTimes(1);
|
||||||
|
expect(service["scrollSubscription"]).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resets stored values", () => {
|
||||||
|
service.stop(true);
|
||||||
|
|
||||||
|
expect(service["scrollPosition"]).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("start", () => {
|
||||||
|
const elementScrolled$ = new Subject();
|
||||||
|
const focus = jest.fn();
|
||||||
|
const nativeElement = {
|
||||||
|
scrollTop: 0,
|
||||||
|
querySelector: jest.fn(() => ({ focus })),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
style: {
|
||||||
|
visibility: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const virtualElement = {
|
||||||
|
elementScrolled: () => elementScrolled$,
|
||||||
|
getElementRef: () => ({ nativeElement }),
|
||||||
|
scrollTo: jest.fn(),
|
||||||
|
} as unknown as CdkVirtualScrollableElement;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// remove the actual subscription created by `.subscribe`
|
||||||
|
service["scrollSubscription"]?.unsubscribe();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("initial scroll position", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
(virtualElement.scrollTo as jest.Mock).mockClear();
|
||||||
|
nativeElement.querySelector.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not scroll when `scrollPosition` is null", () => {
|
||||||
|
service["scrollPosition"] = null;
|
||||||
|
|
||||||
|
service.start(virtualElement);
|
||||||
|
|
||||||
|
expect(virtualElement.scrollTo).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls the virtual element to `scrollPosition`", fakeAsync(() => {
|
||||||
|
service["scrollPosition"] = 500;
|
||||||
|
nativeElement.scrollTop = 500;
|
||||||
|
|
||||||
|
service.start(virtualElement);
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(virtualElement.scrollTo).toHaveBeenCalledWith({ behavior: "instant", top: 500 });
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("scroll listener", () => {
|
||||||
|
it("unsubscribes from any existing subscription", () => {
|
||||||
|
service.start(virtualElement);
|
||||||
|
|
||||||
|
expect(unsubscribe).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("subscribes to `elementScrolled`", fakeAsync(() => {
|
||||||
|
virtualElement.measureScrollOffset = jest.fn(() => 455);
|
||||||
|
|
||||||
|
service.start(virtualElement);
|
||||||
|
|
||||||
|
elementScrolled$.next(null); // first subscription is skipped by `skip(1)`
|
||||||
|
elementScrolled$.next(null);
|
||||||
|
tick();
|
||||||
|
|
||||||
|
expect(virtualElement.measureScrollOffset).toHaveBeenCalledTimes(1);
|
||||||
|
expect(virtualElement.measureScrollOffset).toHaveBeenCalledWith("top");
|
||||||
|
expect(service["scrollPosition"]).toBe(455);
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { CdkVirtualScrollableElement } from "@angular/cdk/scrolling";
|
||||||
|
import { inject, Injectable } from "@angular/core";
|
||||||
|
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||||
|
import { NavigationEnd, Router } from "@angular/router";
|
||||||
|
import { filter, skip, Subscription } from "rxjs";
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: "root",
|
||||||
|
})
|
||||||
|
export class VaultPopupScrollPositionService {
|
||||||
|
private router = inject(Router);
|
||||||
|
|
||||||
|
/** Path of the vault screen */
|
||||||
|
private readonly vaultPath = "/tabs/vault";
|
||||||
|
|
||||||
|
/** Current scroll position relative to the top of the viewport. */
|
||||||
|
private scrollPosition: number | null = null;
|
||||||
|
|
||||||
|
/** Subscription associated with the virtual scroll element. */
|
||||||
|
private scrollSubscription: Subscription | null = null;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.router.events
|
||||||
|
.pipe(
|
||||||
|
takeUntilDestroyed(),
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||||
|
)
|
||||||
|
.subscribe((event) => {
|
||||||
|
this.resetListenerForNavigation(event);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scrolls the user to the stored scroll position and starts tracking scroll of the page. */
|
||||||
|
start(virtualScrollElement: CdkVirtualScrollableElement) {
|
||||||
|
if (this.hasScrollPosition()) {
|
||||||
|
// Use `setTimeout` to scroll after rendering is complete
|
||||||
|
setTimeout(() => {
|
||||||
|
virtualScrollElement.scrollTo({ top: this.scrollPosition!, behavior: "instant" });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scrollSubscription?.unsubscribe();
|
||||||
|
|
||||||
|
// Skip the first scroll event to avoid settings the scroll from the above `scrollTo` call
|
||||||
|
this.scrollSubscription = virtualScrollElement
|
||||||
|
?.elementScrolled()
|
||||||
|
.pipe(skip(1))
|
||||||
|
.subscribe(() => {
|
||||||
|
const offset = virtualScrollElement.measureScrollOffset("top");
|
||||||
|
this.scrollPosition = offset;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Stops the scroll listener from updating the stored location. */
|
||||||
|
stop(reset?: true) {
|
||||||
|
this.scrollSubscription?.unsubscribe();
|
||||||
|
this.scrollSubscription = null;
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
this.scrollPosition = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns true when a scroll position has been stored. */
|
||||||
|
hasScrollPosition() {
|
||||||
|
return this.scrollPosition !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Conditionally resets the scroll listeners based on the ending path of the navigation */
|
||||||
|
private resetListenerForNavigation(event: NavigationEnd): void {
|
||||||
|
// The vault page is the target of the scroll listener, return early
|
||||||
|
if (event.url === this.vaultPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For all other tab pages reset the scroll position
|
||||||
|
if (event.url.startsWith("/tabs/")) {
|
||||||
|
this.stop(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user