Skip to content

Commit

Permalink
[PM-7231] Product Switcher within navigation sidebar (#8810)
Browse files Browse the repository at this point in the history
* refactor: move logic for products into a service

- This is in preparation for having having the navigation menu show products based off of the same logic.

* add extra small font size to tailwind config

* remove absolute positioning from toggle width component
- it now sits beneath the product switcher

* update product switcher to have UI details that are only shown in the navigation pane

* add navigation oriented product switcher

* integrate navigation product switcher into secrets manager

* integrate navigation product switcher into provider console

* integrate navigation product switcher into user layout

* integrate navigation product switcher into organizations

* add translation for "switch"

* hide active styles from navigation product switcher

* update storybook for product switcher stories

* remove unneeded full width style

* use protected readonly variable instead of getter

* migrate stories to CSF3

* remove double subscription to `moreProducts$`

* only use wrapping div in navigation switcher story

- less vertical space is taken up

* update to satisfies

* refactor `navigationUI` to `otherProductOverrides`

* move observables to protected readonly

* apply margin-top via class on the host component

* remove switch text from the navigation product switcher

* Allow for the active navigation switcher to be shown

* remove xxs font style

* remove unneeded module

* remove switch from stories

* remove defensive nullish coalescing

* remove merge leftovers

* Defect PM-7899 - show organizations product at the top of the other products list

* Defect PM-7951 use attr.icon to keep the icon as an attribute after prod mode is enabled

* Defect PM-7948 update path based on the current org

* force active styles for navigation items (#9128)

* add horizontal margin to icon

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
  • Loading branch information
nick-livefront and eliykat committed May 16, 2024
1 parent ff19514 commit 07076eb
Show file tree
Hide file tree
Showing 21 changed files with 932 additions and 168 deletions.
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<bit-layout variant="secondary">
<nav slot="sidebar" *ngIf="organization$ | async as organization">
<nav
slot="sidebar"
*ngIf="organization$ | async as organization"
class="tw-flex tw-flex-col tw-h-full"
>
<a routerLink="." class="tw-m-5 tw-mt-7 tw-block" [appA11yTitle]="'adminConsole' | i18n">
<bit-icon [icon]="logo"></bit-icon>
</a>
Expand Down Expand Up @@ -106,6 +110,8 @@
></bit-nav-item>
</bit-nav-group>

<navigation-product-switcher class="tw-mt-auto"></navigation-product-switcher>

<app-toggle-width></app-toggle-width>
</nav>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { BannerModule, IconModule, LayoutComponent, NavigationModule } from "@bi

import { PaymentMethodWarningsModule } from "../../../billing/shared";
import { OrgSwitcherComponent } from "../../../layouts/org-switcher/org-switcher.component";
import { ProductSwitcherModule } from "../../../layouts/product-switcher/product-switcher.module";
import { ToggleWidthComponent } from "../../../layouts/toggle-width.component";
import { AdminConsoleLogo } from "../../icons/admin-console-logo";

Expand All @@ -43,6 +44,7 @@ import { AdminConsoleLogo } from "../../icons/admin-console-logo";
BannerModule,
PaymentMethodWarningsModule,
ToggleWidthComponent,
ProductSwitcherModule,
],
})
export class OrganizationLayoutComponent implements OnInit, OnDestroy {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<div class="tw-mt-auto">
<!-- [attr.icon] is used to keep the icon attribute on the bit-nav-item after prod mode is enabled. Matches other navigation items and assists in automated testing. -->
<bit-nav-item
*ngFor="let product of accessibleProducts$ | async"
[icon]="product.icon"
[text]="product.name"
[route]="product.appRoute"
[attr.icon]="product.icon"
[forceActiveStyles]="product.isActive"
>
</bit-nav-item>
<ng-container *ngIf="moreProducts$ | async as moreProducts">
<section
*ngIf="moreProducts.length > 0"
class="tw-mt-2 tw-flex tw-w-full tw-flex-col tw-gap-2 tw-border-0 tw-border-t tw-border-solid tw-border-t-text-alt2"
>
<span class="tw-text-xs !tw-text-alt2 tw-p-2 tw-pb-0">{{ "moreFromBitwarden" | i18n }}</span>
<a
*ngFor="let more of moreProducts"
[href]="more.marketingRoute"
target="_blank"
rel="noreferrer"
class="tw-flex tw-py-2 tw-px-4 tw-font-semibold !tw-text-alt2 !tw-no-underline hover:tw-bg-primary-300/60 [&>:not(.bwi)]:hover:tw-underline"
>
<i class="bwi bwi-fw {{ more.icon }} tw-mt-1 tw-mx-1"></i>
<div>
{{ more.otherProductOverrides?.name ?? more.name }}
<div *ngIf="more.otherProductOverrides?.supportingText" class="tw-text-xs tw-font-normal">
{{ more.otherProductOverrides.supportingText }}
</div>
</div>
</a>
</section>
</ng-container>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { ActivatedRoute, RouterModule } from "@angular/router";
import { mock, MockProxy } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";

import { I18nPipe } from "@bitwarden/angular/platform/pipes/i18n.pipe";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { BitIconButtonComponent } from "@bitwarden/components/src/icon-button/icon-button.component";
import { NavItemComponent } from "@bitwarden/components/src/navigation/nav-item.component";

import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";

import { NavigationProductSwitcherComponent } from "./navigation-switcher.component";

describe("NavigationProductSwitcherComponent", () => {
let fixture: ComponentFixture<NavigationProductSwitcherComponent>;
let productSwitcherService: MockProxy<ProductSwitcherService>;

const mockProducts$ = new BehaviorSubject<{
bento: ProductSwitcherItem[];
other: ProductSwitcherItem[];
}>({
bento: [],
other: [],
});

beforeEach(async () => {
productSwitcherService = mock<ProductSwitcherService>();
productSwitcherService.products$ = mockProducts$;
mockProducts$.next({ bento: [], other: [] });

await TestBed.configureTestingModule({
imports: [RouterModule],
declarations: [
NavigationProductSwitcherComponent,
NavItemComponent,
BitIconButtonComponent,
I18nPipe,
],
providers: [
{ provide: ProductSwitcherService, useValue: productSwitcherService },
{
provide: I18nService,
useValue: mock<I18nService>(),
},
{
provide: ActivatedRoute,
useValue: mock<ActivatedRoute>(),
},
],
}).compileComponents();
});

beforeEach(() => {
fixture = TestBed.createComponent(NavigationProductSwitcherComponent);
fixture.detectChanges();
});

describe("other products", () => {
it("links to `marketingRoute`", () => {
mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
},
],
});

fixture.detectChanges();

const link = fixture.nativeElement.querySelector("a");

expect(link.getAttribute("href")).toBe("https://www.example.com/");
});

it("uses `otherProductOverrides` when available", () => {
mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
otherProductOverrides: { name: "Alternate name" },
},
],
});

