1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-20 00:08:02 +01:00

feat: Advanced playground table (#3978)

<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->
Implements the Advanced Playground Table

## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

<!-- Does it close an issue? Multiple? -->
Closes #
[1-1007](https://linear.app/unleash/issue/1-1007/env-aware-results-table)

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
<!--
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#
-->

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->


![Screenshot 2023-06-14 at 15 04
08](https://github.com/Unleash/unleash/assets/104830839/2f76d6f5-f92b-4586-bb4b-265f26eeb836)

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-06-15 12:29:31 +03:00 committed by GitHub
parent a066d7888d
commit 650f6cc857
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 744 additions and 46 deletions

View File

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

View File

@ -0,0 +1,246 @@
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 { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { usePlaygroundApi } from 'hooks/api/actions/usePlayground/usePlayground';
import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments';
import { PlaygroundForm } from './PlaygroundForm/PlaygroundForm';
import {
resolveDefaultEnvironment,
resolveEnvironments,
resolveProjects,
resolveResultsWidth,
} from './playground.utils';
import { PlaygroundGuidance } from './PlaygroundGuidance/PlaygroundGuidance';
import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundGuidancePopper';
import Loader from '../../common/Loader/Loader';
import { AdvancedPlaygroundResultsTable } from './AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable';
import { AdvancedPlaygroundResponseSchema } from 'openapi';
export const AdvancedPlayground: 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<
AdvancedPlaygroundResponseSchema | undefined
>();
const { setToastData } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const { evaluateAdvancedPlayground, loading } = usePlaygroundApi();
useEffect(() => {
setEnvironments([resolveDefaultEnvironment(availableEnvironments)]);
}, [availableEnvironments]);
useEffect(() => {
loadInitialValuesFromUrl();
}, []);
const loadInitialValuesFromUrl = () => {
try {
const environments = resolveEnvironmentsFromUrl();
const projects = resolveProjectsFromUrl();
const context = resolveContextFromUrl();
const makePlaygroundRequest = async () => {
if (environments && context) {
await evaluatePlaygroundContext(
environments || [],
projects || '*',
context
);
}
};
makePlaygroundRequest();
} catch (error) {
setToastData({
type: 'error',
title: `Failed to parse URL parameters: ${formatUnknownError(
error
)}`,
});
}
};
const resolveEnvironmentsFromUrl = (): string[] | null => {
let environmentArray: string[] | null = null;
const environmentsFromUrl = searchParams.get('environments');
if (environmentsFromUrl) {
environmentArray = environmentsFromUrl.split(',');
setEnvironments(environmentArray);
}
return environmentArray;
};
const resolveProjectsFromUrl = (): string[] | null => {
let projectsArray: string[] | null = null;
let projectsFromUrl = searchParams.get('projects');
if (projectsFromUrl) {
projectsArray = projectsFromUrl.split(',');
setProjects(projectsArray);
}
return projectsArray;
};
const resolveContextFromUrl = () => {
let contextFromUrl = searchParams.get('context');
if (contextFromUrl) {
contextFromUrl = decodeURI(contextFromUrl);
setContext(contextFromUrl);
}
return contextFromUrl;
};
const evaluatePlaygroundContext = async (
environments: string[] | string,
projects: string[] | string,
context: string | undefined,
action?: () => void
) => {
try {
const parsedContext = JSON.parse(context || '{}');
const response = await evaluateAdvancedPlayground({
environments: resolveEnvironments(environments),
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,
projects,
context,
setURLParameters
);
};
const setURLParameters = () => {
searchParams.set('context', encodeURI(context || '')); // always set because of native validation
if (
Array.isArray(environments) &&
environments.length > 0 &&
!(environments.length === 1 && environments[0] === '*')
) {
searchParams.set('environments', environments.join(','));
} else {
searchParams.delete('projects');
}
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={
<AdvancedPlaygroundResultsTable
loading={loading}
features={results?.features}
input={results?.input}
/>
}
elseShow={<PlaygroundGuidance />}
/>
}
/>
</Box>
</Box>
</PageContent>
);
};
export default AdvancedPlayground;

View File

@ -0,0 +1,95 @@
import { ConditionallyRender } from '../../../../common/ConditionallyRender/ConditionallyRender';
import { Box, IconButton, Popover, styled, useTheme } from '@mui/material';
import { flexRow } from '../../../../../themes/themeStyles';
import { PlaygroundResultChip } from '../../PlaygroundResultsTable/PlaygroundResultChip/PlaygroundResultChip';
import { InfoOutlined } from '@mui/icons-material';
import React, { useState } from 'react';
import { AdvancedPlaygroundEnvironmentFeatureSchema } from 'openapi';
import { PlaygroundEnvironmentTable } from '../../PlaygroundEnvironmentTable/PlaygroundEnvironmentTable';
const StyledContainer = styled(
'div',
{}
)(({ theme }) => ({
flexGrow: 0,
...flexRow,
justifyContent: 'flex-start',
margin: theme.spacing(0, 1.5),
}));
const StyledPlaygroundChipContainer = styled(Box)(({ theme }) => ({
display: 'flex',
flexDirection: 'row',
gap: theme.spacing(1),
}));
export interface IAdvancedPlaygroundEnvironmentCellProps {
value: AdvancedPlaygroundEnvironmentFeatureSchema[];
}
export const AdvancedPlaygroundEnvironmentCell = ({
value,
}: IAdvancedPlaygroundEnvironmentCellProps) => {
const theme = useTheme();
const [anchor, setAnchorEl] = useState<null | Element>(null);
const onOpen = (event: React.FormEvent<HTMLButtonElement>) =>
setAnchorEl(event.currentTarget);
const onClose = () => setAnchorEl(null);
const open = Boolean(anchor);
const enabled = (value || []).filter(evaluation => evaluation.isEnabled);
const disabled = (value || []).filter(evaluation => !evaluation.isEnabled);
return (
<StyledContainer>
<StyledPlaygroundChipContainer>
<ConditionallyRender
condition={enabled.length > 0}
show={
<PlaygroundResultChip
enabled={true}
label={`${enabled.length}`}
showIcon={true}
/>
}
/>
<ConditionallyRender
condition={disabled.length > 0}
show={
<PlaygroundResultChip
enabled={false}
label={`${disabled.length}`}
showIcon={true}
/>
}
/>
</StyledPlaygroundChipContainer>
<>
<IconButton onClick={onOpen}>
<InfoOutlined />
</IconButton>
<Popover
open={open}
id={`${value}-result-details`}
PaperProps={{
sx: {
borderRadius: `${theme.shape.borderRadiusLarge}px`,
},
}}
onClose={onClose}
anchorEl={anchor}
anchorOrigin={{
vertical: 'bottom',
horizontal: -320,
}}
>
<PlaygroundEnvironmentTable features={value} />
</Popover>
</>
</StyledContainer>
);
};

View File

@ -0,0 +1,286 @@
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 {
Box,
Link,
styled,
Typography,
useMediaQuery,
useTheme,
} from '@mui/material';
import useLoading from 'hooks/useLoading';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
import { AdvancedPlaygroundEnvironmentCell } from './AdvancedPlaygroundEnvironmentCell/AdvancedPlaygroundEnvironmentCell';
import {
AdvancedPlaygroundRequestSchema,
AdvancedPlaygroundFeatureSchema,
} from 'openapi';
import { capitalizeFirst } from 'utils/capitalizeFirst';
const defaultSort: SortingRule<string> = { id: 'name' };
const { value, setValue } = createLocalStorage(
'AdvancedPlaygroundResultsTable:v1',
defaultSort
);
const StyledButton = styled(Link)(({ theme }) => ({
textAlign: 'left',
textDecorationStyle: 'dotted',
textUnderlineOffset: theme.spacing(0.75),
color: theme.palette.neutral.dark,
}));
interface IAdvancedPlaygroundResultsTableProps {
features?: AdvancedPlaygroundFeatureSchema[];
input?: AdvancedPlaygroundRequestSchema;
loading: boolean;
}
export const AdvancedPlaygroundResultsTable = ({
features,
input,
loading,
}: IAdvancedPlaygroundResultsTableProps) => {
const [searchParams, setSearchParams] = useSearchParams();
const ref = useLoading(loading);
const [searchValue, setSearchValue] = useState(
searchParams.get('search') || ''
);
const theme = useTheme();
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,
minWidth: 150,
Cell: ({ value }: any) => (
<LinkCell title={value} to={`/projects/${value}`} />
),
},
...(input?.environments?.map((name: string) => {
return {
Header: loading ? () => '' : capitalizeFirst(name),
maxWidth: 140,
id: `environments.${name}`,
align: 'flex-start',
Cell: ({ row }: any) => (
<AdvancedPlaygroundEnvironmentCell
value={row.original.environments[name]}
/>
),
};
}) || []),
{
Header: 'Diff',
minWidth: 150,
id: 'diff',
align: 'left',
Cell: ({ row }: any) => (
<StyledButton variant={'body2'}>Preview diff</StyledButton>
),
},
];
}, [input]);
const {
data: searchedData,
getSearchText,
getSearchContext,
} = useSearch(COLUMNS, searchValue, features || []);
const data = useMemo(() => {
return loading
? Array(5).fill({
name: 'Feature name',
projectId: 'Feature Project',
environments: { name: 'Feature Envrironments', 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: isSmallScreen,
columns: ['projectId'],
},
],
setHiddenColumns,
COLUMNS
);
useEffect(() => {
if (loading) {
return;
}
const tableState = 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} sx={{ overflow: 'auto' }}>
<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

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

View File

@ -21,11 +21,11 @@ import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundG
import Loader from '../../common/Loader/Loader'; import Loader from '../../common/Loader/Loader';
export const Playground: VFC<{}> = () => { export const Playground: VFC<{}> = () => {
const { environments } = useEnvironments(); const { environments: availableEnvironments } = useEnvironments();
const theme = useTheme(); const theme = useTheme();
const matches = useMediaQuery(theme.breakpoints.down('lg')); const matches = useMediaQuery(theme.breakpoints.down('lg'));
const [environment, setEnvironment] = useState<string>(''); const [environments, setEnvironments] = useState<string[]>([]);
const [projects, setProjects] = useState<string[]>([]); const [projects, setProjects] = useState<string[]>([]);
const [context, setContext] = useState<string>(); const [context, setContext] = useState<string>();
const [results, setResults] = useState< const [results, setResults] = useState<
@ -36,7 +36,7 @@ export const Playground: VFC<{}> = () => {
const { evaluatePlayground, loading } = usePlaygroundApi(); const { evaluatePlayground, loading } = usePlaygroundApi();
useEffect(() => { useEffect(() => {
setEnvironment(resolveDefaultEnvironment(environments)); setEnvironments([resolveDefaultEnvironment(availableEnvironments)]);
}, [environments]); }, [environments]);
useEffect(() => { useEffect(() => {
@ -44,7 +44,7 @@ export const Playground: VFC<{}> = () => {
try { try {
const environmentFromUrl = searchParams.get('environment'); const environmentFromUrl = searchParams.get('environment');
if (environmentFromUrl) { if (environmentFromUrl) {
setEnvironment(environmentFromUrl); setEnvironments([environmentFromUrl]);
} }
let projectsArray: string[]; let projectsArray: string[];
@ -115,7 +115,7 @@ export const Playground: VFC<{}> = () => {
event.preventDefault(); event.preventDefault();
await evaluatePlaygroundContext( await evaluatePlaygroundContext(
environment, environments[0],
projects, projects,
context, context,
setURLParameters setURLParameters
@ -124,7 +124,7 @@ export const Playground: VFC<{}> = () => {
const setURLParameters = () => { const setURLParameters = () => {
searchParams.set('context', encodeURI(context || '')); // always set because of native validation searchParams.set('context', encodeURI(context || '')); // always set because of native validation
searchParams.set('environment', environment); searchParams.set('environment', environments[0]);
if ( if (
Array.isArray(projects) && Array.isArray(projects) &&
projects.length > 0 && projects.length > 0 &&
@ -182,11 +182,11 @@ export const Playground: VFC<{}> = () => {
onSubmit={onSubmit} onSubmit={onSubmit}
context={context} context={context}
setContext={setContext} setContext={setContext}
environments={environments} availableEnvironments={availableEnvironments}
projects={projects} projects={projects}
environment={environment} environments={environments}
setProjects={setProjects} setProjects={setProjects}
setEnvironment={setEnvironment} setEnvironments={setEnvironments}
/> />
</Paper> </Paper>
</Box> </Box>

View File

@ -18,6 +18,7 @@ import { FeatureStatusCell } from '../PlaygroundResultsTable/FeatureStatusCell/F
import { FeatureResultInfoPopoverCell } from '../PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureResultInfoPopoverCell'; import { FeatureResultInfoPopoverCell } from '../PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureResultInfoPopoverCell';
import { VariantCell } from '../PlaygroundResultsTable/VariantCell/VariantCell'; import { VariantCell } from '../PlaygroundResultsTable/VariantCell/VariantCell';
import { HighlightCell } from '../../../common/Table/cells/HighlightCell/HighlightCell'; import { HighlightCell } from '../../../common/Table/cells/HighlightCell/HighlightCell';
import { capitalizeFirst } from 'utils/capitalizeFirst';
interface IPlaygroundEnvironmentTableProps { interface IPlaygroundEnvironmentTableProps {
features: AdvancedPlaygroundEnvironmentFeatureSchema[]; features: AdvancedPlaygroundEnvironmentFeatureSchema[];
@ -32,7 +33,7 @@ export const PlaygroundEnvironmentTable = ({
const dynamicHeaders = Object.keys(features[0].context) const dynamicHeaders = Object.keys(features[0].context)
.filter(contextField => contextField !== 'appName') .filter(contextField => contextField !== 'appName')
.map(contextField => ({ .map(contextField => ({
Header: contextField, Header: capitalizeFirst(contextField),
accessor: `context.${contextField}`, accessor: `context.${contextField}`,
minWidth: 160, minWidth: 160,
Cell: HighlightCell, Cell: HighlightCell,

View File

@ -10,11 +10,11 @@ import useProjects from 'hooks/api/getters/useProjects/useProjects';
import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator'; import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator';
interface IPlaygroundConnectionFieldsetProps { interface IPlaygroundConnectionFieldsetProps {
environment: string; environments: string[];
projects: string[]; projects: string[];
setProjects: (projects: string[]) => void; setProjects: (projects: string[]) => void;
setEnvironment: (environment: string) => void; setEnvironments: (environments: string[]) => void;
environmentOptions: string[]; availableEnvironments: string[];
} }
interface IOption { interface IOption {
@ -27,11 +27,11 @@ const allOption: IOption = { label: 'ALL', id: '*' };
export const PlaygroundConnectionFieldset: VFC< export const PlaygroundConnectionFieldset: VFC<
IPlaygroundConnectionFieldsetProps IPlaygroundConnectionFieldsetProps
> = ({ > = ({
environment, environments,
projects, projects,
setProjects, setProjects,
setEnvironment, setEnvironments,
environmentOptions, availableEnvironments,
}) => { }) => {
const theme = useTheme(); const theme = useTheme();
@ -44,6 +44,13 @@ export const PlaygroundConnectionFieldset: VFC<
})), })),
]; ];
const environmentOptions = [
...availableEnvironments.map(name => ({
label: name,
id: name,
})),
];
const onProjectsChange: ComponentProps<typeof Autocomplete>['onChange'] = ( const onProjectsChange: ComponentProps<typeof Autocomplete>['onChange'] = (
event, event,
value, value,
@ -71,6 +78,23 @@ export const PlaygroundConnectionFieldset: VFC<
return setProjects([newProjects.id]); return setProjects([newProjects.id]);
}; };
const onEnvironmentsChange: ComponentProps<
typeof Autocomplete
>['onChange'] = (event, value, reason) => {
const newEnvironments = value as IOption | IOption[];
if (reason === 'clear' || newEnvironments === null) {
return setEnvironments([]);
}
if (Array.isArray(newEnvironments)) {
if (newEnvironments.length === 0) {
return setEnvironments([]);
}
return setEnvironments(newEnvironments.map(({ id }) => id));
}
return setEnvironments([newEnvironments.id]);
};
const isAllProjects = const isAllProjects =
projects.length === 0 || (projects.length === 1 && projects[0] === '*'); projects.length === 0 || (projects.length === 1 && projects[0] === '*');
@ -89,15 +113,18 @@ export const PlaygroundConnectionFieldset: VFC<
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}> <Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap' }}>
<Autocomplete <Autocomplete
disablePortal disablePortal
multiple
id="environment" id="environment"
options={environmentOptions} options={environmentOptions}
sx={{ width: 200, maxWidth: '100%' }} sx={{ width: 200, maxWidth: '100%' }}
renderInput={params => ( renderInput={params => (
<TextField {...params} label="Environment" required /> <TextField {...params} label="Environments" />
)} )}
value={environment}
onChange={(event, value) => setEnvironment(value || '')}
size="small" size="small"
value={environmentOptions.filter(({ id }) =>
environments.includes(id)
)}
onChange={onEnvironmentsChange}
/> />
<Autocomplete <Autocomplete
disablePortal disablePortal

View File

@ -2,28 +2,27 @@ import { Box, Button, Divider, useTheme } from '@mui/material';
import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator'; import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator';
import { IEnvironment } from 'interfaces/environments'; import { IEnvironment } from 'interfaces/environments';
import { FormEvent, VFC } from 'react'; import { FormEvent, VFC } from 'react';
import { getEnvironmentOptions } from '../playground.utils';
import { PlaygroundCodeFieldset } from './PlaygroundCodeFieldset/PlaygroundCodeFieldset'; import { PlaygroundCodeFieldset } from './PlaygroundCodeFieldset/PlaygroundCodeFieldset';
import { PlaygroundConnectionFieldset } from './PlaygroundConnectionFieldset/PlaygroundConnectionFieldset'; import { PlaygroundConnectionFieldset } from './PlaygroundConnectionFieldset/PlaygroundConnectionFieldset';
interface IPlaygroundFormProps { interface IPlaygroundFormProps {
environments: IEnvironment[]; availableEnvironments: IEnvironment[];
onSubmit: (event: FormEvent<HTMLFormElement>) => void; onSubmit: (event: FormEvent<HTMLFormElement>) => void;
environment: string; environments: string | string[];
projects: string[]; projects: string[];
setProjects: React.Dispatch<React.SetStateAction<string[]>>; setProjects: React.Dispatch<React.SetStateAction<string[]>>;
setEnvironment: React.Dispatch<React.SetStateAction<string>>; setEnvironments: React.Dispatch<React.SetStateAction<string[]>>;
context: string | undefined; context: string | undefined;
setContext: React.Dispatch<React.SetStateAction<string | undefined>>; setContext: React.Dispatch<React.SetStateAction<string | undefined>>;
} }
export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({ export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({
availableEnvironments,
environments, environments,
environment,
onSubmit, onSubmit,
projects, projects,
setProjects, setProjects,
setEnvironment, setEnvironments,
context, context,
setContext, setContext,
}) => { }) => {
@ -39,11 +38,15 @@ export const PlaygroundForm: VFC<IPlaygroundFormProps> = ({
}} }}
> >
<PlaygroundConnectionFieldset <PlaygroundConnectionFieldset
environment={environment} environments={
Array.isArray(environments) ? environments : [environments]
}
projects={projects} projects={projects}
setEnvironment={setEnvironment} setEnvironments={setEnvironments}
setProjects={setProjects} setProjects={setProjects}
environmentOptions={getEnvironmentOptions(environments)} availableEnvironments={availableEnvironments.map(
({ name }) => name
)}
/> />
<Divider <Divider
variant="fullWidth" variant="fullWidth"

View File

@ -1,5 +1,9 @@
import { PlaygroundResponseSchema } from 'openapi'; import {
PlaygroundResponseSchema,
AdvancedPlaygroundResponseSchema,
} from 'openapi';
import { IEnvironment } from 'interfaces/environments'; import { IEnvironment } from 'interfaces/environments';
import { ensureArray } from '@server/util/ensureArray';
export const resolveProjects = ( export const resolveProjects = (
projects: string[] | string projects: string[] | string
@ -12,11 +16,13 @@ export const resolveProjects = (
return '*'; return '*';
} }
if (Array.isArray(projects)) { return ensureArray(projects);
return projects; };
}
return [projects]; export const resolveEnvironments = (
envrironments: string[] | string
): string[] => {
return ensureArray(envrironments);
}; };
export const resolveDefaultEnvironment = ( export const resolveDefaultEnvironment = (
@ -38,7 +44,10 @@ export const getEnvironmentOptions = (environments: IEnvironment[]) => {
export const resolveResultsWidth = ( export const resolveResultsWidth = (
matches: boolean, matches: boolean,
results: PlaygroundResponseSchema | undefined results:
| PlaygroundResponseSchema
| AdvancedPlaygroundResponseSchema
| undefined
) => { ) => {
if (matches) { if (matches) {
return '100%'; return '100%';

View File

@ -1,8 +1,10 @@
import useAPI from '../useApi/useApi'; import useAPI from '../useApi/useApi';
import { import {
AdvancedPlaygroundRequestSchema,
AdvancedPlaygroundResponseSchema,
PlaygroundRequestSchema, PlaygroundRequestSchema,
PlaygroundResponseSchema, PlaygroundResponseSchema,
} from '../../../../openapi'; } from 'openapi';
export const usePlaygroundApi = () => { export const usePlaygroundApi = () => {
const { makeRequest, createRequest, errors, loading } = useAPI({ const { makeRequest, createRequest, errors, loading } = useAPI({
@ -12,8 +14,7 @@ export const usePlaygroundApi = () => {
const URI = 'api/admin/playground'; const URI = 'api/admin/playground';
const evaluatePlayground = async (payload: PlaygroundRequestSchema) => { const evaluatePlayground = async (payload: PlaygroundRequestSchema) => {
const path = URI; const req = createRequest(URI, {
const req = createRequest(path, {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
@ -27,8 +28,27 @@ export const usePlaygroundApi = () => {
} }
}; };
const evaluateAdvancedPlayground = async (
payload: AdvancedPlaygroundRequestSchema
) => {
const path = `${URI}/advanced`;
const req = createRequest(path, {
method: 'POST',
body: JSON.stringify(payload),
});
try {
const res = await makeRequest(req.caller, req.id);
return res.json() as Promise<AdvancedPlaygroundResponseSchema>;
} catch (error) {
throw error;
}
};
return { return {
evaluatePlayground, evaluatePlayground,
evaluateAdvancedPlayground,
errors, errors,
loading, loading,
}; };

View File

@ -0,0 +1,3 @@
export const capitalizeFirst = (string: string) => {
return string.charAt(0).toUpperCase() + string.slice(1);
};

View File

@ -0,0 +1,3 @@
export function ensureArray<T>(input: T | T[]): T[] {
return Array.isArray(input) ? input : [input];
}