mirror of
https://github.com/bitwarden/browser
synced 2026-02-13 23:13:36 +00:00
Merge branch 'main' into ps/extension-refresh
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
CreateCredentialParams,
|
||||
@@ -54,6 +55,7 @@ describe("Fido2Background", () => {
|
||||
let fido2ClientService!: MockProxy<Fido2ClientService>;
|
||||
let vaultSettingsService!: MockProxy<VaultSettingsService>;
|
||||
let scriptInjectorServiceMock!: MockProxy<BrowserScriptInjectorService>;
|
||||
let configServiceMock!: MockProxy<ConfigService>;
|
||||
let enablePasskeysMock$!: BehaviorSubject<boolean>;
|
||||
let fido2Background!: Fido2Background;
|
||||
|
||||
@@ -71,6 +73,7 @@ describe("Fido2Background", () => {
|
||||
abortController = mock<AbortController>();
|
||||
registeredContentScripsMock = mock<browser.contentScripts.RegisteredContentScript>();
|
||||
scriptInjectorServiceMock = mock<BrowserScriptInjectorService>();
|
||||
configServiceMock = mock<ConfigService>();
|
||||
|
||||
enablePasskeysMock$ = new BehaviorSubject(true);
|
||||
vaultSettingsService.enablePasskeys$ = enablePasskeysMock$;
|
||||
@@ -80,6 +83,7 @@ describe("Fido2Background", () => {
|
||||
fido2ClientService,
|
||||
vaultSettingsService,
|
||||
scriptInjectorServiceMock,
|
||||
configServiceMock,
|
||||
);
|
||||
fido2Background["abortManager"] = abortManagerMock;
|
||||
abortManagerMock.runWithAbortController.mockImplementation((_requestId, runner) =>
|
||||
@@ -110,6 +114,7 @@ describe("Fido2Background", () => {
|
||||
tabsQuerySpy.mockResolvedValueOnce([tabMock, secondTabMock, insecureTab, noUrlTab]);
|
||||
|
||||
await fido2Background.injectFido2ContentScriptsInAllTabs();
|
||||
await flushPromises();
|
||||
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: tabMock.id,
|
||||
@@ -133,6 +138,7 @@ describe("Fido2Background", () => {
|
||||
tabsQuerySpy.mockResolvedValueOnce([tabMock]);
|
||||
|
||||
await fido2Background.injectFido2ContentScriptsInAllTabs();
|
||||
await flushPromises();
|
||||
|
||||
expect(scriptInjectorServiceMock.inject).toHaveBeenCalledWith({
|
||||
tabId: tabMock.id,
|
||||
@@ -206,6 +212,22 @@ describe("Fido2Background", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("registers the page-script-delay-append-mv2.js content script when the DelayFido2PageScriptInitWithinMv2 feature flag is enabled", async () => {
|
||||
configServiceMock.getFeatureFlag.mockResolvedValue(true);
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
|
||||
|
||||
enablePasskeysMock$.next(true);
|
||||
await flushPromises();
|
||||
|
||||
expect(BrowserApi.registerContentScriptsMv2).toHaveBeenCalledWith({
|
||||
js: [
|
||||
{ file: Fido2ContentScript.PageScriptDelayAppend },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
...sharedRegistrationOptions,
|
||||
});
|
||||
});
|
||||
|
||||
it("unregisters any existing registered content scripts when the enablePasskeys setting is set to `false`", async () => {
|
||||
isManifestVersionSpy.mockImplementation((manifestVersion) => manifestVersion === 2);
|
||||
fido2Background["registeredContentScripts"] = registeredContentScripsMock;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { firstValueFrom, startWith } from "rxjs";
|
||||
import { pairwise } from "rxjs/operators";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import {
|
||||
AssertCredentialParams,
|
||||
AssertCredentialResult,
|
||||
@@ -50,6 +52,7 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
private fido2ClientService: Fido2ClientService,
|
||||
private vaultSettingsService: VaultSettingsService,
|
||||
private scriptInjectorService: ScriptInjectorService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -132,7 +135,7 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
|
||||
this.registeredContentScripts = await BrowserApi.registerContentScriptsMv2({
|
||||
js: [
|
||||
{ file: Fido2ContentScript.PageScriptAppend },
|
||||
{ file: await this.getFido2PageScriptAppendFileName() },
|
||||
{ file: Fido2ContentScript.ContentScript },
|
||||
],
|
||||
...this.sharedRegistrationOptions,
|
||||
@@ -176,7 +179,7 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
void this.scriptInjectorService.inject({
|
||||
tabId: tab.id,
|
||||
injectDetails: { frame: "all_frames", ...this.sharedInjectionDetails },
|
||||
mv2Details: { file: Fido2ContentScript.PageScriptAppend },
|
||||
mv2Details: { file: await this.getFido2PageScriptAppendFileName() },
|
||||
mv3Details: { file: Fido2ContentScript.PageScript, world: "MAIN" },
|
||||
});
|
||||
|
||||
@@ -353,4 +356,20 @@ export class Fido2Background implements Fido2BackgroundInterface {
|
||||
|
||||
this.fido2ContentScriptPortsSet.delete(port);
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the file name of the page-script used within mv2. Will return the
|
||||
* delayed append script if the associated feature flag is enabled.
|
||||
*/
|
||||
private async getFido2PageScriptAppendFileName() {
|
||||
const shouldDelayInit = await this.configService.getFeatureFlag(
|
||||
FeatureFlag.DelayFido2PageScriptInitWithinMv2,
|
||||
);
|
||||
|
||||
if (shouldDelayInit) {
|
||||
return Fido2ContentScript.PageScriptDelayAppend;
|
||||
}
|
||||
|
||||
return Fido2ContentScript.PageScriptAppend;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* This script handles injection of the FIDO2 override page script into the document.
|
||||
* This is required for manifest v2, but will be removed when we migrate fully to manifest v3.
|
||||
*/
|
||||
import { Fido2ContentScript } from "../enums/fido2-content-script.enum";
|
||||
|
||||
(function (globalContext) {
|
||||
if (globalContext.document.contentType !== "text/html") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (globalContext.document.readyState === "complete") {
|
||||
loadScript();
|
||||
} else {
|
||||
globalContext.addEventListener("DOMContentLoaded", loadScript);
|
||||
}
|
||||
|
||||
function loadScript() {
|
||||
const script = globalContext.document.createElement("script");
|
||||
script.src = chrome.runtime.getURL(Fido2ContentScript.PageScript);
|
||||
script.addEventListener("load", () => script.remove());
|
||||
|
||||
const scriptInsertionPoint =
|
||||
globalContext.document.head || globalContext.document.documentElement;
|
||||
scriptInsertionPoint.insertBefore(script, scriptInsertionPoint.firstChild);
|
||||
}
|
||||
})(globalThis);
|
||||
@@ -1,6 +1,7 @@
|
||||
export const Fido2ContentScript = {
|
||||
PageScript: "content/fido2-page-script.js",
|
||||
PageScriptAppend: "content/fido2-page-script-append-mv2.js",
|
||||
PageScriptDelayAppend: "content/fido2-page-script-delay-append-mv2.js",
|
||||
ContentScript: "content/fido2-content-script.js",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1025,6 +1025,7 @@ export default class MainBackground {
|
||||
this.fido2ClientService,
|
||||
this.vaultSettingsService,
|
||||
this.scriptInjectorService,
|
||||
this.configService,
|
||||
);
|
||||
this.runtimeBackground = new RuntimeBackground(
|
||||
this,
|
||||
|
||||
@@ -301,6 +301,8 @@ if (manifestVersion == 2) {
|
||||
"./src/tools/content/lp-suppress-import-download-script-append.mv2.ts";
|
||||
mainConfig.entry["content/fido2-page-script-append-mv2"] =
|
||||
"./src/autofill/fido2/content/fido2-page-script-append.mv2.ts";
|
||||
mainConfig.entry["content/fido2-page-script-delay-append-mv2"] =
|
||||
"./src/autofill/fido2/content/fido2-page-script-delay-append.mv2.ts";
|
||||
|
||||
configs.push(mainConfig);
|
||||
} else {
|
||||
|
||||
@@ -30,6 +30,7 @@ export enum FeatureFlag {
|
||||
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
|
||||
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
|
||||
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
|
||||
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
|
||||
}
|
||||
|
||||
export type AllowedFeatureFlagTypes = boolean | number | string;
|
||||
@@ -70,6 +71,7 @@ export const DefaultFeatureFlagValue = {
|
||||
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
|
||||
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
|
||||
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
|
||||
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
|
||||
} satisfies Record<FeatureFlag, AllowedFeatureFlagTypes>;
|
||||
|
||||
export type DefaultFeatureFlagValueType = typeof DefaultFeatureFlagValue;
|
||||
|
||||
125
libs/common/src/tools/dependencies.ts
Normal file
125
libs/common/src/tools/dependencies.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { Policy } from "@bitwarden/common/admin-console/models/domain/policy";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/** error emitted when the `SingleUserDependency` changes Ids */
|
||||
export type UserChangedError = {
|
||||
/** the userId pinned by the single user dependency */
|
||||
expectedUserId: UserId;
|
||||
/** the userId received in error */
|
||||
actualUserId: UserId;
|
||||
};
|
||||
|
||||
/** A pattern for types that depend upon a dynamic policy stream and return
|
||||
* an observable.
|
||||
*
|
||||
* Consumers of this dependency should emit when `policy$`
|
||||
* emits, provided that the latest message materially
|
||||
* changes the output of the consumer. If `policy$` emits
|
||||
* an unrecoverable error, the consumer should continue using
|
||||
* the last-emitted policy. If `policy$` completes, the consumer
|
||||
* should continue using the last-emitted policy.
|
||||
*/
|
||||
export type PolicyDependency = {
|
||||
/** A stream that emits policies when subscribed and
|
||||
* when the policy changes. The stream should not
|
||||
* emit null or undefined.
|
||||
*/
|
||||
policy$: Observable<Policy[]>;
|
||||
};
|
||||
|
||||
/** A pattern for types that depend upon a dynamic userid and return
|
||||
* an observable.
|
||||
*
|
||||
* Consumers of this dependency should emit when `userId$` changes.
|
||||
* If `userId$` completes, the consumer should also complete. If
|
||||
* `userId$` emits an unrecoverable error, the consumer should
|
||||
* also emit the error.
|
||||
*/
|
||||
export type UserDependency = {
|
||||
/** A stream that emits a UserId when subscribed and when
|
||||
* the userId changes. The stream should not emit null
|
||||
* or undefined.
|
||||
*/
|
||||
userId$: Observable<UserId>;
|
||||
};
|
||||
|
||||
/** A pattern for types that depend upon a fixed userid and return
|
||||
* an observable.
|
||||
*
|
||||
* Consumers of this dependency should emit a `UserChangedError` if
|
||||
* the value of `singleUserId$` changes. If `singleUserId$` completes,
|
||||
* the consumer should also complete. If `singleUserId$` errors, the
|
||||
* consumer should also emit the error.
|
||||
*
|
||||
* @remarks Check the consumer's documentation to determine how it
|
||||
* responds to repeat emissions.
|
||||
*/
|
||||
export type SingleUserDependency = {
|
||||
/** A stream that emits a UserId when subscribed and the user's account
|
||||
* is unlocked, and completes when the account is locked or logged out.
|
||||
* The stream should not emit null or undefined.
|
||||
*/
|
||||
singleUserId$: Observable<UserId>;
|
||||
};
|
||||
|
||||
/** A pattern for types that emit values exclusively when the dependency
|
||||
* emits a message.
|
||||
*
|
||||
* Consumers of this dependency should emit when `on$` emits. If `on$`
|
||||
* completes, the consumer should also complete. If `on$`
|
||||
* errors, the consumer should also emit the error.
|
||||
*
|
||||
* @remarks This dependency is useful when you have a nondeterministic
|
||||
* or stateful algorithm that you would like to run when an event occurs.
|
||||
*/
|
||||
export type OnDependency = {
|
||||
/** The stream that controls emissions
|
||||
*/
|
||||
on$: Observable<void>;
|
||||
};
|
||||
|
||||
/** A pattern for types that emit when a dependency is `true`.
|
||||
*
|
||||
* Consumers of this dependency may emit when `when$` emits a true
|
||||
* value. If `when$` completes, the consumer should also complete. If
|
||||
* `when$` errors, the consumer should also emit the error.
|
||||
*
|
||||
* @remarks Check the consumer's documentation to determine how it
|
||||
* responds to emissions.
|
||||
*/
|
||||
export type WhenDependency = {
|
||||
/** The stream to observe for true emissions. */
|
||||
when$: Observable<boolean>;
|
||||
};
|
||||
|
||||
/** A pattern for types that allow their managed settings to
|
||||
* be overridden.
|
||||
*
|
||||
* Consumers of this dependency should emit when `settings$`
|
||||
* change. If `settings$` completes, the consumer should also
|
||||
* complete. If `settings$` errors, the consumer should also
|
||||
* emit the error.
|
||||
*/
|
||||
export type SettingsDependency<Settings> = {
|
||||
/** A stream that emits settings when settings become available
|
||||
* and when they change. If the settings are not available, the
|
||||
* stream should wait to emit until they become available.
|
||||
*/
|
||||
settings$: Observable<Settings>;
|
||||
};
|
||||
|
||||
/** A pattern for types that accept an arbitrary dependency and
|
||||
* inject it into behavior-customizing functions.
|
||||
*
|
||||
* Unlike most other dependency types, this interface does not
|
||||
* functionally constrain the behavior of the consumer.
|
||||
*
|
||||
* @remarks Consumers of this dependency wholly determine
|
||||
* their response. Check the consumer's documentation
|
||||
* to find this information.
|
||||
*/
|
||||
export type Dependencies<TCombine> = {
|
||||
dependencies$: Observable<TCombine>;
|
||||
};
|
||||
467
libs/common/src/tools/state/user-state-subject.spec.ts
Normal file
467
libs/common/src/tools/state/user-state-subject.spec.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import { BehaviorSubject, of, Subject } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { awaitAsync, FakeSingleUserState } from "../../../spec";
|
||||
|
||||
import { UserStateSubject } from "./user-state-subject";
|
||||
|
||||
const SomeUser = "some user" as UserId;
|
||||
type TestType = { foo: string };
|
||||
|
||||
describe("UserStateSubject", () => {
|
||||
describe("dependencies", () => {
|
||||
it("ignores repeated when$ emissions", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
// the subject to update
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
|
||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||
subject.next({ foo: "next" });
|
||||
await awaitAsync();
|
||||
when$.next(true);
|
||||
await awaitAsync();
|
||||
when$.next(true);
|
||||
when$.next(true);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ignores repeated singleUserId$ emissions", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
// the subject to update
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
|
||||
// the interleaved await asyncs are only necessary b/c `nextValue` is called asynchronously
|
||||
subject.next({ foo: "next" });
|
||||
await awaitAsync();
|
||||
singleUserId$.next(SomeUser);
|
||||
await awaitAsync();
|
||||
singleUserId$.next(SomeUser);
|
||||
singleUserId$.next(SomeUser);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("next", () => {
|
||||
it("emits the next value", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const expected: TestType = { foo: "next" };
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe((value) => {
|
||||
actual = value;
|
||||
});
|
||||
subject.next(expected);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("ceases emissions once complete", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe((value) => {
|
||||
actual = value;
|
||||
});
|
||||
subject.complete();
|
||||
subject.next({ foo: "ignored" });
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(initialState);
|
||||
});
|
||||
|
||||
it("evaluates shouldUpdate", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
|
||||
expect(shouldUpdate).toHaveBeenCalledWith(initialValue, nextVal, null);
|
||||
});
|
||||
|
||||
it("evaluates shouldUpdate with a dependency", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => true);
|
||||
const dependencyValue = { bar: "dependency" };
|
||||
const subject = new UserStateSubject(state, {
|
||||
singleUserId$,
|
||||
shouldUpdate,
|
||||
dependencies$: of(dependencyValue),
|
||||
});
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
|
||||
expect(shouldUpdate).toHaveBeenCalledWith(initialValue, nextVal, dependencyValue);
|
||||
});
|
||||
|
||||
it("emits a value when shouldUpdate returns `true`", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
||||
const expected: TestType = { foo: "next" };
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe((value) => {
|
||||
actual = value;
|
||||
});
|
||||
subject.next(expected);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("retains the current value when shouldUpdate returns `false`", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const shouldUpdate = jest.fn(() => false);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, shouldUpdate });
|
||||
|
||||
subject.next({ foo: "next" });
|
||||
await awaitAsync();
|
||||
let actual: TestType = null;
|
||||
subject.subscribe((value) => {
|
||||
actual = value;
|
||||
});
|
||||
|
||||
expect(actual).toEqual(initialValue);
|
||||
});
|
||||
|
||||
it("evaluates nextValue", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalledWith(initialValue, nextVal, null);
|
||||
});
|
||||
|
||||
it("evaluates nextValue with a dependency", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const dependencyValue = { bar: "dependency" };
|
||||
const subject = new UserStateSubject(state, {
|
||||
singleUserId$,
|
||||
nextValue,
|
||||
dependencies$: of(dependencyValue),
|
||||
});
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalledWith(initialValue, nextVal, dependencyValue);
|
||||
});
|
||||
|
||||
it("evaluates nextValue when when$ is true", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
// the subject to update
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits to evaluate nextValue until when$ is true", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
// the subject to update.
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const when$ = new BehaviorSubject(false);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue, when$ });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
expect(nextValue).not.toHaveBeenCalled();
|
||||
|
||||
when$.next(true);
|
||||
await awaitAsync();
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits to evaluate nextValue until singleUserId$ emits", async () => {
|
||||
// this test looks for `nextValue` because a subscription isn't necessary for
|
||||
// the subject to update.
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new Subject<UserId>();
|
||||
const nextValue = jest.fn((_, next) => next);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, nextValue });
|
||||
|
||||
const nextVal: TestType = { foo: "next" };
|
||||
subject.next(nextVal);
|
||||
await awaitAsync();
|
||||
expect(nextValue).not.toHaveBeenCalled();
|
||||
singleUserId$.next(SomeUser);
|
||||
await awaitAsync();
|
||||
|
||||
expect(nextValue).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("error", () => {
|
||||
it("emits errors", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const expected: TestType = { foo: "error" };
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe({
|
||||
error: (value) => {
|
||||
actual = value;
|
||||
},
|
||||
});
|
||||
subject.error(expected);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("ceases emissions once errored", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let actual: TestType = null;
|
||||
subject.subscribe({
|
||||
error: (value) => {
|
||||
actual = value;
|
||||
},
|
||||
});
|
||||
subject.error("expectedError");
|
||||
subject.error("ignored");
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual("expectedError");
|
||||
});
|
||||
|
||||
it("ceases emissions once complete", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let shouldNotRun = false;
|
||||
subject.subscribe({
|
||||
error: () => {
|
||||
shouldNotRun = true;
|
||||
},
|
||||
});
|
||||
subject.complete();
|
||||
subject.error("ignored");
|
||||
await awaitAsync();
|
||||
|
||||
expect(shouldNotRun).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("complete", () => {
|
||||
it("emits completes", async () => {
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, { foo: "init" });
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
complete: () => {
|
||||
actual = true;
|
||||
},
|
||||
});
|
||||
subject.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toBeTruthy();
|
||||
});
|
||||
|
||||
it("ceases emissions once errored", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let shouldNotRun = false;
|
||||
subject.subscribe({
|
||||
complete: () => {
|
||||
shouldNotRun = true;
|
||||
},
|
||||
// prevent throw
|
||||
error: () => {},
|
||||
});
|
||||
subject.error("occurred");
|
||||
subject.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(shouldNotRun).toBeFalsy();
|
||||
});
|
||||
|
||||
it("ceases emissions once complete", async () => {
|
||||
const initialState = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialState);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let timesRun = 0;
|
||||
subject.subscribe({
|
||||
complete: () => {
|
||||
timesRun++;
|
||||
},
|
||||
});
|
||||
subject.complete();
|
||||
subject.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(timesRun).toEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("subscribe", () => {
|
||||
it("completes when singleUserId$ completes", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
complete: () => {
|
||||
actual = true;
|
||||
},
|
||||
});
|
||||
singleUserId$.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toBeTruthy();
|
||||
});
|
||||
|
||||
it("completes when when$ completes", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, when$ });
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
complete: () => {
|
||||
actual = true;
|
||||
},
|
||||
});
|
||||
when$.complete();
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toBeTruthy();
|
||||
});
|
||||
|
||||
// FIXME: add test for `this.state.catch` once `FakeSingleUserState` supports
|
||||
// simulated errors
|
||||
|
||||
it("errors when singleUserId$ changes", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const errorUserId = "error" as UserId;
|
||||
|
||||
let error = false;
|
||||
subject.subscribe({
|
||||
error: (e) => {
|
||||
error = e;
|
||||
},
|
||||
});
|
||||
singleUserId$.next(errorUserId);
|
||||
await awaitAsync();
|
||||
|
||||
expect(error).toEqual({ expectedUserId: SomeUser, actualUserId: errorUserId });
|
||||
});
|
||||
|
||||
it("errors when singleUserId$ errors", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const subject = new UserStateSubject(state, { singleUserId$ });
|
||||
const expected = { error: "description" };
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
error: (e) => {
|
||||
actual = e;
|
||||
},
|
||||
});
|
||||
singleUserId$.error(expected);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it("errors when when$ errors", async () => {
|
||||
const initialValue: TestType = { foo: "init" };
|
||||
const state = new FakeSingleUserState<TestType>(SomeUser, initialValue);
|
||||
const singleUserId$ = new BehaviorSubject(SomeUser);
|
||||
const when$ = new BehaviorSubject(true);
|
||||
const subject = new UserStateSubject(state, { singleUserId$, when$ });
|
||||
const expected = { error: "description" };
|
||||
|
||||
let actual = false;
|
||||
subject.subscribe({
|
||||
error: (e) => {
|
||||
actual = e;
|
||||
},
|
||||
});
|
||||
when$.error(expected);
|
||||
await awaitAsync();
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
199
libs/common/src/tools/state/user-state-subject.ts
Normal file
199
libs/common/src/tools/state/user-state-subject.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import {
|
||||
Observer,
|
||||
SubjectLike,
|
||||
Unsubscribable,
|
||||
ReplaySubject,
|
||||
filter,
|
||||
map,
|
||||
Subject,
|
||||
takeUntil,
|
||||
pairwise,
|
||||
combineLatest,
|
||||
distinctUntilChanged,
|
||||
BehaviorSubject,
|
||||
race,
|
||||
ignoreElements,
|
||||
endWith,
|
||||
startWith,
|
||||
} from "rxjs";
|
||||
import { Simplify } from "type-fest";
|
||||
|
||||
import { SingleUserState } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { Dependencies, SingleUserDependency, WhenDependency } from "../dependencies";
|
||||
|
||||
/** dependencies accepted by the user state subject */
|
||||
export type UserStateSubjectDependencies<State, Dependency> = Simplify<
|
||||
SingleUserDependency &
|
||||
Partial<WhenDependency> &
|
||||
Partial<Dependencies<Dependency>> & {
|
||||
/** Compute the next stored value. If this is not set, values
|
||||
* provided to `next` unconditionally override state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns the value to store in state
|
||||
*/
|
||||
nextValue?: (current: State, next: State, dependencies?: Dependency) => State;
|
||||
/**
|
||||
* Compute whether the state should update. If this is not set, values
|
||||
* provided to `next` always update the state.
|
||||
* @param current the value stored in state
|
||||
* @param next the value received by the user state subject's `next` member
|
||||
* @param dependencies the latest value from `Dependencies<TCombine>`
|
||||
* @returns `true` if the value should be stored, otherwise `false`.
|
||||
*/
|
||||
shouldUpdate?: (value: State, next: State, dependencies?: Dependency) => boolean;
|
||||
}
|
||||
>;
|
||||
|
||||
/**
|
||||
* Adapt a state provider to an rxjs subject.
|
||||
*
|
||||
* This subject buffers the last value it received in memory. The buffer is erased
|
||||
* if the subject receives a complete or error event. It does not persist the buffer.
|
||||
*
|
||||
* Warning! The user state subject has a synchronous interface, but subscriptions are
|
||||
* always asynchronous.
|
||||
*
|
||||
* @template State the state stored by the subject
|
||||
* @template Dependencies use-specific dependencies provided by the user.
|
||||
*/
|
||||
export class UserStateSubject<State, Dependencies = null> implements SubjectLike<State> {
|
||||
/**
|
||||
* Instantiates the user state subject
|
||||
* @param state the backing store of the subject
|
||||
* @param dependencies tailor the subject's behavior for a particular
|
||||
* purpose.
|
||||
* @param dependencies.when$ blocks updates to the state subject until
|
||||
* this becomes true. When this occurs, only the last-received update
|
||||
* is applied. The blocked update is kept in memory. It does not persist
|
||||
* to disk.
|
||||
* @param dependencies.singleUserId$ writes block until the singleUserId$
|
||||
* is available.
|
||||
*/
|
||||
constructor(
|
||||
private state: SingleUserState<State>,
|
||||
private dependencies: UserStateSubjectDependencies<State, Dependencies>,
|
||||
) {
|
||||
// normalize dependencies
|
||||
const when$ = (this.dependencies.when$ ?? new BehaviorSubject(true)).pipe(
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
const userIdAvailable$ = this.dependencies.singleUserId$.pipe(
|
||||
startWith(state.userId),
|
||||
pairwise(),
|
||||
map(([expectedUserId, actualUserId]) => {
|
||||
if (expectedUserId === actualUserId) {
|
||||
return true;
|
||||
} else {
|
||||
throw { expectedUserId, actualUserId };
|
||||
}
|
||||
}),
|
||||
distinctUntilChanged(),
|
||||
);
|
||||
|
||||
// observe completion
|
||||
const whenComplete$ = when$.pipe(ignoreElements(), endWith(true));
|
||||
const inputComplete$ = this.input.pipe(ignoreElements(), endWith(true));
|
||||
const userIdComplete$ = this.dependencies.singleUserId$.pipe(ignoreElements(), endWith(true));
|
||||
const completion$ = race(whenComplete$, inputComplete$, userIdComplete$);
|
||||
|
||||
// wire subscriptions
|
||||
this.outputSubscription = this.state.state$.subscribe(this.output);
|
||||
this.inputSubscription = combineLatest([this.input, when$, userIdAvailable$])
|
||||
.pipe(
|
||||
filter(([_, when]) => when),
|
||||
map(([state]) => state),
|
||||
takeUntil(completion$),
|
||||
)
|
||||
.subscribe({
|
||||
next: (r) => this.onNext(r),
|
||||
error: (e: unknown) => this.onError(e),
|
||||
complete: () => this.onComplete(),
|
||||
});
|
||||
}
|
||||
|
||||
next(value: State) {
|
||||
this.input?.next(value);
|
||||
}
|
||||
|
||||
error(err: any) {
|
||||
this.input?.error(err);
|
||||
}
|
||||
|
||||
complete() {
|
||||
this.input?.complete();
|
||||
}
|
||||
|
||||
/** Subscribe to the subject's event stream
|
||||
* @param observer listening for events
|
||||
* @returns the subscription
|
||||
*/
|
||||
subscribe(observer: Partial<Observer<State>> | ((value: State) => void)): Unsubscribable {
|
||||
return this.output.subscribe(observer);
|
||||
}
|
||||
|
||||
// using subjects to ensure the right semantics are followed;
|
||||
// if greater efficiency becomes desirable, consider implementing
|
||||
// `SubjectLike` directly
|
||||
private input = new Subject<State>();
|
||||
private readonly output = new ReplaySubject<State>(1);
|
||||
|
||||
private inputSubscription: Unsubscribable;
|
||||
private outputSubscription: Unsubscribable;
|
||||
|
||||
private onNext(value: State) {
|
||||
const nextValue = this.dependencies.nextValue ?? ((_: State, next: State) => next);
|
||||
const shouldUpdate = this.dependencies.shouldUpdate ?? ((_: State) => true);
|
||||
|
||||
this.state
|
||||
.update(
|
||||
(state, dependencies) => {
|
||||
const next = nextValue(state, value, dependencies);
|
||||
return next;
|
||||
},
|
||||
{
|
||||
shouldUpdate(current, dependencies) {
|
||||
const update = shouldUpdate(current, value, dependencies);
|
||||
return update;
|
||||
},
|
||||
combineLatestWith: this.dependencies.dependencies$,
|
||||
},
|
||||
)
|
||||
.catch((e: any) => this.onError(e));
|
||||
}
|
||||
|
||||
private onError(value: any) {
|
||||
if (!this.isDisposed) {
|
||||
this.output.error(value);
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
private onComplete() {
|
||||
if (!this.isDisposed) {
|
||||
this.output.complete();
|
||||
}
|
||||
|
||||
this.dispose();
|
||||
}
|
||||
|
||||
private get isDisposed() {
|
||||
return this.input === null;
|
||||
}
|
||||
|
||||
private dispose() {
|
||||
if (!this.isDisposed) {
|
||||
// clean up internal subscriptions
|
||||
this.inputSubscription.unsubscribe();
|
||||
this.outputSubscription.unsubscribe();
|
||||
this.inputSubscription = null;
|
||||
this.outputSubscription = null;
|
||||
|
||||
// drop input to ensure its value is removed from memory
|
||||
this.input = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import * as stories from "./badge.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { BadgeModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Badge
|
||||
|
||||
Badges are primarily used as labels, counters, and small buttons.
|
||||
|
||||
@@ -70,9 +70,9 @@ export const Primary: Story = {
|
||||
props: args,
|
||||
template: /*html*/ `
|
||||
<span class="tw-text-main">Span </span><span bitBadge [variant]="variant" [truncate]="truncate">Badge containing lengthy text</span>
|
||||
<br><br>
|
||||
<br /><br />
|
||||
<span class="tw-text-main">Link </span><a href="#" bitBadge [variant]="variant" [truncate]="truncate">Badge</a>
|
||||
<br><br>
|
||||
<br /><br />
|
||||
<span class="tw-text-main">Button </span><button bitBadge [variant]="variant" [truncate]="truncate">Badge</button>
|
||||
`,
|
||||
}),
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as stories from "./button.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { ButtonModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Button
|
||||
|
||||
Buttons are interactive elements that can be triggered using a mouse, keyboard, or touch. They are
|
||||
|
||||
32
libs/components/src/checkbox/checkbox.mdx
Normal file
32
libs/components/src/checkbox/checkbox.mdx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./checkbox.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { CheckboxModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Checkbox
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Stories
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
|
||||
### Hint
|
||||
|
||||
<Canvas of={stories.Hint} />
|
||||
|
||||
### Custom
|
||||
|
||||
<Canvas of={stories.Custom} />
|
||||
|
||||
### Intermediate
|
||||
|
||||
<Canvas of={stories.Indeterminate} />
|
||||
@@ -20,7 +20,7 @@ import { CheckboxModule } from "./checkbox.module";
|
||||
const template = /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox">
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox" />
|
||||
<bit-label>Click me</bit-label>
|
||||
</bit-form-control>
|
||||
</form>
|
||||
@@ -133,7 +133,7 @@ export const Hint: Story = {
|
||||
template: /*html*/ `
|
||||
<form [formGroup]="formObj">
|
||||
<bit-form-control>
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox">
|
||||
<input type="checkbox" bitCheckbox formControlName="checkbox" />
|
||||
<bit-label>Really long value that never ends.</bit-label>
|
||||
<bit-hint>
|
||||
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur iaculis consequat enim vitae elementum.
|
||||
@@ -181,15 +181,15 @@ export const Custom: Story = {
|
||||
<div class="tw-flex tw-flex-col tw-w-32">
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
A-Z
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
a-z
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
<label class="tw-text-main tw-flex tw-bg-secondary-300 tw-p-2">
|
||||
0-9
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox>
|
||||
<input class="tw-ml-auto focus-visible:tw-ring-offset-secondary-300" type="checkbox" bitCheckbox />
|
||||
</label>
|
||||
</div>
|
||||
`,
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as stories from "./dialog.stories";
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { DialogModule } from "@bitwarden/components";
|
||||
import { DialogModule, DialogService } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Dialog
|
||||
|
||||
@@ -145,9 +145,9 @@ export const ScrollingContent: Story = {
|
||||
template: `
|
||||
<bit-dialog title="Scrolling Example" [dialogSize]="dialogSize" [loading]="loading" [disablePadding]="disablePadding">
|
||||
<span bitDialogContent>
|
||||
Dialog body text goes here.<br>
|
||||
Dialog body text goes here.<br />
|
||||
<ng-container *ngFor="let _ of [].constructor(100)">
|
||||
repeating lines of characters <br>
|
||||
repeating lines of characters <br />
|
||||
</ng-container>
|
||||
end of sequence!
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,7 @@ import * as stories from "./simple-dialog.stories";
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { DialogModule } from "@bitwarden/components";
|
||||
import { DialogModule, DialogService } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Simple Dialogs
|
||||
|
||||
@@ -64,10 +64,9 @@ export const ScrollingContent: Story = {
|
||||
<bit-simple-dialog>
|
||||
<span bitDialogTitle>Alert Dialog</span>
|
||||
<span bitDialogContent>
|
||||
Message Content
|
||||
Message text goes here.<br>
|
||||
Message Content Message text goes here.<br />
|
||||
<ng-container *ngFor="let _ of [].constructor(100)">
|
||||
repeating lines of characters <br>
|
||||
repeating lines of characters <br />
|
||||
</ng-container>
|
||||
end of sequence!
|
||||
</span>
|
||||
|
||||
60
libs/components/src/form-field/form-field.mdx
Normal file
60
libs/components/src/form-field/form-field.mdx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./form-field.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { FormFieldModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Field
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Stories
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
|
||||
### Required
|
||||
|
||||
<Canvas of={stories.Required} />
|
||||
|
||||
### Hint
|
||||
|
||||
<Canvas of={stories.Hint} />
|
||||
|
||||
### Disabled
|
||||
|
||||
<Canvas of={stories.Disabled} />
|
||||
|
||||
### Readonly
|
||||
|
||||
<Canvas of={stories.Readonly} />
|
||||
|
||||
### Input Group
|
||||
|
||||
<Canvas of={stories.InputGroup} />
|
||||
|
||||
### Button Input Group
|
||||
|
||||
<Canvas of={stories.ButtonInputGroup} />
|
||||
|
||||
### Disabled Button Input Group
|
||||
|
||||
<Canvas of={stories.DisabledButtonInputGroup} />
|
||||
|
||||
### Select
|
||||
|
||||
<Canvas of={stories.Select} />
|
||||
|
||||
### Advanced Select
|
||||
|
||||
<Canvas of={stories.AdvancedSelect} />
|
||||
|
||||
### Textarea
|
||||
|
||||
<Canvas of={stories.Textarea} />
|
||||
@@ -96,19 +96,19 @@ export const FullExample: Story = {
|
||||
<bit-label>Name</bit-label>
|
||||
<input bitInput formControlName="name" />
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Email</bit-label>
|
||||
<input bitInput formControlName="email" />
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Country</bit-label>
|
||||
<bit-select formControlName="country">
|
||||
<bit-option *ngFor="let country of countries" [value]="country.value" [label]="country.name"></bit-option>
|
||||
</bit-select>
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
<bit-form-field>
|
||||
<bit-label>Age</bit-label>
|
||||
<input
|
||||
@@ -119,13 +119,13 @@ export const FullExample: Story = {
|
||||
max="150"
|
||||
/>
|
||||
</bit-form-field>
|
||||
|
||||
|
||||
<bit-form-control>
|
||||
<bit-label>Agree to terms</bit-label>
|
||||
<input type="checkbox" bitCheckbox formControlName="terms">
|
||||
<input type="checkbox" bitCheckbox formControlName="terms" />
|
||||
<bit-hint>Required for the service to work properly</bit-hint>
|
||||
</bit-form-control>
|
||||
|
||||
|
||||
<bit-radio-group formControlName="updates">
|
||||
<bit-label>Subscribe to updates?</bit-label>
|
||||
<bit-radio-button value="yes">
|
||||
@@ -138,7 +138,7 @@ export const FullExample: Story = {
|
||||
<bit-label>Decide later</bit-label>
|
||||
</bit-radio-button>
|
||||
</bit-radio-group>
|
||||
|
||||
|
||||
<button type="submit" bitButton buttonType="primary">Submit</button>
|
||||
</form>
|
||||
`,
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as stories from "./icon-button.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { IconButtonModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Icon Button
|
||||
|
||||
Icon buttons are used when no text accompanies the button. It consists of an icon that may be
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as stories from "./link.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { LinkModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Link / Text button
|
||||
|
||||
Text Links and Buttons use the `primary-600` color and can use either the `<a>` or `<button>` tags.
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<div
|
||||
class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6 tw-text-center"
|
||||
>
|
||||
<div class="tw-max-w-sm">
|
||||
<div class="tw-mx-auto tw-flex tw-flex-col tw-items-center tw-justify-center tw-pt-6">
|
||||
<div class="tw-max-w-sm tw-flex tw-flex-col tw-items-center">
|
||||
<bit-icon [icon]="icon" aria-hidden="true"></bit-icon>
|
||||
<h3 class="tw-font-semibold">
|
||||
<h3 class="tw-font-semibold tw-text-center">
|
||||
<ng-content select="[slot=title]"></ng-content>
|
||||
</h3>
|
||||
<p>
|
||||
<p class="tw-text-center">
|
||||
<ng-content select="[slot=description]"></ng-content>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
20
libs/components/src/search/search.mdx
Normal file
20
libs/components/src/search/search.mdx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./search.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { SearchModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Search
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Stories
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
20
libs/components/src/select/select.mdx
Normal file
20
libs/components/src/select/select.mdx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./select.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { SelectModule } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Select
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Stories
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
@@ -44,7 +44,7 @@ export const Default: Story = {
|
||||
<ng-template body>
|
||||
<tr bitRow [alignContent]="alignRowContent">
|
||||
<td bitCell>Cell 1</td>
|
||||
<td bitCell>Cell 2 <br> Multiline Cell</td>
|
||||
<td bitCell>Cell 2 <br /> Multiline Cell</td>
|
||||
<td bitCell>Cell 3</td>
|
||||
</tr>
|
||||
<tr bitRow [alignContent]="alignRowContent">
|
||||
@@ -53,7 +53,7 @@ export const Default: Story = {
|
||||
<td bitCell>Cell 6</td>
|
||||
</tr>
|
||||
<tr bitRow [alignContent]="alignRowContent">
|
||||
<td bitCell>Cell 7 <br> Multiline Cell</td>
|
||||
<td bitCell>Cell 7 <br /> Multiline Cell</td>
|
||||
<td bitCell>Cell 8</td>
|
||||
<td bitCell>Cell 9</td>
|
||||
</tr>
|
||||
|
||||
30
libs/components/src/toast/toast.mdx
Normal file
30
libs/components/src/toast/toast.mdx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Meta, Canvas, Source, Primary, Controls } from "@storybook/addon-docs";
|
||||
|
||||
import * as stories from "./toast.stories";
|
||||
|
||||
<Meta of={stories} />
|
||||
|
||||
```ts
|
||||
import { ToastService } from "@bitwarden/components";
|
||||
```
|
||||
|
||||
# Toast
|
||||
|
||||
<Primary />
|
||||
<Controls />
|
||||
|
||||
## Stories
|
||||
|
||||
### Default
|
||||
|
||||
<Canvas of={stories.Default} />
|
||||
|
||||
### Long Content
|
||||
|
||||
Avoid using long messages in toasts.
|
||||
|
||||
<Canvas of={stories.LongContent} />
|
||||
|
||||
### Service
|
||||
|
||||
<Canvas of={stories.Service} />
|
||||
Reference in New Issue
Block a user