Files
calendar/packages/features/feature-opt-in/lib/computeEffectiveState.test.ts
T
Eunjae LeeGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
adc198b37b feat: add feature opt-in banner system (#26625)
* docs: add feature opt-in banner system plan

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* docs: iterate on feature opt-in banner plan based on feedback

- Use config.ts for feature metadata instead of TRPC response
- Consolidate localStorage dismissal into single key to avoid bloat
- Replace separate success dialog with in-dialog success state for smoother UX

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: implement feature opt-in banner system

- Add checkFeatureOptInEligibility TRPC procedure to check if user can opt-in
- Create useFeatureOptInBanner hook with localStorage dismissal state
- Create FeatureOptInBanner floating component for bottom-right display
- Create FeatureOptInConfirmDialog with role-based options and success state
- Integrate banner into bookings-view.tsx as example usage

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add i18n translation keys for feature opt-in banner

* refactor: create FeatureOptInBannerWrapper for simpler consumer integration

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: use @calcom/lib/webstorage and add Zod schema for localStorage

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: address PR review comments - use PBAC, move logic to service, improve typing

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

# Conflicts:
#	packages/features/feature-opt-in/services/FeatureOptInService.ts

* test: add comprehensive tests for checkFeatureOptInEligibility method

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: correct import and Checkbox type errors

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update message

* invalidate after dialog dismiss

* feat: simulate user opt-in to determine banner eligibility

When a feature has a strict policy and org/team hasn't explicitly
enabled it, user opt-in alone won't enable the feature. This change
simulates what would happen if the user opts in and only shows the
banner if opting in would actually enable the feature.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add early return for missing featureConfig and clarify simulation comment

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix repository usage

* refactor: simplify FeatureOptInConfirmDialog UI and remove unused translation

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: replace radio buttons with multi-select dropdown in FeatureOptInConfirmDialog

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* resize banner

* refactor: use AnimatedPopover pattern for team selection and rename titleI18nKey to nameI18nKey

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: change 'Just for me' to 'For me', fix width clipping, remove divider

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update style

* feat: make 'Just for me' mutually exclusive with teams/org and store opt-ins in localStorage

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: remove barrel imports and update to direct imports

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: wrap Popover.Content with Portal to prevent layout interference in dialogs

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* Revert "fix: use flexbox gap instead of space-y to prevent popover wrapper from affecting layout"

This reverts commit 8385adafa890c2f2674aa3ae3b10ca6e0c55ba5c.

* Revert "fix: wrap Popover.Content with Portal to prevent layout interference in dialogs"

This reverts commit e84301a35a99b3559d47c2d37316af290834ca2d.

* Revert "fix: add modal prop to AnimatedPopover to fix scroll lock conflict in dialogs"

This reverts commit b015af2f1cf31ef7087c02cb8535d96cc4a34125.

* feat: replace Radix Dialog and AnimatedPopover with coss-ui components

This migration uses @coss/ui Dialog and Popover components which are built on
Base UI instead of Radix. Base UI handles scroll locking and positioning
differently, which should fix the gap issue when opening the popover inside
the dialog.

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: replace remaining @calcom/ui components with coss-ui

- Replace Divider with Separator from @coss/ui
- Replace Label with Label from @coss/ui
- Replace CheckboxField with Checkbox + Label from @coss/ui
- Replace Icon with direct lucide-react icons (UserIcon, BuildingIcon, UsersIcon, CheckIcon)
- Replace showToast with toastManager.add() from @coss/ui

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* refactor: replace Popover with Menu component from coss-ui

- Replace Popover + FilterCheckboxField with Menu + MenuCheckboxItem
- MenuCheckboxItem provides built-in checkbox indicator and styling
- MenuSeparator provides proper menu separators
- Cleaner API with onCheckedChange callback
- Better keyboard navigation support

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: align icon and text on same line in MenuCheckboxItem, widen dialog

- Wrap icon and text in flex container with items-center gap-2
- Change dialog width from sm:max-w-md to sm:max-w-lg to prevent text wrapping

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* use dimmed bg for coss dialog title

* refactor: change nameI18nKey/descriptionI18nKey to nested i18n object

- Change OptInFeatureConfig interface to use i18n: { name, description }
- Update all component usages to use featureConfig.i18n.name and featureConfig.i18n.description
- Update test mocks to use new structure

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* feat: add i18n.title to OptInFeatureConfig type definition

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update texts

* use title instead of name

* update banner style

* refactor: split FeatureOptInConfirmDialog into separate success and confirmation dialogs

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: don't record dismissed info in local storage after successful opt-in

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* fix: add checkFeatureOptInEligibility to IFeatureOptInService interface

Co-Authored-By: eunjae@cal.com <hey@eunjae.dev>

* update common.json

* refactor: move tRPC usage from features package to apps/web/modules

Addresses Cubic AI review feedback (confidence 9/10):
- Remove @calcom/trpc import from FeatureOptInConfirmDialog.tsx
- Add FeatureOptInMutations type for passing mutation functions via props
- Move tRPC hooks to useFeatureOptInBanner hook in apps/web/modules
- Pass mutations through FeatureOptInBannerWrapper to dialog component

This maintains the architectural constraint that packages/features
should not import from @calcom/trpc to prevent circular dependencies.

Co-Authored-By: unknown <>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-01-19 18:48:26 +00:00

491 lines
18 KiB
TypeScript

import type { FeatureState } from "@calcom/features/flags/config";
import { describe, expect, it } from "vitest";
import type { OptInFeaturePolicy } from "../types";
import { computeEffectiveStateAcrossTeams } from "./computeEffectiveState";
/**
* Feature Opt-In Effective State Computation
* ==========================================
*
* This module computes whether a feature is effectively enabled for a user
* based on global, organization, team, and user-level settings.
*
* Two policies are supported:
* - `permissive` (default): User opt-in can activate the feature; disables only win if ALL teams disable
* - `strict`: User opt-in alone is NOT enough; requires explicit enable from org/team; ANY disable blocks
*
*
* SCENARIO TABLE - PERMISSIVE POLICY
* ============================================
*
* | # | Global | Org | Teams | User | Result | Reason |
* |---|--------|----------|--------------------------|----------|---------|-------------------------------|
* | 1 | true | inherit | [inherit, inherit] | enabled | ALLOWED | User opt-in activates |
* | 2 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement propagates |
* | 3 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement |
* | 4 | true | inherit | [enabled, disabled] | inherit | ALLOWED | At least one team enabled |
* | 5 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement propagates |
* | 6 | true | enabled | [disabled, disabled] | enabled | BLOCKED | All teams disabled |
* | 7 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all |
* | 8 | true | inherit | [disabled] | enabled | BLOCKED | All teams disabled |
* | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled |
* |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled |
*
*
* SCENARIO TABLE - STRICT POLICY
* ==============================
*
* | # | Global | Org | Teams | User | Result | Reason |
* |---|--------|----------|--------------------------|----------|---------|-------------------------------|
* | 1 | true | inherit | [inherit, inherit] | inherit | BLOCKED | No explicit enablement |
* | 2 | true | inherit | [inherit, inherit] | enabled | BLOCKED | User-only not allowed |
* | 3 | true | inherit | [enabled, inherit] | inherit | ALLOWED | Team enablement works |
* | 4 | true | inherit | [enabled, inherit] | enabled | ALLOWED | Team + user enabled |
* | 5 | true | inherit | [enabled, disabled] | enabled | BLOCKED | Any team disabled blocks |
* | 6 | true | enabled | [inherit] | inherit | ALLOWED | Org enablement works |
* | 7 | true | enabled | [disabled] | enabled | BLOCKED | Any team disabled blocks |
* | 8 | true | disabled | [enabled] | enabled | BLOCKED | Org disabled blocks all |
* | 9 | true | inherit | [enabled] | disabled | BLOCKED | User disabled |
* |10 | false | enabled | [enabled] | enabled | BLOCKED | Global disabled |
*
*
* KEY DIFFERENCES BETWEEN POLICIES
* ================================
*
* | Aspect | Permissive | Strict |
* |-----------------------------|-------------------------------|----------------------------------|
* | User opt-in alone | Can activate feature | NOT enough, needs org/team |
* | Team disable behavior | Only blocks if ALL disabled | ANY disable blocks |
* | Org/team enablement needed | No (user can self-enable) | Yes (required for activation) |
*
*/
describe("computeEffectiveStateAcrossTeams", () => {
describe("Global Kill Switch", () => {
it.each([
{
policy: "permissive" as OptInFeaturePolicy,
orgState: "enabled" as FeatureState,
teamStates: ["enabled"] as FeatureState[],
userState: "enabled" as FeatureState,
},
{
policy: "strict" as OptInFeaturePolicy,
orgState: "enabled" as FeatureState,
teamStates: ["enabled"] as FeatureState[],
userState: "enabled" as FeatureState,
},
])("blocks feature when global is disabled (policy: $policy)", ({
policy,
orgState,
teamStates,
userState,
}) => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: false,
orgState,
teamStates,
userState,
policy,
})
).toEqual({ enabled: false, reason: "feature_global_disabled" });
});
});
describe("Organization Level Blocking", () => {
it.each([
{
policy: "permissive" as OptInFeaturePolicy,
teamStates: ["enabled"] as FeatureState[],
userState: "enabled" as FeatureState,
},
{
policy: "strict" as OptInFeaturePolicy,
teamStates: ["enabled"] as FeatureState[],
userState: "enabled" as FeatureState,
},
])("blocks feature when org is disabled (policy: $policy)", ({ policy, teamStates, userState }) => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "disabled",
teamStates,
userState,
policy,
})
).toEqual({ enabled: false, reason: "feature_org_disabled" });
});
});
describe("Permissive Policy", () => {
describe("User Opt-In Behavior", () => {
it("allows user to self-enable even when org and teams only inherit", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when no explicit enablement exists anywhere", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
});
});
describe("Team Enablement Propagation", () => {
it("allows feature when at least one team is enabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "inherit", "inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("allows feature when one team enabled and another disabled (mixed)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled", "inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("Team Disable Behavior (ALL must disable)", () => {
it("blocks only when ALL teams have explicitly disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled", "disabled"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
});
it("blocks when single team is disabled (user in one team)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
});
});
describe("Organization Enablement", () => {
it("allows feature when org is enabled and teams inherit", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when org enabled but all teams disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled", "disabled"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_all_teams_disabled" });
});
});
describe("User Disable Behavior", () => {
it("blocks when user explicitly disables (even with team enabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_user_disabled" });
});
});
describe("No Teams Scenario", () => {
it("allows when org enabled and user inherits (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("allows when user self-enables (no teams, org inherits)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when no enablement anywhere (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
});
});
});
describe("Strict Policy", () => {
describe("User Opt-In Alone NOT Sufficient", () => {
it("blocks when user enables but no org/team enablement exists", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" });
});
it("blocks when no explicit enablement exists anywhere", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit", "inherit"],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_no_explicit_enablement" });
});
});
describe("Team Enablement Required", () => {
it("allows when team is enabled and user inherits", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "inherit", "inherit"],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("allows when team is enabled and user also enables", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "inherit", "inherit"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("ANY Team Disable Blocks", () => {
it("blocks when any team is disabled (even if another is enabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled", "inherit"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
});
it("blocks when single team is disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["disabled"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
});
it("blocks when org enabled but any team disabled", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["disabled"],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_any_team_disabled" });
});
});
describe("Organization Enablement", () => {
it("allows when org is enabled and teams inherit", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: ["inherit"],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("User Disable Behavior", () => {
it("blocks when user explicitly disables (even with team enabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled"],
userState: "disabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_user_disabled" });
});
});
describe("No Teams Scenario", () => {
it("allows when org enabled (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "enabled",
teamStates: [],
userState: "inherit",
policy: "strict",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("blocks when user self-enables but no org enablement (no teams)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: [],
userState: "enabled",
policy: "strict",
})
).toEqual({ enabled: false, reason: "feature_user_only_not_allowed" });
});
});
});
describe("Permissive Policy Additional Cases", () => {
it("allows user to self-enable with single inheriting team", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["inherit"],
userState: "enabled",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
it("allows feature with mixed team states (one enabled, one disabled)", () => {
expect(
computeEffectiveStateAcrossTeams({
globalEnabled: true,
orgState: "inherit",
teamStates: ["enabled", "disabled"],
userState: "inherit",
policy: "permissive",
})
).toEqual({ enabled: true, reason: "feature_enabled" });
});
});
describe("Policy Comparison (same inputs, different outcomes)", () => {
it("user-only enablement: allowed in permissive, blocked in strict", () => {
const input = {
globalEnabled: true,
orgState: "inherit" as FeatureState,
teamStates: ["inherit", "inherit"] as FeatureState[],
userState: "enabled" as FeatureState,
};
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({
enabled: true,
reason: "feature_enabled",
});
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({
enabled: false,
reason: "feature_user_only_not_allowed",
});
});
it("mixed team states: allowed in permissive, blocked in strict", () => {
const input = {
globalEnabled: true,
orgState: "inherit" as FeatureState,
teamStates: ["enabled", "disabled"] as FeatureState[],
userState: "enabled" as FeatureState,
};
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "permissive" })).toEqual({
enabled: true,
reason: "feature_enabled",
});
expect(computeEffectiveStateAcrossTeams({ ...input, policy: "strict" })).toEqual({
enabled: false,
reason: "feature_any_team_disabled",
});
});
});
});