Browse Source
* 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 modulepull/4309/head
22 changed files with 387 additions and 32 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
export * from "./product-switcher.module"; |
||||
@ -0,0 +1,39 @@
@@ -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> |
||||
@ -0,0 +1,93 @@
@@ -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) {} |
||||
} |
||||
@ -0,0 +1,9 @@
@@ -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> |
||||
@ -0,0 +1,19 @@
@@ -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"; |
||||
} |
||||
@ -0,0 +1,18 @@
@@ -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 {} |
||||
@ -0,0 +1,134 @@
@@ -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 }], |
||||
}; |
||||
@ -1 +0,0 @@
@@ -1 +0,0 @@
|
||||
<i class="bwi bwi-fw bwi-filter tw-text-2xl"></i> |
||||
@ -1,7 +0,0 @@
@@ -1,7 +0,0 @@
|
||||
import { Component } from "@angular/core"; |
||||
|
||||
@Component({ |
||||
selector: "sm-filter", |
||||
templateUrl: "./filter.component.html", |
||||
}) |
||||
export class FilterComponent {} |
||||
Loading…
Reference in new issue