Add Feature Flags section in Settings

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/4018
GitOrigin-RevId: 418fe0c10b5f1ce17a984399a3f773a9a76f83ea
This commit is contained in:
Alberto Francesco Motta 2022-03-23 11:56:09 +01:00 committed by hasura-bot
parent e79aa185f9
commit 121f183231
25 changed files with 417 additions and 16 deletions

View File

@ -161,7 +161,7 @@ function:
- console: disable search indexing with HTML meta tag
- cli: fix inherited roles metadata not being updated when dropping all roles (#7872)
- cli: add support for customization field in sources metadata (#8292)
- ci: ubuntu and centos flavoured graphql-engine images are now available
- console: add feature flags section in settings
## v2.4.0-beta.2

View File

@ -71,3 +71,5 @@ export {
export * from './table';
export { ReactQueryProvider, reactQueryClient } from '../src/lib/reactQuery';
export { FeatureFlags } from '../src/features/FeatureFlags'

View File

@ -4903,6 +4903,55 @@
"@babel/runtime": "^7.13.10"
}
},
"@radix-ui/react-label": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-0.1.5.tgz",
"integrity": "sha512-Au9+n4/DhvjR0IHhvZ1LPdx/OW+3CGDie30ZyCkbSHIuLp4/CV4oPPGBwJ1vY99Jog3zyQhsGww9MXj8O9Aj/A==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-id": "0.1.5",
"@radix-ui/react-primitive": "0.1.4"
},
"dependencies": {
"@radix-ui/react-context": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz",
"integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==",
"requires": {
"@babel/runtime": "^7.13.10"
}
},
"@radix-ui/react-id": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-0.1.5.tgz",
"integrity": "sha512-IPc4H/63bes0IZ1GJJozSEkSWcDyhNGtKFWUpJ+XtaLyQ1X3x7Mf6fWwWhDcpqlYEP+5WtAvfqcyEsyjP+ZhBQ==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-use-layout-effect": "0.1.0"
}
},
"@radix-ui/react-primitive": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz",
"integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "0.1.2"
}
},
"@radix-ui/react-slot": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz",
"integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "0.1.0"
}
}
}
},
"@radix-ui/react-popper": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-0.1.0.tgz",
@ -4957,6 +5006,66 @@
"@radix-ui/react-compose-refs": "0.1.0"
}
},
"@radix-ui/react-switch": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-0.1.5.tgz",
"integrity": "sha512-ITtslJPK+Yi34iNf7K9LtsPaLD76oRIVzn0E8JpEO5HW8gpRBGb2NNI9mxKtEB30TVqIcdjdL10AmuIfOMwjtg==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/primitive": "0.1.0",
"@radix-ui/react-compose-refs": "0.1.0",
"@radix-ui/react-context": "0.1.1",
"@radix-ui/react-label": "0.1.5",
"@radix-ui/react-primitive": "0.1.4",
"@radix-ui/react-use-controllable-state": "0.1.0",
"@radix-ui/react-use-previous": "0.1.1",
"@radix-ui/react-use-size": "0.1.1"
},
"dependencies": {
"@radix-ui/react-context": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-0.1.1.tgz",
"integrity": "sha512-PkyVX1JsLBioeu0jB9WvRpDBBLtLZohVDT3BB5CTSJqActma8S8030P57mWZb4baZifMvN7KKWPAA40UmWKkQg==",
"requires": {
"@babel/runtime": "^7.13.10"
}
},
"@radix-ui/react-primitive": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-0.1.4.tgz",
"integrity": "sha512-6gSl2IidySupIMJFjYnDIkIWRyQdbu/AHK7rbICPani+LW4b0XdxBXc46og/iZvuwW8pjCS8I2SadIerv84xYA==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-slot": "0.1.2"
}
},
"@radix-ui/react-slot": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-0.1.2.tgz",
"integrity": "sha512-ADkqfL+agEzEguU3yS26jfB50hRrwf7U4VTwAOZEmi/g+ITcBWe12yM46ueS/UCIMI9Py+gFUaAdxgxafFvY2Q==",
"requires": {
"@babel/runtime": "^7.13.10",
"@radix-ui/react-compose-refs": "0.1.0"
}
},
"@radix-ui/react-use-previous": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-0.1.1.tgz",
"integrity": "sha512-O/ZgrDBr11dR8rhO59ED8s5zIXBRFi8MiS+CmFGfi7MJYdLbfqVOmQU90Ghf87aifEgWe6380LA69KBneaShAg==",
"requires": {
"@babel/runtime": "^7.13.10"
}
},
"@radix-ui/react-use-size": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-0.1.1.tgz",
"integrity": "sha512-pTgWM5qKBu6C7kfKxrKPoBI2zZYZmp2cSXzpUiGM3qEBQlMLtYhaY2JXdXUCxz+XmD1YEjc8oRwvyfsD4AG4WA==",
"requires": {
"@babel/runtime": "^7.13.10"
}
}
}
},
"@radix-ui/react-tooltip": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-0.1.0.tgz",