fixture.detectChanges();

expect(fixture.nativeElement.querySelector("a").textContent.trim()).toBe("Alternate name");

mockProducts$.next({
bento: [],
other: [
{
isActive: false,
name: "Other Product",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
otherProductOverrides: { name: "Alternate name", supportingText: "Supporting Text" },
},
],
});

fixture.detectChanges();

expect(fixture.nativeElement.querySelector("a").textContent.trim().replace(/\s+/g, " ")).toBe(
"Alternate name Supporting Text",
);
});

it("shows Organizations first in the other products list", () => {
mockProducts$.next({
bento: [],
other: [
{ name: "AA Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{ name: "Test Product", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
{ name: "Organizations", icon: "bwi-lock", marketingRoute: "https://www.example.com/" },
],
});

fixture.detectChanges();

const links = fixture.nativeElement.querySelectorAll("a");

expect(links.length).toBe(3);

expect(links[0].textContent).toContain("Organizations");
expect(links[1].textContent).toContain("AA Product");
expect(links[2].textContent).toContain("Test Product");
});

it('shows the nav item as active when "isActive" is true', () => {
mockProducts$.next({
bento: [
{
name: "Organizations",
icon: "bwi-lock",
marketingRoute: "https://www.example.com/",
isActive: true,
},
],
other: [],
});

fixture.detectChanges();

const navItem = fixture.debugElement.query(By.directive(NavItemComponent));

expect(navItem.componentInstance.forceActiveStyles).toBe(true);
});
});

describe("available products", () => {
it("shows all products", () => {
mockProducts$.next({
bento: [
{ isActive: true, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" },
{ isActive: false, name: "Secret Manager", icon: "bwi-lock", appRoute: "/sm" },
],
other: [],
});

fixture.detectChanges();

const links = fixture.nativeElement.querySelectorAll("a");

expect(links.length).toBe(2);

expect(links[0].textContent).toContain("Password Manager");
expect(links[1].textContent).toContain("Secret Manager");
});
});

it("links to `appRoute`", () => {
mockProducts$.next({
bento: [{ isActive: false, name: "Password Manager", icon: "bwi-lock", appRoute: "/vault" }],
other: [],
});

fixture.detectChanges();

const link = fixture.nativeElement.querySelector("a");

expect(link.getAttribute("href")).toBe("/vault");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Component } from "@angular/core";
import { map, Observable } from "rxjs";

import { ProductSwitcherItem, ProductSwitcherService } from "../shared/product-switcher.service";

@Component({
selector: "navigation-product-switcher",
templateUrl: "./navigation-switcher.component.html",
})
export class NavigationProductSwitcherComponent {
constructor(private productSwitcherService: ProductSwitcherService) {}

protected readonly accessibleProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(map((products) => products.bento ?? []));

protected readonly moreProducts$: Observable<ProductSwitcherItem[]> =
this.productSwitcherService.products$.pipe(
map((products) => products.other ?? []),
// Ensure that organizations is displayed first in the other products list
// This differs from the order in `ProductSwitcherContentComponent` but matches the intent
// from product & design
map((products) => products.sort((product) => (product.name === "Organizations" ? -1 : 1))),
);
}

0 comments on commit 07076eb

Please sign in to comment.