feat(console): Add the IsFeatureEnabled core

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/8410
GitOrigin-RevId: 2fc16f678fcb49226f2028c324d06314749e27f2
This commit is contained in:
Stefano Magni 2023-03-31 12:52:56 +02:00 committed by hasura-bot
parent 40921f20bc
commit 2359322feb
11 changed files with 1264 additions and 9 deletions

View File

@ -0,0 +1,5 @@
import { Meta } from '@storybook/addon-docs';
<Meta title="Dev/IsFeatureEnabled" />
Please look at the [README.md](https://github.com/hasura/graphql-engine-mono/blob/main/frontend/libs/console/legacy-ce/src/lib/features/IsFeatureEnabled/README.md) file at the root of the feature directory.

View File

@ -0,0 +1,165 @@
# IsFeatureEnabled
React APIs to enable a feature in specific Hasura plans. Aim to replace all the sources of truth when it comes to identify if a feature is enabled or not.
## Status
### Hasura plan support status
Environments
- [x] Hasura CE (_not tested in production yet_)
- [x] Hasura EE with/without license (_not tested in production yet_)
- [ ] Hasura Cloud
- [ ] Self-hosted Hasura Cloud
- [ ] EE Classic (hybrid Hasura Cloud, with self-hosted HGE server and Hasura-hosted Lux)
Modes
- [x] Server and CLI without distinction
- [ ] Server only
- [ ] CLI only
### Dev APIs status
- [x] React hook API
- [x] React component API
- [ ] Storybook APIs
- [ ] Unit testing APIs
- [ ] Console wrapper to load all the async source of truths
### Others
- [ ] Refactor all the usages of `isProConsole` etc. APIs
- [ ] Manage also the feature flags
- [ ] Mark `window.__env` as deprecated
- [ ] Refactor all the usages of `window.__env`
- [ ] Document how to tun the Console in all the modes
## Console Usage
### Simple usage
To only render something when a feature is enabled or not:
- Using the React component API
```tsx
function Prometheus() {
return (
<IsFeatureEnabled feature="prometheus">
<div>Enjoy Prometheus!</div>
</IsFeatureEnabled>
);
}
```
- Using the React hook API
```tsx
function Prometheus() {
const { status } = useIsFeatureEnabled('prometheus');
if (status === 'disabled') return null;
return <div>Enjoy Prometheus!</div>;
}
```
### Advanced usage
To render something when a feature is enabled, or something different when the feature is disabled:
- Using the React component API
```tsx
function Prometheus() {
return (
<IsFeatureEnabled
feature="prometheus"
ifDisabled={(doNotMatch, current: { hasuraPlan }) => {
if (doNotMatch.ee) {
if (hasuraPlan.type === 'ce') {
return (
<div>
Try EE Lite and give all the paid feature a try for free!
</div>
);
}
return <div>Prometheus is enabled for EE Lite only</div>;
}
}}
>
<div>Enjoy Prometheus!</div>
</IsFeatureEnabled>
);
}
```
- Using the React hook API
```tsx
function Prometheus() {
const {
status,
doNotMatch,
current: { hasuraPlan },
} = useIsFeatureEnabled('prometheus');
if (status === 'disabled') {
if (doNotMatch.ee) {
if (hasuraPlan.type === 'ce') {
return (
<div>Try EE Lite and give all the paid feature a try for free!</div>
);
}
return <div>Prometheus is enabled for EE Lite only</div>;
}
}
return <div>Enjoy Prometheus!</div>;
}
```
## Testing
### Storybook
❌ It's not possible to use the new APIs in Storybook yet. ❌
### Jest
❌ It's not possible to use the new APIs in Jest yet. ❌
## Adding more source of truths or Console types to the Hasura plan
No shortcuts here, look at
1. The `checkCompatibility` function and its types
2. The `checkCompatibility` runtime and types tests
3. The `store` types
## Adding new features
1. Add a new object to the `features.ts` module
2. Add is to the `features` object exposed by the `features.ts` module
## FAQ
_As a developer, I want to be sure my component is not rendered until the Hasura plan data is available because I do not want to manage the loading state._
When a Console wrapper will be ready, it will prevent rendering the Console until the source of truths, no need to manage the loading states.
_I see Rect APIs, but I do not see pure JavaScript APIs, why?_
React APIs includes "reactivity" by definition. Vanilla JavaScript APIs cannot offer the same reactivity in an easy way. If you need to consume the Hasura plan data offered by `useIsFeatureEnabled`, read it from a React component and pass it down to your vanilla JavaScript functions.
_What about getting the APIs accepting an array of features instead of just one?_
We wil evaluate if the feature is really needed because the TypeScript gymnastics needed for the `doMatch`/`doNotMatch` objects returned by the APIs are not trivial, and making them working with arrays is even worse.
_What about specifying only the required properties in the compatibility object?_
Getting all the properties explicit enforces managing them also when we add more Console types/mode and source of info. It's a by-design choice.

View File

@ -0,0 +1,106 @@
import { type HasuraPlan } from './store';
import { type Compatibility, type ElaboratingMatch } from './isFeatureEnabled';
type CheckPassed = boolean;
// ------------------------------------------------------------
// PLAN CHECKERS
// ------------------------------------------------------------
export function isRunningCePlan(currentState: { hasuraPlan: HasuraPlan }) {
return currentState.hasuraPlan.name === 'ce';
}
export function isRunningEeWithoutLicensePlan(currentState: {
hasuraPlan: HasuraPlan;
}) {
return (
currentState.hasuraPlan.name === 'ee' &&
(currentState.hasuraPlan.license.status === 'missing' ||
currentState.hasuraPlan.license.status === 'deactivated' ||
currentState.hasuraPlan.license.status === 'expired' ||
currentState.hasuraPlan.license.status === 'licenseApiError' ||
currentState.hasuraPlan.license.status === 'unknown')
);
}
export function isRunningEeWithLicensePlan(currentState: {
hasuraPlan: HasuraPlan;
}) {
return (
currentState.hasuraPlan.name === 'ee' &&
(currentState.hasuraPlan.license.status === 'active' ||
currentState.hasuraPlan.license.status === 'grace')
);
}
// ------------------------------------------------------------
// COMPATIBILITY CHECKERS
// ------------------------------------------------------------
export function checkCe(params: {
compatibility: Compatibility;
isRunningCe: boolean;
mutableDoMatch: ElaboratingMatch;
mutableDoNotMatch: ElaboratingMatch;
}): CheckPassed {
const { compatibility, isRunningCe, mutableDoMatch, mutableDoNotMatch } =
params;
if (compatibility.ce === 'enabled') {
if (isRunningCe) mutableDoMatch.ce = true;
else mutableDoNotMatch.ce = true;
}
return !!mutableDoMatch.ce;
}
export function checkEe(params: {
compatibility: Compatibility;
isRunningEeWithoutLicense: boolean;
isRunningEeWithLicense: boolean;
mutableDoMatch: ElaboratingMatch;
mutableDoNotMatch: ElaboratingMatch;
}): CheckPassed {
const {
compatibility,
isRunningEeWithoutLicense,
isRunningEeWithLicense,
mutableDoMatch,
mutableDoNotMatch,
} = params;
let passEeCheck = false;
if ('ee' in compatibility) {
// These two objects will be later removed if remain empty
mutableDoMatch.ee = {};
mutableDoNotMatch.ee = {};
if (compatibility.ee.withoutLicense === 'enabled') {
if (isRunningEeWithoutLicense) {
mutableDoMatch.ee.withoutLicense = true;
} else {
mutableDoNotMatch.ee.withoutLicense = true;
}
}
if (compatibility.ee.withLicense === 'enabled') {
if (isRunningEeWithLicense) {
mutableDoMatch.ee.withLicense = true;
} else {
mutableDoNotMatch.ee.withLicense = true;
}
}
passEeCheck =
mutableDoMatch.ee.withoutLicense === true ||
mutableDoMatch.ee.withLicense === true;
// Sub-objects not containing `enabled` options must be removed to avoid mismatching with the
// types
if (Object.keys(mutableDoMatch.ee).length === 0) delete mutableDoMatch.ee;
if (Object.keys(mutableDoNotMatch.ee).length === 0)
delete mutableDoNotMatch.ee;
}
return passEeCheck;
}

View File

@ -0,0 +1,56 @@
// The various
// `as const satisfies Compatibility;`
// allow to use the object itself as the source of truth instead of the type, later allowing a
// better type DX with when consuming the `checkCompatibility` function.
import { type Compatibility } from './isFeatureEnabled';
/**
* A fake feature that allows for testing the APIs.
*
* TODO: Remove this in favor of a real feature
* @deprecated
*/
const unitTestActiveEeTrialFake = {
ce: 'disabled',
ee: {
withLicense: 'enabled',
withoutLicense: 'disabled',
},
} as const satisfies Compatibility;
/**
* A fake feature that allows for testing the APIs.
*
* TODO: Remove this in favor of a real feature
* @deprecated
*/
const unitTestEeFake = {
ce: 'disabled',
ee: {
withLicense: 'enabled',
withoutLicense: 'enabled',
},
} as const satisfies Compatibility;
/**
* A fake feature that allows for testing the APIs.
*
* TODO: Remove this in favor of a real feature
* @deprecated
*/
const unitTestCeFake = {
ce: 'enabled',
ee: {
withLicense: 'disabled',
withoutLicense: 'disabled',
},
} as const satisfies Compatibility;
export const features = {
unitTestCeFake,
unitTestEeFake,
unitTestActiveEeTrialFake,
} as const satisfies Record<string, Compatibility>;
export type Feature = keyof typeof features;

View File

@ -0,0 +1,3 @@
export { IsFeatureEnabled, useIsFeatureEnabled } from './isFeatureEnabled';
export { useHasuraPlan } from './store';

View File

@ -0,0 +1,265 @@
import { render, screen } from '@testing-library/react';
import {
type Compatibility,
IsFeatureEnabled,
checkCompatibility,
} from './isFeatureEnabled';
import { type HasuraPlan, StoreProvider } from './store';
describe('checkCompatibility', () => {
describe('Types testing and types/runtime match testing', () => {
it('When passed with an `enabled` property, then the doMatch and doNotMatch should not contain extra properties', () => {
// Arrange
const hasuraPlan: HasuraPlan = { name: 'ce' };
const compatibility = {
// ↓ will be in doMatch and doNotMatch
ce: 'enabled',
// ↓ will NOT be in doMatch and doNotMatch
ee: { withLicense: 'disabled', withoutLicense: 'disabled' },
} as const satisfies Compatibility;
const expectedResult: ReturnType<
typeof checkCompatibility<typeof compatibility>
> = {
status: 'enabled',
doMatch: { ce: true },
doNotMatch: {},
current: { hasuraPlan: { name: 'ce' } },
};
// Act
const result = checkCompatibility(compatibility, { hasuraPlan });
// Assert
expect(result.doMatch).toEqual(expectedResult.doMatch);
expect(result.doNotMatch).toEqual(expectedResult.doNotMatch);
// Types testing
// @ts-expect-error The error is expected because here we are checking that the value reflects the runtime value
expect(result.doMatch.ee).toBe(undefined);
expect(result.doMatch).not.toHaveProperty('ee');
});
it('When passed with some `enabled` properties, then the doMatch and doNotMatch should not contain extra properties', () => {
// Arrange
const hasuraPlan: HasuraPlan = { name: 'ce' };
const compatibility = {
// ↓ will be in doMatch and doNotMatch
ce: 'enabled',
// ↓ will be in doMatch and doNotMatch
ee: { withLicense: 'enabled', withoutLicense: 'enabled' },
} as const satisfies Compatibility;
const expectedResult: ReturnType<
typeof checkCompatibility<typeof compatibility>
> = {
status: 'enabled',
doMatch: { ce: true },
doNotMatch: { ee: { withLicense: true, withoutLicense: true } },
current: { hasuraPlan: { name: 'ce' } },
};
// Act
const result = checkCompatibility(compatibility, { hasuraPlan });
// Assert
expect(result.doMatch).toEqual(expectedResult.doMatch);
expect(result.doNotMatch).toEqual(expectedResult.doNotMatch);
// Types testing
expect(result.doMatch.ee).toBe(undefined);
expect(result.doMatch).not.toHaveProperty('ee');
});
it('When passed with some `enabled` properties for the license, then the doMatch and doNotMatch should not contain extra properties', () => {
// Arrange
const hasuraPlan: HasuraPlan = { name: 'ce' };
const compatibility = {
ce: 'enabled', // <-- will be in doMatch and doNotMatch
ee: {
withLicense: 'enabled', // <-- will be in doMatch and doNotMatch
withoutLicense: 'disabled', // <-- will NOT be in doMatch and doNotMatch
},
} as const satisfies Compatibility;
const expectedResult: ReturnType<
typeof checkCompatibility<typeof compatibility>
> = {
status: 'enabled',
doMatch: { ce: true },
doNotMatch: { ee: { withLicense: true } },
current: { hasuraPlan: { name: 'ce' } },
};
// Act
const result = checkCompatibility(compatibility, { hasuraPlan });
// Assert
expect(result.doMatch).toEqual(expectedResult.doMatch);
expect(result.doNotMatch).toEqual(expectedResult.doNotMatch);
// Types testing
// @ts-expect-error The error is expected because here we are checking that the value reflects the runtime value
expect(result.doNotMatch.ee?.withoutLicense).toBe(undefined);
expect(result.doNotMatch.ee).not.toHaveProperty('withoutLicense');
});
it('When passed with all the license details as `disabled`, then the doMatch and doNotMatch should not contain them', () => {
// Arrange
const hasuraPlan: HasuraPlan = { name: 'ce' };
const compatibility = {
ce: 'disabled',
// ↓ will NOT be in doMatch and doNotMatch
ee: {
withLicense: 'disabled',
withoutLicense: 'disabled',
},
} as const satisfies Compatibility;
const expectedResult: ReturnType<
typeof checkCompatibility<typeof compatibility>
> = {
status: 'disabled',
doMatch: {},
doNotMatch: {},
current: { hasuraPlan: { name: 'ce' } },
};
// Act
const result = checkCompatibility(compatibility, { hasuraPlan });
// Assert
expect(result.doMatch).toEqual(expectedResult.doMatch);
expect(result.doNotMatch).toEqual(expectedResult.doNotMatch);
// Types testing
// @ts-expect-error The error is expected because here we are checking that the value reflects the runtime value
expect(result.doNotMatch.ee).toBe(undefined);
expect(result.doNotMatch).not.toHaveProperty('ee');
});
});
describe('Compatibility testing', () => {
it('When passed with a CE plan, and the feature is enabled only in the CE plan, then return `enabled`', () => {
// Arrange
const hasuraPlan: HasuraPlan = { name: 'ce' };
const compatibility = {
ce: 'enabled',
ee: { withLicense: 'disabled', withoutLicense: 'disabled' },
} as const satisfies Compatibility;
const expectedResult: ReturnType<
typeof checkCompatibility<typeof compatibility>
> = {
status: 'enabled',
doMatch: { ce: true },
doNotMatch: {},
current: { hasuraPlan: { name: 'ce' } },
};
// Act
const result = checkCompatibility(compatibility, { hasuraPlan });
// Assert
expect(result).toEqual(expectedResult);
// Types testing
// @ts-expect-error The error is expected because here we are checking that the value reflects the runtime value
expect(result.doMatch.ee).toBe(undefined);
expect(result.doMatch).not.toHaveProperty('ee');
});
it('When passed with a EE plan with an active license, and the feature is enabled only in the EE plan with an active license, then return `enabled`', () => {
const date = new Date('2100-03-10T14:42:52.775Z');
// Arrange
const hasuraPlan: HasuraPlan = {
name: 'ee',
license: {
status: 'active',
expiresAt: date,
type: 'paid',
},
};
const compatibility = {
ce: 'disabled',
ee: {
withLicense: 'enabled',
withoutLicense: 'disabled',
},
} as const satisfies Compatibility;
const expectedResult: ReturnType<
typeof checkCompatibility<typeof compatibility>
> = {
status: 'enabled',
doMatch: { ee: { withLicense: true } },
doNotMatch: {},
current: {
hasuraPlan: {
license: {
expiresAt: date,
status: 'active',
type: 'paid',
},
name: 'ee',
},
},
};
// Act
const result = checkCompatibility(compatibility, { hasuraPlan });
// Assert
expect(result).toEqual(expectedResult);
});
});
});
describe('isFeatureEnabled', () => {
describe('Types testing', () => {
it('When passed with some `enabled` properties for the license, then the doMatch and doNotMatch should not contain extra properties', () => {
// This test is helpful to be sure the doMatch/doNotMatch types remain correct during the IsFeatureEnabled -> useIsFeatureEnabled -> checkCompatibility chain
// All the unit tests can be found above
render(
<StoreProvider>
<IsFeatureEnabled
// TODO: use a real feature
feature="unitTestActiveEeTrialFake"
ifDisabled={result => {
// Types testing
// @ts-expect-error The error is expected because here we are checking that the value reflects the runtime value
expect(result.doNotMatch.ee?.withoutLicense).toBe(undefined);
expect(result.doNotMatch.ee).not.toHaveProperty('withoutLicense');
return <div>Disabled</div>;
}}
>
<div>TODO:</div>
</IsFeatureEnabled>
</StoreProvider>
);
expect(screen.getByText('Disabled')).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,296 @@
import type { ReactElement } from 'react';
import type { PartialDeep } from 'type-fest';
import type { Feature } from './features';
import { type HasuraPlan, useHasuraPlan } from './store';
import type { ConditionalPickDeepCompatibilityProperties } from './types-utils';
import { features } from './features';
import {
checkCe,
checkEe,
isRunningCePlan,
isRunningEeWithLicensePlan,
isRunningEeWithoutLicensePlan,
} from './checkers';
// --------------------------------------------------
// TYPES
// --------------------------------------------------
type EnabledOrNot = 'enabled' | 'disabled';
export type Compatibility = {
ce: EnabledOrNot;
/**
* Please note that the EE plan has been also internally called:
* - "EE Lite
* - Pro Lite
* - EE Trial (EE Trial is a temporary state of EE, when the customers are using a Trial license.
* Use `ee` and specify if the feature needs a license or not)
*/
ee: {
/**
* If set to 'enabled', the feature is enabled when there is an active (trial, grace, or paid)
* license.
*/
withLicense: EnabledOrNot;
/**
* If set to 'enabled', the feature is enabled when there is not a license or when the license
* is expired.
*/
withoutLicense: EnabledOrNot;
};
// --------------------------------------------------
// UNSUPPORTED PLANS YET
/**
* The Cloud plan is not supported yet.
*/
cloud?: never;
/**
* The EE Classic plan is not supported yet.
*
* Please note that EE Classic has been internally called "Pro" for a long time.
*/
eeClassic?: never;
// --------------------------------------------------
/**
* The `unknown` state exits only for async reasons (the server type is retrieved form the
* /v1/version) and cannot be used to identify if a feature is enabled or not.
* Theoretically, if the plan is unknown, the Console should not show anything to the customers.
*/
unknown?: never;
};
/**
* Include all the properties (top level and deep levels) that are specified as enabled, but with the
* type `true`.
*
* @example
* type CeOnly = Match<{ce: 'enabled', ee: 'disabled'}>
* // ^? {ce: true}
* type CeAndEe = Match<{ce: 'enabled', ee: 'enabled'}>
* // ^? {ce: true, ee: true}
* type eeTrialOnly = Match<{ce: 'disabled', ee: {licenseType: {trial: 'enabled', paid: 'disabled' }}}>
* // ^? {ee: {licenseType: {trial: true}}}
* TODO: after the completion of the API (when ALL the environments are managed) check if this
* variable shape management is still needed or not
*/
export type Match<PASSED_COMPATIBILITY extends Compatibility> =
ConditionalPickDeepCompatibilityProperties<PASSED_COMPATIBILITY, 'enabled'>;
/**
* An internal type used to add as much type safety as possible to the checkCompatibility function.
* The final type received by the consumers will include ONLY the properties that are originally
* passed as 'enabled' in the compatibility object.
*/
export type ElaboratingMatch = PartialDeep<{
ce: true;
ee: {
withLicense: true;
withoutLicense: true;
};
}>;
type CompatibilityCheckResult<COMPATIBILITY extends Compatibility> = {
status: 'enabled' | 'disabled';
doMatch: PartialDeep<Match<COMPATIBILITY>>;
doNotMatch: PartialDeep<Match<COMPATIBILITY>>;
current: {
hasuraPlan: HasuraPlan;
};
};
// --------------------------------------------------
// CORE
// --------------------------------------------------
/**
* Check if a compatibility object matches the current state or not.
*
* The returned `doMatch` and `doNotMatch` objects only contains the properties that are
* marked has `enabled` in the compatibility object (ex. `{ce:'enabled'}` will result in
* doMatch/doNotMatch having only the `ce` property in them).
*
* The main goal of this function is to completely hide all the messy, boring, and error-prone logic
* of checking if a feature is enabled or not in all the existing combinations of plans/modes/server
* types, console types, etc.
*/
export function checkCompatibility<PASSED_COMPATIBILITY extends Compatibility>(
compatibility: PASSED_COMPATIBILITY,
currentState: {
hasuraPlan: HasuraPlan;
}
): CompatibilityCheckResult<PASSED_COMPATIBILITY> {
const doMatch: ElaboratingMatch = {};
const doNotMatch: ElaboratingMatch = {};
const passCeCheck = checkCe({
compatibility,
mutableDoMatch: doMatch,
mutableDoNotMatch: doNotMatch,
isRunningCe: isRunningCePlan(currentState),
});
const passEeCheck = checkEe({
compatibility,
mutableDoMatch: doMatch,
mutableDoNotMatch: doNotMatch,
isRunningEeWithLicense: isRunningEeWithLicensePlan(currentState),
isRunningEeWithoutLicense: isRunningEeWithoutLicensePlan(currentState),
});
// --------------------------------------------------
// --------------------------------------------------
// --------------------------------------------------
// TODO: Notes for when the server/cli mode will be implemented: a feature compatible with the
// mode but not enabled in one of the Hasura plans must then not be enabled!
if (passCeCheck || passEeCheck) {
return {
status: 'enabled',
// @ts-expect-error doMatch cannot be typed correctly inside this function because it
// depends on the literal object passed in the function call and cannot be determined here
doMatch,
// @ts-expect-error doNotMatch cannot be typed correctly inside this function because it
// depends on the literal object passed in the function call and cannot be determined here
doNotMatch,
current: currentState,
};
}
return {
status: 'disabled',
// @ts-expect-error doMatch cannot be typed correctly inside this function because it
// depends on the literal object passed in the function call and cannot be determined here
doMatch,
// @ts-expect-error doMatch cannot be typed correctly inside this function because it
// depends on the literal object passed in the function call and cannot be determined here
doNotMatch,
current: currentState,
};
}
// --------------------------------------------------
// REACT APIs
// --------------------------------------------------
/**
* Allow to check if a feature is enabled or not, returning an object that contains all the details
* about the compatibilities and incompatibilities with the current Hasura plan the Console is running.
*
* @example Simplest usage
* function Prometheus() {
* const { status } = useIsFeatureEnabled('prometheus');
*
* if(status === 'disabled') return null
*
* return <div>Enjoy Prometheus!</div>
* }
*
* @example Advanced usage: render something different when the feature is disabled
* function Prometheus() {
* const {
* status,
* doNotMatch,
* current: { hasuraPlan }
* } = useIsFeatureEnabled('prometheus');
*
* if(status === 'disabled') {
* if (doNotMatch.ee) {
* if(hasuraPlan.type === 'ce') {
* return <div>Try EE Lite and give all the paid feature a try for free!</div>
* }
*
* return <div>Prometheus is enabled for EE Lite only</div>
* }
* }
*
* return <div>Enjoy Prometheus!</div>
* }
*/
export function useIsFeatureEnabled<FEATURE extends Feature>(
featureName: FEATURE
) {
const hasuraPlan = useHasuraPlan();
const compatibility = features[featureName];
return checkCompatibility(compatibility, { hasuraPlan });
}
type IsFeatureEnabledProps<FEATURE extends Feature> = {
/**
* The name of the feature.
*/
feature: FEATURE;
/**
* The children to render when the feature is enabled.
*/
children: ReactElement;
/**
* A render function called when the feature is not enabled. It receives
* - all the details of the check, including all the matches that did not pass
* - the current state of the Hasura plan
*/
ifDisabled?: (
result: ReturnType<typeof useIsFeatureEnabled<FEATURE>>
) => ReactElement;
};
/**
* Render the passed children when a feature is enabled and accept a `ifDisabled` render function
* that receives an object containing all the details about the compatibilities and incompatibilities
* with the current Hasura plan the Console is running.
*
* @example Simplest usage
* function Prometheus() {
* return (
* <IsFeatureEnabled feature="prometheus">
* <div>Enjoy Prometheus!</div>
* </IsFeatureEnabled>
* );
* }
*
* @example Advanced usage: render something different when the feature is disabled
* function Prometheus() {
* return (
* <IsFeatureEnabled
* feature="prometheus"
* ifDisabled={(doNotMatch, current: { hasuraPlan }) => {
* if (doNotMatch.ee) {
* if(hasuraPlan.type === 'ce') {
* return <div>Try EE Lite and give all the paid feature a try for free!</div>
* }
*
* return <div>Prometheus is enabled for EE Lite only</div>
* }
* }}
* >
* <div>Enjoy Prometheus!</div>
* </IsFeatureEnabled>
* );
* }
*/
export function IsFeatureEnabled<FEATURE extends Feature>(
props: IsFeatureEnabledProps<FEATURE>
) {
const { feature: featureName, children, ifDisabled } = props;
const result = useIsFeatureEnabled(featureName);
if (result.status === 'enabled') {
return children;
}
return ifDisabled?.(result) ?? null;
}

View File

@ -0,0 +1,232 @@
import { z } from 'zod';
import React, { createContext } from 'react';
import { devtools } from 'zustand/middleware';
import { createStore, useStore as useZustandStore } from 'zustand';
// --------------------------------------------------
// TYPES
// --------------------------------------------------
export type State = {
hasuraPlan: HasuraPlan;
serverEnvVars: ServerEnvVars;
};
type Setters = {
setHasuraPlan: (hasuraPlan: HasuraPlan) => void;
setEeLicense: (eeLicense: EeLicense) => void;
setServerEnvVars: (envVars: ServerEnvVars) => void;
};
export type Store = State & Setters;
/**
* All the info about the current Hasura plan.
*
* If you are experienced with the window.__env usage, think of this object as a computed object
* that includes all the info the Console previously tried to gather from the various window.__env
* combinations.
*
* All the missing plans are not supported yet.
*/
export type HasuraPlan =
| {
// The plan info still needs to be fetched. This happens when
// - the result of the `version` API is not available yet
// - the plan is not managed yet by the feature-first API
name: 'unknown';
}
| {
name: 'ce';
}
| {
name: 'ee';
license:
| {
// The license info still need to be fetched.
status: 'unknown';
}
| {
// The license API failed to respond
status: 'licenseApiError';
}
| {
// Corresponds to the EELicenseInfo['status'] == 'none'.
status: 'missing';
}
| {
status: 'active';
expiresAt: Date;
type: EeLicenseType;
}
| {
status: 'grace';
expiresAt: Date;
type: EeLicenseType;
}
| {
status: 'expired';
expiredAt?: Date;
type: EeLicenseType;
}
| {
status: 'deactivated';
type: EeLicenseType;
};
}
| {
name: 'cloud';
}
| {
name: 'eeClassic';
};
type EeLicenseType = 'trial' | 'paid';
type EeLicense = Extract<HasuraPlan, { name: 'ee' }>['license'];
const serverEnvVarsSchema = z.object({
consoleType: z
.union([
z.literal('oss'),
z.literal('pro'),
z.literal('cloud'),
z.literal('pro-lite'),
z.literal('ee-classic'),
])
// The CLI web server does not pass the consoleType
.optional(),
});
export type ServerEnvVars = z.infer<typeof serverEnvVarsSchema>;
// --------------------------------------------------
// STORE
// --------------------------------------------------
const defaultState: State = {
hasuraPlan: { name: 'unknown' },
serverEnvVars: {},
};
function createNewStore() {
const consoleInfoStore = createStore<Store>()(
devtools(
set => ({
...defaultState,
setServerEnvVars: serverEnvVars =>
set(
prev => ({
...prev,
serverEnvVars,
}),
false,
{
type: 'setServerEnvVars',
serverEnvVars,
}
),
setHasuraPlan: hasuraPlan =>
set(
prev => ({
...prev,
hasuraPlan,
}),
false,
{
type: 'setHasuraPlan',
hasuraPlan,
}
),
setEeLicense: eeLicense =>
set(
prev => {
if (prev.hasuraPlan.name !== 'ee') {
console.error(
`The EE license cannot be set on a ${prev.hasuraPlan.name} plan`
);
return prev;
}
return {
...prev,
hasuraPlan: {
...prev.hasuraPlan,
license: eeLicense,
},
};
},
false,
{
type: 'setEeLicense',
eeLicense,
}
),
}),
// Assign a name to the store for debugging purposes
{ name: 'ConsoleInfoStore' }
)
);
return consoleInfoStore;
}
// --------------------------------------------------
// CONTEXT
// --------------------------------------------------
const useStoreValue = () => {
return createNewStore();
};
export type StoreContextType = ReturnType<typeof useStoreValue>;
const UseStore = createContext<StoreContextType | undefined>(undefined);
export const StoreProvider = ({ children }: { children: React.ReactNode }) => {
const value = useStoreValue();
return <UseStore.Provider value={value}>{children}</UseStore.Provider>;
};
export const MockStoreContextProvider = (
props: React.PropsWithChildren<{
params: StoreContextType;
}>
) => {
return <UseStore.Provider value={props.params} {...props} />;
};
export const useStore = () => {
const context = React.useContext(UseStore);
if (context === undefined) {
throw new Error('useCount must be used within a StoreProvider');
}
return context;
};
// --------------------------------------------------
// REACT APIS
// --------------------------------------------------
export function useHasuraPlan() {
const store = useStore();
return useZustandStore(store, state => state.hasuraPlan);
}
export function useSetHasuraPlan() {
const store = useStore();
return useZustandStore(store, state => state.setHasuraPlan);
}
export function useSetEeLicense() {
const store = useStore();
return useZustandStore(store, state => state.setEeLicense);
}
export function useSetServerEnvVars() {
const store = useStore();
return useZustandStore(store, state => state.setServerEnvVars);
}

View File

@ -0,0 +1,72 @@
import type { Opaque } from 'type-fest';
import type { IsEqual } from 'type-fest';
import type { ConditionalExcept } from 'type-fest';
// The following types come from types-fest' ConditionalPickDeep type. I ported them here because I
// needed to change the type of the properties that are not matching the condition (see the comment
// below).
type ConditionalPickDeepSymbol = Opaque<symbol, 'conditional-pick-deep-symbol'>;
type AssertCondition<
Type,
Condition,
Options extends ConditionalPickDeepOptions
> = Options['condition'] extends 'equality'
? IsEqual<Type, Condition>
: Type extends Condition
? true
: false;
type ConditionalPickDeepOptions = {
condition?: 'extends' | 'equality';
};
export type ConditionalPickDeepCompatibilityProperties<
Type,
Condition,
Options extends ConditionalPickDeepOptions = {}
> = ConditionalSimplifyDeep<
ConditionalExcept<
{
[Key in keyof Type]: AssertCondition<
Type[Key],
Condition,
Options
> extends true
? // --------------------------------------------------
// --------------------------------------------------
// This is the change compared to the types-fest original type. The original type allow to
// maintain the original value of the property. Instead, I need the property to be
// transformed to `true`
// ? Type[Key]
true // <-- changed to avoid getting the original type but the `true` type
: // --------------------------------------------------
// --------------------------------------------------
Type[Key] extends object
? ConditionalPickDeepCompatibilityProperties<
Type[Key],
Condition,
Options
>
: ConditionalPickDeepSymbol;
},
(ConditionalPickDeepSymbol | undefined) | Record<PropertyKey, never>
>
>;
type ConditionalSimplifyDeep<
Type,
ExcludeType = never,
IncludeType = unknown
> = Type extends ExcludeType
? Type
: Type extends IncludeType
? {
[TypeKey in keyof Type]: ConditionalSimplifyDeep<
Type[TypeKey],
ExcludeType,
IncludeType
>;
}
: Type;

View File

@ -136,7 +136,8 @@
"uuid": "8.3.2",
"valid-url": "1.0.9",
"xstate": "^4.30.1",
"zod": "3.20.2"
"zod": "3.20.2",
"zustand": "^4.3.6"
},
"devDependencies": {
"@babel/core": "7.12.13",
@ -318,6 +319,7 @@
"ts-jest": "29.0.5",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"type-fest": "^3.6.1",
"typescript": "4.9.5",
"unplugin-dynamic-asset-loader": "1.0.0",
"url-loader": "^4.1.1",
@ -26029,6 +26031,18 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-escapes/node_modules/type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"dev": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/ansi-html-community": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ansi-html-community/-/ansi-html-community-0.0.8.tgz",
@ -62854,12 +62868,12 @@
}
},
"node_modules/type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.6.1.tgz",
"integrity": "sha512-htXWckxlT6U4+ilVgweNliPqlsVSSucbxVexRYllyMVJDtf5rTjv6kF/s+qAd4QSL1BZcnJPEJavYBPQiWuZDA==",
"dev": true,
"engines": {
"node": ">=10"
"node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
@ -65424,6 +65438,29 @@
"url": "https://github.com/sponsors/colinhacks"
}
},
"node_modules/zustand": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.6.tgz",
"integrity": "sha512-6J5zDxjxLE+yukC2XZWf/IyWVKnXT9b9HUv09VJ/bwGCpKNcaTqp7Ws28Xr8jnbvnZcdRaidztAPsXFBIqufiw==",
"dependencies": {
"use-sync-external-store": "1.2.0"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"immer": ">=9.0",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/zwitch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",
@ -85288,6 +85325,14 @@
"dev": true,
"requires": {
"type-fest": "^0.21.3"
},
"dependencies": {
"type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"dev": true
}
}
},
"ansi-html-community": {
@ -113665,9 +113710,9 @@
"dev": true
},
"type-fest": {
"version": "0.21.3",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz",
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==",
"version": "3.6.1",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.6.1.tgz",
"integrity": "sha512-htXWckxlT6U4+ilVgweNliPqlsVSSucbxVexRYllyMVJDtf5rTjv6kF/s+qAd4QSL1BZcnJPEJavYBPQiWuZDA==",
"dev": true
},
"type-is": {
@ -115626,6 +115671,14 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-3.20.2.tgz",
"integrity": "sha512-1MzNQdAvO+54H+EaK5YpyEy0T+Ejo/7YLHS93G3RnYWh5gaotGHwGeN/ZO687qEDU2y4CdStQYXVHIgrUl5UVQ=="
},
"zustand": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.3.6.tgz",
"integrity": "sha512-6J5zDxjxLE+yukC2XZWf/IyWVKnXT9b9HUv09VJ/bwGCpKNcaTqp7Ws28Xr8jnbvnZcdRaidztAPsXFBIqufiw==",
"requires": {
"use-sync-external-store": "1.2.0"
}
},
"zwitch": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz",

View File

@ -174,7 +174,8 @@
"uuid": "8.3.2",
"valid-url": "1.0.9",
"xstate": "^4.30.1",
"zod": "3.20.2"
"zod": "3.20.2",
"zustand": "^4.3.6"
},
"devDependencies": {
"@babel/core": "7.12.13",
@ -356,6 +357,7 @@
"ts-jest": "29.0.5",
"ts-node": "10.9.1",
"tslib": "^2.3.0",
"type-fest": "^3.6.1",
"typescript": "4.9.5",
"unplugin-dynamic-asset-loader": "1.0.0",
"url-loader": "^4.1.1",