1
0
mirror of https://github.com/bitwarden/browser synced 2026-01-08 11:33:28 +00:00

[PM-12273] Admin Console Integration Page (#11883)

* Integration page initial implementation

* replace placeholder integrations

* fix linting and tests

* fix locales

* update locale

* Change logos, add link to SCIM page

* refactor to standalone components, add integration filtering pipe

* refactor modules and imports. Remove hyperlink text from integration card template

* refactor i18n usage to be more generic

* Add storybooks

* fix tests

* minify svgs, include spec files in TS config, fix stories

* Update apps/web/src/locales/en/messages.json

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>

* fix imports, add static dir for stories

* Add darkmode svgs for integrations

* hide nav link for non enterprise orgs

* add router guard

* change rxjs selector

* Remove tailwind class causing style issues

---------

Co-authored-by: Thomas Rittson <31796059+eliykat@users.noreply.github.com>
This commit is contained in:
Brandon Treston
2024-12-05 10:09:40 -05:00
committed by GitHub
parent 6dc68b174b
commit c11f429ddb
54 changed files with 764 additions and 110 deletions

View File

@@ -0,0 +1,4 @@
export * from "./integrations/integration-card/integration-card.component";
export * from "./integrations/integration-grid/integration-grid.component";
export * from "./integrations/integrations.pipe";
export * from "./integrations/models";

View File

@@ -0,0 +1,32 @@
<div
class="tw-block tw-h-full tw-overflow-hidden tw-rounded tw-border tw-border-solid tw-border-secondary-600 tw-relative tw-transition-all hover:tw-scale-105 focus-within:tw-outline-none focus-within:tw-ring focus-within:tw-ring-primary-700 focus-within:tw-ring-offset-2"
>
<div class="tw-flex tw-bg-secondary-100 tw-items-center tw-justify-end tw-pt-4 tw-pr-4">
<i class="bwi bwi-external-link"></i>
</div>
<div
class="tw-flex tw-h-32 tw-bg-secondary-100 tw-items-center tw-justify-center tw-pb-2 tw-px-6 lg:tw-pb-4 lg:tw-px-12"
>
<div class="tw-flex tw-items-center tw-justify-center tw-h-28 tw-w-28 lg:tw-w-40">
<img
#imageEle
[src]="image"
alt=""
class="tw-block tw-mx-auto tw-h-auto tw-max-w-full tw-max-h-full"
/>
</div>
</div>
<div class="tw-p-5">
<h3 class="tw-text-main tw-text-lg tw-font-semibold">{{ name }}</h3>
<a
class="tw-block tw-mb-0 tw-font-bold hover:tw-no-underline focus:tw-outline-none after:tw-content-[''] after:tw-block after:tw-absolute after:tw-w-full after:tw-h-full after:tw-left-0 after:tw-top-0"
[href]="linkURL"
rel="noopener noreferrer"
target="_blank"
>
</a>
<span *ngIf="showNewBadge()" bitBadge class="tw-mt-3" variant="secondary">
{{ "new" | i18n }}
</span>
</div>
</div>

View File

@@ -0,0 +1,183 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { mock } from "jest-mock-extended";
import { BehaviorSubject } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/components/src/shared/i18n.pipe";
import { IntegrationCardComponent } from "./integration-card.component";
describe("IntegrationCardComponent", () => {
let component: IntegrationCardComponent;
let fixture: ComponentFixture<IntegrationCardComponent>;
const systemTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
const usersPreferenceTheme$ = new BehaviorSubject<ThemeType>(ThemeType.Light);
beforeEach(async () => {
// reset system theme
systemTheme$.next(ThemeType.Light);
await TestBed.configureTestingModule({
imports: [IntegrationCardComponent, SharedModule],
providers: [
{
provide: ThemeStateService,
useValue: { selectedTheme$: usersPreferenceTheme$ },
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: systemTheme$,
},
{
provide: I18nPipe,
useValue: mock<I18nPipe>(),
},
{
provide: I18nService,
useValue: mock<I18nService>(),
},
],
}).compileComponents();
});
beforeEach(() => {
fixture = TestBed.createComponent(IntegrationCardComponent);
component = fixture.componentInstance;
component.name = "Integration Name";
component.image = "test-image.png";
component.linkURL = "https://example.com/";
fixture.detectChanges();
});
it("assigns link href", () => {
const link = fixture.nativeElement.querySelector("a");
expect(link.href).toBe("https://example.com/");
});
it("renders card body", () => {
const name = fixture.nativeElement.querySelector("h3");
expect(name.textContent).toBe("Integration Name");
});
it("assigns external rel attribute", () => {
component.externalURL = true;
fixture.detectChanges();
const link = fixture.nativeElement.querySelector("a");
expect(link.rel).toBe("noopener noreferrer");
});
describe("new badge", () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date("2023-09-01"));
});
afterEach(() => {
jest.useRealTimers();
});
it("shows when expiration is in the future", () => {
component.newBadgeExpiration = "2023-09-02";
expect(component.showNewBadge()).toBe(true);
});
it("does not show when expiration is not set", () => {
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is in the past", () => {
component.newBadgeExpiration = "2023-08-31";
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is today", () => {
component.newBadgeExpiration = "2023-09-01";
expect(component.showNewBadge()).toBe(false);
});
it("does not show when expiration is invalid", () => {
component.newBadgeExpiration = "not-a-date";
expect(component.showNewBadge()).toBe(false);
});
});
describe("imageDarkMode", () => {
it("ignores theme changes when darkModeImage is not set", () => {
systemTheme$.next(ThemeType.Dark);
usersPreferenceTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
describe("user prefers the system theme", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("sets image src to imageDarkMode", () => {
usersPreferenceTheme$.next(ThemeType.System);
systemTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png");
});
it("sets image src to light mode image", () => {
component.imageEle.nativeElement.src = "test-image-dark.png";
usersPreferenceTheme$.next(ThemeType.System);
systemTheme$.next(ThemeType.Light);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
});
describe("user prefers dark mode", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("updates image to dark mode", () => {
systemTheme$.next(ThemeType.Light); // system theme shouldn't matter
usersPreferenceTheme$.next(ThemeType.Dark);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image-dark.png");
});
});
describe("user prefers light mode", () => {
beforeEach(() => {
component.imageDarkMode = "test-image-dark.png";
});
it("updates image to light mode", () => {
component.imageEle.nativeElement.src = "test-image-dark.png";
systemTheme$.next(ThemeType.Dark); // system theme shouldn't matter
usersPreferenceTheme$.next(ThemeType.Light);
fixture.detectChanges();
expect(component.imageEle.nativeElement.src).toContain("test-image.png");
});
});
});
});