View File

@ -61,6 +61,7 @@
"@graphql-codegen/typescript": "^1.17.10",
"@hookform/resolvers": "^2.8.1",
"@radix-ui/react-collapsible": "^0.1.1",
"@radix-ui/react-switch": "^0.1.5",
"@radix-ui/react-tooltip": "^0.1.0",
"@reduxjs/toolkit": "^1.5.1",
"@types/lodash.get": "^4.4.6",

View File

@ -31,7 +31,8 @@ type SectionDataKey =
| 'logout'
| 'about'
| 'inherited-roles'
| 'insecure-domain';
| 'insecure-domain'
| 'feature-flags';
interface SectionData {
key: SectionDataKey;
@ -114,6 +115,13 @@ const Sidebar: React.FC<SidebarProps> = ({ location, metadata }) => {
title: 'Insecure TLS Allow List',
});
sectionsData.push({
key: 'feature-flags',
link: '/settings/feature-flags',
dataTestVal: 'feature-flags-link',
title: 'Feature Flags',
});
const currentLocation = location.pathname;
const sections: JSX.Element[] = [];

View File

@ -0,0 +1,10 @@
import { ComponentMeta } from '@storybook/react';
import React from 'react';
import { FeatureFlagFloatingButton } from './FeatureFlagFloatingButton';
export default {
title: 'feature/FeatureFlags/FeatureFlagFloatingButton',
component: FeatureFlagFloatingButton,
} as ComponentMeta<typeof FeatureFlagFloatingButton>;
export const Main = () => <FeatureFlagFloatingButton />;

View File

@ -0,0 +1,10 @@
import React from 'react';
import { FaFlask } from 'react-icons/fa';
export const FeatureFlagFloatingButton = () => {
return (
<button className="fixed flex items-center justify-center bottom-4 right-4 bg-white border overflow-hidden shadow-xl rounded-lg font-sans w-12 h-12">
<FaFlask />
</button>
);
};

View File

@ -0,0 +1 @@
export { FeatureFlagFloatingButton } from './FeatureFlagFloatingButton';

View File

