1
0
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:
Nuno Góis 2023-05-18 09:38:59 +01:00 committed by GitHub
parent 2487b990bd
commit db61a8a40c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 350 additions and 91 deletions

View File

@ -6,23 +6,30 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { MessageBannerDialog } from './MessageBannerDialog/MessageBannerDialog';
import { useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { useVariant } from 'hooks/useVariant';
const StyledBar = styled('aside', {
shouldForwardProp: prop => prop !== 'variant',
})<{ variant: BannerVariant }>(({ theme, variant }) => ({
position: 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette[variant].border,
background: theme.palette[variant].light,
color: theme.palette[variant].dark,
fontSize: theme.fontSizes.smallBody,
}));
shouldForwardProp: prop => prop !== 'variant' && prop !== 'sticky',
})<{ variant: BannerVariant; sticky?: boolean }>(
({ theme, variant, sticky }) => ({
position: sticky ? 'sticky' : 'relative',
zIndex: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(1),
gap: theme.spacing(1),
borderBottom: '1px solid',
borderColor: theme.palette[variant].border,
background: theme.palette[variant].light,
color: theme.palette[variant].dark,
fontSize: theme.fontSizes.smallBody,
...(sticky && {
top: 0,
zIndex: theme.zIndex.sticky,
}),
})
);
const StyledIcon = styled('div', {
shouldForwardProp: prop => prop !== 'variant',
@ -44,6 +51,7 @@ interface IMessageFlag {
enabled: boolean;
message: string;
variant?: BannerVariant;
sticky?: boolean;
icon?: string;
link?: string;
linkText?: string;
@ -52,61 +60,30 @@ interface IMessageFlag {
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 = () => {
const { uiConfig } = useUiConfig();
const [open, setOpen] = useState(false);
const messageBanner = useVariant<IMessageFlag>(
uiConfig.flags.messageBanner
);
if (!messageBanner) return null;
const {
enabled,
message,
variant = 'neutral',
sticky,
icon,
link,
linkText = 'More info',
plausibleEvent,
dialogTitle,
dialog,
} = { ...mockFlag2, enabled: uiConfig.flags.messageBanner };
if (!enabled) return null;
} = messageBanner;
return (
<StyledBar variant={variant}>
<StyledBar variant={variant} sticky={sticky}>
<StyledIcon variant={variant}>
<BannerIcon icon={icon} variant={variant} />
</StyledIcon>

View 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]);
};

View File

@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import { Variant } from 'utils/variants';
export interface IUiConfig {
authenticationType?: string;
@ -38,7 +39,7 @@ export interface IFlags {
UG?: boolean;
embedProxyFrontend?: boolean;
maintenanceMode?: boolean;
messageBanner?: boolean;
messageBanner?: Variant;
featuresExportImport?: boolean;
caseInsensitiveInOperators?: boolean;
proPlanAutoCharge?: boolean;

View 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;
}
};

View File

@ -146,7 +146,7 @@
"stoppable": "^1.1.0",
"ts-toolbelt": "^9.6.0",
"type-is": "^1.6.18",
"unleash-client": "3.18.1",
"unleash-client": "3.20.0",
"uuid": "^9.0.0"
},
"devDependencies": {

View File

@ -63,6 +63,7 @@ exports[`should create default config 1`] = `
},
"experimental": {
"externalResolver": {
"getVariant": [Function],
"isEnabled": [Function],
},
"flags": {
@ -76,7 +77,14 @@ exports[`should create default config 1`] = `
"googleAuthEnabled": false,
"groupRootRoles": false,
"maintenanceMode": false,
"messageBanner": false,
"messageBanner": {
"enabled": false,
"name": "message-banner",
"payload": {
"type": "json",
"value": "",
},
},
"migrationLock": false,
"personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false,
@ -98,7 +106,14 @@ exports[`should create default config 1`] = `
"googleAuthEnabled": false,
"groupRootRoles": false,
"maintenanceMode": false,
"messageBanner": false,
"messageBanner": {
"enabled": false,
"name": "message-banner",
"payload": {
"type": "json",
"value": "",
},
},
"migrationLock": false,
"personalAccessTokensKillSwitch": false,
"proPlanAutoCharge": false,
@ -108,6 +123,7 @@ exports[`should create default config 1`] = `
"variantMetrics": false,
},
"externalResolver": {
"getVariant": [Function],
"isEnabled": [Function],
},
},

View File