View File

@@ -0,0 +1,96 @@
import {
AfterViewInit,
Component,
ElementRef,
Inject,
Input,
OnDestroy,
ViewChild,
} from "@angular/core";
import { Observable, Subject, combineLatest, takeUntil } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { ThemeType } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { SharedModule } from "../../../shared.module";
@Component({
selector: "app-integration-card",
templateUrl: "./integration-card.component.html",
standalone: true,
imports: [SharedModule],
})
export class IntegrationCardComponent implements AfterViewInit, OnDestroy {
private destroyed$: Subject<void> = new Subject();
@ViewChild("imageEle") imageEle: ElementRef<HTMLImageElement>;
@Input() name: string;
@Input() image: string;
@Input() imageDarkMode?: string;
@Input() linkURL: string;
/** Adds relevant `rel` attribute to external links */
@Input() externalURL?: boolean;
/**
* Date of when the new badge should be hidden.
* When omitted, the new badge is never shown.
*
* @example "2024-12-31"
*/
@Input() newBadgeExpiration?: string;
constructor(
private themeStateService: ThemeStateService,
@Inject(SYSTEM_THEME_OBSERVABLE)
private systemTheme$: Observable<ThemeType>,
) {}
ngAfterViewInit() {
combineLatest([this.themeStateService.selectedTheme$, this.systemTheme$])
.pipe(takeUntil(this.destroyed$))
.subscribe(([theme, systemTheme]) => {
// When the card doesn't have a dark mode image, exit early
if (!this.imageDarkMode) {
return;
}
if (theme === ThemeType.System) {
// When the user's preference is the system theme,
// use the system theme to determine the image
const prefersDarkMode =
systemTheme === ThemeType.Dark || systemTheme === ThemeType.SolarizedDark;
this.imageEle.nativeElement.src = prefersDarkMode ? this.imageDarkMode : this.image;
} else if (theme === ThemeType.Dark || theme === ThemeType.SolarizedDark) {
// When the user's preference is dark mode, use the dark mode image
this.imageEle.nativeElement.src = this.imageDarkMode;
} else {
// Otherwise use the light mode image
this.imageEle.nativeElement.src = this.image;
}
});
}
ngOnDestroy(): void {
this.destroyed$.next();
this.destroyed$.complete();
}
/** Show the "new" badge when expiration is in the future */
showNewBadge() {
if (!this.newBadgeExpiration) {
return false;
}
const expirationDate = new Date(this.newBadgeExpiration);
// Do not show the new badge for invalid dates
if (isNaN(expirationDate.getTime())) {
return false;
}
return expirationDate > new Date();
}
}

