Skip to content

Commit

Permalink
feat: subscription updates (#505)
Browse files Browse the repository at this point in the history
  • Loading branch information
CristhianF7 authored Aug 28, 2024
1 parent cf9e943 commit acc8150
Show file tree
Hide file tree
Showing 9 changed files with 234 additions and 70 deletions.
5 changes: 4 additions & 1 deletion app/lib/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const { ENTERPRISE_API_URL } = process.env;

export async function validateLicense() {
try {
return (await axios.post<License>(`${ENTERPRISE_API_URL}/api/v1/subscription/validate`)).data;
const response = await axios.post<License>(
`${ENTERPRISE_API_URL}/api/v1/subscription/validate`,
);
return response.data;
} catch (error) {
// supressing error. license not found
return {} as License;
Expand Down
9 changes: 5 additions & 4 deletions components/Tag/Tag.styled.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ export const TagContainer = styled(Row)<{ bg?: string; textColor?: string }>`
`;

export const StyledText = muiStyled(Typography)`
textTransform: 'initial',
display: 'flex',
alignItems: 'center',
gap: 1,
text-transform: initial;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
`;
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React, { ComponentProps, FC, useCallback, useEffect, useMemo } from 'react';
import { useFormContext } from 'react-hook-form';
import Box from '@mui/material/Box';
import StarBorderOutlinedIcon from '@mui/icons-material/StarBorderOutlined';

import { usePhysicalClustersPermissions } from '../../../hooks/usePhysicalClustersPermission';

Expand All @@ -13,7 +12,7 @@ import ControlledTextField from '@/components/controlledFields/ControlledTextFie
import { useAppDispatch, useAppSelector } from '@/redux/store';
import Typography from '@/components/Typography/Typography';
import { ClusterType, NewWorkloadClusterConfig, ClusterEnvironment } from '@/types/provision';
import { ASMANI_SKY, BISCAY, EXCLUSIVE_PLUM } from '@/constants/colors';
import { BISCAY, EXCLUSIVE_PLUM } from '@/constants/colors';
import ControlledNumberInput from '@/components/controlledFields/ControlledNumberInput/ControlledNumberInput';
import ControlledRadioGroup from '@/components/controlledFields/ControlledRadioGroup/ControlledRadioGroup';
import {
Expand All @@ -37,14 +36,7 @@ import {
getRegionZones,
} from '@/redux/thunks/api.thunk';
import ControlledTagsAutocomplete from '@/components/controlledFields/ControlledAutoComplete/ControlledTagsAutoComplete';
import {
selectHasLicenseKey,
selectIsLicenseActive,
} from '@/redux/selectors/subscription.selector';
import Tag from '@/components/Tag/Tag';
import { getCloudProviderAuth } from '@/utils/getCloudProviderAuth';
import { FeatureFlag } from '@/types/config';
import useFeatureFlag from '@/hooks/useFeatureFlag';
import { selectApiState } from '@/redux/selectors/api.selector';
import { selectEnvironmentsState } from '@/redux/selectors/environments.selector';

Expand All @@ -54,9 +46,6 @@ const ClusterCreationForm: FC<ComponentProps<'div'>> = (props) => {
selectApiState(),
);
const { environments, error } = useAppSelector(selectEnvironmentsState());
const hasLicenseKey = useAppSelector(selectHasLicenseKey());
const isLicenseActive = useAppSelector(selectIsLicenseActive());
const { isEnabled: isSubscriptionEnabled } = useFeatureFlag(FeatureFlag.SAAS_SUBSCRIPTION);

const dispatch = useAppDispatch();

Expand Down Expand Up @@ -155,10 +144,6 @@ const ClusterCreationForm: FC<ComponentProps<'div'>> = (props) => {
const draftCluster = clusterMap[RESERVED_DRAFT_CLUSTER_NAME];
const isVCluster = type === ClusterType.WORKLOAD_V_CLUSTER;

const handleRedirect = (): void => {
window.open(`${location.origin}/settings/subscription/plans`, '_blank');
};

const clusterOptions = useMemo(() => {
let clusterTypes;

Expand All @@ -170,31 +155,8 @@ const ClusterCreationForm: FC<ComponentProps<'div'>> = (props) => {
);
}

if (!isSubscriptionEnabled) {
return clusterTypes;
}

return hasLicenseKey && isLicenseActive
? clusterTypes
: clusterTypes.map((option) => {
if (option.value === ClusterType.WORKLOAD) {
return {
...option,
isDisabled: true,
tag: (
<Tag
onClick={handleRedirect}
text="Upgrade to use this feature"
bgColor="mistery"
iconComponent={<StarBorderOutlinedIcon htmlColor={ASMANI_SKY} fontSize="small" />}
/>
),
};
}

return option;
});
}, [hasLicenseKey, hasPermissions, isLicenseActive, isSubscriptionEnabled]);
return clusterTypes;
}, [hasPermissions]);

useEffect(() => {
const subscription = watch((values) => {
Expand Down
18 changes: 7 additions & 11 deletions containers/ClusterManagement/ClusterManagement.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import KubeConfigModal from '@/components/KubeConfigModal/KubeConfigModal';
import { createNotification } from '@/redux/slices/notifications.slice';
import useFeatureFlag from '@/hooks/useFeatureFlag';
import Column from '@/components/Column/Column';
import { SaasFeatures } from '@/types/subscription';

const ClusterManagement: FunctionComponent = () => {
const {
Expand Down Expand Up @@ -173,22 +174,17 @@ const ClusterManagement: FunctionComponent = () => {
}, [clusterCreationStep, managementCluster, dispatch, openCreateClusterFlow]);

const handleCreateCluster = () => {
const draftCluster = clusterMap[RESERVED_DRAFT_CLUSTER_NAME];

if (
draftCluster?.type === ClusterType.WORKLOAD &&
clusterCreationStep !== ClusterCreationStep.DETAILS
) {
const canCreatePhysicalClusters = canUseFeature('physicalClusters');
if (clusterCreationStep !== ClusterCreationStep.DETAILS) {
const canCreateWorkloadClusters = canUseFeature(SaasFeatures.WorkloadClustersLimit);

if (isSassSubscriptionEnabled && !canCreatePhysicalClusters) {
if (isSassSubscriptionEnabled && !canCreateWorkloadClusters) {
return openUpgradeModal();
}
}

if (clusterCreationStep !== ClusterCreationStep.DETAILS) {
dispatch(createWorkloadCluster());
}
// if (clusterCreationStep !== ClusterCreationStep.DETAILS) {
// dispatch(createWorkloadCluster());
// }
};

const handleDeleteMenuClick = useCallback(
Expand Down
178 changes: 178 additions & 0 deletions hooks/__tests__/usePaywall.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import { renderHook } from '@testing-library/react';

import usePaywall from '../usePaywall';
// eslint-disable-next-line import/order
import { SaasFeatures, SaasPlans } from '../../types/subscription';

jest.mock('@/redux/store', () => ({
useAppSelector: jest.fn(),
}));

import { useAppSelector } from '../../redux/store';

describe('usePaywall', () => {
beforeEach(() => {
jest.clearAllMocks();
});

it('should return the correct plan', () => {
const mockState = {
api: { clusterMap: {} },
subscription: {
license: { plan: { name: SaasPlans.Pro }, is_active: true, clusters: [] },
},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.plan).toBe(SaasPlans.Pro);
});

it('should return active clusters', () => {
const activeClusters = [
{ id: 1, isActive: true },
{ id: 2, isActive: true },
];

const mockState = {
api: { clusterMap: {} },
subscription: {
license: { plan: { name: SaasPlans.Pro }, is_active: true, clusters: activeClusters },
},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.activeClusters).toEqual(activeClusters);
});

it('should return false if the feature is not available', () => {
const mockState = {
api: { clusterMap: {} },
subscription: {
license: { plan: { name: SaasPlans.Pro, features: [] }, is_active: true, clusters: [] },
},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.canUseFeature('some_feature')).toBe(false);
});

describe('pro plan', () => {
it('should return true if the feature is available', () => {
const featureCode = 'some_feature';
const mockState = {
api: { clusterMap: {} },
subscription: {
license: {
plan: { name: SaasPlans.Pro, features: [{ code: featureCode }] },
is_active: true,
clusters: [],
},
},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.canUseFeature(featureCode)).toBe(true);
});

it('should allow feature if cluster limit is not exceeded', () => {
const activeClusters = [
{ id: 1, isActive: true },
{ id: 2, isActive: true },
];
const featureCode = SaasFeatures.WorkloadClustersLimit;
const mockState = {
api: { clusterMap: {} },
subscription: {
license: {
plan: {
name: SaasPlans.Pro,
features: [{ code: featureCode, data: { limit: 3 } }],
},
is_active: true,
clusters: activeClusters,
},
},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.canUseFeature(featureCode)).toBe(true);
});

it('should not allow feature if cluster limit has exceeded', () => {
const activeClusters = [
{ id: 1, isActive: true },
{ id: 2, isActive: true },
{ id: 2, isActive: true },
{ id: 2, isActive: true },
];
const featureCode = SaasFeatures.WorkloadClustersLimit;
const mockState = {
api: { clusterMap: {} },
subscription: {
license: {
plan: {
name: SaasPlans.Pro,
features: [{ code: featureCode, data: { limit: 3 } }],
},
is_active: true,
clusters: activeClusters,
},
},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.canUseFeature(featureCode)).toBe(false);
});
});

describe('community plan', () => {
it('should enforce cluster limit for Community plan without license key', () => {
const clusterMap = {
cluster1: { id: 1 },
cluster2: { id: 2 },
cluster3: { id: 3 },
cluster4: { id: 4 },
};

const mockState = {
api: { clusterMap },
subscription: {},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.canUseFeature(SaasFeatures.WorkloadClustersLimit)).toBe(false);
});

it('should allow use feature for Community plan without license key when the clusters are less than 3', () => {
const clusterMap = {
cluster1: { id: 1 },
cluster2: { id: 2 },
draft: { id: 3 },
};

const mockState = {
api: { clusterMap },
subscription: {},
};

(useAppSelector as jest.Mock).mockImplementation((selector) => selector(mockState));

const { result } = renderHook(() => usePaywall());
expect(result.current.canUseFeature(SaasFeatures.WorkloadClustersLimit)).toBe(true);
});
});
});
29 changes: 18 additions & 11 deletions hooks/usePaywall.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,46 @@
import { useMemo } from 'react';

import { useAppSelector } from '@/redux/store';
import { ClusterType } from '@/types/provision';
import { SaasPlans } from '@/types/subscription';
import { SaasFeatures, SaasPlans } from '@/types/subscription';

export const CLUSTERS_LIMIT: { [key: string]: number } = {
export const CLUSTERS_LIMIT_FALLBACK: { [key: string]: number } = {
[SaasPlans.Community]: 3,
[SaasPlans.Pro]: 10,
[SaasPlans.Enterprise]: Infinity,
};

export default function usePaywall() {
const { license, plan } = useAppSelector(({ subscription }) => ({
const { clusterMap, license, plan } = useAppSelector(({ api, subscription }) => ({
clusterMap: api.clusterMap,
license: subscription.license,
plan: subscription.license?.plan?.name,
}));

const canUseFeature = (featureCode: string): boolean => {
if (license?.plan && license?.is_active) {
if (featureCode === 'physicalClusters') {
const clusterLimit = CLUSTERS_LIMIT[plan as string];
const feature = license.plan.features.find(({ code }) => code === featureCode);

if (featureCode === SaasFeatures.WorkloadClustersLimit) {
const clusterLimit = feature?.data.limit || CLUSTERS_LIMIT_FALLBACK[plan as string];

return !!activeClusters && clusterLimit > activeClusters.length;
}

return !!license.plan.features.find(({ code }) => code === featureCode);
return !!feature;
}

if (!license?.licenseKey) {
return (
Object.keys(clusterMap).filter((clusterKey) => clusterKey != 'draft').length <
CLUSTERS_LIMIT_FALLBACK[SaasPlans.Community]
);
}

return false;
};

const activeClusters = useMemo(
() =>
license?.clusters?.filter(
({ isActive, clusterType }) => isActive && clusterType === ClusterType.WORKLOAD,
),
() => license?.clusters?.filter(({ isActive }) => isActive),
[license?.clusters],
);

Expand Down
Loading

0 comments on commit acc8150

Please sign in to comment.