mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: display the addon license UI (#22948)
<img width="1052" height="234" alt="Screenshot 2026-03-18 at 21 58 57" src="https://github.com/user-attachments/assets/136ccb1f-e47a-44fd-804d-859301161435" /> --------- Co-authored-by: Steven Masley <stevenmasley@gmail.com>
This commit is contained in:
co-authored by
Steven Masley
parent
89eaf6ad74
commit
6f244cddde
@@ -46,6 +46,12 @@ func Entitlements(
|
||||
return codersdk.Entitlements{}, xerrors.Errorf("query active user count: %w", err)
|
||||
}
|
||||
|
||||
// nolint:gocritic // Getting active AI seat count is a system function.
|
||||
activeAISeatCount, err := db.GetActiveAISeatCount(dbauthz.AsSystemRestricted(ctx))
|
||||
if err != nil {
|
||||
return codersdk.Entitlements{}, xerrors.Errorf("query active AI seat count: %w", err)
|
||||
}
|
||||
|
||||
// nolint:gocritic // Getting external templates is a system function.
|
||||
externalTemplates, err := db.GetTemplatesWithFilter(dbauthz.AsSystemRestricted(ctx), database.GetTemplatesWithFilterParams{
|
||||
HasExternalAgent: sql.NullBool{
|
||||
@@ -59,6 +65,7 @@ func Entitlements(
|
||||
|
||||
entitlements, err := LicensesEntitlements(ctx, now, licenses, enablements, keys, FeatureArguments{
|
||||
ActiveUserCount: activeUserCount,
|
||||
ActiveAISeatCount: activeAISeatCount,
|
||||
ReplicaCount: replicaCount,
|
||||
ExternalAuthCount: externalAuthCount,
|
||||
ExternalTemplateCount: int64(len(externalTemplates)),
|
||||
@@ -88,6 +95,7 @@ func Entitlements(
|
||||
|
||||
type FeatureArguments struct {
|
||||
ActiveUserCount int64
|
||||
ActiveAISeatCount int64
|
||||
ReplicaCount int
|
||||
ExternalAuthCount int
|
||||
ExternalTemplateCount int64
|
||||
@@ -326,6 +334,9 @@ func LicensesEntitlements(
|
||||
if featureName == codersdk.FeatureUserLimit {
|
||||
actual = &featureArguments.ActiveUserCount
|
||||
}
|
||||
if featureName == codersdk.FeatureAIGovernanceUserLimit {
|
||||
actual = &featureArguments.ActiveAISeatCount
|
||||
}
|
||||
|
||||
entitlements.AddFeature(featureName, codersdk.Feature{
|
||||
Enabled: true,
|
||||
@@ -478,6 +489,24 @@ func LicensesEntitlements(
|
||||
"Your deployment has %d active users but the license with the limit %d is expired.",
|
||||
featureArguments.ActiveUserCount, *userLimit.Limit))
|
||||
}
|
||||
if featureArguments.ActiveAISeatCount > 0 {
|
||||
feature := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
|
||||
switch {
|
||||
case feature.Entitlement == codersdk.EntitlementNotEntitled:
|
||||
// If the limit is not set
|
||||
entitlements.Errors = append(entitlements.Errors,
|
||||
fmt.Sprintf("Your deployment has %d active AI governance seats but the license is not entitled to this feature.", featureArguments.ActiveAISeatCount))
|
||||
case feature.Entitlement == codersdk.EntitlementGracePeriod && feature.Limit != nil:
|
||||
entitlements.Warnings = append(entitlements.Warnings,
|
||||
fmt.Sprintf(
|
||||
"Your deployment has %d active AI governance seats but the license with the limit %d is expired.",
|
||||
featureArguments.ActiveAISeatCount, *feature.Limit))
|
||||
case feature.Limit != nil && featureArguments.ActiveAISeatCount > *feature.Limit:
|
||||
entitlements.Warnings = append(entitlements.Warnings, fmt.Sprintf(
|
||||
"Your deployment has %d active AI governance seats but is only licensed for %d.",
|
||||
featureArguments.ActiveAISeatCount, *feature.Limit))
|
||||
}
|
||||
}
|
||||
|
||||
// Add a warning for every feature that is enabled but not entitled or
|
||||
// is in a grace period.
|
||||
@@ -486,6 +515,9 @@ func LicensesEntitlements(
|
||||
if featureName == codersdk.FeatureUserLimit {
|
||||
continue
|
||||
}
|
||||
if featureName == codersdk.FeatureAIGovernanceUserLimit {
|
||||
continue
|
||||
}
|
||||
// High availability has it's own warnings based on replica count!
|
||||
if featureName == codersdk.FeatureHighAvailability {
|
||||
continue
|
||||
|
||||
@@ -851,6 +851,9 @@ func TestEntitlements(t *testing.T) {
|
||||
mDB.EXPECT().
|
||||
GetActiveUserCount(gomock.Any(), false).
|
||||
Return(int64(1), nil)
|
||||
mDB.EXPECT().
|
||||
GetActiveAISeatCount(gomock.Any()).
|
||||
Return(int64(27), nil)
|
||||
mDB.EXPECT().
|
||||
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Cond(func(params database.GetTotalUsageDCManagedAgentsV1Params) bool {
|
||||
// gomock doesn't seem to compare times very nicely, so check
|
||||
@@ -885,10 +888,72 @@ func TestEntitlements(t *testing.T) {
|
||||
require.NotNil(t, managedAgentLimit.Actual)
|
||||
require.EqualValues(t, 175, *managedAgentLimit.Actual)
|
||||
|
||||
aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, aiGovernanceSeatLimit.Actual)
|
||||
require.EqualValues(t, 27, *aiGovernanceSeatLimit.Actual)
|
||||
require.NotNil(t, aiGovernanceSeatLimit.Limit)
|
||||
require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit)
|
||||
|
||||
// Usage exceeds the limit, so an exceeded warning should be present.
|
||||
require.Len(t, entitlements.Warnings, 1)
|
||||
require.Equal(t, codersdk.LicenseManagedAgentLimitExceededWarningText, entitlements.Warnings[0])
|
||||
})
|
||||
|
||||
t.Run("AIGovernanceSeatLimitExceededWarning", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctrl := gomock.NewController(t)
|
||||
mDB := dbmock.NewMockStore(ctrl)
|
||||
|
||||
licenseOpts := (&coderdenttest.LicenseOptions{
|
||||
FeatureSet: codersdk.FeatureSetPremium,
|
||||
NotBefore: dbtime.Now().Add(-time.Hour).Truncate(time.Second),
|
||||
GraceAt: dbtime.Now().Add(time.Hour * 24 * 60).Truncate(time.Second),
|
||||
ExpiresAt: dbtime.Now().Add(time.Hour * 24 * 90).Truncate(time.Second),
|
||||
Addons: []codersdk.Addon{codersdk.AddonAIGovernance},
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAIGovernanceUserLimit: 100,
|
||||
},
|
||||
}).
|
||||
UserLimit(100)
|
||||
|
||||
lic := database.License{
|
||||
ID: 1,
|
||||
JWT: coderdenttest.GenerateLicense(t, *licenseOpts),
|
||||
Exp: licenseOpts.ExpiresAt,
|
||||
}
|
||||
|
||||
mDB.EXPECT().
|
||||
GetUnexpiredLicenses(gomock.Any()).
|
||||
Return([]database.License{lic}, nil)
|
||||
mDB.EXPECT().
|
||||
GetActiveUserCount(gomock.Any(), false).
|
||||
Return(int64(1), nil)
|
||||
mDB.EXPECT().
|
||||
GetActiveAISeatCount(gomock.Any()).
|
||||
Return(int64(127), nil)
|
||||
mDB.EXPECT().
|
||||
GetTotalUsageDCManagedAgentsV1(gomock.Any(), gomock.Any()).
|
||||
Return(int64(0), nil)
|
||||
mDB.EXPECT().
|
||||
GetTemplatesWithFilter(gomock.Any(), gomock.Any()).
|
||||
Return([]database.Template{}, nil)
|
||||
|
||||
entitlements, err := license.Entitlements(context.Background(), mDB, 1, 0, coderdenttest.Keys, all)
|
||||
require.NoError(t, err)
|
||||
require.True(t, entitlements.HasLicense)
|
||||
|
||||
aiGovernanceSeatLimit, ok := entitlements.Features[codersdk.FeatureAIGovernanceUserLimit]
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, aiGovernanceSeatLimit.Actual)
|
||||
require.EqualValues(t, 127, *aiGovernanceSeatLimit.Actual)
|
||||
require.NotNil(t, aiGovernanceSeatLimit.Limit)
|
||||
require.EqualValues(t, 100, *aiGovernanceSeatLimit.Limit)
|
||||
|
||||
require.Len(t, entitlements.Warnings, 1)
|
||||
require.Equal(t, "Your deployment has 127 active AI governance seats but is only licensed for 100.", entitlements.Warnings[0])
|
||||
})
|
||||
}
|
||||
|
||||
func TestLicenseEntitlements(t *testing.T) {
|
||||
|
||||
@@ -425,6 +425,7 @@ type Claims = {
|
||||
all_features: boolean;
|
||||
// feature_set is omitted on legacy licenses
|
||||
feature_set?: string;
|
||||
addons?: string[];
|
||||
version: number;
|
||||
features: Record<string, number>;
|
||||
require_telemetry?: boolean;
|
||||
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard";
|
||||
|
||||
const meta: Meta<typeof AIGovernanceAddOnCard> = {
|
||||
title:
|
||||
"pages/DeploymentSettingsPage/LicensesSettingsPage/AIGovernanceAddOnCard",
|
||||
component: AIGovernanceAddOnCard,
|
||||
args: {
|
||||
title: "AI governance",
|
||||
unit: "Seats",
|
||||
actual: 750,
|
||||
limit: 1000,
|
||||
isExceeded: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof AIGovernanceAddOnCard>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Exceeded: Story = {
|
||||
args: {
|
||||
actual: 1200,
|
||||
limit: 1000,
|
||||
isExceeded: true,
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Badge } from "components/Badge/Badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "components/Tooltip/Tooltip";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
|
||||
type AIGovernanceAddOnCardProps = {
|
||||
title: string;
|
||||
unit: string;
|
||||
actual?: number;
|
||||
limit: number;
|
||||
isExceeded: boolean;
|
||||
};
|
||||
|
||||
export const AIGovernanceAddOnCard: FC<AIGovernanceAddOnCardProps> = ({
|
||||
title,
|
||||
unit,
|
||||
actual,
|
||||
limit,
|
||||
isExceeded,
|
||||
}) => {
|
||||
const actualLabel = actual === undefined ? "—" : actual.toLocaleString();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`min-w-[320px] flex-1 rounded-sm border border-solid py-3 ${
|
||||
isExceeded ? "border-border-destructive" : "border-border"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-1 px-6 py-1.5">
|
||||
<div className="flex flex-1 items-center gap-1">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-sm font-medium text-content-primary">
|
||||
{title}
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="AI governance seat information"
|
||||
className="m-0 inline-flex appearance-none border-0 bg-transparent p-0 text-content-secondary"
|
||||
>
|
||||
<InfoIcon className="size-3" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top">
|
||||
Seats consumed by users using AI Governance features.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Badge variant="magenta" size="sm" border="solid">
|
||||
AI add-on
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[100px] flex-1 pl-8 pr-3">
|
||||
<div className="text-xs">
|
||||
<div className="font-medium text-content-secondary">{unit}</div>
|
||||
<div className="font-normal text-content-primary">
|
||||
<span
|
||||
className={
|
||||
isExceeded
|
||||
? "text-content-destructive"
|
||||
: "text-content-primary"
|
||||
}
|
||||
>
|
||||
{actualLabel}
|
||||
</span>{" "}
|
||||
/ {limit.toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
+21
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, within } from "storybook/test";
|
||||
import { AIGovernanceUsersConsumption } from "./AIGovernanceUsersConsumptionChart";
|
||||
|
||||
const meta: Meta<typeof AIGovernanceUsersConsumption> = {
|
||||
@@ -10,6 +11,7 @@ const meta: Meta<typeof AIGovernanceUsersConsumption> = {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
limit: 1000,
|
||||
actual: 750,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -19,6 +21,25 @@ type Story = StoryObj<typeof AIGovernanceUsersConsumption>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Exceeded: Story = {
|
||||
args: {
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
limit: 1000,
|
||||
actual: 1200,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1,200")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByText(/of 1,000 Users Entitled/i),
|
||||
).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
aiGovernanceUserFeature: {
|
||||
|
||||
+47
-8
@@ -9,6 +9,7 @@ import {
|
||||
import { Link } from "components/Link/Link";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { docs } from "utils/docs";
|
||||
|
||||
interface AIGovernanceUsersConsumptionProps {
|
||||
@@ -42,8 +43,15 @@ export const AIGovernanceUsersConsumption: FC<
|
||||
return <ErrorAlert error="Invalid license usage limits" />;
|
||||
}
|
||||
|
||||
const actual = aiGovernanceUserFeature.actual;
|
||||
const isExceeded = actual !== undefined && actual > limit;
|
||||
|
||||
return (
|
||||
<section className="border border-solid rounded">
|
||||
<section
|
||||
className={cn("border border-solid rounded", {
|
||||
"border-border-destructive": isExceeded,
|
||||
})}
|
||||
>
|
||||
<div className="p-4">
|
||||
<Collapsible>
|
||||
<header className="flex flex-col gap-2 items-start">
|
||||
@@ -105,13 +113,44 @@ export const AIGovernanceUsersConsumption: FC<
|
||||
|
||||
<div className="px-6 py-12 border-0 border-t border-solid">
|
||||
<div className="flex flex-col gap-4 text-center justify-center items-center">
|
||||
<div className="flex flex-col gap-2 text-center justify-center items-center">
|
||||
<div className="text-3xl font-bold">{limit.toLocaleString()}</div>
|
||||
<div className="text-sm text-content-secondary">Users Entitled</div>
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
Additional analytics and measurements coming soon
|
||||
</div>
|
||||
{actual !== undefined ? (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-center justify-center items-center">
|
||||
<div
|
||||
className={cn("text-3xl font-bold", {
|
||||
"text-content-destructive": isExceeded,
|
||||
})}
|
||||
>
|
||||
{actual.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
of {limit.toLocaleString()} Users Entitled
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn("text-sm", {
|
||||
"text-content-destructive font-medium": isExceeded,
|
||||
"text-content-secondary": !isExceeded,
|
||||
})}
|
||||
>
|
||||
{isExceeded ? "Add-on exceeded" : "Active"}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 text-center justify-center items-center">
|
||||
<div className="text-3xl font-bold">
|
||||
{limit.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
Users Entitled
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-sm text-content-secondary">
|
||||
Additional analytics and measurements coming soon
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
import { chromatic } from "testHelpers/chromatic";
|
||||
import { MockLicenseResponse } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import dayjs from "dayjs";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
|
||||
import { LicenseCard } from "./LicenseCard";
|
||||
|
||||
const meta: Meta<typeof LicenseCard> = {
|
||||
title: "pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard",
|
||||
component: LicenseCard,
|
||||
parameters: { chromatic },
|
||||
args: {
|
||||
license: MockLicenseResponse[0],
|
||||
userLimitActual: 4,
|
||||
userLimitLimit: 10,
|
||||
onRemove: fn(),
|
||||
isRemoving: false,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LicenseCard>;
|
||||
|
||||
export const Default: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("#1")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("4 / 10")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Enterprise")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UnlimitedUsers: Story = {
|
||||
args: {
|
||||
userLimitLimit: undefined,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("4 / Unlimited")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const UsesLicenseUserLimit: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[0],
|
||||
claims: {
|
||||
...MockLicenseResponse[0].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[0].claims.features,
|
||||
user_limit: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
userLimitActual: 1,
|
||||
userLimitLimit: 100,
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("1 / 3")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Premium: Story = {
|
||||
args: {
|
||||
license: MockLicenseResponse[1],
|
||||
},
|
||||
};
|
||||
|
||||
export const PremiumWithAIGovernance: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 750,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/ai governance/i)).toBeInTheDocument();
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("750 / 1,000");
|
||||
},
|
||||
};
|
||||
|
||||
export const PremiumWithoutAIGovernanceAddOn: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 100,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("AI governance")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const Expired: Story = {
|
||||
args: {
|
||||
license: MockLicenseResponse[3],
|
||||
},
|
||||
};
|
||||
|
||||
export const ExceededUserLimit: Story = {
|
||||
args: {
|
||||
userLimitActual: 15,
|
||||
userLimitLimit: 10,
|
||||
},
|
||||
};
|
||||
|
||||
export const ExceededAIGovernance: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 1200,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("1,200 / 1,000");
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpiredAIGovernanceOverageShowsExpired: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
license_expires: dayjs().subtract(1, "day").unix(),
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 1200,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Expired")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument();
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("—");
|
||||
await expect(seatsValue).toHaveTextContent("/ 1,000");
|
||||
},
|
||||
};
|
||||
|
||||
export const ExpiredAIGovernanceInGracePeriodShowsExceeded: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
license_expires: dayjs().subtract(1, "day").unix(),
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "grace_period",
|
||||
actual: 1200,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Add-on exceeded")).toBeInTheDocument();
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("1,200 / 1,000");
|
||||
},
|
||||
};
|
||||
|
||||
export const NotYetValid: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
nbf: dayjs().add(7, "day").unix(),
|
||||
},
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/Not started/)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const FutureAIGovernanceOverageShowsStartsOn: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
nbf: dayjs().add(7, "day").unix(),
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 1200,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/Not started/)).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument();
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("—");
|
||||
await expect(seatsValue).toHaveTextContent("/ 1,000");
|
||||
},
|
||||
};
|
||||
|
||||
export const FutureAIGovernanceUsageShowsNoCurrentSeats: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
nbf: dayjs().add(7, "day").unix(),
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("—");
|
||||
await expect(seatsValue).toHaveTextContent("/ 1,000");
|
||||
await expect(seatsValue).not.toHaveTextContent("0 / 1,000");
|
||||
},
|
||||
};
|
||||
|
||||
export const LowerLimitCardUsesMergedEntitlement: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 500,
|
||||
},
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 750,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const seatsLabel = canvas.getByText("Seats");
|
||||
const seatsValue = seatsLabel.nextElementSibling;
|
||||
await expect(seatsValue).toHaveTextContent("—");
|
||||
await expect(seatsValue).toHaveTextContent("/ 500");
|
||||
await expect(seatsValue).not.toHaveTextContent("750 / 500");
|
||||
await expect(canvas.queryByText("Add-on exceeded")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const EnterpriseDoesNotShowAIGovernanceAddOn: Story = {
|
||||
args: {
|
||||
license: {
|
||||
...MockLicenseResponse[1],
|
||||
claims: {
|
||||
...MockLicenseResponse[1].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[1].claims.features,
|
||||
ai_governance_user_limit: 1000,
|
||||
},
|
||||
feature_set: "enterprise",
|
||||
addons: ["ai_governance"],
|
||||
},
|
||||
},
|
||||
aiGovernanceUserFeature: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 750,
|
||||
limit: 1000,
|
||||
},
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText("Add-ons")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("AI add-on")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
@@ -1,46 +1,17 @@
|
||||
import { MockLicenseResponse } from "testHelpers/entities";
|
||||
import { render } from "testHelpers/renderHelpers";
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import userEvent, { type UserEvent } from "@testing-library/user-event";
|
||||
import { LicenseCard } from "./LicenseCard";
|
||||
|
||||
const openRemoveDialog = async (user: UserEvent) => {
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: /show license actions/i }),
|
||||
);
|
||||
await user.click(await screen.findByRole("menuitem", { name: /remove/i }));
|
||||
};
|
||||
|
||||
describe("LicenseCard", () => {
|
||||
it("renders (smoke test)", async () => {
|
||||
// When
|
||||
render(
|
||||
<LicenseCard
|
||||
license={MockLicenseResponse[0]}
|
||||
userLimitActual={1}
|
||||
userLimitLimit={10}
|
||||
onRemove={() => null}
|
||||
isRemoving={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await screen.findByText("#1");
|
||||
await screen.findByText("1 / 10");
|
||||
await screen.findByText("Enterprise");
|
||||
});
|
||||
|
||||
it("renders userLimit as unlimited if there is not user limit", async () => {
|
||||
// When
|
||||
render(
|
||||
<LicenseCard
|
||||
license={MockLicenseResponse[0]}
|
||||
userLimitActual={1}
|
||||
userLimitLimit={undefined}
|
||||
onRemove={() => null}
|
||||
isRemoving={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await screen.findByText("#1");
|
||||
await screen.findByText("1 / Unlimited");
|
||||
await screen.findByText("Enterprise");
|
||||
});
|
||||
|
||||
it("shows expired removal message for expired licenses", async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
@@ -53,8 +24,7 @@ describe("LicenseCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const removeButton = await screen.findByRole("button", { name: /remove/i });
|
||||
await user.click(removeButton);
|
||||
await openRemoveDialog(user);
|
||||
|
||||
const dialog = await screen.findByTestId("dialog");
|
||||
expect(dialog).toHaveTextContent(/This license has already expired/);
|
||||
@@ -72,42 +42,13 @@ describe("LicenseCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
const removeButton = await screen.findByRole("button", { name: /remove/i });
|
||||
await user.click(removeButton);
|
||||
await openRemoveDialog(user);
|
||||
|
||||
await screen.findByText(
|
||||
/Removing this license will disable all Premium features/,
|
||||
);
|
||||
});
|
||||
|
||||
it("renders license's user_limit when it is available instead of using the default", async () => {
|
||||
const licenseUserLimit = 3;
|
||||
const license = {
|
||||
...MockLicenseResponse[0],
|
||||
claims: {
|
||||
...MockLicenseResponse[0].claims,
|
||||
features: {
|
||||
...MockLicenseResponse[0].claims.features,
|
||||
user_limit: licenseUserLimit,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// When
|
||||
render(
|
||||
<LicenseCard
|
||||
license={license}
|
||||
userLimitActual={1}
|
||||
userLimitLimit={100} // This should not be used
|
||||
onRemove={() => null}
|
||||
isRemoving={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Then
|
||||
await screen.findByText("1 / 3");
|
||||
});
|
||||
|
||||
it("requires typing the license ID before allowing removal", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onRemove = vi.fn();
|
||||
@@ -123,7 +64,7 @@ describe("LicenseCard", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /remove/i }));
|
||||
await openRemoveDialog(user);
|
||||
|
||||
const dialog = await screen.findByTestId("dialog");
|
||||
const dialogScope = within(dialog);
|
||||
|
||||
@@ -1,12 +1,27 @@
|
||||
import type { GetLicensesResponse } from "api/api";
|
||||
import type { Feature } from "api/typesGenerated";
|
||||
import { Button } from "components/Button/Button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "components/Collapsible/Collapsible";
|
||||
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "components/DropdownMenu/DropdownMenu";
|
||||
import dayjs from "dayjs";
|
||||
import { ChevronDownIcon, EllipsisVerticalIcon, TrashIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { cn } from "utils/cn";
|
||||
import { AIGovernanceAddOnCard } from "./AIGovernanceAddOnCard";
|
||||
|
||||
type LicenseCardProps = {
|
||||
license: GetLicensesResponse;
|
||||
aiGovernanceUserFeature?: Feature;
|
||||
userLimitActual?: number;
|
||||
userLimitLimit?: number;
|
||||
onRemove: (licenseId: number) => void;
|
||||
@@ -15,6 +30,7 @@ type LicenseCardProps = {
|
||||
|
||||
export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
license,
|
||||
aiGovernanceUserFeature,
|
||||
userLimitActual,
|
||||
userLimitLimit,
|
||||
onRemove,
|
||||
@@ -24,24 +40,117 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
number | undefined
|
||||
>(undefined);
|
||||
|
||||
const currentUserLimit = license.claims.features.user_limit || userLimitLimit;
|
||||
const currentUserLimit = license.claims.features.user_limit ?? userLimitLimit;
|
||||
const confirmationName = licenseIDMarkedForRemoval?.toString() ?? "";
|
||||
|
||||
const isExpired = dayjs
|
||||
.unix(license.claims.license_expires)
|
||||
.isBefore(dayjs());
|
||||
const isNotYetValid =
|
||||
license.claims.nbf !== undefined &&
|
||||
dayjs.unix(license.claims.nbf).isAfter(dayjs());
|
||||
const isPremium = license.claims.feature_set?.toLowerCase() === "premium";
|
||||
const aiGovernanceActual = aiGovernanceUserFeature?.actual;
|
||||
const aiGovernanceMergedLimit = aiGovernanceUserFeature?.limit;
|
||||
const aiGovernanceLimit =
|
||||
license.claims.features?.ai_governance_user_limit ?? 0;
|
||||
|
||||
const licenseType = license.claims.trial
|
||||
? "Trial"
|
||||
: license.claims.feature_set?.toLowerCase() === "premium"
|
||||
: isPremium
|
||||
? "Premium"
|
||||
: "Enterprise";
|
||||
|
||||
const hasExplicitAiGovernanceAddOn = (license.claims.addons ?? []).includes(
|
||||
"ai_governance",
|
||||
);
|
||||
const isAiGovernanceEntitlementInGracePeriod =
|
||||
aiGovernanceUserFeature?.entitlement === "grace_period";
|
||||
// Overage/display checks only apply to licenses that are currently effective.
|
||||
const isLicenseApplicableForAiGovernanceOverage =
|
||||
!isNotYetValid && (!isExpired || isAiGovernanceEntitlementInGracePeriod);
|
||||
// A license "wins" when its AI governance limit matches the merged limit.
|
||||
const isWinningAiGovernanceLicense =
|
||||
aiGovernanceMergedLimit !== undefined &&
|
||||
aiGovernanceLimit > 0 &&
|
||||
aiGovernanceLimit === aiGovernanceMergedLimit;
|
||||
const canUseAiGovernanceUsageForThisLicense =
|
||||
isLicenseApplicableForAiGovernanceOverage &&
|
||||
hasExplicitAiGovernanceAddOn &&
|
||||
isWinningAiGovernanceLicense;
|
||||
// Show the add-on as exceeded only for the winning, active add-on license.
|
||||
const isAiGovernanceAddOnExceeded =
|
||||
canUseAiGovernanceUsageForThisLicense &&
|
||||
aiGovernanceActual !== undefined &&
|
||||
aiGovernanceActual > aiGovernanceLimit;
|
||||
// Show actual usage only when this license is the one providing the limit.
|
||||
const aiGovernanceDisplayActual = canUseAiGovernanceUsageForThisLicense
|
||||
? aiGovernanceActual
|
||||
: undefined;
|
||||
const statusClassName =
|
||||
isAiGovernanceAddOnExceeded || isExpired
|
||||
? "text-content-destructive"
|
||||
: isNotYetValid
|
||||
? "text-content-warning"
|
||||
: "text-content-success";
|
||||
const statusText = isAiGovernanceAddOnExceeded
|
||||
? "Add-on exceeded"
|
||||
: isExpired
|
||||
? "Expired"
|
||||
: isNotYetValid
|
||||
? "Not started"
|
||||
: "Active";
|
||||
const hasCollapsibleContent = isPremium && hasExplicitAiGovernanceAddOn;
|
||||
const headerContent = (
|
||||
<>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{hasCollapsibleContent && (
|
||||
<ChevronDownIcon className="license-chevron size-4 text-content-secondary transition-colors transition-transform group-hover:text-content-primary" />
|
||||
)}
|
||||
<span className="text-base font-medium text-content-secondary">
|
||||
#{license.id}
|
||||
</span>
|
||||
<span className="account-type text-base font-medium text-content-primary capitalize">
|
||||
{licenseType}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-12 text-xs font-medium">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Status</span>
|
||||
<span className={statusClassName}>{statusText}</span>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Users</span>
|
||||
<span className="text-content-primary user-limit">
|
||||
{userLimitActual} {` / ${currentUserLimit || "Unlimited"}`}
|
||||
</span>
|
||||
</div>
|
||||
{license.claims.nbf && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Valid From</span>
|
||||
<span
|
||||
className={cn("license-valid-from", {
|
||||
"text-content-warning": statusText === "Not started",
|
||||
"text-content-primary": statusText !== "Not started",
|
||||
})}
|
||||
>
|
||||
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Valid Until</span>
|
||||
<span className="text-content-primary license-expires">
|
||||
{dayjs.unix(license.claims.license_expires).format("MMMM D, YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={license.id}
|
||||
className="license-card rounded-lg border border-solid border-border bg-surface-secondary p-4 text-sm shadow-sm"
|
||||
>
|
||||
<Collapsible defaultOpen>
|
||||
<DeleteDialog
|
||||
key={licenseIDMarkedForRemoval}
|
||||
isOpen={licenseIDMarkedForRemoval !== undefined}
|
||||
@@ -64,52 +173,69 @@ export const LicenseCard: FC<LicenseCardProps> = ({
|
||||
}
|
||||
confirmLoading={isRemoving}
|
||||
/>
|
||||
<div className="flex flex-row gap-4 items-center">
|
||||
<span className="text-content-secondary text-lg font-semibold">
|
||||
#{license.id}
|
||||
</span>
|
||||
<span className="account-type font-semibold text-lg capitalize">
|
||||
{licenseType}
|
||||
</span>
|
||||
<div className="flex flex-row justify-end gap-16 items-end flex-1">
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Users</span>
|
||||
<span className="text-content-primary user-limit">
|
||||
{userLimitActual} {` / ${currentUserLimit || "Unlimited"}`}
|
||||
</span>
|
||||
</div>
|
||||
{license.claims.nbf && (
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-content-secondary">Valid From</span>
|
||||
<span className="text-content-secondary license-valid-from">
|
||||
{dayjs.unix(license.claims.nbf).format("MMMM D, YYYY")}
|
||||
</span>
|
||||
<div className="license-card group overflow-hidden rounded-md border border-solid border-border bg-surface-secondary text-sm shadow-sm">
|
||||
<div className="flex items-center gap-6 p-3">
|
||||
{hasCollapsibleContent ? (
|
||||
<CollapsibleTrigger
|
||||
asChild
|
||||
className="[&[data-state=closed]_.license-chevron]:-rotate-90"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="m-0 flex min-w-0 flex-1 appearance-none items-center gap-6 border-0 bg-transparent p-0 text-left"
|
||||
>
|
||||
{headerContent}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
) : (
|
||||
<div className="m-0 flex min-w-0 flex-1 items-center gap-6">
|
||||
{headerContent}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center">
|
||||
{isExpired ? (
|
||||
<Pill className="mb-1" type="error">
|
||||
Expired
|
||||
</Pill>
|
||||
) : (
|
||||
<span className="text-content-secondary">Valid Until</span>
|
||||
)}
|
||||
<span className="text-content-secondary license-expires">
|
||||
{dayjs
|
||||
.unix(license.claims.license_expires)
|
||||
.format("MMMM D, YYYY")}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => setLicenseIDMarkedForRemoval(license.id)}
|
||||
className="remove-button"
|
||||
>
|
||||
Remove…
|
||||
</Button>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="subtle"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
className="size-[30px]"
|
||||
>
|
||||
<EllipsisVerticalIcon />
|
||||
<span className="sr-only">Show license actions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={() => setLicenseIDMarkedForRemoval(license.id)}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove…
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<CollapsibleContent>
|
||||
{hasCollapsibleContent && (
|
||||
<div className="border-0 border-t border-solid border-border bg-surface-primary px-4 py-4">
|
||||
<div className="text-sm font-medium text-content-secondary">
|
||||
Add-ons
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-3">
|
||||
<AIGovernanceAddOnCard
|
||||
title="AI governance"
|
||||
unit="Seats"
|
||||
actual={aiGovernanceDisplayActual}
|
||||
limit={aiGovernanceLimit}
|
||||
isExceeded={isAiGovernanceAddOnExceeded}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
|
||||
+148
-6
@@ -1,5 +1,7 @@
|
||||
import { MockLicenseResponse } from "testHelpers/entities";
|
||||
import { MockEntitlements, MockLicenseResponse } from "testHelpers/entities";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import dayjs from "dayjs";
|
||||
import { expect, within } from "storybook/test";
|
||||
import LicensesSettingsPage from "./LicensesSettingsPage";
|
||||
|
||||
const meta: Meta<typeof LicensesSettingsPage> = {
|
||||
@@ -16,12 +18,152 @@ const meta: Meta<typeof LicensesSettingsPage> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LicensesSettingsPage>;
|
||||
|
||||
const USER_STATUS_COUNTS_QUERY = {
|
||||
key: ["insights", "userStatusCounts"],
|
||||
data: { active: [] },
|
||||
};
|
||||
|
||||
const withBaseQueries = ({
|
||||
entitlements = MockEntitlements,
|
||||
licenses = MockLicenseResponse,
|
||||
}: {
|
||||
entitlements?: typeof MockEntitlements;
|
||||
licenses?: typeof MockLicenseResponse | unknown[];
|
||||
}) => ({
|
||||
queries: [
|
||||
{ key: ["entitlements"], data: entitlements },
|
||||
{ key: ["licenses"], data: licenses },
|
||||
USER_STATUS_COUNTS_QUERY,
|
||||
],
|
||||
});
|
||||
|
||||
const createEntitlements = ({
|
||||
userLimit,
|
||||
aiGovernanceUserLimit,
|
||||
}: {
|
||||
userLimit: {
|
||||
enabled: boolean;
|
||||
entitlement: "entitled" | "not_entitled";
|
||||
actual: number;
|
||||
limit?: number;
|
||||
};
|
||||
aiGovernanceUserLimit?: {
|
||||
enabled: boolean;
|
||||
entitlement: "entitled" | "not_entitled" | "grace_period";
|
||||
actual: number;
|
||||
limit: number;
|
||||
};
|
||||
}) => ({
|
||||
...MockEntitlements,
|
||||
has_license: true,
|
||||
features: {
|
||||
...MockEntitlements.features,
|
||||
user_limit: userLimit,
|
||||
ai_governance_user_limit:
|
||||
aiGovernanceUserLimit ??
|
||||
MockEntitlements.features.ai_governance_user_limit,
|
||||
},
|
||||
});
|
||||
|
||||
const createLicense = ({
|
||||
id,
|
||||
uuid,
|
||||
featureSet,
|
||||
uploadedDaysAgo,
|
||||
expiresInDays,
|
||||
licenseExpiresInDays,
|
||||
nbfOffsetDays,
|
||||
aiGovernanceUserLimit,
|
||||
userLimit,
|
||||
addons,
|
||||
}: {
|
||||
id: number;
|
||||
uuid: string;
|
||||
featureSet: "PREMIUM" | "enterprise";
|
||||
uploadedDaysAgo: number;
|
||||
expiresInDays: number;
|
||||
licenseExpiresInDays: number;
|
||||
nbfOffsetDays: number;
|
||||
aiGovernanceUserLimit: number;
|
||||
userLimit: number;
|
||||
addons?: string[];
|
||||
}) => ({
|
||||
id,
|
||||
uploaded_at: String(dayjs().subtract(uploadedDaysAgo, "day").unix()),
|
||||
expires_at: String(dayjs().add(expiresInDays, "day").unix()),
|
||||
uuid,
|
||||
claims: {
|
||||
trial: false,
|
||||
all_features: true,
|
||||
feature_set: featureSet,
|
||||
version: 1,
|
||||
features: {
|
||||
ai_governance_user_limit: aiGovernanceUserLimit,
|
||||
user_limit: userLimit,
|
||||
},
|
||||
addons,
|
||||
license_expires: dayjs().add(licenseExpiresInDays, "day").unix(),
|
||||
nbf: dayjs().add(nbfOffsetDays, "day").unix(),
|
||||
},
|
||||
});
|
||||
|
||||
export const WithoutUserLimitFeature: Story = {
|
||||
parameters: {
|
||||
queries: [
|
||||
{ key: ["entitlements"], data: { features: {} } },
|
||||
{ key: ["licenses"], data: MockLicenseResponse },
|
||||
{ key: ["insights", "userStatusCounts"], data: { active: [] } },
|
||||
],
|
||||
...withBaseQueries({
|
||||
entitlements: {
|
||||
...MockEntitlements,
|
||||
features: {
|
||||
...MockEntitlements.features,
|
||||
user_limit: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
actual: 4,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export const ShowsAddonUiForFutureLicenseBeforeNbf: Story = {
|
||||
parameters: {
|
||||
...withBaseQueries({
|
||||
entitlements: createEntitlements({
|
||||
userLimit: {
|
||||
enabled: true,
|
||||
entitlement: "entitled",
|
||||
actual: 3,
|
||||
limit: 10,
|
||||
},
|
||||
aiGovernanceUserLimit: {
|
||||
enabled: false,
|
||||
entitlement: "not_entitled",
|
||||
actual: 0,
|
||||
limit: 0,
|
||||
},
|
||||
}),
|
||||
licenses: [
|
||||
createLicense({
|
||||
id: 44,
|
||||
uuid: "future-premium-addon-license",
|
||||
featureSet: "PREMIUM",
|
||||
uploadedDaysAgo: 0,
|
||||
expiresInDays: 365,
|
||||
licenseExpiresInDays: 365,
|
||||
nbfOffsetDays: 7,
|
||||
aiGovernanceUserLimit: 100,
|
||||
userLimit: 10,
|
||||
addons: ["ai_governance"],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText(/add-ons/i)).toBeInTheDocument();
|
||||
const aiGovernanceTitles = canvas.getAllByText(/^ai governance$/i);
|
||||
await expect(aiGovernanceTitles.length).toBeGreaterThan(0);
|
||||
await expect(canvas.getByText(/not started/i)).toBeInTheDocument();
|
||||
await expect(canvas.getByText(/valid from/i)).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
+1
@@ -127,6 +127,7 @@ const LicensesSettingsPageView: FC<Props> = ({
|
||||
license={license}
|
||||
userLimitActual={userLimitActual}
|
||||
userLimitLimit={userLimitLimit}
|
||||
aiGovernanceUserFeature={aiGovernanceUserFeature}
|
||||
isRemoving={isRemovingLicense}
|
||||
onRemove={removeLicense}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user