@ -0,0 +1,51 @@
import React from 'react';
import { useFeatureFlagDismiss } from '../../hooks/useFeatureFlagDismiss';
import { useFeatureFlags } from '../../hooks/useFeatureFlags';
import { FeatureFlagDefinition, FeatureFlagId } from '../../types';
import _push from '../../../../components/Services/Data/push';
interface FeatureFlagToastProps {
flagId: FeatureFlagId;
additionalFlags?: FeatureFlagDefinition[];
}
export const FeatureFlagToast = (props: FeatureFlagToastProps) => {
const { flagId, additionalFlags } = props;
const [dismissed, setDismissed] = React.useState(false);
const { isError, isLoading, data } = useFeatureFlags(additionalFlags);
const setPermanentDismiss = useFeatureFlagDismiss();
const featureFlag = data?.find(i => i.id === flagId);
if (
isError ||
isLoading ||
dismissed ||
!featureFlag ||
featureFlag?.state.dismissed
)
return null;
return (
<div className="fixed bottom-8 right-8 bg-white border overflow-hidden shadow-xl rounded-lg w-px-320 font-sans">
<div className="bg-primary px-4 py-3 flex align-middle">
<h3 className="text-lg font-bold mb-0">
Coming Soon: {featureFlag?.title}
</h3>
</div>
<div
className="flex p-4 cursor-pointer items-center justify-between"
onClick={() => _push('/settings/feature-flags')}
>
Try out the new feature before it gets to general availability.
<i className="fa fa-chevron-right ml-2" aria-hidden="true" />
</div>
<div className="flex p-4 justify-between border-t">
<button onClick={() => setDismissed(true)}>Hide for now</button>
<button
className="text-gray-400"
onClick={() => setPermanentDismiss.mutate(featureFlag?.id ?? '')}
>
Don&apos;t show me again
</button>
</div>
</div>
);
};

View File