View File

@@ -0,0 +1,63 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { I18nMockService } from "@bitwarden/components";
import { SharedModule } from "../../../shared.module";
import { IntegrationCardComponent } from "./integration-card.component";
class MockThemeService implements Partial<ThemeStateService> {}
export default {
title: "Web/Integration Layout/Integration Card",
component: IntegrationCardComponent,
decorators: [
moduleMetadata({
imports: [SharedModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({});
},
},
{
provide: ThemeStateService,
useClass: MockThemeService,
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Light),
},
],
}),
],
args: {
integrations: [],
},
} as Meta;
type Story = StoryObj<IntegrationCardComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-integration-card
[name]="name"
[image]="image"
[linkURL]="linkURL"
></app-integration-card>
`,
}),
args: {
name: "Bitwarden",
image: "/integrations/bitwarden-vertical-blue.svg",
linkURL: "https://bitwarden.com",
},
};

View File

@@ -0,0 +1,18 @@
<ul
class="tw-inline-grid tw-grid-cols-3 tw-gap-6 tw-m-0 tw-p-0 tw-w-full tw-auto-cols-auto tw-list-none lg:tw-grid-cols-4 lg:tw-gap-10 lg:tw-w-auto"
>
<li
*ngFor="let integration of integrations"
[title]="tooltipI18nKey | i18n: integration.name"
[attr.aria-label]="ariaI18nKey | i18n: integration.name"
>
<app-integration-card
[name]="integration.name"
[linkURL]="integration.linkURL"
[image]="integration.image"
[imageDarkMode]="integration.imageDarkMode"
[externalURL]="integration.type === IntegrationType.SDK"
[newBadgeExpiration]="integration.newBadgeExpiration"
></app-integration-card>
</li>
</ul>

View File

@@ -0,0 +1,100 @@
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { mock } from "jest-mock-extended";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { SharedModule } from "@bitwarden/components/src/shared";
import { I18nPipe } from "@bitwarden/components/src/shared/i18n.pipe";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
import { IntegrationGridComponent } from "./integration-grid.component";
describe("IntegrationGridComponent", () => {
let component: IntegrationGridComponent;
let fixture: ComponentFixture<IntegrationGridComponent>;
const integrations: Integration[] = [
{
name: "Integration 1",
image: "test-image1.png",
linkURL: "https://example.com/1",
type: IntegrationType.Integration,
},
{
name: "SDK 2",
image: "test-image2.png",
linkURL: "https://example.com/2",
type: IntegrationType.SDK,
},
];
beforeEach(() => {
TestBed.configureTestingModule({
imports: [IntegrationGridComponent, IntegrationCardComponent, SharedModule],
providers: [
{
provide: ThemeStateService,
useValue: mock<ThemeStateService>(),
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Light),
},
{
provide: I18nPipe,
useValue: mock<I18nPipe>(),
},
{
provide: I18nService,
useValue: mock<I18nService>({ t: (key, p1) => key + " " + p1 }),
},
],
});
fixture = TestBed.createComponent(IntegrationGridComponent);
component = fixture.componentInstance;
component.integrations = integrations;
component.ariaI18nKey = "integrationCardAriaLabel";
component.tooltipI18nKey = "integrationCardTooltip";
fixture.detectChanges();
});
it("lists all integrations", () => {
expect(component.integrations).toEqual(integrations);
const cards = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent));
expect(cards.length).toBe(integrations.length);
});
it("assigns the correct attributes to IntegrationCardComponent", () => {
expect(component.integrations).toEqual(integrations);
const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent))[1];
expect(card.componentInstance.name).toBe("SDK 2");
expect(card.componentInstance.image).toBe("test-image2.png");
expect(card.componentInstance.linkURL).toBe("https://example.com/2");
});
it("assigns `externalURL` for SDKs", () => {
const card = fixture.debugElement.queryAll(By.directive(IntegrationCardComponent));
expect(card[0].componentInstance.externalURL).toBe(false);
expect(card[1].componentInstance.externalURL).toBe(true);
});
it("has a tool tip and aria label attributes", () => {
const card: HTMLElement = fixture.debugElement.queryAll(By.css("li"))[0].nativeElement;
expect(card.title).toBe("integrationCardTooltip" + " " + integrations[0].name);
expect(card.getAttribute("aria-label")).toBe(
"integrationCardAriaLabel" + " " + integrations[0].name,
);
});
});

View File

@@ -0,0 +1,22 @@
import { Component, Input } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { SharedModule } from "../../../shared.module";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { Integration } from "../models";
@Component({
selector: "app-integration-grid",
templateUrl: "./integration-grid.component.html",
standalone: true,
imports: [IntegrationCardComponent, SharedModule],
})
export class IntegrationGridComponent {
@Input() integrations: Integration[];
@Input() ariaI18nKey: string = "integrationCardAriaLabel";
@Input() tooltipI18nKey: string = "integrationCardTooltip";
protected IntegrationType = IntegrationType;
}

View File

@@ -0,0 +1,77 @@
import { Meta, StoryObj, moduleMetadata } from "@storybook/angular";
import { of } from "rxjs";
import { SYSTEM_THEME_OBSERVABLE } from "@bitwarden/angular/services/injection-tokens";
import { IntegrationType } from "@bitwarden/common/enums";
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
import { ThemeTypes } from "@bitwarden/common/platform/enums";
import { ThemeStateService } from "@bitwarden/common/platform/theming/theme-state.service";
import { I18nMockService } from "@bitwarden/components";
import { SharedModule } from "../../../shared.module";
import { IntegrationCardComponent } from "../integration-card/integration-card.component";
import { IntegrationGridComponent } from "../integration-grid/integration-grid.component";
class MockThemeService implements Partial<ThemeStateService> {}
export default {
title: "Web/Integration Layout/Integration Grid",
component: IntegrationGridComponent,
decorators: [
moduleMetadata({
imports: [IntegrationCardComponent, SharedModule],
providers: [
{
provide: I18nService,
useFactory: () => {
return new I18nMockService({
integrationCardAriaLabel: "Go to integration",
integrationCardTooltip: "Go to integration",
});
},
},
{
provide: ThemeStateService,
useClass: MockThemeService,
},
{
provide: SYSTEM_THEME_OBSERVABLE,
useValue: of(ThemeTypes.Dark),
},
],
}),
],
} as Meta;
type Story = StoryObj<IntegrationGridComponent>;
export const Default: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<app-integration-grid [integrations]="integrations"></app-integration-grid>
`,
}),
args: {
integrations: [
{
name: "Card 1",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SSO,
},
{
name: "Card 2",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SDK,
},
{
name: "Card 3",
linkURL: "https://bitwarden.com",
image: "/integrations/bitwarden-vertical-blue.svg",
type: IntegrationType.SCIM,
},
],
},
};

View File

@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from "@angular/core";
import { IntegrationType } from "@bitwarden/common/enums";
import { Integration } from "../../../shared/components/integrations/models";
@Pipe({
name: "filterIntegrations",
standalone: true,
})
export class FilterIntegrationsPipe implements PipeTransform {
transform(integrations: Integration[], type: IntegrationType): Integration[] {
return integrations.filter((integration) => integration.type === type);
}
}

View File

@@ -0,0 +1,20 @@
import { IntegrationType } from "@bitwarden/common/enums";
/** Integration or SDK */
export type Integration = {
name: string;
image: string;
/**
* Optional image shown in dark mode.
*/
imageDarkMode?: string;
linkURL: string;
type: IntegrationType;
/**
* Shows the "New" badge until the defined date.
* When omitted, the badge is never shown.
*
* @example "2024-12-31"
*/
newBadgeExpiration?: string;
};

View File

@@ -1,2 +1,3 @@
export * from "./shared.module";
export * from "./loose-components.module";
export * from "./components/index";