## What does this PR do? Adds a `displayLocations` property to the `OptInFeatureConfig` interface to control where opt-in features are displayed (settings page, banner, or both), and applies this filtering at the callers' side. ## How to test locally ``` update "Feature" set enabled = true where slug='bookings-v3'; ``` Run it to globally enable the flag, and then go to the settings page to see the "Features" menu. --- **Changes:** - Added `OptInFeatureDisplayLocation` type with values `"settings"` | `"banner"` - Added optional `displayLocations` property to `OptInFeatureConfig` interface - Added helper functions: - `getFeatureDisplayLocations()` - returns display locations with default of `['settings']` - `shouldDisplayFeatureAt()` - checks if a feature should display at a specific location - `getOptInFeaturesForLocation()` - filters features by location - Updated `getOptInFeaturesForScope()` to accept an optional `displayLocation` parameter for filtering - Simplified `HAS_*_OPT_IN_FEATURES` constants to use `getOptInFeaturesForScope(scope, "settings").length > 0` - Updated `FeatureOptInService.listFeaturesForUser()` to filter by 'settings' location - Updated `FeatureOptInService.listFeaturesForTeam()` to filter by 'settings' location - Updated `useFeatureOptInBanner` hook to check for 'banner' location before showing **Default behavior:** If `displayLocations` is omitted, features default to `['settings']` only. ## Mandatory Tasks (DO NOT REMOVE) - [x] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [x] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). N/A - internal config change only. - [x] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? 1. Verify the helper functions work as expected: - `getFeatureDisplayLocations({ slug: "test", ... })` should return `["settings"]` (default) - `getFeatureDisplayLocations({ slug: "test", displayLocations: ["banner"] })` should return `["banner"]` - `shouldDisplayFeatureAt(feature, "settings")` should return `true` for features without `displayLocations` - `getOptInFeaturesForScope("user", "banner")` should only return user-scoped features with `"banner"` in their `displayLocations` 2. Verify caller-side filtering: - `listFeaturesForUser()` and `listFeaturesForTeam()` should only return features with 'settings' in displayLocations - Banner hook should only show features with 'banner' in displayLocations - `HAS_*_OPT_IN_FEATURES` constants should only be true if there are features with 'settings' location ## Checklist - [x] My code follows the style guidelines of this project - [x] I have commented my code, particularly in hard-to-understand areas - [x] My changes generate no new warnings --- ### Human Review Checklist - [ ] Verify the default behavior (defaulting to `['settings']`) matches requirements - [ ] Confirm the filtering logic is applied correctly in all callers (service methods, banner hook, constants) - [ ] Verify the banner hook correctly prevents showing features without 'banner' in displayLocations - [ ] Note: The test mock returns all features regardless of `displayLocation` parameter - the filtering logic in `getOptInFeaturesForScope` isn't directly tested. Consider if this is acceptable or if tests should be added. **Link to Devin run:** https://app.devin.ai/sessions/a064ee43a56d458caf2892b55959f1ea **Requested by:** @eunjae-lee
150 lines
5.5 KiB
TypeScript
150 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
import type { OptInFeatureConfig } from "@calcom/features/feature-opt-in/config";
|
|
import { getOptInFeatureConfig, shouldDisplayFeatureAt } from "@calcom/features/feature-opt-in/config";
|
|
import { trpc } from "@calcom/trpc/react";
|
|
import { useCallback, useMemo, useState } from "react";
|
|
|
|
import {
|
|
getFeatureOptInTimestamp,
|
|
isFeatureDismissed,
|
|
setFeatureDismissed,
|
|
setFeatureOptedIn,
|
|
} from "../lib/feature-opt-in-storage";
|
|
|
|
type UserRoleContext = {
|
|
isOrgAdmin: boolean;
|
|
orgId: number | null;
|
|
adminTeamIds: number[];
|
|
adminTeamNames: { id: number; name: string }[];
|
|
};
|
|
|
|
type FeatureOptInMutations = {
|
|
setUserState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
|
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) => Promise<unknown>;
|
|
setOrganizationState: (params: { slug: string; state: "enabled" }) => Promise<unknown>;
|
|
setUserAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
|
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) => Promise<unknown>;
|
|
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) => Promise<unknown>;
|
|
invalidateQueries: () => void;
|
|
};
|
|
|
|
type UseFeatureOptInBannerResult = {
|
|
shouldShow: boolean;
|
|
isLoading: boolean;
|
|
featureConfig: OptInFeatureConfig | null;
|
|
canOptIn: boolean;
|
|
blockingReason: string | null;
|
|
userRoleContext: UserRoleContext | null;
|
|
isDialogOpen: boolean;
|
|
openDialog: () => void;
|
|
closeDialog: () => void;
|
|
dismiss: () => void;
|
|
markOptedIn: () => void;
|
|
mutations: FeatureOptInMutations;
|
|
};
|
|
|
|
function isFeatureOptedIn(featureId: string): boolean {
|
|
return getFeatureOptInTimestamp(featureId) !== null;
|
|
}
|
|
|
|
function useFeatureOptInBanner(featureId: string): UseFeatureOptInBannerResult {
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
const [isDismissed, setIsDismissed] = useState(() => isFeatureDismissed(featureId));
|
|
const [isOptedIn, setIsOptedIn] = useState(() => isFeatureOptedIn(featureId));
|
|
|
|
const featureConfig = useMemo(() => getOptInFeatureConfig(featureId) ?? null, [featureId]);
|
|
const utils = trpc.useUtils();
|
|
|
|
const eligibilityQuery = trpc.viewer.featureOptIn.checkFeatureOptInEligibility.useQuery(
|
|
{ featureId },
|
|
{
|
|
enabled: !isDismissed && !isOptedIn && featureConfig !== null,
|
|
refetchOnWindowFocus: false,
|
|
staleTime: 1000 * 60 * 5,
|
|
}
|
|
);
|
|
|
|
const setUserStateMutation = trpc.viewer.featureOptIn.setUserState.useMutation();
|
|
const setTeamStateMutation = trpc.viewer.featureOptIn.setTeamState.useMutation();
|
|
const setOrganizationStateMutation = trpc.viewer.featureOptIn.setOrganizationState.useMutation();
|
|
const setUserAutoOptInMutation = trpc.viewer.featureOptIn.setUserAutoOptIn.useMutation();
|
|
const setTeamAutoOptInMutation = trpc.viewer.featureOptIn.setTeamAutoOptIn.useMutation();
|
|
const setOrganizationAutoOptInMutation = trpc.viewer.featureOptIn.setOrganizationAutoOptIn.useMutation();
|
|
|
|
const mutations: FeatureOptInMutations = useMemo(
|
|
() => ({
|
|
setUserState: (params: { slug: string; state: "enabled" }) => setUserStateMutation.mutateAsync(params),
|
|
setTeamState: (params: { teamId: number; slug: string; state: "enabled" }) =>
|
|
setTeamStateMutation.mutateAsync(params),
|
|
setOrganizationState: (params: { slug: string; state: "enabled" }) =>
|
|
setOrganizationStateMutation.mutateAsync(params),
|
|
setUserAutoOptIn: (params: { autoOptIn: boolean }) => setUserAutoOptInMutation.mutateAsync(params),
|
|
setTeamAutoOptIn: (params: { teamId: number; autoOptIn: boolean }) =>
|
|
setTeamAutoOptInMutation.mutateAsync(params),
|
|
setOrganizationAutoOptIn: (params: { autoOptIn: boolean }) =>
|
|
setOrganizationAutoOptInMutation.mutateAsync(params),
|
|
invalidateQueries: (): void => {
|
|
utils.viewer.featureOptIn.checkFeatureOptInEligibility.invalidate();
|
|
utils.viewer.featureOptIn.listForUser.invalidate();
|
|
},
|
|
}),
|
|
[
|
|
setUserStateMutation,
|
|
setTeamStateMutation,
|
|
setOrganizationStateMutation,
|
|
setUserAutoOptInMutation,
|
|
setTeamAutoOptInMutation,
|
|
setOrganizationAutoOptInMutation,
|
|
utils,
|
|
]
|
|
);
|
|
|
|
const dismiss = useCallback(() => {
|
|
setFeatureDismissed(featureId);
|
|
setIsDismissed(true);
|
|
}, [featureId]);
|
|
|
|
const markOptedIn = useCallback(() => {
|
|
setFeatureOptedIn(featureId);
|
|
setIsOptedIn(true);
|
|
}, [featureId]);
|
|
|
|
const openDialog = useCallback(() => {
|
|
setIsDialogOpen(true);
|
|
}, []);
|
|
|
|
const closeDialog = useCallback(() => {
|
|
setIsDialogOpen(false);
|
|
}, []);
|
|
|
|
const shouldShow = useMemo(() => {
|
|
if (isDismissed) return false;
|
|
if (isOptedIn) return false;
|
|
if (!featureConfig) return false;
|
|
// Only show banner if the feature is configured to be displayed as a banner
|
|
if (!shouldDisplayFeatureAt(featureConfig, "banner")) return false;
|
|
if (eligibilityQuery.isLoading) return false;
|
|
if (!eligibilityQuery.data) return false;
|
|
return eligibilityQuery.data.status === "can_opt_in";
|
|
}, [isDismissed, isOptedIn, featureConfig, eligibilityQuery.isLoading, eligibilityQuery.data]);
|
|
|
|
return {
|
|
shouldShow,
|
|
isLoading: eligibilityQuery.isLoading,
|
|
featureConfig,
|
|
canOptIn: eligibilityQuery.data?.canOptIn ?? false,
|
|
blockingReason: eligibilityQuery.data?.blockingReason ?? null,
|
|
userRoleContext: eligibilityQuery.data?.userRoleContext ?? null,
|
|
isDialogOpen,
|
|
openDialog,
|
|
closeDialog,
|
|
dismiss,
|
|
markOptedIn,
|
|
mutations,
|
|
};
|
|
}
|
|
|
|
export { useFeatureOptInBanner };
|
|
export type { FeatureOptInMutations, UseFeatureOptInBannerResult };
|