Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: subscription updates #505

Merged
merged 1 commit into from
Aug 28, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -9,7 +9,7 @@
import Button from '@/components/Button/Button';
import Typography from '@/components/Typography/Typography';
import { useAppDispatch, useAppSelector } from '@/redux/store';
import { createWorkloadCluster, deleteCluster, downloadKubeconfig } from '@/redux/thunks/api.thunk';

Check warning on line 12 in containers/ClusterManagement/ClusterManagement.tsx

View workflow job for this annotation

GitHub Actions / build

'createWorkloadCluster' is defined but never used
import {
ClusterCreationStep,
ClusterStatus,
Expand Down Expand Up @@ -46,6 +46,7 @@
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 @@
}, [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
Loading