mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-14 17:02:49 +03:00
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:
parent
40921f20bc
commit
2359322feb
@ -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.
|
@ -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.
|
@ -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;
|
||||
}
|
@ -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;
|
@ -0,0 +1,3 @@
|
||||
export { IsFeatureEnabled, useIsFeatureEnabled } from './isFeatureEnabled';
|
||||
|
||||
export { useHasuraPlan } from './store';
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
@ -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;
|
||||
}
|
@ -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);
|
||||
}
|
@ -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;
|
69
frontend/package-lock.json
generated
69
frontend/package-lock.json
generated
@ -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",
|
||||
|
@ -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",
|
||||
|
Loading…
Reference in New Issue
Block a user