mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: message banner (variants) (#3788)
-
https://linear.app/unleash/issue/2-546/fetch-the-payload-from-a-real-feature-flag
-
https://linear.app/unleash/issue/2-547/adapt-ui-to-use-the-feature-flag-information-were-fetching
Tackles the 2 tasks above.
Adapts our `FlagResolver` logic to support variants, so we can use them
for our message banner project but also anything else in the future.
Also adapts MessageBanner to the new logic.
- Add support for variants in `FlagResolver`;
- Adapt `MessageBanner` to a variants flag;
- Adds `sticky` support for the `MessageBanner`;
- Adds our first variants flag to `uiConfig` and `experimental`:
`messageBanner`;
- Adds a `variant-flag-schema` to make it easy to represent the variant
output that we specify in `uiConfig`;
- Adapts `experimental` to be able to represent default variants while
still maintaining type safety;
- Adds helpers to make it easy to use variants in our project, such as
`getVariantValue` and the `useVariant` hook;
- Adapts and adds new tests in `flag-resolver.test.ts`;
### Notes
- ~~The `as PayloadType` assertions need
https://github.com/Unleash/unleash-client-node/pull/454 since it
includes https://github.com/Unleash/unleash-client-node/pull/452~~
(50ccf60893
);
- ~~Enterprise needs a PR that will follow soon~~;
![image](https://github.com/Unleash/unleash/assets/14320932/034ff64f-3020-4ed0-863b-ed1fd9190430)
This commit is contained in:
parent
2487b990bd
commit
db61a8a40c
@ -6,23 +6,30 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
|||||||
import { MessageBannerDialog } from './MessageBannerDialog/MessageBannerDialog';
|
import { MessageBannerDialog } from './MessageBannerDialog/MessageBannerDialog';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import ReactMarkdown from 'react-markdown';
|
import ReactMarkdown from 'react-markdown';
|
||||||
|
import { useVariant } from 'hooks/useVariant';
|
||||||
|
|
||||||
const StyledBar = styled('aside', {
|
const StyledBar = styled('aside', {
|
||||||
shouldForwardProp: prop => prop !== 'variant',
|
shouldForwardProp: prop => prop !== 'variant' && prop !== 'sticky',
|
||||||
})<{ variant: BannerVariant }>(({ theme, variant }) => ({
|
})<{ variant: BannerVariant; sticky?: boolean }>(
|
||||||
position: 'relative',
|
({ theme, variant, sticky }) => ({
|
||||||
zIndex: 1,
|
position: sticky ? 'sticky' : 'relative',
|
||||||
display: 'flex',
|
zIndex: 1,
|
||||||
alignItems: 'center',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
alignItems: 'center',
|
||||||
padding: theme.spacing(1),
|
justifyContent: 'center',
|
||||||
gap: theme.spacing(1),
|
padding: theme.spacing(1),
|
||||||
borderBottom: '1px solid',
|
gap: theme.spacing(1),
|
||||||
borderColor: theme.palette[variant].border,
|
borderBottom: '1px solid',
|
||||||
background: theme.palette[variant].light,
|
borderColor: theme.palette[variant].border,
|
||||||
color: theme.palette[variant].dark,
|
background: theme.palette[variant].light,
|
||||||
fontSize: theme.fontSizes.smallBody,
|
color: theme.palette[variant].dark,
|
||||||
}));
|
fontSize: theme.fontSizes.smallBody,
|
||||||
|
...(sticky && {
|
||||||
|
top: 0,
|
||||||
|
zIndex: theme.zIndex.sticky,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const StyledIcon = styled('div', {
|
const StyledIcon = styled('div', {
|
||||||
shouldForwardProp: prop => prop !== 'variant',
|
shouldForwardProp: prop => prop !== 'variant',
|
||||||
@ -44,6 +51,7 @@ interface IMessageFlag {
|
|||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
message: string;
|
message: string;
|
||||||
variant?: BannerVariant;
|
variant?: BannerVariant;
|
||||||
|
sticky?: boolean;
|
||||||
icon?: string;
|
icon?: string;
|
||||||
link?: string;
|
link?: string;
|
||||||
linkText?: string;
|
linkText?: string;
|
||||||
@ -52,61 +60,30 @@ interface IMessageFlag {
|
|||||||
dialog?: string;
|
dialog?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Grab a real feature flag instead
|
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
||||||
const mockFlag: IMessageFlag = {
|
|
||||||
enabled: true,
|
|
||||||
message:
|
|
||||||
'**Heads up!** It seems like one of your client instances might be misbehaving.',
|
|
||||||
variant: 'warning',
|
|
||||||
link: '/admin/network',
|
|
||||||
linkText: 'View Network',
|
|
||||||
plausibleEvent: 'network_warning',
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockFlag2: IMessageFlag = {
|
|
||||||
enabled: true,
|
|
||||||
message:
|
|
||||||
'**Unleash v5 is finally here!** Check out what changed in the newest major release.',
|
|
||||||
variant: 'secondary',
|
|
||||||
link: 'dialog',
|
|
||||||
linkText: "What's new?",
|
|
||||||
plausibleEvent: 'change_log_v5',
|
|
||||||
dialog: `![Unleash v5](https://www.getunleash.io/logos/unleash_pos.svg)
|
|
||||||
## Unleash v5 🎉
|
|
||||||
**Unleash v5 is finally here!**
|
|
||||||
|
|
||||||
Check out what changed in the newest major release:
|
|
||||||
|
|
||||||
- An Amazing Feature
|
|
||||||
- Another Amazing Feature
|
|
||||||
- We'll save the best for last
|
|
||||||
- And the best is...
|
|
||||||
- **Unleash v5 is finally here!**
|
|
||||||
|
|
||||||
You can read more about it on our newest [blog post](https://www.getunleash.io/blog).`,
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MessageBanner = () => {
|
export const MessageBanner = () => {
|
||||||
const { uiConfig } = useUiConfig();
|
const { uiConfig } = useUiConfig();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const messageBanner = useVariant<IMessageFlag>(
|
||||||
|
uiConfig.flags.messageBanner
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!messageBanner) return null;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
enabled,
|
|
||||||
message,
|
message,
|
||||||
variant = 'neutral',
|
variant = 'neutral',
|
||||||
|
sticky,
|
||||||
icon,
|
icon,
|
||||||
link,
|
link,
|
||||||
linkText = 'More info',
|
linkText = 'More info',
|
||||||
plausibleEvent,
|
plausibleEvent,
|
||||||
dialogTitle,
|
dialogTitle,
|
||||||
dialog,
|
dialog,
|
||||||
} = { ...mockFlag2, enabled: uiConfig.flags.messageBanner };
|
} = messageBanner;
|
||||||
|
|
||||||
if (!enabled) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledBar variant={variant}>
|
<StyledBar variant={variant} sticky={sticky}>
|
||||||
<StyledIcon variant={variant}>
|
<StyledIcon variant={variant}>
|
||||||
<BannerIcon icon={icon} variant={variant} />
|
<BannerIcon icon={icon} variant={variant} />
|
||||||
</StyledIcon>
|
</StyledIcon>
|
||||||
|
10
frontend/src/hooks/useVariant.ts
Normal file
10
frontend/src/hooks/useVariant.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Variant, getVariantValue } from 'utils/variants';
|
||||||
|
|
||||||
|
export const useVariant = <T = string>(variant?: Variant) => {
|
||||||
|
return useMemo(() => {
|
||||||
|
if (variant?.enabled) {
|
||||||
|
return getVariantValue<T>(variant);
|
||||||
|
}
|
||||||
|
}, [variant]);
|
||||||
|
};
|
@ -1,4 +1,5 @@
|
|||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
import { Variant } from 'utils/variants';
|
||||||
|
|
||||||
export interface IUiConfig {
|
export interface IUiConfig {
|
||||||
authenticationType?: string;
|
authenticationType?: string;
|
||||||
@ -38,7 +39,7 @@ export interface IFlags {
|
|||||||
UG?: boolean;
|
UG?: boolean;
|
||||||
embedProxyFrontend?: boolean;
|
embedProxyFrontend?: boolean;
|
||||||
maintenanceMode?: boolean;
|
maintenanceMode?: boolean;
|
||||||
messageBanner?: boolean;
|
messageBanner?: Variant;
|
||||||
featuresExportImport?: boolean;
|
featuresExportImport?: boolean;
|
||||||
caseInsensitiveInOperators?: boolean;
|
caseInsensitiveInOperators?: boolean;
|
||||||
proPlanAutoCharge?: boolean;
|
proPlanAutoCharge?: boolean;
|
||||||
|
28
frontend/src/utils/variants.ts
Normal file
28
frontend/src/utils/variants.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
export enum PayloadType {
|
||||||
|
STRING = 'string',
|
||||||
|
JSON = 'json',
|
||||||
|
CSV = 'csv',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Payload {
|
||||||
|
type: PayloadType;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Variant {
|
||||||
|
name: string;
|
||||||
|
enabled: boolean;
|
||||||
|
payload?: Payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getVariantValue = <T = string>(
|
||||||
|
variant: Variant | undefined
|
||||||
|
): T | undefined => {
|
||||||
|
if (variant?.payload !== undefined) {
|
||||||
|
if (variant.payload.type === PayloadType.JSON) {
|
||||||
|
return JSON.parse(variant.payload.value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return variant.payload.value as T;
|
||||||
|
}
|
||||||
|
};
|
@ -146,7 +146,7 @@
|
|||||||
"stoppable": "^1.1.0",
|
"stoppable": "^1.1.0",
|
||||||
"ts-toolbelt": "^9.6.0",
|
"ts-toolbelt": "^9.6.0",
|
||||||
"type-is": "^1.6.18",
|
"type-is": "^1.6.18",
|
||||||
"unleash-client": "3.18.1",
|
"unleash-client": "3.20.0",
|
||||||
"uuid": "^9.0.0"
|
"uuid": "^9.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
@ -63,6 +63,7 @@ exports[`should create default config 1`] = `
|
|||||||
},
|
},
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"externalResolver": {
|
"externalResolver": {
|
||||||
|
"getVariant": [Function],
|
||||||
"isEnabled": [Function],
|
"isEnabled": [Function],
|
||||||
},
|
},
|
||||||
"flags": {
|
"flags": {
|
||||||
@ -76,7 +77,14 @@ exports[`should create default config 1`] = `
|
|||||||
"googleAuthEnabled": false,
|
"googleAuthEnabled": false,
|
||||||
"groupRootRoles": false,
|
"groupRootRoles": false,
|
||||||
"maintenanceMode": false,
|
"maintenanceMode": false,
|
||||||
"messageBanner": false,
|
"messageBanner": {
|
||||||
|
"enabled": false,
|
||||||
|
"name": "message-banner",
|
||||||
|
"payload": {
|
||||||
|
"type": "json",
|
||||||
|
"value": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
"migrationLock": false,
|
"migrationLock": false,
|
||||||
"personalAccessTokensKillSwitch": false,
|
"personalAccessTokensKillSwitch": false,
|
||||||
"proPlanAutoCharge": false,
|
"proPlanAutoCharge": false,
|
||||||
@ -98,7 +106,14 @@ exports[`should create default config 1`] = `
|
|||||||
"googleAuthEnabled": false,
|
"googleAuthEnabled": false,
|
||||||
"groupRootRoles": false,
|
"groupRootRoles": false,
|
||||||
"maintenanceMode": false,
|
"maintenanceMode": false,
|
||||||
"messageBanner": false,
|
"messageBanner": {
|
||||||
|
"enabled": false,
|
||||||
|
"name": "message-banner",
|
||||||
|
"payload": {
|
||||||
|
"type": "json",
|
||||||
|
"value": "",
|
||||||
|
},
|
||||||
|
},
|
||||||
"migrationLock": false,
|
"migrationLock": false,
|
||||||
"personalAccessTokensKillSwitch": false,
|
"personalAccessTokensKillSwitch": false,
|
||||||
"proPlanAutoCharge": false,
|
"proPlanAutoCharge": false,
|
||||||
@ -108,6 +123,7 @@ exports[`should create default config 1`] = `
|
|||||||
"variantMetrics": false,
|
"variantMetrics": false,
|
||||||
},
|
},
|
||||||
"externalResolver": {
|
"externalResolver": {
|
||||||
|
"getVariant": [Function],
|
||||||
"isEnabled": [Function],
|
"isEnabled": [Function],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -134,6 +134,7 @@ import {
|
|||||||
validatePasswordSchema,
|
validatePasswordSchema,
|
||||||
validateTagTypeSchema,
|
validateTagTypeSchema,
|
||||||
variantSchema,
|
variantSchema,
|
||||||
|
variantFlagSchema,
|
||||||
variantsSchema,
|
variantsSchema,
|
||||||
versionSchema,
|
versionSchema,
|
||||||
} from './spec';
|
} from './spec';
|
||||||
@ -319,6 +320,7 @@ export const schemas: UnleashSchemas = {
|
|||||||
validatePasswordSchema,
|
validatePasswordSchema,
|
||||||
validateTagTypeSchema,
|
validateTagTypeSchema,
|
||||||
variantSchema,
|
variantSchema,
|
||||||
|
variantFlagSchema,
|
||||||
variantsSchema,
|
variantsSchema,
|
||||||
versionSchema,
|
versionSchema,
|
||||||
projectOverviewSchema,
|
projectOverviewSchema,
|
||||||
|
@ -168,6 +168,7 @@ const metaRules: Rule[] = [
|
|||||||
'validatePasswordSchema',
|
'validatePasswordSchema',
|
||||||
'validateTagTypeSchema',
|
'validateTagTypeSchema',
|
||||||
'variantSchema',
|
'variantSchema',
|
||||||
|
'variantFlagSchema',
|
||||||
'versionSchema',
|
'versionSchema',
|
||||||
'projectOverviewSchema',
|
'projectOverviewSchema',
|
||||||
'importTogglesSchema',
|
'importTogglesSchema',
|
||||||
@ -277,6 +278,7 @@ const metaRules: Rule[] = [
|
|||||||
'validatePasswordSchema',
|
'validatePasswordSchema',
|
||||||
'validateTagTypeSchema',
|
'validateTagTypeSchema',
|
||||||
'variantSchema',
|
'variantSchema',
|
||||||
|
'variantFlagSchema',
|
||||||
'variantsSchema',
|
'variantsSchema',
|
||||||
'versionSchema',
|
'versionSchema',
|
||||||
'importTogglesSchema',
|
'importTogglesSchema',
|
||||||
|
@ -27,6 +27,7 @@ export * from './profile-schema';
|
|||||||
export * from './project-schema';
|
export * from './project-schema';
|
||||||
export * from './segment-schema';
|
export * from './segment-schema';
|
||||||
export * from './variant-schema';
|
export * from './variant-schema';
|
||||||
|
export * from './variant-flag-schema';
|
||||||
export * from './version-schema';
|
export * from './version-schema';
|
||||||
export * from './features-schema';
|
export * from './features-schema';
|
||||||
export * from './feedback-schema';
|
export * from './feedback-schema';
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
import { PayloadType } from 'unleash-client';
|
||||||
|
|
||||||
export const proxyFeatureSchema = {
|
export const proxyFeatureSchema = {
|
||||||
$id: '#/components/schemas/proxyFeatureSchema',
|
$id: '#/components/schemas/proxyFeatureSchema',
|
||||||
@ -31,7 +32,10 @@ export const proxyFeatureSchema = {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
required: ['type', 'value'],
|
required: ['type', 'value'],
|
||||||
properties: {
|
properties: {
|
||||||
type: { type: 'string', enum: ['string'] },
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: Object.values(PayloadType),
|
||||||
|
},
|
||||||
value: { type: 'string' },
|
value: { type: 'string' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { FromSchema } from 'json-schema-to-ts';
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
import { versionSchema } from './version-schema';
|
import { versionSchema } from './version-schema';
|
||||||
|
import { variantFlagSchema } from './variant-flag-schema';
|
||||||
|
|
||||||
export const uiConfigSchema = {
|
export const uiConfigSchema = {
|
||||||
$id: '#/components/schemas/uiConfigSchema',
|
$id: '#/components/schemas/uiConfigSchema',
|
||||||
@ -52,7 +53,14 @@ export const uiConfigSchema = {
|
|||||||
flags: {
|
flags: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: {
|
additionalProperties: {
|
||||||
type: 'boolean',
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$ref: '#/components/schemas/variantFlagSchema',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
links: {
|
links: {
|
||||||
@ -79,6 +87,7 @@ export const uiConfigSchema = {
|
|||||||
components: {
|
components: {
|
||||||
schemas: {
|
schemas: {
|
||||||
versionSchema,
|
versionSchema,
|
||||||
|
variantFlagSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
30
src/lib/openapi/spec/variant-flag-schema.ts
Normal file
30
src/lib/openapi/spec/variant-flag-schema.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const variantFlagSchema = {
|
||||||
|
$id: '#/components/schemas/variantFlagSchema',
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
name: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
enabled: {
|
||||||
|
type: 'boolean',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
type: 'object',
|
||||||
|
additionalProperties: false,
|
||||||
|
properties: {
|
||||||
|
type: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
value: {
|
||||||
|
type: 'string',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type VariantFlagSchema = FromSchema<typeof variantFlagSchema>;
|
@ -1,9 +1,29 @@
|
|||||||
|
import { Variant, PayloadType } from 'unleash-client';
|
||||||
import { parseEnvVarBoolean } from '../util';
|
import { parseEnvVarBoolean } from '../util';
|
||||||
|
|
||||||
export type IFlags = Partial<typeof flags>;
|
export type IFlagKey =
|
||||||
export type IFlagKey = keyof IFlags;
|
| 'anonymiseEventLog'
|
||||||
|
| 'embedProxy'
|
||||||
|
| 'embedProxyFrontend'
|
||||||
|
| 'responseTimeWithAppNameKillSwitch'
|
||||||
|
| 'maintenanceMode'
|
||||||
|
| 'messageBanner'
|
||||||
|
| 'featuresExportImport'
|
||||||
|
| 'caseInsensitiveInOperators'
|
||||||
|
| 'strictSchemaValidation'
|
||||||
|
| 'proPlanAutoCharge'
|
||||||
|
| 'personalAccessTokensKillSwitch'
|
||||||
|
| 'cleanClientApi'
|
||||||
|
| 'groupRootRoles'
|
||||||
|
| 'migrationLock'
|
||||||
|
| 'demo'
|
||||||
|
| 'strategyImprovements'
|
||||||
|
| 'googleAuthEnabled'
|
||||||
|
| 'variantMetrics';
|
||||||
|
|
||||||
const flags = {
|
export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
|
||||||
|
|
||||||
|
const flags: IFlags = {
|
||||||
anonymiseEventLog: false,
|
anonymiseEventLog: false,
|
||||||
embedProxy: parseEnvVarBoolean(
|
embedProxy: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
|
||||||
@ -21,10 +41,18 @@ const flags = {
|
|||||||
process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE_MODE,
|
process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE_MODE,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
messageBanner: parseEnvVarBoolean(
|
messageBanner: {
|
||||||
process.env.UNLEASH_EXPERIMENTAL_MESSAGE_BANNER,
|
name: 'message-banner',
|
||||||
false,
|
enabled: parseEnvVarBoolean(
|
||||||
),
|
process.env.UNLEASH_EXPERIMENTAL_MESSAGE_BANNER,
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
payload: {
|
||||||
|
type: PayloadType.JSON,
|
||||||
|
value:
|
||||||
|
process.env.UNLEASH_EXPERIMENTAL_MESSAGE_BANNER_PAYLOAD ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
featuresExportImport: parseEnvVarBoolean(
|
featuresExportImport: parseEnvVarBoolean(
|
||||||
process.env.UNLEASH_EXPERIMENTAL_FEATURES_EXPORT_IMPORT,
|
process.env.UNLEASH_EXPERIMENTAL_FEATURES_EXPORT_IMPORT,
|
||||||
true,
|
true,
|
||||||
@ -68,7 +96,10 @@ const flags = {
|
|||||||
|
|
||||||
export const defaultExperimentalOptions: IExperimentalOptions = {
|
export const defaultExperimentalOptions: IExperimentalOptions = {
|
||||||
flags,
|
flags,
|
||||||
externalResolver: { isEnabled: (): boolean => false },
|
externalResolver: {
|
||||||
|
isEnabled: (): boolean => false,
|
||||||
|
getVariant: () => undefined,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface IExperimentalOptions {
|
export interface IExperimentalOptions {
|
||||||
@ -83,8 +114,16 @@ export interface IFlagContext {
|
|||||||
export interface IFlagResolver {
|
export interface IFlagResolver {
|
||||||
getAll: (context?: IFlagContext) => IFlags;
|
getAll: (context?: IFlagContext) => IFlags;
|
||||||
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
|
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
|
||||||
|
getVariant: (
|
||||||
|
expName: IFlagKey,
|
||||||
|
context?: IFlagContext,
|
||||||
|
) => Variant | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IExternalFlagResolver {
|
export interface IExternalFlagResolver {
|
||||||
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
|
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
|
||||||
|
getVariant: (
|
||||||
|
flagName: IFlagKey,
|
||||||
|
context?: IFlagContext,
|
||||||
|
) => Variant | undefined;
|
||||||
}
|
}
|
||||||
|
@ -3,10 +3,7 @@ import { Context } from './context';
|
|||||||
import { FeatureInterface } from './feature';
|
import { FeatureInterface } from './feature';
|
||||||
import normalizedValue from './strategy/util';
|
import normalizedValue from './strategy/util';
|
||||||
import { resolveContextValue } from './helpers';
|
import { resolveContextValue } from './helpers';
|
||||||
|
import { PayloadType } from 'unleash-client';
|
||||||
enum PayloadType {
|
|
||||||
STRING = 'string',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Override {
|
interface Override {
|
||||||
contextName: string;
|
contextName: string;
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
|
import { PayloadType } from 'unleash-client';
|
||||||
import { defaultExperimentalOptions, IFlagKey } from '../types/experimental';
|
import { defaultExperimentalOptions, IFlagKey } from '../types/experimental';
|
||||||
import FlagResolver from './flag-resolver';
|
import FlagResolver, { getVariantValue } from './flag-resolver';
|
||||||
import { IExperimentalOptions } from '../types/experimental';
|
import { IExperimentalOptions } from '../types/experimental';
|
||||||
|
|
||||||
test('should produce empty exposed flags', () => {
|
test('should produce empty exposed flags', () => {
|
||||||
@ -29,6 +30,7 @@ test('should use external resolver for dynamic flags', () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getVariant: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -48,6 +50,7 @@ test('should not use external resolver for enabled experiments', () => {
|
|||||||
isEnabled: () => {
|
isEnabled: () => {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
getVariant: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -67,6 +70,7 @@ test('should load experimental flags', () => {
|
|||||||
isEnabled: () => {
|
isEnabled: () => {
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
|
getVariant: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -87,6 +91,7 @@ test('should load experimental flags from external provider', () => {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
getVariant: () => undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
@ -99,3 +104,60 @@ test('should load experimental flags from external provider', () => {
|
|||||||
expect(resolver.isEnabled('someFlag' as IFlagKey)).toBe(true);
|
expect(resolver.isEnabled('someFlag' as IFlagKey)).toBe(true);
|
||||||
expect(resolver.isEnabled('extraFlag' as IFlagKey)).toBe(true);
|
expect(resolver.isEnabled('extraFlag' as IFlagKey)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should support variant flags', () => {
|
||||||
|
const variant = {
|
||||||
|
enabled: true,
|
||||||
|
name: 'variant',
|
||||||
|
payload: {
|
||||||
|
type: PayloadType.STRING,
|
||||||
|
value: 'variant-A',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const externalResolver = {
|
||||||
|
isEnabled: () => true,
|
||||||
|
getVariant: (name: string) => {
|
||||||
|
if (name === 'extraFlag') {
|
||||||
|
return variant;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
flags: { extraFlag: undefined, someFlag: true, otherflag: false },
|
||||||
|
externalResolver,
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolver = new FlagResolver(config as IExperimentalOptions);
|
||||||
|
|
||||||
|
expect(resolver.getVariant('someFlag' as IFlagKey)).toBe(undefined);
|
||||||
|
expect(resolver.getVariant('otherFlag' as IFlagKey)).toBe(undefined);
|
||||||
|
expect(resolver.getVariant('extraFlag' as IFlagKey)).toStrictEqual(variant);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should expose an helper to get variant value', () => {
|
||||||
|
expect(
|
||||||
|
getVariantValue({
|
||||||
|
enabled: true,
|
||||||
|
name: 'variant',
|
||||||
|
payload: {
|
||||||
|
type: PayloadType.STRING,
|
||||||
|
value: 'variant-A',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toBe('variant-A');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getVariantValue({
|
||||||
|
enabled: true,
|
||||||
|
name: 'variant',
|
||||||
|
payload: {
|
||||||
|
type: PayloadType.JSON,
|
||||||
|
value: `{"foo": "bar"}`,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toStrictEqual({
|
||||||
|
foo: 'bar',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { Variant, PayloadType } from 'unleash-client';
|
||||||
import {
|
import {
|
||||||
IExperimentalOptions,
|
IExperimentalOptions,
|
||||||
IExternalFlagResolver,
|
IExternalFlagResolver,
|
||||||
@ -6,6 +7,7 @@ import {
|
|||||||
IFlagResolver,
|
IFlagResolver,
|
||||||
IFlagKey,
|
IFlagKey,
|
||||||
} from '../types/experimental';
|
} from '../types/experimental';
|
||||||
|
|
||||||
export default class FlagResolver implements IFlagResolver {
|
export default class FlagResolver implements IFlagResolver {
|
||||||
private experiments: IFlags;
|
private experiments: IFlags;
|
||||||
|
|
||||||
@ -20,20 +22,51 @@ export default class FlagResolver implements IFlagResolver {
|
|||||||
const flags: IFlags = { ...this.experiments };
|
const flags: IFlags = { ...this.experiments };
|
||||||
|
|
||||||
Object.keys(flags).forEach((flagName: IFlagKey) => {
|
Object.keys(flags).forEach((flagName: IFlagKey) => {
|
||||||
if (!this.experiments[flagName])
|
if (!this.experiments[flagName]) {
|
||||||
flags[flagName] = this.externalResolver.isEnabled(
|
if (typeof flags[flagName] === 'boolean') {
|
||||||
flagName,
|
flags[flagName] = this.externalResolver.isEnabled(
|
||||||
context,
|
flagName,
|
||||||
);
|
context,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
flags[flagName] = this.externalResolver.getVariant(
|
||||||
|
flagName,
|
||||||
|
context,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return flags;
|
return flags;
|
||||||
}
|
}
|
||||||
|
|
||||||
isEnabled(expName: IFlagKey, context?: IFlagContext): boolean {
|
isEnabled(expName: IFlagKey, context?: IFlagContext): boolean {
|
||||||
if (this.experiments[expName]) {
|
const exp = this.experiments[expName];
|
||||||
return true;
|
if (exp) {
|
||||||
|
if (typeof exp === 'boolean') return exp;
|
||||||
|
else return exp.enabled;
|
||||||
}
|
}
|
||||||
return this.externalResolver.isEnabled(expName, context);
|
return this.externalResolver.isEnabled(expName, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getVariant(expName: IFlagKey, context?: IFlagContext): Variant | undefined {
|
||||||
|
const exp = this.experiments[expName];
|
||||||
|
if (exp) {
|
||||||
|
if (typeof exp === 'boolean') return undefined;
|
||||||
|
else return exp;
|
||||||
|
}
|
||||||
|
return this.externalResolver.getVariant(expName, context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const getVariantValue = <T = string>(
|
||||||
|
variant: Variant | undefined,
|
||||||
|
): T | undefined => {
|
||||||
|
if (variant?.payload !== undefined) {
|
||||||
|
if (variant.payload.type === PayloadType.JSON) {
|
||||||
|
return JSON.parse(variant.payload.value) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return variant.payload.value as T;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
@ -6,10 +6,7 @@ import { ISegment } from 'lib/types/model';
|
|||||||
import { serializeDates } from '../../lib/types/serialize-dates';
|
import { serializeDates } from '../../lib/types/serialize-dates';
|
||||||
import { Operator } from './feature-evaluator/constraint';
|
import { Operator } from './feature-evaluator/constraint';
|
||||||
import { FeatureInterface } from 'unleash-client/lib/feature';
|
import { FeatureInterface } from 'unleash-client/lib/feature';
|
||||||
|
import { PayloadType } from 'unleash-client';
|
||||||
enum PayloadType {
|
|
||||||
STRING = 'string',
|
|
||||||
}
|
|
||||||
|
|
||||||
type NonEmptyList<T> = [T, ...T[]];
|
type NonEmptyList<T> = [T, ...T[]];
|
||||||
|
|
||||||
@ -24,7 +21,7 @@ export const mapFeaturesForClient = (
|
|||||||
...variant,
|
...variant,
|
||||||
payload: variant.payload && {
|
payload: variant.payload && {
|
||||||
...variant.payload,
|
...variant.payload,
|
||||||
type: variant.payload.type as unknown as PayloadType,
|
type: variant.payload.type as PayloadType,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
project: feature.project,
|
project: feature.project,
|
||||||
|
@ -2,6 +2,7 @@ import { start } from './lib/server-impl';
|
|||||||
import { createConfig } from './lib/create-config';
|
import { createConfig } from './lib/create-config';
|
||||||
import { LogLevel } from './lib/logger';
|
import { LogLevel } from './lib/logger';
|
||||||
import { ApiTokenType } from './lib/types/models/api-token';
|
import { ApiTokenType } from './lib/types/models/api-token';
|
||||||
|
import { PayloadType } from 'unleash-client';
|
||||||
|
|
||||||
process.nextTick(async () => {
|
process.nextTick(async () => {
|
||||||
try {
|
try {
|
||||||
@ -40,6 +41,23 @@ process.nextTick(async () => {
|
|||||||
responseTimeWithAppNameKillSwitch: false,
|
responseTimeWithAppNameKillSwitch: false,
|
||||||
variantMetrics: true,
|
variantMetrics: true,
|
||||||
strategyImprovements: true,
|
strategyImprovements: true,
|
||||||
|
messageBanner: {
|
||||||
|
name: 'message-banner',
|
||||||
|
enabled: true,
|
||||||
|
payload: {
|
||||||
|
type: PayloadType.JSON,
|
||||||
|
value: `{
|
||||||
|
"message": "**New message banner!** Check out this new feature.",
|
||||||
|
"variant": "secondary",
|
||||||
|
"sticky": true,
|
||||||
|
"link": "dialog",
|
||||||
|
"linkText": "What is this?",
|
||||||
|
"plausibleEvent": "message_banner",
|
||||||
|
"dialog": "![Message Banner](https://www.getunleash.io/logos/unleash_pos.svg)\\n## Message Banner 🎉\\n**New message banner!**\\n\\nCheck out this new feature:\\n\\n- Get the latest announcements\\n- Get warnings about your Unleash instance\\n\\nYou can read more about it on our newest [blog post](https://www.getunleash.io/blog).",
|
||||||
|
"icon": "none"
|
||||||
|
}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
authentication: {
|
authentication: {
|
||||||
|
@ -3947,6 +3947,8 @@ Stats are divided into current and previous **windows**.
|
|||||||
"type": {
|
"type": {
|
||||||
"enum": [
|
"enum": [
|
||||||
"string",
|
"string",
|
||||||
|
"json",
|
||||||
|
"csv",
|
||||||
],
|
],
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
@ -4823,7 +4825,14 @@ Stats are divided into current and previous **windows**.
|
|||||||
},
|
},
|
||||||
"flags": {
|
"flags": {
|
||||||
"additionalProperties": {
|
"additionalProperties": {
|
||||||
"type": "boolean",
|
"anyOf": [
|
||||||
|
{
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/variantFlagSchema",
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
@ -5269,6 +5278,30 @@ Stats are divided into current and previous **windows**.
|
|||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"variantFlagSchema": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"type": "boolean",
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"payload": {
|
||||||
|
"additionalProperties": false,
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"variantSchema": {
|
"variantSchema": {
|
||||||
"additionalProperties": false,
|
"additionalProperties": false,
|
||||||
"properties": {
|
"properties": {
|
||||||
|
@ -7210,10 +7210,10 @@ universalify@^0.1.0:
|
|||||||
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
|
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
|
||||||
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
|
||||||
|
|
||||||
unleash-client@3.18.1:
|
unleash-client@3.20.0:
|
||||||
version "3.18.1"
|
version "3.20.0"
|
||||||
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.18.1.tgz#d9e928f3cf0c11dafce27bae298b183b28615b4d"
|
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.20.0.tgz#1a6deb0e803eed0d0cefbed1bac17f3c3d3b0143"
|
||||||
integrity sha512-fWVxeas4XzXkPPkTxLr2MKVvN4DUkYDVOKDG9zlnqQnmWvZQjLnRqOCOvf/vFkd4qJj+4fSWIYKTrMYQIpNUKw==
|
integrity sha512-CXseZTHH+lfT3qZY7nufpPKbnNcWvdt61Pgc313spFnQBV63r24fhMmwvQcltc+pp2z/14p2mM6iq11R2PYw3g==
|
||||||
dependencies:
|
dependencies:
|
||||||
ip "^1.1.8"
|
ip "^1.1.8"
|
||||||
make-fetch-happen "^10.2.1"
|
make-fetch-happen "^10.2.1"
|
||||||
|
Loading…
Reference in New Issue
Block a user