1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-15 15:53:27 +00:00

[SM-43] create product-switcher (#4189)

* rebase to master

* use bit-menu in product switcher; add focusStrategy to bit-menu

* recommit locales after rebase

* add light style to iconButton, use in product-switcher

* move out of component library

* add buttonType input

* gate behind sm flag

* update aria-label

* add role input to bit-menu

* style changes

* simplify partition logic

* split into two components for Storybook

* update focus styles; update grid sizing to relative

* fix underline on hover

* update attribute binding

* move to layouts dir

* add bitLink; update grid gap

* reorder loose components

* move orgs mock

* move a11y module

* fix aria role bug; add aria label to menu

* update colors

* update ring color

* simplify colors

* remove duplicate link module
This commit is contained in:
Will Martin
2022-12-21 16:50:41 -05:00
committed by GitHub
parent 7d3063942e
commit eeb407b8a4
22 changed files with 387 additions and 32 deletions

View File

@@ -38,6 +38,7 @@
</ng-container>
</ul>
</div>
<product-switcher buttonType="light"></product-switcher>
<ul class="navbar-nav flex-row ml-md-auto d-none d-md-flex">
<li>
<button

View File

@@ -0,0 +1 @@
export * from "./product-switcher.module";

View File

@@ -0,0 +1,39 @@
<bit-menu #menu ariaRole="dialog" [ariaLabel]="'switchProducts' | i18n">
<div class="tw-px-4 tw-py-2" *ngIf="products$ | async as products">
<!-- Bento options -->
<!-- grid-template-columns is dynamic so we can collapse empty columns -->
<section
[ngStyle]="{
'--num-products': products.bento.length,
'grid-template-columns': 'repeat(min(var(--num-products,1),3),auto)'
}"
class="tw-grid tw-gap-2"
>
<a
*ngFor="let product of products.bento"
[routerLink]="product.appRoute"
class="tw-group tw-flex tw-h-24 tw-w-28 tw-flex-col tw-items-center tw-justify-center tw-rounded tw-p-1 tw-text-primary-500 tw-outline-none hover:tw-bg-background-alt hover:tw-text-primary-700 hover:tw-no-underline focus-visible:!tw-ring-2 focus-visible:!tw-ring-primary-700"
routerLinkActive="tw-font-bold tw-bg-primary-500 hover:tw-bg-primary-500 !tw-text-contrast tw-ring-offset-2"
ariaCurrentWhenActive="page"
>
<i class="bwi {{ product.icon }} tw-text-4xl !tw-m-0 !tw-mb-1"></i>
<span class="tw-text-center tw-text-sm tw-leading-snug group-hover:tw-underline">{{
product.name
}}</span>
</a>
</section>
<!-- Other options -->
<section
*ngIf="products.other.length > 0"
class="tw-mt-4 tw-flex tw-w-full tw-flex-col tw-border-0 tw-border-t tw-border-solid tw-border-t-text-muted tw-p-2 tw-pb-0"
>
<span class="tw-mb-1 tw-text-xs tw-text-muted">{{ "moreFromBitwarden" | i18n }}</span>
<a *ngFor="let product of products.other" bitLink [href]="product.marketingRoute">
<span class="tw-font-normal">
<i class="bwi {{ product.icon }} tw-m-0 !tw-mr-3"></i>{{ product.name }}
</span>
</a>
</section>
</div>
</bit-menu>

View File

