1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-03-04 00:18:40 +01:00

feat: advancedPlayground flag used only for runtime control (#4262)

This commit is contained in:
Mateusz Kwasniewski 2023-07-18 08:49:04 +02:00 committed by GitHub
parent 1f21770977
commit 593f83d5d3
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 25 additions and 583 deletions

View File

@ -133,7 +133,14 @@ exports[`returns all baseRoutes 1`] = `
"type": "protected",
},
{
"component": [Function],
"component": {
"$$typeof": Symbol(react.lazy),
"_init": [Function],
"_payload": {
"_result": [Function],
"_status": -1,
},
},
"hidden": false,
"menu": {
"mobile": true,

View File

@ -1,15 +1,3 @@
import { lazy } from 'react';
import useUiConfig from '../../../hooks/api/getters/useUiConfig/useUiConfig';
export const LazyLegacyPlayground = lazy(() => import('./Playground'));
export const LazyAdvancedPlayground = lazy(
() => import('./AdvancedPlayground')
);
export const LazyPlayground = () => {
const { uiConfig } = useUiConfig();
if (uiConfig.flags.advancedPlayground) return <LazyAdvancedPlayground />;
return <LazyLegacyPlayground />;
};
export const LazyPlayground = lazy(() => import('./AdvancedPlayground'));

View File

@ -1,223 +0,0 @@
import { FormEventHandler, useEffect, useState, VFC } from 'react';
import { useSearchParams } from 'react-router-dom';
import { Box, Paper, useMediaQuery, useTheme } from '@mui/material';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { PlaygroundResultsTable } from './PlaygroundResultsTable/PlaygroundResultsTable';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { usePlaygroundApi } from 'hooks/api/actions/usePlayground/usePlayground';
import { PlaygroundResponseSchema } from 'openapi';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import { PlaygroundForm } from './PlaygroundForm/PlaygroundForm';
import {
resolveDefaultEnvironment,
resolveProjects,
resolveResultsWidth,
} from './playground.utils';
import { PlaygroundGuidance } from './PlaygroundGuidance/PlaygroundGuidance';
import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundGuidancePopper';
import Loader from '../../common/Loader/Loader';
export const Playground: VFC<{}> = () => {
const { environments: availableEnvironments } = useEnvironments();
const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.down('lg'));
const [environments, setEnvironments] = useState<string[]>([]);
const [projects, setProjects] = useState<string[]>([]);
const [context, setContext] = useState<string>();
const [results, setResults] = useState<
PlaygroundResponseSchema | undefined
>();
const { setToastData } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const { evaluatePlayground, loading } = usePlaygroundApi();
useEffect(() => {
setEnvironments([resolveDefaultEnvironment(availableEnvironments)]);
}, [JSON.stringify(availableEnvironments)]);
useEffect(() => {
// Load initial values from URL
try {
const environmentFromUrl = searchParams.get('environment');
if (environmentFromUrl) {
setEnvironments([environmentFromUrl]);
}
let projectsArray: string[];
let projectsFromUrl = searchParams.get('projects');
if (projectsFromUrl) {
projectsArray = projectsFromUrl.split(',');
setProjects(projectsArray);
}
let contextFromUrl = searchParams.get('context');
if (contextFromUrl) {
contextFromUrl = decodeURI(contextFromUrl);
setContext(contextFromUrl);
}
const makePlaygroundRequest = async () => {
if (environmentFromUrl && contextFromUrl) {
await evaluatePlaygroundContext(
environmentFromUrl,
projectsArray || '*',
contextFromUrl
);
}
};
makePlaygroundRequest();
} catch (error) {
setToastData({
type: 'error',
title: `Failed to parse URL parameters: ${formatUnknownError(
error
)}`,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const evaluatePlaygroundContext = async (
environment: string,
projects: string[] | string,
context: string | undefined,
action?: () => void
) => {
try {
const parsedContext = JSON.parse(context || '{}');
const response = await evaluatePlayground({
environment,
projects: resolveProjects(projects),
context: {
appName: 'playground',
...parsedContext,
},
});
if (action && typeof action === 'function') {
action();
}
setResults(response);
} catch (error: unknown) {
setToastData({
type: 'error',
title: `Error parsing context: ${formatUnknownError(error)}`,
});
}
};
const onSubmit: FormEventHandler<HTMLFormElement> = async event => {
event.preventDefault();
await evaluatePlaygroundContext(
environments[0],
projects,
context,
setURLParameters
);
};
const setURLParameters = () => {
searchParams.set('context', encodeURI(context || '')); // always set because of native validation
searchParams.set('environment', environments[0]);
if (
Array.isArray(projects) &&
projects.length > 0 &&
!(projects.length === 1 && projects[0] === '*')
) {
searchParams.set('projects', projects.join(','));
} else {
searchParams.delete('projects');
}
setSearchParams(searchParams);
};
const formWidth = results && !matches ? '35%' : 'auto';
const resultsWidth = resolveResultsWidth(matches, results);
return (
<PageContent
header={
<PageHeader
title="Unleash playground"
actions={<PlaygroundGuidancePopper />}
/>
}
disableLoading
bodyClass={'no-padding'}
>
<Box
sx={{
display: 'flex',
flexDirection: !matches ? 'row' : 'column',
}}
>
<Box
sx={{
background: theme.palette.background.elevation2,
borderBottomLeftRadius: theme.shape.borderRadiusMedium,
}}
>
<Paper
elevation={0}
sx={{
px: 4,
py: 3,
mb: 4,
mt: 2,
background: theme.palette.background.elevation2,
transition: 'width 0.4s ease',
minWidth: matches ? 'auto' : '500px',
width: formWidth,
position: 'sticky',
top: 0,
}}
>
<PlaygroundForm
onSubmit={onSubmit}
context={context}
setContext={setContext}
availableEnvironments={availableEnvironments}
projects={projects}
environments={environments}
setProjects={setProjects}
setEnvironments={setEnvironments}
/>
</Paper>
</Box>
<Box
sx={theme => ({
width: resultsWidth,
transition: 'width 0.4s ease',
padding: theme.spacing(4, 2),
})}
>
<ConditionallyRender
condition={loading}
show={<Loader />}
elseShow={
<ConditionallyRender
condition={Boolean(results)}
show={
<PlaygroundResultsTable
loading={loading}
features={results?.features}
input={results?.input}
/>
}
elseShow={<PlaygroundGuidance />}
/>
}
/>
</Box>
</Box>
</PageContent>
);
};
export default Playground;

View File

@ -1,303 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import { useSearchParams } from 'react-router-dom';
import {
SortingRule,
useFlexLayout,
useGlobalFilter,
useSortBy,
useTable,
} from 'react-table';
import { TablePlaceholder, VirtualizedTable } from 'component/common/Table';
import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightContext/SearchHighlightContext';
import { sortTypes } from 'utils/sortTypes';
import { HighlightCell } from 'component/common/Table/cells/HighlightCell/HighlightCell';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { Search } from 'component/common/Search/Search';
import { LinkCell } from 'component/common/Table/cells/LinkCell/LinkCell';
import { useSearch } from 'hooks/useSearch';
import { createLocalStorage } from 'utils/createLocalStorage';
import { FeatureStatusCell } from './FeatureStatusCell/FeatureStatusCell';
import { PlaygroundFeatureSchema, PlaygroundRequestSchema } from 'openapi';
import { Box, Typography, useMediaQuery, useTheme } from '@mui/material';
import useLoading from 'hooks/useLoading';
import { VariantCell } from './VariantCell/VariantCell';
import { FeatureResultInfoPopoverCell } from './FeatureResultInfoPopoverCell/FeatureResultInfoPopoverCell';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
const defaultSort: SortingRule<string> = { id: 'name' };
const { value, setValue } = createLocalStorage(
'PlaygroundResultsTable:v1',
defaultSort
);
interface IPlaygroundResultsTableProps {
features?: PlaygroundFeatureSchema[];
input?: PlaygroundRequestSchema;
loading: boolean;
}
export const PlaygroundResultsTable = ({
features,
input,
loading,
}: IPlaygroundResultsTableProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const ref = useLoading(loading);
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || ''
);
const theme = useTheme();
const isExtraSmallScreen = useMediaQuery(theme.breakpoints.down('sm'));
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
const COLUMNS = useMemo(() => {
return [
{
Header: 'Name',
accessor: 'name',
searchable: true,
minWidth: 160,
Cell: ({ value, row: { original } }: any) => (
<LinkCell
title={value}
to={`/projects/${original?.projectId}/features/${value}`}
/>
),
},
{
Header: 'Project ID',
accessor: 'projectId',
sortType: 'alphanumeric',
filterName: 'projectId',
searchable: true,
maxWidth: 170,
Cell: ({ value }: any) => (
<LinkCell title={value} to={`/projects/${value}`} />
),
},
{
Header: 'Variant',
id: 'variant',
accessor: 'variant.name',
sortType: 'alphanumeric',
filterName: 'variant',
searchable: true,
maxWidth: 200,
Cell: ({
value,
row: {
original: { variant, feature, variants, isEnabled },
},
}: any) => (
<VariantCell
variant={variant?.enabled ? value : ''}
variants={variants}
feature={feature}
isEnabled={isEnabled}
/>
),
},
{
id: 'isEnabled',
Header: 'isEnabled',
filterName: 'isEnabled',
accessor: (row: PlaygroundFeatureSchema) =>
row?.isEnabled
? 'true'
: row?.strategies?.result === 'unknown'
? 'unknown'
: 'false',
Cell: ({ row }: any) => (
<FeatureStatusCell feature={row.original} />
),
sortType: 'playgroundResultState',
maxWidth: 120,
sortInverted: true,
},
{
Header: '',
maxWidth: 70,
id: 'info',
Cell: ({ row }: any) => (
<FeatureResultInfoPopoverCell
feature={row.original}
input={input}
/>
),
},
];
}, [input]);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(COLUMNS, searchValue, features || []);
const data = useMemo(() => {
return loading
? Array(5).fill({
name: 'Feature name',
projectId: 'FeatureProject',
variant: { name: 'FeatureVariant', variants: [] },
enabled: true,
})
: searchedData;
}, [searchedData, loading]);
const [initialState] = useState(() => ({
sortBy: [
{
id: searchParams.get('sort') || value.id,
desc: searchParams.has('order')
? searchParams.get('order') === 'desc'
: value.desc,
},
],
}));
const {
headerGroups,
rows,
state: { sortBy },
prepareRow,
setHiddenColumns,
} = useTable(
{
initialState,
columns: COLUMNS as any,
data: data as any,
sortTypes,
autoResetGlobalFilter: false,
autoResetHiddenColumns: false,
autoResetSortBy: false,
disableSortRemove: true,
disableMultiSort: true,
defaultColumn: {
Cell: HighlightCell,
},
},
useGlobalFilter,
useFlexLayout,
useSortBy
);
useConditionallyHiddenColumns(
[
{
condition: isExtraSmallScreen,
columns: ['variant'],
},
{
condition: isSmallScreen,
columns: ['projectId'],
},
],
setHiddenColumns,
COLUMNS
);
useEffect(() => {
if (loading) {
return;
}
const tableState: Record<string, string> =
Object.fromEntries(searchParams);
tableState.sort = sortBy[0].id;
if (sortBy[0].desc) {
tableState.order = 'desc';
} else if (tableState.order) {
delete tableState.order;
}
if (searchValue) {
tableState.search = searchValue;
} else {
delete tableState.search;
}
setSearchParams(tableState, {
replace: true,
});
setValue({ id: sortBy[0].id, desc: sortBy[0].desc || false });
// eslint-disable-next-line react-hooks/exhaustive-deps -- don't re-render after search params change
}, [loading, sortBy, searchValue]);
return (
<>
<Box
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
mb: 3,
}}
>
<Typography variant="subtitle1" sx={{ ml: 1 }}>
{features !== undefined && !loading
? `Results (${
rows.length < data.length
? `${rows.length} of ${data.length}`
: data.length
})`
: 'Results'}
</Typography>
<Search
initialValue={searchValue}
onChange={setSearchValue}
hasFilters
getSearchContext={getSearchContext}
disabled={loading}
containerStyles={{ marginLeft: '1rem', maxWidth: '400px' }}
/>
</Box>
<ConditionallyRender
condition={!loading && !data}
show={() => (
<TablePlaceholder>
{data === undefined
? 'None of the feature toggles were evaluated yet.'
: 'No results found.'}
</TablePlaceholder>
)}
elseShow={() => (
<Box ref={ref}>
<SearchHighlightProvider
value={getSearchText(searchValue)}
>
<VirtualizedTable
rows={rows}
headerGroups={headerGroups}
prepareRow={prepareRow}
/>
</SearchHighlightProvider>
<ConditionallyRender
condition={
data.length === 0 && searchValue?.length > 0
}
show={
<TablePlaceholder>
No feature toggles found matching &ldquo;
{searchValue}&rdquo;
</TablePlaceholder>
}
/>
<ConditionallyRender
condition={
data && data.length === 0 && !searchValue
}
show={
<TablePlaceholder>
No features toggles to display
</TablePlaceholder>
}
/>
</Box>
)}
/>
</>
);
};

View File

@ -13,21 +13,6 @@ export const usePlaygroundApi = () => {
const URI = 'api/admin/playground';
const evaluatePlayground = async (payload: PlaygroundRequestSchema) => {
const req = createRequest(URI, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res.json() as Promise<PlaygroundResponseSchema>;
} catch (error) {
throw error;
}
};
const evaluateAdvancedPlayground = async (
payload: AdvancedPlaygroundRequestSchema
) => {
@ -47,7 +32,6 @@ export const usePlaygroundApi = () => {
};
return {
evaluatePlayground,
evaluateAdvancedPlayground,
errors,
loading,

View File

@ -67,7 +67,6 @@ exports[`should create default config 1`] = `
"isEnabled": [Function],
},
"flags": {
"advancedPlayground": false,
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"cleanClientApi": false,
@ -102,7 +101,6 @@ exports[`should create default config 1`] = `
},
"flagResolver": FlagResolver {
"experiments": {
"advancedPlayground": false,
"anonymiseEventLog": false,
"caseInsensitiveInOperators": false,
"cleanClientApi": false,

View File

@ -55,7 +55,7 @@ export default class PlaygroundController extends Controller {
},
requestBody: createRequestSchema('playgroundRequestSchema'),
description:
'Use the provided `context`, `environment`, and `projects` to evaluate toggles on this Unleash instance. Returns a list of all toggles that match the parameters and what they evaluate to. The response also contains the input parameters that were provided.',
'Deprecated. Will be removed in the next Unleash major update. Use the provided `context`, `environment`, and `projects` to evaluate toggles on this Unleash instance. Returns a list of all toggles that match the parameters and what they evaluate to. The response also contains the input parameters that were provided.',
summary:
'Evaluate an Unleash context against a set of environments and projects.',
}),
@ -115,9 +115,8 @@ export default class PlaygroundController extends Controller {
req: Request<any, any, AdvancedPlaygroundRequestSchema>,
res: Response<AdvancedPlaygroundResponseSchema>,
): Promise<void> {
if (this.flagResolver.isEnabled('advancedPlayground')) {
const { payload } =
this.flagResolver.getVariant('advancedPlayground');
// used for runtime control, do not remove
const { payload } = this.flagResolver.getVariant('advancedPlayground');
const limit =
payload?.value && Number.isInteger(parseInt(payload?.value))
? parseInt(payload?.value)
@ -134,8 +133,5 @@ export default class PlaygroundController extends Controller {
advancedPlaygroundViewModel(req.body, result);
res.json(response);
} else {
res.status(409).end();
}
}
}

View File

@ -95,10 +95,6 @@ const flags: IFlags = {
process.env.DISABLE_NOTIFICATIONS,
false,
),
advancedPlayground: parseEnvVarBoolean(
process.env.ADVANCED_PLAYGROUND,
false,
),
customRootRoles: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_CUSTOM_ROOT_ROLES,
false,

View File

@ -37,7 +37,6 @@ process.nextTick(async () => {
embedProxyFrontend: true,
anonymiseEventLog: false,
responseTimeWithAppNameKillSwitch: false,
advancedPlayground: true,
strategyVariant: true,
newProjectLayout: true,
emitPotentiallyStaleEvents: true,