mirror of
https://github.com/bitwarden/browser
synced 2026-03-01 19:11:22 +00:00
[PM-29604] [PM-29605] [PM-29606] Premium subscription page redesign (#18300)
* refactor(subscription-card): Correctly name card action * feat(storage-card): Switch 'callsToActionDisabled' into 1 input per CTA * refactor(additional-options-card): Switch 'callsToActionDisabled' into 1 input per CTA * feat(contract-alignment): Remove 'active' property from Discount * feat(contract-alignment): Rename 'name' to 'translationKey' in Cart model * feat(response-models): Add DiscountResponse * feat(response-models): Add StorageResponse * feat(response-models): Add CartResponse * feat(response-models): Add BitwardenSubscriptionResponse * feat(clients): Add new endpoint invocations * feat(redesign): Add feature flags * feat(redesign): Add AdjustAccountSubscriptionStorageDialogComponent * feat(redesign): Add AccountSubscriptionComponent * feat(redesign): Pivot subscription component on FF * docs: Note FF removal POIs * fix(subscription-card): Resolve compilation error in stories * fix(upgrade-payment.service): Fix failing tests
This commit is contained in:
committed by
jaasen-livefront
parent
0bac1e8c16
commit
77c1ec1251
@@ -13,8 +13,8 @@
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('download-license')"
|
||||
[disabled]="downloadLicenseDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.DownloadLicense)"
|
||||
>
|
||||
{{ "downloadLicense" | i18n }}
|
||||
</button>
|
||||
@@ -22,8 +22,8 @@
|
||||
bitButton
|
||||
buttonType="danger"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('cancel-subscription')"
|
||||
[disabled]="cancelSubscriptionDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.CancelSubscription)"
|
||||
>
|
||||
{{ "cancelSubscription" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -21,6 +21,8 @@ subscription actions.
|
||||
- [Examples](#examples)
|
||||
- [Default](#default)
|
||||
- [Actions Disabled](#actions-disabled)
|
||||
- [Download License Disabled](#download-license-disabled)
|
||||
- [Cancel Subscription Disabled](#cancel-subscription-disabled)
|
||||
- [Features](#features)
|
||||
- [Do's and Don'ts](#dos-and-donts)
|
||||
- [Accessibility](#accessibility)
|
||||
@@ -44,9 +46,10 @@ import { AdditionalOptionsCardComponent } from "@bitwarden/subscription";
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ----------------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
|
||||
| Input | Type | Description |
|
||||
| ---------------------------- | --------- | ----------------------------------------------------------------------------- |
|
||||
| `downloadLicenseDisabled` | `boolean` | Optional. Disables download license button when true. Defaults to `false`. |
|
||||
| `cancelSubscriptionDisabled` | `boolean` | Optional. Disables cancel subscription button when true. Defaults to `false`. |
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -109,14 +112,46 @@ Component with action buttons disabled (useful during async operations):
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[callsToActionDisabled]="true"
|
||||
[downloadLicenseDisabled]="true"
|
||||
[cancelSubscriptionDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
|
||||
downloading the license or processing subscription cancellation.
|
||||
**Note:** Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` independently to control
|
||||
button states during async operations like downloading the license or processing subscription
|
||||
cancellation.
|
||||
|
||||
### Download License Disabled
|
||||
|
||||
Component with only the download license button disabled:
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.DownloadLicenseDisabled} />
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[downloadLicenseDisabled]="true"
|
||||
[cancelSubscriptionDisabled]="false"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
### Cancel Subscription Disabled
|
||||
|
||||
Component with only the cancel subscription button disabled:
|
||||
|
||||
<Canvas of={AdditionalOptionsCardStories.CancelSubscriptionDisabled} />
|
||||
|
||||
```html
|
||||
<billing-additional-options-card
|
||||
[downloadLicenseDisabled]="false"
|
||||
[cancelSubscriptionDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-additional-options-card>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
@@ -133,9 +168,11 @@ downloading the license or processing subscription cancellation.
|
||||
|
||||
- Handle both `download-license` and `cancel-subscription` events in parent components
|
||||
- Show appropriate confirmation dialogs before executing destructive actions (cancel subscription)
|
||||
- Disable buttons or show loading states during async operations
|
||||
- Use `downloadLicenseDisabled` and `cancelSubscriptionDisabled` to control button states during
|
||||
operations
|
||||
- Provide clear user feedback after action completion
|
||||
- Consider adding additional safety measures for subscription cancellation
|
||||
- Control button states independently based on business logic
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
|
||||
@@ -66,9 +66,32 @@ describe("AdditionalOptionsCardComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("callsToActionDisabled", () => {
|
||||
it("should disable both buttons when callsToActionDisabled is true", () => {
|
||||
fixture.componentRef.setInput("callsToActionDisabled", true);
|
||||
describe("button disabled states", () => {
|
||||
it("should enable both buttons by default", () => {
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable download license button when downloadLicenseDisabled is true", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should disable cancel subscription button when cancelSubscriptionDisabled is true", () => {
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should disable both buttons independently", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", true);
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
@@ -76,18 +99,23 @@ describe("AdditionalOptionsCardComponent", () => {
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable both buttons when callsToActionDisabled is false", () => {
|
||||
fixture.componentRef.setInput("callsToActionDisabled", false);
|
||||
it("should allow download enabled while cancel disabled", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", false);
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable both buttons by default", () => {
|
||||
it("should allow cancel enabled while download disabled", () => {
|
||||
fixture.componentRef.setInput("downloadLicenseDisabled", true);
|
||||
fixture.componentRef.setInput("cancelSubscriptionDisabled", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,23 @@ export const Default: Story = {
|
||||
export const ActionsDisabled: Story = {
|
||||
name: "Actions Disabled",
|
||||
args: {
|
||||
callsToActionDisabled: true,
|
||||
downloadLicenseDisabled: true,
|
||||
cancelSubscriptionDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const DownloadLicenseDisabled: Story = {
|
||||
name: "Download License Disabled",
|
||||
args: {
|
||||
downloadLicenseDisabled: true,
|
||||
cancelSubscriptionDisabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const CancelSubscriptionDisabled: Story = {
|
||||
name: "Cancel Subscription Disabled",
|
||||
args: {
|
||||
downloadLicenseDisabled: false,
|
||||
cancelSubscriptionDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -3,7 +3,13 @@ import { Component, ChangeDetectionStrategy, output, input } from "@angular/core
|
||||
import { ButtonModule, CardComponent, TypographyModule } from "@bitwarden/components";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
export type AdditionalOptionsCardAction = "download-license" | "cancel-subscription";
|
||||
export const AdditionalOptionsCardActions = {
|
||||
DownloadLicense: "download-license",
|
||||
CancelSubscription: "cancel-subscription",
|
||||
} as const;
|
||||
|
||||
export type AdditionalOptionsCardAction =
|
||||
(typeof AdditionalOptionsCardActions)[keyof typeof AdditionalOptionsCardActions];
|
||||
|
||||
@Component({
|
||||
selector: "billing-additional-options-card",
|
||||
@@ -12,6 +18,10 @@ export type AdditionalOptionsCardAction = "download-license" | "cancel-subscript
|
||||
imports: [ButtonModule, CardComponent, TypographyModule, I18nPipe],
|
||||
})
|
||||
export class AdditionalOptionsCardComponent {
|
||||
readonly callsToActionDisabled = input<boolean>(false);
|
||||
readonly downloadLicenseDisabled = input<boolean>(false);
|
||||
readonly cancelSubscriptionDisabled = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<AdditionalOptionsCardAction>();
|
||||
|
||||
protected readonly actions = AdditionalOptionsCardActions;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled()"
|
||||
(click)="callToActionClicked.emit('add-storage')"
|
||||
[disabled]="addStorageDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.AddStorage)"
|
||||
>
|
||||
{{ "addStorage" | i18n }}
|
||||
</button>
|
||||
@@ -30,8 +30,8 @@
|
||||
bitButton
|
||||
buttonType="secondary"
|
||||
type="button"
|
||||
[disabled]="callsToActionDisabled() || !canRemoveStorage()"
|
||||
(click)="callToActionClicked.emit('remove-storage')"
|
||||
[disabled]="removeStorageDisabled()"
|
||||
(click)="callToActionClicked.emit(actions.RemoveStorage)"
|
||||
>
|
||||
{{ "removeStorage" | i18n }}
|
||||
</button>
|
||||
|
||||
@@ -30,6 +30,8 @@ full).
|
||||
- [Large Storage Pool (1TB)](#large-storage-pool-1tb)
|
||||
- [Small Storage Pool (1GB)](#small-storage-pool-1gb)
|
||||
- [Actions Disabled](#actions-disabled)
|
||||
- [Add Storage Disabled](#add-storage-disabled)
|
||||
- [Remove Storage Disabled](#remove-storage-disabled)
|
||||
- [Features](#features)
|
||||
- [Do's and Don'ts](#dos-and-donts)
|
||||
- [Accessibility](#accessibility)
|
||||
@@ -53,10 +55,11 @@ import { StorageCardComponent, Storage } from "@bitwarden/subscription";
|
||||
|
||||
### Inputs
|
||||
|
||||
| Input | Type | Description |
|
||||
| ----------------------- | --------- | ---------------------------------------------------------------------- |
|
||||
| `storage` | `Storage` | **Required.** Storage data including available, used, and readable |
|
||||
| `callsToActionDisabled` | `boolean` | Optional. Disables both action buttons when true. Defaults to `false`. |
|
||||
| Input | Type | Description |
|
||||
| ----------------------- | --------- | ------------------------------------------------------------------------ |
|
||||
| `storage` | `Storage` | **Required.** Storage data including available, used, and readable |
|
||||
| `addStorageDisabled` | `boolean` | Optional. Disables add storage button when true. Defaults to `false`. |
|
||||
| `removeStorageDisabled` | `boolean` | Optional. Disables remove storage button when true. Defaults to `false`. |
|
||||
|
||||
### Outputs
|
||||
|
||||
@@ -93,7 +96,8 @@ The component automatically adapts its appearance based on storage usage:
|
||||
Key behaviors:
|
||||
|
||||
- Progress bar color changes from blue (primary) to red (danger) when full
|
||||
- Remove storage button is disabled when storage is full
|
||||
- Button disabled states are controlled independently via `addStorageDisabled` and
|
||||
`removeStorageDisabled` inputs
|
||||
- Title changes to "Storage full" when at capacity
|
||||
- Description provides context-specific messaging
|
||||
|
||||
@@ -123,7 +127,7 @@ Storage with no files uploaded:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 0,
|
||||
readableUsed: '0 GB'
|
||||
readableUsed: '0 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -141,7 +145,7 @@ Storage with partial usage (50%):
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB'
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -159,15 +163,15 @@ Storage at full capacity with disabled remove button:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 5,
|
||||
readableUsed: '5 GB'
|
||||
readableUsed: '5 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
**Note:** When storage is full, the "Remove storage" button is disabled and the progress bar turns
|
||||
red.
|
||||
**Note:** When storage is full, the progress bar turns red. Button disabled states are controlled
|
||||
independently via the `addStorageDisabled` and `removeStorageDisabled` inputs.
|
||||
|
||||
### Low Usage (10%)
|
||||
|
||||
@@ -180,7 +184,7 @@ Minimal storage usage:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 0.5,
|
||||
readableUsed: '500 MB'
|
||||
readableUsed: '500 MB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -198,7 +202,7 @@ Substantial storage usage:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 3.75,
|
||||
readableUsed: '3.75 GB'
|
||||
readableUsed: '3.75 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -216,7 +220,7 @@ Storage approaching capacity:
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 4.75,
|
||||
readableUsed: '4.75 GB'
|
||||
readableUsed: '4.75 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -234,7 +238,7 @@ Enterprise-level storage allocation:
|
||||
[storage]="{
|
||||
available: 1000,
|
||||
used: 734,
|
||||
readableUsed: '734 GB'
|
||||
readableUsed: '734 GB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -252,7 +256,7 @@ Minimal storage allocation:
|
||||
[storage]="{
|
||||
available: 1,
|
||||
used: 0.8,
|
||||
readableUsed: '800 MB'
|
||||
readableUsed: '800 MB',
|
||||
}"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
@@ -270,16 +274,57 @@ Storage card with action buttons disabled (useful during async operations):
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB'
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
[callsToActionDisabled]="true"
|
||||
[addStorageDisabled]="true"
|
||||
[removeStorageDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
**Note:** Use `callsToActionDisabled` to prevent user interactions during async operations like
|
||||
adding or removing storage.
|
||||
**Note:** Use `addStorageDisabled` and `removeStorageDisabled` independently to control button
|
||||
states during async operations like adding or removing storage.
|
||||
|
||||
### Add Storage Disabled
|
||||
|
||||
Storage card with only the add button disabled:
|
||||
|
||||
<Canvas of={StorageCardStories.AddStorageDisabled} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
[addStorageDisabled]="true"
|
||||
[removeStorageDisabled]="false"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
### Remove Storage Disabled
|
||||
|
||||
Storage card with only the remove button disabled:
|
||||
|
||||
<Canvas of={StorageCardStories.RemoveStorageDisabled} />
|
||||
|
||||
```html
|
||||
<billing-storage-card
|
||||
[storage]="{
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: '2.5 GB',
|
||||
}"
|
||||
[addStorageDisabled]="false"
|
||||
[removeStorageDisabled]="true"
|
||||
(callToActionClicked)="handleAction($event)"
|
||||
>
|
||||
</billing-storage-card>
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
@@ -304,13 +349,14 @@ adding or removing storage.
|
||||
- Use human-readable format strings (e.g., "2.5 GB", "500 MB") for `readableUsed`
|
||||
- Keep `used` value less than or equal to `available` under normal circumstances
|
||||
- Update storage data in real-time when user adds or removes storage
|
||||
- Disable UI interactions when storage operations are in progress
|
||||
- Use `addStorageDisabled` and `removeStorageDisabled` to control button states during operations
|
||||
- Show loading states during async storage operations
|
||||
- Control button states independently based on business logic
|
||||
|
||||
### ❌ Don't
|
||||
|
||||
- Omit the `readableUsed` field - it's required for display
|
||||
- Use inconsistent units between `available` and `used` (both should be in GB)
|
||||
- Omit the `readableUsed` field - it's required
|
||||
- Use inconsistent units between `available` and `used` (all should be in GB)
|
||||
- Allow negative values for storage amounts
|
||||
- Ignore the `callToActionClicked` events - they require handling
|
||||
- Display inaccurate or stale storage information
|
||||
|
||||
@@ -163,18 +163,6 @@ describe("StorageCardComponent", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canRemoveStorage", () => {
|
||||
it("should return true when storage is not full", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
expect(component.canRemoveStorage()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false when storage is full", () => {
|
||||
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
|
||||
expect(component.canRemoveStorage()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("button rendering", () => {
|
||||
it("should render both buttons", () => {
|
||||
setupComponent(baseStorage);
|
||||
@@ -182,25 +170,46 @@ describe("StorageCardComponent", () => {
|
||||
expect(buttons.length).toBe(2);
|
||||
});
|
||||
|
||||
it("should enable remove button when storage is not full", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
it("should enable add button by default", () => {
|
||||
setupComponent(baseStorage);
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const addButton = buttons[0].nativeElement;
|
||||
expect(addButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable add button when addStorageDisabled is true", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const addButton = buttons[0];
|
||||
expect(addButton.attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable remove button by default", () => {
|
||||
setupComponent(baseStorage);
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const removeButton = buttons[1].nativeElement;
|
||||
expect(removeButton.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should disable remove button when storage is full", () => {
|
||||
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
|
||||
it("should disable remove button when removeStorageDisabled is true", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
const removeButton = buttons[1];
|
||||
expect(removeButton.attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callsToActionDisabled", () => {
|
||||
it("should disable both buttons when callsToActionDisabled is true", () => {
|
||||
describe("independent button disabled states", () => {
|
||||
it("should disable both buttons independently", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("callsToActionDisabled", true);
|
||||
fixture.componentRef.setInput("addStorageDisabled", true);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
@@ -208,9 +217,10 @@ describe("StorageCardComponent", () => {
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should enable both buttons when callsToActionDisabled is false and storage is not full", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
fixture.componentRef.setInput("callsToActionDisabled", false);
|
||||
it("should enable both buttons when both disabled inputs are false", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", false);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
@@ -218,15 +228,27 @@ describe("StorageCardComponent", () => {
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("should keep remove button disabled when callsToActionDisabled is false but storage is full", () => {
|
||||
setupComponent({ ...baseStorage, used: 5, readableUsed: "5 GB" });
|
||||
fixture.componentRef.setInput("callsToActionDisabled", false);
|
||||
it("should allow add button enabled while remove button disabled", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", false);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", true);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].nativeElement.disabled).toBe(false);
|
||||
expect(buttons[1].attributes["aria-disabled"]).toBe("true");
|
||||
});
|
||||
|
||||
it("should allow remove button enabled while add button disabled", () => {
|
||||
setupComponent(baseStorage);
|
||||
fixture.componentRef.setInput("addStorageDisabled", true);
|
||||
fixture.componentRef.setInput("removeStorageDisabled", false);
|
||||
fixture.detectChanges();
|
||||
|
||||
const buttons = fixture.debugElement.queryAll(By.css("button"));
|
||||
expect(buttons[0].attributes["aria-disabled"]).toBe("true");
|
||||
expect(buttons[1].nativeElement.disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("button click events", () => {
|
||||
@@ -243,7 +265,7 @@ describe("StorageCardComponent", () => {
|
||||
});
|
||||
|
||||
it("should emit remove-storage action when remove button is clicked", () => {
|
||||
setupComponent({ ...baseStorage, used: 2.5, readableUsed: "2.5 GB" });
|
||||
setupComponent(baseStorage);
|
||||
|
||||
const emitSpy = jest.spyOn(component.callToActionClicked, "emit");
|
||||
|
||||
|
||||
@@ -143,6 +143,33 @@ export const ActionsDisabled: Story = {
|
||||
used: 2.5,
|
||||
readableUsed: "2.5 GB",
|
||||
} satisfies Storage,
|
||||
callsToActionDisabled: true,
|
||||
addStorageDisabled: true,
|
||||
removeStorageDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const AddStorageDisabled: Story = {
|
||||
name: "Add Storage Disabled",
|
||||
args: {
|
||||
storage: {
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: "2.5 GB",
|
||||
} satisfies Storage,
|
||||
addStorageDisabled: true,
|
||||
removeStorageDisabled: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const RemoveStorageDisabled: Story = {
|
||||
name: "Remove Storage Disabled",
|
||||
args: {
|
||||
storage: {
|
||||
available: 5,
|
||||
used: 2.5,
|
||||
readableUsed: "2.5 GB",
|
||||
} satisfies Storage,
|
||||
addStorageDisabled: false,
|
||||
removeStorageDisabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,7 +12,12 @@ import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { Storage } from "../../types/storage";
|
||||
|
||||
export type StorageCardAction = "add-storage" | "remove-storage";
|
||||
export const StorageCardActions = {
|
||||
AddStorage: "add-storage",
|
||||
RemoveStorage: "remove-storage",
|
||||
} as const;
|
||||
|
||||
export type StorageCardAction = (typeof StorageCardActions)[keyof typeof StorageCardActions];
|
||||
|
||||
@Component({
|
||||
selector: "billing-storage-card",
|
||||
@@ -25,7 +30,8 @@ export class StorageCardComponent {
|
||||
|
||||
readonly storage = input.required<Storage>();
|
||||
|
||||
readonly callsToActionDisabled = input<boolean>(false);
|
||||
readonly addStorageDisabled = input<boolean>(false);
|
||||
readonly removeStorageDisabled = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<StorageCardAction>();
|
||||
|
||||
@@ -64,5 +70,5 @@ export class StorageCardComponent {
|
||||
return this.isFull() ? "danger" : "primary";
|
||||
});
|
||||
|
||||
readonly canRemoveStorage = computed<boolean>(() => !this.isFull());
|
||||
protected readonly actions = StorageCardActions;
|
||||
}
|
||||
|
||||
@@ -67,14 +67,14 @@ import { SubscriptionCardComponent, BitwardenSubscription } from "@bitwarden/sub
|
||||
|
||||
### Outputs
|
||||
|
||||
| Output | Type | Description |
|
||||
| --------------------- | ---------------- | ---------------------------------------------------------- |
|
||||
| `callToActionClicked` | `PlanCardAction` | Emitted when a user clicks an action button in the callout |
|
||||
| Output | Type | Description |
|
||||
| --------------------- | ------------------------ | ---------------------------------------------------------- |
|
||||
| `callToActionClicked` | `SubscriptionCardAction` | Emitted when a user clicks an action button in the callout |
|
||||
|
||||
**PlanCardAction Type:**
|
||||
**SubscriptionCardAction Type:**
|
||||
|
||||
```typescript
|
||||
type PlanCardAction =
|
||||
type SubscriptionCardAction =
|
||||
| "contact-support"
|
||||
| "manage-invoices"
|
||||
| "reinstate-subscription"
|
||||
|
||||
@@ -14,7 +14,7 @@ describe("SubscriptionCardComponent", () => {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 50,
|
||||
},
|
||||
},
|
||||
|
||||
@@ -103,7 +103,7 @@ export const Active: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -131,7 +131,7 @@ export const ActiveWithUpgrade: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -157,7 +157,7 @@ export const Trial: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -185,7 +185,7 @@ export const TrialWithUpgrade: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -212,7 +212,7 @@ export const Incomplete: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -239,7 +239,7 @@ export const IncompleteExpired: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -266,7 +266,7 @@ export const PastDue: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -293,7 +293,7 @@ export const PendingCancellation: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -320,7 +320,7 @@ export const Unpaid: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -346,7 +346,7 @@ export const Canceled: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 1,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 10.0,
|
||||
},
|
||||
},
|
||||
@@ -372,31 +372,30 @@ export const Enterprise: Story = {
|
||||
passwordManager: {
|
||||
seats: {
|
||||
quantity: 5,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 7,
|
||||
},
|
||||
additionalStorage: {
|
||||
quantity: 2,
|
||||
name: "additionalStorageGB",
|
||||
translationKey: "additionalStorageGB",
|
||||
cost: 0.5,
|
||||
},
|
||||
},
|
||||
secretsManager: {
|
||||
seats: {
|
||||
quantity: 3,
|
||||
name: "members",
|
||||
translationKey: "members",
|
||||
cost: 13,
|
||||
},
|
||||
additionalServiceAccounts: {
|
||||
quantity: 5,
|
||||
name: "additionalServiceAccountsV2",
|
||||
translationKey: "additionalServiceAccountsV2",
|
||||
cost: 1,
|
||||
},
|
||||
},
|
||||
discount: {
|
||||
type: DiscountTypes.PercentOff,
|
||||
active: true,
|
||||
value: 0.25,
|
||||
value: 25,
|
||||
},
|
||||
cadence: "monthly",
|
||||
estimatedTax: 6.4,
|
||||
|
||||
@@ -16,12 +16,16 @@ import { CartSummaryComponent, Maybe } from "@bitwarden/pricing";
|
||||
import { BitwardenSubscription, SubscriptionStatuses } from "@bitwarden/subscription";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
export type PlanCardAction =
|
||||
| "contact-support"
|
||||
| "manage-invoices"
|
||||
| "reinstate-subscription"
|
||||
| "update-payment"
|
||||
| "upgrade-plan";
|
||||
export const SubscriptionCardActions = {
|
||||
ContactSupport: "contact-support",
|
||||
ManageInvoices: "manage-invoices",
|
||||
ReinstateSubscription: "reinstate-subscription",
|
||||
UpdatePayment: "update-payment",
|
||||
UpgradePlan: "upgrade-plan",
|
||||
} as const;
|
||||
|
||||
export type SubscriptionCardAction =
|
||||
(typeof SubscriptionCardActions)[keyof typeof SubscriptionCardActions];
|
||||
|
||||
type Badge = { text: string; variant: BadgeVariant };
|
||||
|
||||
@@ -33,7 +37,7 @@ type Callout = Maybe<{
|
||||
callsToAction?: {
|
||||
text: string;
|
||||
buttonType: ButtonType;
|
||||
action: PlanCardAction;
|
||||
action: SubscriptionCardAction;
|
||||
}[];
|
||||
}>;
|
||||
|
||||
@@ -64,7 +68,7 @@ export class SubscriptionCardComponent {
|
||||
|
||||
readonly showUpgradeButton = input<boolean>(false);
|
||||
|
||||
readonly callToActionClicked = output<PlanCardAction>();
|
||||
readonly callToActionClicked = output<SubscriptionCardAction>();
|
||||
|
||||
readonly badge = computed<Badge>(() => {
|
||||
const subscription = this.subscription();
|
||||
@@ -136,12 +140,12 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("updatePayment"),
|
||||
buttonType: "unstyled",
|
||||
action: "update-payment",
|
||||
action: SubscriptionCardActions.UpdatePayment,
|
||||
},
|
||||
{
|
||||
text: this.i18nService.t("contactSupportShort"),
|
||||
buttonType: "unstyled",
|
||||
action: "contact-support",
|
||||
action: SubscriptionCardActions.ContactSupport,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -155,7 +159,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("contactSupportShort"),
|
||||
buttonType: "unstyled",
|
||||
action: "contact-support",
|
||||
action: SubscriptionCardActions.ContactSupport,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -172,7 +176,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("reinstateSubscription"),
|
||||
buttonType: "unstyled",
|
||||
action: "reinstate-subscription",
|
||||
action: SubscriptionCardActions.ReinstateSubscription,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -189,7 +193,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("upgradeNow"),
|
||||
buttonType: "unstyled",
|
||||
action: "upgrade-plan",
|
||||
action: SubscriptionCardActions.UpgradePlan,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -208,7 +212,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("manageInvoices"),
|
||||
buttonType: "unstyled",
|
||||
action: "manage-invoices",
|
||||
action: SubscriptionCardActions.ManageInvoices,
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -225,7 +229,7 @@ export class SubscriptionCardComponent {
|
||||
{
|
||||
text: this.i18nService.t("manageInvoices"),
|
||||
buttonType: "unstyled",
|
||||
action: "manage-invoices",
|
||||
action: SubscriptionCardActions.ManageInvoices,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user