@@ -0,0 +1,93 @@
import { Component, ViewChild } from "@angular/core";
import { ActivatedRoute } from "@angular/router";
import { combineLatest, map } from "rxjs";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { MenuComponent } from "@bitwarden/components";
type ProductSwitcherItem = {
/**
* Displayed name
*/
name: string;
/**
* Displayed icon
*/
icon: string;
/**
* Route for items in the `bentoProducts$` section
*/
appRoute?: string | any[];
/**
* Route for items in the `otherProducts$` section
*/
marketingRoute?: string | any[];
};
@Component({
selector: "product-switcher-content",
templateUrl: "./product-switcher-content.component.html",
})
export class ProductSwitcherContentComponent {
@ViewChild("menu")
menu: MenuComponent;
protected products$ = combineLatest([
this.organizationService.organizations$,
this.route.paramMap,
]).pipe(
map(([orgs, paramMap]) => {
const routeOrg = orgs.find((o) => o.id === paramMap.get("organizationId"));
// If the active route org doesn't have access to SM, find the first org that does.
const smOrg = routeOrg?.canAccessSecretsManager
? routeOrg
: orgs.find((o) => o.canAccessSecretsManager);
/**
* We can update this to the "satisfies" type upon upgrading to TypeScript 4.9
* https://devblogs.microsoft.com/typescript/announcing-typescript-4-9/#satisfies
*/
const products: Record<"pm" | "sm" | "orgs", ProductSwitcherItem> = {
pm: {
name: "Password Manager",
icon: "bwi-lock",
appRoute: "/vault",
marketingRoute: "https://bitwarden.com/products/personal/",
},
sm: {
name: "Secrets Manager Beta",
icon: "bwi-cli",
appRoute: ["/sm", smOrg?.id],
// TODO: update marketing link
marketingRoute: "#",
},
orgs: {
name: "Organizations",
icon: "bwi-business",
marketingRoute: "https://bitwarden.com/products/business/",
},
};
const bento: ProductSwitcherItem[] = [products.pm];
const other: ProductSwitcherItem[] = [];
if (smOrg) {
bento.push(products.sm);
}
if (orgs.length === 0) {
other.push(products.orgs);
}
return {
bento,
other,
};
})
);
constructor(private organizationService: OrganizationService, private route: ActivatedRoute) {}
}

View File

@@ -0,0 +1,9 @@
<ng-template [ngIf]="isEnabled">
<button
bitIconButton="bwi bwi-fw bwi-filter"
[bitMenuTriggerFor]="content?.menu"
[buttonType]="buttonType"
[attr.aria-label]="'switchProducts' | i18n"
></button>
<product-switcher-content #content></product-switcher-content>
</ng-template>

View File

@@ -0,0 +1,19 @@
import { Component, Input } from "@angular/core";
import { IconButtonType } from "@bitwarden/components/src/icon-button/icon-button.component";
import { flagEnabled } from "../../../utils/flags";
@Component({
selector: "product-switcher",
templateUrl: "./product-switcher.component.html",
})
export class ProductSwitcherComponent {
protected isEnabled = flagEnabled("secretsManager");
/**
* Passed to the product switcher's `bitIconButton`
*/
@Input()
buttonType: IconButtonType = "main";
}

View File

@@ -0,0 +1,18 @@
import { A11yModule } from "@angular/cdk/a11y";
import { NgModule } from "@angular/core";
import { RouterModule } from "@angular/router";
import { I18nPipe } from "@bitwarden/angular/pipes/i18n.pipe";
import { SharedModule } from "../../shared";
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
import { ProductSwitcherComponent } from "./product-switcher.component";
@NgModule({
imports: [SharedModule, A11yModule, RouterModule],
declarations: [ProductSwitcherComponent, ProductSwitcherContentComponent],
exports: [ProductSwitcherComponent],
providers: [I18nPipe],
})
export class ProductSwitcherModule {}

View File