@ -134,6 +134,7 @@ import {
validatePasswordSchema,
validateTagTypeSchema,
variantSchema,
variantFlagSchema,
variantsSchema,
versionSchema,
} from './spec';
@ -319,6 +320,7 @@ export const schemas: UnleashSchemas = {
validatePasswordSchema,
validateTagTypeSchema,
variantSchema,
variantFlagSchema,
variantsSchema,
versionSchema,
projectOverviewSchema,

View File

@ -168,6 +168,7 @@ const metaRules: Rule[] = [
'validatePasswordSchema',
'validateTagTypeSchema',
'variantSchema',
'variantFlagSchema',
'versionSchema',
'projectOverviewSchema',
'importTogglesSchema',
@ -277,6 +278,7 @@ const metaRules: Rule[] = [
'validatePasswordSchema',
'validateTagTypeSchema',
'variantSchema',
'variantFlagSchema',
'variantsSchema',
'versionSchema',
'importTogglesSchema',

View File

@ -27,6 +27,7 @@ export * from './profile-schema';
export * from './project-schema';
export * from './segment-schema';
export * from './variant-schema';
export * from './variant-flag-schema';
export * from './version-schema';
export * from './features-schema';
export * from './feedback-schema';

View File

@ -1,4 +1,5 @@
import { FromSchema } from 'json-schema-to-ts';
import { PayloadType } from 'unleash-client';
export const proxyFeatureSchema = {
$id: '#/components/schemas/proxyFeatureSchema',
@ -31,7 +32,10 @@ export const proxyFeatureSchema = {
additionalProperties: false,
required: ['type', 'value'],
properties: {
type: { type: 'string', enum: ['string'] },
type: {
type: 'string',
enum: Object.values(PayloadType),
},
value: { type: 'string' },
},
},

View File

@ -1,5 +1,6 @@
import { FromSchema } from 'json-schema-to-ts';
import { versionSchema } from './version-schema';
import { variantFlagSchema } from './variant-flag-schema';
export const uiConfigSchema = {
$id: '#/components/schemas/uiConfigSchema',
@ -52,7 +53,14 @@ export const uiConfigSchema = {
flags: {
type: 'object',
additionalProperties: {
type: 'boolean',
anyOf: [
{
type: 'boolean',
},
{
$ref: '#/components/schemas/variantFlagSchema',
},
],
},
},
links: {
@ -79,6 +87,7 @@ export const uiConfigSchema = {
components: {
schemas: {
versionSchema,
variantFlagSchema,
},
},
} as const;

View 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>;

View File

@ -1,9 +1,29 @@
import { Variant, PayloadType } from 'unleash-client';
import { parseEnvVarBoolean } from '../util';
export type IFlags = Partial<typeof flags>;
export type IFlagKey = keyof IFlags;
export type IFlagKey =
| '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,
embedProxy: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_EMBED_PROXY,
@ -21,10 +41,18 @@ const flags = {
process.env.UNLEASH_EXPERIMENTAL_MAINTENANCE_MODE,
false,
),
messageBanner: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_MESSAGE_BANNER,
false,
),
messageBanner: {
name: 'message-banner',
enabled: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_MESSAGE_BANNER,
false,
),
payload: {
type: PayloadType.JSON,
value:
process.env.UNLEASH_EXPERIMENTAL_MESSAGE_BANNER_PAYLOAD ?? '',
},
},
featuresExportImport: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_FEATURES_EXPORT_IMPORT,
true,
@ -68,7 +96,10 @@ const flags = {
export const defaultExperimentalOptions: IExperimentalOptions = {
flags,
externalResolver: { isEnabled: (): boolean => false },
externalResolver: {
isEnabled: (): boolean => false,
getVariant: () => undefined,
},
};
export interface IExperimentalOptions {
@ -83,8 +114,16 @@ export interface IFlagContext {
export interface IFlagResolver {
getAll: (context?: IFlagContext) => IFlags;
isEnabled: (expName: IFlagKey, context?: IFlagContext) => boolean;
getVariant: (
expName: IFlagKey,
context?: IFlagContext,
) => Variant | undefined;
}
export interface IExternalFlagResolver {
isEnabled: (flagName: IFlagKey, context?: IFlagContext) => boolean;
getVariant: (
flagName: IFlagKey,
context?: IFlagContext,
) => Variant | undefined;
}

View File

@ -3,10 +3,7 @@ import { Context } from './context';
import { FeatureInterface } from './feature';
import normalizedValue from './strategy/util';
import { resolveContextValue } from './helpers';
enum PayloadType {
STRING = 'string',
}
import { PayloadType } from 'unleash-client';
interface Override {
contextName: string;

View File

@ -1,5 +1,6 @@
import { PayloadType } from 'unleash-client';
import { defaultExperimentalOptions, IFlagKey } from '../types/experimental';
import FlagResolver from './flag-resolver';
import FlagResolver, { getVariantValue } from './flag-resolver';
import { IExperimentalOptions } from '../types/experimental';
test('should produce empty exposed flags', () => {
@ -29,6 +30,7 @@ test('should use external resolver for dynamic flags', () => {
return true;
}
},
getVariant: () => undefined,
};
const config = {
@ -48,6 +50,7 @@ test('should not use external resolver for enabled experiments', () => {
isEnabled: () => {
return false;
},
getVariant: () => undefined,
};
const config = {
@ -67,6 +70,7 @@ test('should load experimental flags', () => {
isEnabled: () => {
return false;
},
getVariant: () => undefined,
};
const config = {
@ -87,6 +91,7 @@ test('should load experimental flags from external provider', () => {
return true;
}
},
getVariant: () => undefined,
};
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('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',
});
});

View File

@ -1,3 +1,4 @@
import { Variant, PayloadType } from 'unleash-client';
import {
IExperimentalOptions,
IExternalFlagResolver,
@ -6,6 +7,7 @@ import {
IFlagResolver,
IFlagKey,
} from '../types/experimental';
export default class FlagResolver implements IFlagResolver {
private experiments: IFlags;
@ -20,20 +22,51 @@ export default class FlagResolver implements IFlagResolver {
const flags: IFlags = { ...this.experiments };
Object.keys(flags).forEach((flagName: IFlagKey) => {
if (!this.experiments[flagName])
flags[flagName] = this.externalResolver.isEnabled(
flagName,
context,
);
if (!this.experiments[flagName]) {
if (typeof flags[flagName] === 'boolean') {
flags[flagName] = this.externalResolver.isEnabled(
flagName,
context,
);
} else {
flags[flagName] = this.externalResolver.getVariant(
flagName,
context,
);
}
}
});
return flags;
}
isEnabled(expName: IFlagKey, context?: IFlagContext): boolean {
if (this.experiments[expName]) {
return true;
const exp = this.experiments[expName];
if (exp) {
if (typeof exp === 'boolean') return exp;
else return exp.enabled;
}
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;
}
};

View File

@ -6,10 +6,7 @@ import { ISegment } from 'lib/types/model';
import { serializeDates } from '../../lib/types/serialize-dates';
import { Operator } from './feature-evaluator/constraint';
import { FeatureInterface } from 'unleash-client/lib/feature';
enum PayloadType {
STRING = 'string',
}
import { PayloadType } from 'unleash-client';
type NonEmptyList<T> = [T, ...T[]];
@ -24,7 +21,7 @@ export const mapFeaturesForClient = (
...variant,
payload: variant.payload && {
...variant.payload,
type: variant.payload.type as unknown as PayloadType,
type: variant.payload.type as PayloadType,
},
})),
project: feature.project,

View File

@ -2,6 +2,7 @@ import { start } from './lib/server-impl';
import { createConfig } from './lib/create-config';
import { LogLevel } from './lib/logger';
import { ApiTokenType } from './lib/types/models/api-token';
import { PayloadType } from 'unleash-client';
process.nextTick(async () => {
try {
@ -40,6 +41,23 @@ process.nextTick(async () => {
responseTimeWithAppNameKillSwitch: false,
variantMetrics: 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: {

View File

@ -3947,6 +3947,8 @@ Stats are divided into current and previous **windows**.
"type": {
"enum": [
"string",
"json",
"csv",
],
"type": "string",
},
@ -4823,7 +4825,14 @@ Stats are divided into current and previous **windows**.
},
"flags": {
"additionalProperties": {
"type": "boolean",
"anyOf": [
{
"type": "boolean",
},
{
"$ref": "#/components/schemas/variantFlagSchema",
},
],
},
"type": "object",
},
@ -5269,6 +5278,30 @@ Stats are divided into current and previous **windows**.
],
"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": {
"additionalProperties": false,
"properties": {

View File

@ -7210,10 +7210,10 @@ universalify@^0.1.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
unleash-client@3.18.1:
version "3.18.1"
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.18.1.tgz#d9e928f3cf0c11dafce27bae298b183b28615b4d"
integrity sha512-fWVxeas4XzXkPPkTxLr2MKVvN4DUkYDVOKDG9zlnqQnmWvZQjLnRqOCOvf/vFkd4qJj+4fSWIYKTrMYQIpNUKw==
unleash-client@3.20.0:
version "3.20.0"
resolved "https://registry.yarnpkg.com/unleash-client/-/unleash-client-3.20.0.tgz#1a6deb0e803eed0d0cefbed1bac17f3c3d3b0143"
integrity sha512-CXseZTHH+lfT3qZY7nufpPKbnNcWvdt61Pgc313spFnQBV63r24fhMmwvQcltc+pp2z/14p2mM6iq11R2PYw3g==
dependencies:
ip "^1.1.8"
make-fetch-happen "^10.2.1"