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

upcoming: [M3-7670] - Add and handle ACLB Account Capability #10098

Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Upcoming Features
---

Add `Akamai Cloud Load Balancer` to `AccountCapability` type ([#10098](https://github.com/linode/manager/pull/10098))
1 change: 1 addition & 0 deletions packages/api-v4/src/account/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export interface Account {
export type BillingSource = 'linode' | 'akamai';

export type AccountCapability =
| 'Akamai Cloud Load Balancer'
| 'Block Storage'
| 'Cloud Firewall'
| 'Kubernetes'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Handle ACLB Account Capability ([#10098](https://github.com/linode/manager/pull/10098))
4 changes: 3 additions & 1 deletion packages/manager/src/GoTo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { makeStyles } from 'tss-react/mui';

import EnhancedSelect, { Item } from 'src/components/EnhancedSelect/Select';

import { useIsACLBEnabled } from './features/LoadBalancers/utils';
import { useAccountManagement } from './hooks/useAccountManagement';
import { useFlags } from './hooks/useFlags';
import { useGlobalKeyboardListener } from './hooks/useGlobalKeyboardListener';
Expand Down Expand Up @@ -60,6 +61,7 @@ export const GoTo = React.memo(() => {
const { _hasAccountAccess, _isManagedAccount } = useAccountManagement();
const flags = useFlags();

const { isACLBEnabled } = useIsACLBEnabled();
const { goToOpen, setGoToOpen } = useGlobalKeyboardListener();

const onClose = () => {
Expand Down Expand Up @@ -89,7 +91,7 @@ export const GoTo = React.memo(() => {
},
{
display: 'Load Balancers',
hide: !flags.aglb,
hide: !isACLBEnabled,
href: '/loadbalancers',
},
{
Expand Down
5 changes: 4 additions & 1 deletion packages/manager/src/MainContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { ENABLE_MAINTENANCE_MODE } from './constants';
import { complianceUpdateContext } from './context/complianceUpdateContext';
import { FlagSet } from './featureFlags';
import { useGlobalErrors } from './hooks/useGlobalErrors';
import { useIsACLBEnabled } from './features/LoadBalancers/utils';

const useStyles = makeStyles()((theme: Theme) => ({
activationWrapper: {
Expand Down Expand Up @@ -223,6 +224,8 @@ export const MainContent = () => {
account?.capabilities ?? []
);

const { isACLBEnabled } = useIsACLBEnabled();

const defaultRoot = _isManagedAccount ? '/managed' : '/linodes';

const shouldDisplayMainContentBanner =
Expand Down Expand Up @@ -338,7 +341,7 @@ export const MainContent = () => {
/>
<Route component={Volumes} path="/volumes" />
<Redirect path="/volumes*" to="/volumes" />
{flags.aglb && (
{isACLBEnabled && (
<Route
component={LoadBalancers}
path="/loadbalancer*"
Expand Down
59 changes: 59 additions & 0 deletions packages/manager/src/components/PrimaryNav/PrimaryNav.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import * as React from 'react';

import { accountFactory } from 'src/factories';
import { rest, server } from 'src/mocks/testServer';
import { queryClientFactory } from 'src/queries/base';
import { renderWithTheme, wrapWithTheme } from 'src/utilities/testHelpers';
Expand Down Expand Up @@ -70,4 +71,62 @@ describe('PrimaryNav', () => {

expect(getByTestId('menu-item-Databases')).toBeInTheDocument();
});

it('should show ACLB if the feature flag is on, but there is not an account capability', async () => {
const account = accountFactory.build({
capabilities: [],
});

server.use(
rest.get('*/account', (req, res, ctx) => {
return res(ctx.json(account));
})
);

const { findByText } = renderWithTheme(<PrimaryNav {...props} />, {
flags: { aglb: true },
});

const loadbalancerNavItem = await findByText('Global Load Balancers');

expect(loadbalancerNavItem).toBeVisible();
});

it('should show ACLB if the feature flag is off, but the account has the capability', async () => {
const account = accountFactory.build({
capabilities: ['Akamai Cloud Load Balancer'],
});

server.use(
rest.get('*/account', (req, res, ctx) => {
return res(ctx.json(account));
})
);

const { findByText } = renderWithTheme(<PrimaryNav {...props} />, {
flags: { aglb: false },
});

const loadbalancerNavItem = await findByText('Global Load Balancers');

expect(loadbalancerNavItem).toBeVisible();
});

it('should not show ACLB if the feature flag is off and there is no account capability', async () => {
const account = accountFactory.build({
capabilities: [],
});

server.use(
rest.get('*/account', (req, res, ctx) => {
return res(ctx.json(account));
})
);

const { queryByText } = renderWithTheme(<PrimaryNav {...props} />, {
flags: { aglb: false },
});

expect(queryByText('Global Load Balancers')).not.toBeInTheDocument();
});
});
7 changes: 5 additions & 2 deletions packages/manager/src/components/PrimaryNav/PrimaryNav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import Longview from 'src/assets/icons/longview.svg';
import AkamaiLogo from 'src/assets/logo/akamai-logo.svg';
import { BetaChip } from 'src/components/BetaChip/BetaChip';
import { Divider } from 'src/components/Divider';
import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils';
import { useAccountManagement } from 'src/hooks/useAccountManagement';
import { useFlags } from 'src/hooks/useFlags';
import { usePrefetch } from 'src/hooks/usePreFetch';
Expand Down Expand Up @@ -145,6 +146,8 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
account?.capabilities ?? []
);

const { isACLBEnabled } = useIsACLBEnabled();

const prefetchObjectStorage = () => {
if (!enableObjectPrefetch) {
setEnableObjectPrefetch(true);
Expand Down Expand Up @@ -190,7 +193,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
{
betaChipClassName: 'beta-chip-aglb',
display: 'Global Load Balancers',
hide: !flags.aglb,
hide: !isACLBEnabled,
href: '/loadbalancers',
icon: <LoadBalancer />,
isBeta: true,
Expand Down Expand Up @@ -297,7 +300,7 @@ export const PrimaryNav = (props: PrimaryNavProps) => {
allowObjPrefetch,
allowMarketplacePrefetch,
flags.databaseBeta,
flags.aglb,
isACLBEnabled,
flags.vmPlacement,
showVPCs,
]
Expand Down
21 changes: 21 additions & 0 deletions packages/manager/src/features/LoadBalancers/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { useFlags } from 'src/hooks/useFlags';
import { useAccount } from 'src/queries/account';
import { isFeatureEnabled } from 'src/utilities/accountCapabilities';

/**
* Hook to help determine if the Akamai Cloud Load Balancer should be shown.
*
* @returns true if Akamai Cloud Load Balancer should be shown for the current user
*/
export const useIsACLBEnabled = () => {
const { data: account } = useAccount();
const flags = useFlags();

const isACLBEnabled = isFeatureEnabled(
'Akamai Cloud Load Balancer',
Boolean(flags.aglb),
account?.capabilities ?? []
);

return { isACLBEnabled };
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this, should be make this a pattern to follow?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like it! It's a nice simple abstraction. I did it just incase we need to change how isACLBEnabled is determined

Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import VolumeIcon from 'src/assets/icons/entityIcons/volume.svg';
import VPCIcon from 'src/assets/icons/entityIcons/vpc.svg';
import { Button } from 'src/components/Button/Button';
import { Divider } from 'src/components/Divider';
import { useIsACLBEnabled } from 'src/features/LoadBalancers/utils';
import { useFlags } from 'src/hooks/useFlags';
import { useAccount } from 'src/queries/account';
import { useDatabaseEnginesQuery } from 'src/queries/databases';
Expand Down Expand Up @@ -66,6 +67,8 @@ export const AddNewMenu = () => {
account?.capabilities ?? []
);

const { isACLBEnabled } = useIsACLBEnabled();

const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
Expand All @@ -91,7 +94,7 @@ export const AddNewMenu = () => {
// TODO AGLB: Replace with AGLB copy when available
description: 'Ensure your services are highly available',
entity: 'Global Load Balancer',
hide: !flags.aglb,
hide: !isACLBEnabled,
icon: LoadBalancerIcon,
link: '/loadbalancers/create',
},
Expand Down
5 changes: 4 additions & 1 deletion packages/manager/src/queries/images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,10 @@ export const useAllImagesQuery = (
export const useUploadImageQuery = (payload: ImageUploadPayload) =>
useMutation<UploadImageResponse, APIError[]>(() => uploadImage(payload));

export const imageEventsHandler = ({ event, queryClient }: EventHandlerData) => {
export const imageEventsHandler = ({
event,
queryClient,
}: EventHandlerData) => {
const { action, entity, status } = event;

// Keep the getAll query up to date so that when we have to use it, it contains accurate data
Expand Down
52 changes: 14 additions & 38 deletions packages/manager/src/utilities/accountCapabilities.test.ts
Original file line number Diff line number Diff line change
@@ -1,47 +1,23 @@
import { isFeatureEnabled } from './accountCapabilities';

const isObjectStorageEnabled = isFeatureEnabled('Object Storage');
describe('isFeatureEnabled', () => {
it('returns `false` when both the flag is off and the item is not in account capabilities', () => {
expect(isFeatureEnabled('Object Storage', false, [])).toBe(false);
});

describe('isObjectStorageEnabled', () => {
beforeEach(() => {
vi.resetModules();
it('returns `true` when the flag is on, but the capability is not present', () => {
expect(isFeatureEnabled('Object Storage', true, [])).toBe(true);
});
describe('when "Object Storage EAP" is NOT in beta_programs...', () => {
it("returns `false` when OBJ isn't enabled for the environment", () => {
expect(isObjectStorageEnabled(false, [])).toBe(false);
expect(isObjectStorageEnabled(false, ['Hello', 'World'] as any)).toBe(
false
);
});

it('returns `true` when OBJ is enabled for the environment', () => {
expect(isObjectStorageEnabled(true, [])).toBe(true);
expect(isObjectStorageEnabled(true, ['Hello', 'World'] as any)).toBe(
true
);
});
it('returns `true` when the flag is off, but the account capability is present', () => {
expect(isFeatureEnabled('Object Storage', false, ['Object Storage'])).toBe(
true
);
});

describe('when "Object Storage EAP" IS in beta_programs', () => {
it('returns `true` if OBJ is disabled for environment', () => {
expect(isObjectStorageEnabled(false, ['Object Storage'])).toBe(true);
expect(
isObjectStorageEnabled(false, [
'Hello',
'World',
'Object Storage',
] as any)
).toBe(true);
});
it('returns `true` if OBJ is enabled for environment', () => {
expect(isObjectStorageEnabled(false, ['Object Storage'])).toBe(true);
expect(
isObjectStorageEnabled(false, [
'Hello',
'World',
'Object Storage',
] as any)
).toBe(true);
});
it('returns `true` when both the flag is on and the account capability is present', () => {
expect(isFeatureEnabled('Object Storage', true, ['Object Storage'])).toBe(
true
);
});
});
38 changes: 16 additions & 22 deletions packages/manager/src/utilities/accountCapabilities.ts
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I de-ramda-ifyed this and tried to make the comment more useful.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd been confused by isFeatureEnabled before too. My past, present, and future self thanks you for documenting this function for better clarity. πŸ™ŒπŸΌ

Original file line number Diff line number Diff line change
@@ -1,28 +1,22 @@
import { AccountCapability } from '@linode/api-v4/lib/account';
import { curry } from 'ramda';
import type { AccountCapability } from '@linode/api-v4';

/**
* Determines if a feature should be enabled. If the feature is returned from account.capabilities or if it is explicitly enabled
* in an environment variable (from Jenkins or .env), enable the feature.
* Determines if a feature should be enabled.
*
* Curried to make later feature functions easier to write. Usage:
* @returns true if the feature is returned from account.capabilities **or** if it is explicitly enabled
* by a feature flag
*
* const isMyFeatureEnabled = isFeatureEnabled('Feature two');
* isMyFeatureEnabled(ENV_VAR, account.capabilities)
* We use "or" instead of "and" here to allow us to enable features in "lower" environments
* without needing the customer capability.
*
* or, since we have access to environment variables from this file:
*
* const isMyFeatureEnabled = isFeatureEnabled('feature name', ENV_VAR);
*
* isMyFeatureEnabled(['Feature one', 'Feature two']) // true
* If you need to launch a production feature, but have it be gated,
* you would turn the flag *off* for that environment, but have the API return
* the account capability.
*/

export const isFeatureEnabled = curry(
(
featureName: AccountCapability,
environmentVar: boolean,
capabilities: AccountCapability[]
) => {
return environmentVar || capabilities.indexOf(featureName) > -1;
}
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

capabilities.indexOf(featureName) > -1 is savage. I wonder if includes did not exist when this was written πŸ˜…

);
export const isFeatureEnabled = (
featureName: AccountCapability,
isFeatureFlagEnabled: boolean,
capabilities: AccountCapability[]
) => {
return isFeatureFlagEnabled || capabilities.includes(featureName);
};
Loading