@ -0,0 +1,28 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { FeatureFlagToast } from './FeatureFlagToast';
import { FeatureFlagDefinition } from '../../types';
export default {
title: 'feature/FeatureFlags/FeatureFlagToast',
component: FeatureFlagToast,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof FeatureFlagToast>;
const additionalFlags: FeatureFlagDefinition[] = [
{
id: '1',
title: 'Database Table → Remote Schema Relationship',
description:
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.",
section: 'api',
status: 'alpha',
defaultValue: false,
discussionUrl: '',
},
];
export const Main = () => (
<FeatureFlagToast flagId="1" additionalFlags={additionalFlags} />
);

View File

@ -0,0 +1 @@
export { FeatureFlagToast } from './FeatureFlagToast';

View File

@ -0,0 +1,48 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { FeatureFlags } from './FeatureFlags';
import { FeatureFlagDefinition } from '../../types';
export default {
title: 'feature/FeatureFlags',
component: FeatureFlags,
decorators: [ReactQueryDecorator()],
} as ComponentMeta<typeof FeatureFlags>;
const additionalFlags: FeatureFlagDefinition[] = [
{
id: '1',
title: 'Database Table → Remote Schema Relationship',
description:
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.",
section: 'api',
status: 'alpha',
defaultValue: false,
discussionUrl: '',
},
{
id: '2',
title: 'Database Table → Remote Schema Relationship',
description:
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.",
section: 'api',
status: 'alpha',
defaultValue: true,
discussionUrl: '',
},
{
id: '3',
title: 'Database Table → Remote Schema Relationship',
description:
"Lorem Ipsum is simply dummy text of the printing and typesetting industry. Lorem Ipsum has been the industry's standard dummy text ever since the 1500s, when an unknown printer took a galley of type and scrambled it to make a type specimen book.",
section: 'api',
status: 'alpha',
defaultValue: false,
discussionUrl: '',
},
];
export const Main = () => <FeatureFlags additionalFlags={additionalFlags} />;
export const EmptyState = () => <FeatureFlags />;

View File

@ -0,0 +1,84 @@
import { CardedTable } from '@/new-components/CardedTable';
import { Switch } from '@/new-components/Switch';
import React from 'react';
import { useFeatureFlags } from '../../hooks/useFeatureFlags';
import { useSetFeatureFlagEnabled } from '../../hooks/useSetFeatureFlagEnabled';
import { FeatureFlagDefinition, FeatureFlagType } from '../../types';
interface FeatureCellProps {
title: string;
description: string;
}
const FeatureCell = (props: FeatureCellProps) => {
const { description, title } = props;
return (
<div className="pr-4">
<div className="text-gray-600 font-semibold break-all">{title}</div>
<div className="text-gray-600 whitespace-normal">{description}</div>
</div>
);
};
const columns = [null, 'Feature', 'section', 'status'];
const formatData = (
data: Array<FeatureFlagType>,
mutation: ReturnType<typeof useSetFeatureFlagEnabled>
): React.ReactNode[][] =>
data.map(item => [
<Switch
checked={item.state.enabled}
onCheckedChange={() =>
mutation.mutate({ flagId: item.id, newState: !item.state.enabled })
}
/>,
<FeatureCell title={item.title} description={item.description} />,
item.section,
item.status,
]);
interface FeatureFlagsProps {
additionalFlags?: FeatureFlagDefinition[];
}
const FeatureFlagsBody = (props: FeatureFlagsProps) => {
const { additionalFlags } = props;
const result = useFeatureFlags(additionalFlags);
const setFeatureFlagEnabled = useSetFeatureFlagEnabled();
const { isError, isLoading, data } = result;
if (isLoading) return <h3 className="text-lg">Loading...</h3>;
if (isError)
return (
<h3 className="text-lg">
There was an error while loading. <br />
Please try again later.
</h3>
);
if (data && data?.length > 0) {
return (
<CardedTable
columns={columns}
data={formatData(data, setFeatureFlagEnabled)}
/>
);
}
return (
<h3 className="text-lg">There are currently no Feature flags available.</h3>
);
};
export const FeatureFlags = (props: FeatureFlagsProps) => {
const { additionalFlags } = props;
return (
<div className="p-4">
<h2 className="text-xl font-semibold mb-3.5">Feature Flags</h2>
<p>
Feature flags enable experimental features in Console. <br />
These features may be actively in development or in beta status.
</p>
<div className="mb-10" />
<FeatureFlagsBody additionalFlags={additionalFlags} />
</div>
);
};

View File

@ -0,0 +1 @@
export { FeatureFlags } from './FeatureFlags';

View File

@ -15,7 +15,7 @@ describe('mergeFlagWithState', () => {
},
];
const inputState: FeatureFlagState[] = [
{ id: 'AAA', dismissed: false, enable: true },
{ id: 'AAA', dismissed: false, enabled: true },
];
const result = mergeFlagWithState(inputFlags, inputState);
@ -30,7 +30,7 @@ describe('mergeFlagWithState', () => {
"section": "api",
"state": Object {
"dismissed": false,
"enable": true,
"enabled": true,
"id": "AAA",
},
"status": "beta",
@ -53,7 +53,7 @@ describe('mergeFlagWithState', () => {
},
];
const inputState: FeatureFlagState[] = [
{ id: 'CCC', dismissed: false, enable: true },
{ id: 'CCC', dismissed: false, enabled: true },
];
const result = mergeFlagWithState(inputFlags, inputState);
@ -68,7 +68,7 @@ describe('mergeFlagWithState', () => {
"section": "api",
"state": Object {
"dismissed": false,
"enable": false,
"enabled": false,
},
"status": "beta",
"title": "Hello world",
@ -112,7 +112,7 @@ describe('mergeFlagWithState', () => {
"section": "api",
"state": Object {
"dismissed": false,
"enable": true,
"enabled": true,
},
"status": "beta",
"title": "Hello world",
@ -125,7 +125,7 @@ describe('mergeFlagWithState', () => {
"section": "api",
"state": Object {
"dismissed": false,
"enable": false,
"enabled": false,
},
"status": "beta",
"title": "Hello world",

View File

@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from 'react-query';
import { FeatureFlagId, useFeatureFlags } from '@/features/FeatureFlag';
import { FeatureFlagId, useFeatureFlags } from '@/features/FeatureFlags';
import { saveFeatureFlagsStateToLocalStorage } from '../utils';
export function useFeatureFlagDismiss() {

View File

@ -33,18 +33,20 @@ export const mergeFlagWithState = (
return {
...flag,
state: flagState ?? {
enable: flag.defaultValue,
enabled: flag.defaultValue,
dismissed: false,
},
};
});
};
export function useFeatureFlags() {
export function useFeatureFlags(additionalFlags?: FeatureFlagDefinition[]) {
return useQuery(['featureFlags', 'all'], () =>
Promise.all([
getAvailableFeatureFlags(),
getFeatureFlagStore(),
]).then(([flags, state]) => mergeFlagWithState(flags, state))
]).then(([flags, state]) =>
mergeFlagWithState([...(additionalFlags ?? []), ...flags], state)
)
);
}

View File

@ -1,8 +1,8 @@
import { useMutation, useQueryClient } from 'react-query';
import { FeatureFlagId, useFeatureFlags } from '@/features/FeatureFlag';
import { FeatureFlagId, useFeatureFlags } from '@/features/FeatureFlags';
import { saveFeatureFlagsStateToLocalStorage } from '../utils';
export function useFeatureFlagDismiss() {
export function useSetFeatureFlagEnabled() {
const queryClient = useQueryClient();
const { data, isError, isLoading } = useFeatureFlags();
@ -25,7 +25,7 @@ export function useFeatureFlagDismiss() {
...item,
state: {
...item.state,
enable: newState,
enabled: newState,
enableDate: new Date(),
},
};

View File

@ -1,2 +1,4 @@
export { FeatureFlags } from './components/FeatureFlags';
export { FeatureFlagType, FeatureFlagSections, FeatureFlagId } from './types';
export { useFeatureFlags } from './hooks/useFeatureFlags';
export { FeatureFlagToast } from './components/FeatureFlagToast';

View File

@ -23,7 +23,7 @@ export interface FeatureFlagDefinition {
export interface FeatureFlagState {
id: FeatureFlagId;
enable: boolean;
enabled: boolean;
enableDate?: Date;
dismissed: boolean;
dismissedDate?: Date;

View File

@ -0,0 +1,17 @@
import React from 'react';
import { ComponentMeta } from '@storybook/react';
import { Switch } from './Switch';
export default {
title: 'components/Switch',
component: Switch,
} as ComponentMeta<typeof Switch>;
export const Off = () => <Switch />;
export const On = () => <Switch checked />;
export const Playground = () => {
const [checked, setChecked] = React.useState(false);
return <Switch checked={checked} onCheckedChange={setChecked} />;
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import * as RadixSwitch from '@radix-ui/react-switch';
import clsx from 'clsx';
export const Switch = (props: RadixSwitch.SwitchProps) => {
const { checked } = props;
return (
<RadixSwitch.Root
className={clsx(
checked ? 'bg-green-600' : 'bg-gray-200',
'relative inline-flex flex-shrink-0 h-6 w-11 border-2 border-transparent rounded-full cursor-pointer transition-colors ease-in-out duration-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500'
)}
{...props}
>
<RadixSwitch.Thumb
className={clsx(
checked ? 'translate-x-5' : 'translate-x-0',
'pointer-events-none inline-block h-5 w-5 rounded-full bg-white shadow transform ring-0 transition ease-in-out duration-200'
)}
/>
</RadixSwitch.Root>
);
};

View File

@ -0,0 +1 @@
export { Switch } from './Switch';

View File

@ -37,6 +37,7 @@ import {
INSECURE_TLS_ALLOW_LIST,
} from './helpers/versionUtils';
import AuthContainer from './components/Services/Auth/AuthContainer';
import { FeatureFlags } from './features/FeatureFlags';
const routes = store => {
// load hasuractl migration status
@ -151,6 +152,7 @@ const routes = store => {
{checkFeatureSupport(INSECURE_TLS_ALLOW_LIST) && (
<Route path="insecure-domain" component={InsecureDomains} />
)}
<Route path="feature-flags" component={FeatureFlags} />
</Route>
{dataRouter}
{remoteSchemaRouter}