@@ -0,0 +1,134 @@
import { Component, Directive, Input } from "@angular/core";
import { RouterModule } from "@angular/router";
import { Meta, Story, moduleMetadata } from "@storybook/angular";
import { BehaviorSubject } from "rxjs";
import { JslibModule } from "@bitwarden/angular/jslib.module";
import { I18nService } from "@bitwarden/common/abstractions/i18n.service";
import { OrganizationService } from "@bitwarden/common/abstractions/organization/organization.service.abstraction";
import { Organization } from "@bitwarden/common/models/domain/organization";
import { IconButtonModule, LinkModule, MenuModule } from "@bitwarden/components";
import { I18nMockService } from "@bitwarden/components/src/utils/i18n-mock.service";
import { ProductSwitcherContentComponent } from "./product-switcher-content.component";
import { ProductSwitcherComponent } from "./product-switcher.component";
@Directive({
selector: "[mockOrgs]",
})
class MockOrganizationService implements Partial<OrganizationService> {
private static _orgs = new BehaviorSubject<Organization[]>([]);
organizations$ = MockOrganizationService._orgs; // eslint-disable-line rxjs/no-exposed-subjects
@Input()
set mockOrgs(orgs: Organization[]) {
this.organizations$.next(orgs);
}
}
@Component({
selector: "story-layout",
template: `<ng-content></ng-content>`,
})
class StoryLayoutComponent {}
@Component({
selector: "story-content",
template: ``,
})
class StoryContentComponent {}
export default {
title: "Web/Product Switcher",
decorators: [
moduleMetadata({
declarations: [
ProductSwitcherContentComponent,
ProductSwitcherComponent,
MockOrganizationService,
StoryLayoutComponent,
StoryContentComponent,
],
imports: [
JslibModule,
MenuModule,
IconButtonModule,
LinkModule,
RouterModule.forRoot(
[
{
path: "",
component: StoryLayoutComponent,
children: [
{
path: "",
redirectTo: "vault",
pathMatch: "full",
},
{
path: "sm/:organizationId",
component: StoryContentComponent,
},
{
path: "vault",
component: StoryContentComponent,
},
],
},
],
{ useHash: true }
),
],
providers: [
{ provide: OrganizationService, useClass: MockOrganizationService },
MockOrganizationService,
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
moreFromBitwarden: "More from Bitwarden",
switchProducts: "Switch Products",
});
},
},
],
}),
],
} as Meta;
const Template: Story = (args) => ({
props: args,
template: `
<router-outlet [mockOrgs]="mockOrgs"></router-outlet>
<div class="tw-flex tw-gap-[200px]">
<div>
<h1 class="tw-text-main tw-text-base tw-underline">Closed</h1>
<product-switcher></product-switcher>
</div>
<div>
<h1 class="tw-text-main tw-text-base tw-underline">Open</h1>
<product-switcher-content #content></product-switcher-content>
<div class="tw-h-40">
<div class="cdk-overlay-pane bit-menu-panel">
<ng-container *ngTemplateOutlet="content?.menu?.templateRef"></ng-container>
</div>
</div>
</div>
</div>
`,
});
export const NoOrgs = Template.bind({});
NoOrgs.args = {
mockOrgs: [],
};
export const OrgWithoutSecretsManager = Template.bind({});
OrgWithoutSecretsManager.args = {
mockOrgs: [{ id: "a" }],
};
export const OrgWithSecretsManager = Template.bind({});
OrgWithSecretsManager.args = {
mockOrgs: [{ id: "b", canAccessSecretsManager: true }],
};

View File

@@ -25,6 +25,7 @@ import { UserVerificationComponent } from "../components/user-verification.compo
import { FooterComponent } from "../layouts/footer.component";
import { FrontendLayoutComponent } from "../layouts/frontend-layout.component";
import { NavbarComponent } from "../layouts/navbar.component";
import { ProductSwitcherModule } from "../layouts/product-switcher/product-switcher.module";
import { UserLayoutComponent } from "../layouts/user-layout.component";
import { OrganizationCreateModule } from "../organizations/create/organization-create.module";
import { OrganizationLayoutComponent } from "../organizations/layouts/organization-layout.component";
@@ -130,7 +131,13 @@ import { SharedModule } from ".";
// Please do not add to this list of declarations - we should refactor these into modules when doing so makes sense until there are none left.
// If you are building new functionality, please create or extend a feature module instead.
@NgModule({
imports: [SharedModule, VaultFilterModule, OrganizationCreateModule, RegisterFormModule],
imports: [
SharedModule,
VaultFilterModule,
OrganizationCreateModule,
RegisterFormModule,
ProductSwitcherModule,
],
declarations: [
PremiumBadgeComponent,
AcceptEmergencyComponent,

View File

@@ -17,8 +17,8 @@ import {
FormFieldModule,
IconButtonModule,
IconModule,
MenuModule,
LinkModule,
MenuModule,
NavigationModule,
TableModule,
TabsModule,
@@ -57,6 +57,7 @@ import { WebI18nPipe } from "../core/web-i18n.pipe";
FormFieldModule,
IconButtonModule,
IconModule,
LinkModule,
MenuModule,
NavigationModule,
TableModule,
@@ -87,6 +88,7 @@ import { WebI18nPipe } from "../core/web-i18n.pipe";
FormFieldModule,
IconButtonModule,
IconModule,
LinkModule,
MenuModule,
NavigationModule,
TableModule,

View File

@@ -5783,6 +5783,12 @@
"memberAccessAll": {
"message": "This member can access and modify all items."
},
"moreFromBitwarden": {
"message": "More from Bitwarden"
},
"switchProducts": {
"message": "Switch Products"
},
"searchMyVault": {
"message": "Search My Vault"
},