From 650f6cc8578b840809da213be1555928d748eb72 Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Thu, 15 Jun 2023 12:29:31 +0300 Subject: [PATCH] feat: Advanced playground table (#3978) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the Advanced Playground Table ## About the changes Closes # [1-1007](https://linear.app/unleash/issue/1-1007/env-aware-results-table) ### Important files ![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 --- .../__snapshots__/routes.test.tsx.snap | 9 +- .../Playground/AdvancedPlayground.tsx | 246 +++++++++++++++ .../AdvancedPlaygroundEnvironmentCell.tsx | 95 ++++++ .../AdvancedPlaygroundResultsTable.tsx | 286 ++++++++++++++++++ .../playground/Playground/LazyPlayground.tsx | 14 +- .../playground/Playground/Playground.tsx | 18 +- .../PlaygroundEnvironmentTable.tsx | 3 +- .../PlaygroundConnectionFieldset.tsx | 45 ++- .../PlaygroundForm/PlaygroundForm.tsx | 21 +- .../playground/Playground/playground.utils.ts | 21 +- .../actions/usePlayground/usePlayground.ts | 26 +- frontend/src/utils/capitalizeFirst.ts | 3 + src/lib/util/ensureArray.ts | 3 + 13 files changed, 744 insertions(+), 46 deletions(-) create mode 100644 frontend/src/component/playground/Playground/AdvancedPlayground.tsx create mode 100644 frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundEnvironmentCell/AdvancedPlaygroundEnvironmentCell.tsx create mode 100644 frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable.tsx create mode 100644 frontend/src/utils/capitalizeFirst.ts create mode 100644 src/lib/util/ensureArray.ts diff --git a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap index 9dde07c560..7aac9c6d75 100644 --- a/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap +++ b/frontend/src/component/menu/__tests__/__snapshots__/routes.test.tsx.snap @@ -133,14 +133,7 @@ exports[`returns all baseRoutes 1`] = ` "type": "protected", }, { - "component": { - "$$typeof": Symbol(react.lazy), - "_init": [Function], - "_payload": { - "_result": [Function], - "_status": -1, - }, - }, + "component": [Function], "hidden": false, "menu": { "mobile": true, diff --git a/frontend/src/component/playground/Playground/AdvancedPlayground.tsx b/frontend/src/component/playground/Playground/AdvancedPlayground.tsx new file mode 100644 index 0000000000..c3b1e6edae --- /dev/null +++ b/frontend/src/component/playground/Playground/AdvancedPlayground.tsx @@ -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([]); + const [projects, setProjects] = useState([]); + const [context, setContext] = useState(); + 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 = 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 ( + } + /> + } + disableLoading + bodyClass={'no-padding'} + > + + + + + + + ({ + width: resultsWidth, + transition: 'width 0.4s ease', + padding: theme.spacing(4, 2), + })} + > + } + elseShow={ + + } + elseShow={} + /> + } + /> + + + + ); +}; + +export default AdvancedPlayground; diff --git a/frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundEnvironmentCell/AdvancedPlaygroundEnvironmentCell.tsx b/frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundEnvironmentCell/AdvancedPlaygroundEnvironmentCell.tsx new file mode 100644 index 0000000000..990e01eee9 --- /dev/null +++ b/frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundEnvironmentCell/AdvancedPlaygroundEnvironmentCell.tsx @@ -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); + + const onOpen = (event: React.FormEvent) => + 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 ( + + + 0} + show={ + + } + /> + 0} + show={ + + } + /> + + <> + + + + + + + + + + ); +}; diff --git a/frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable.tsx b/frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable.tsx new file mode 100644 index 0000000000..ef0daea09d --- /dev/null +++ b/frontend/src/component/playground/Playground/AdvancedPlaygroundResultsTable/AdvancedPlaygroundResultsTable.tsx @@ -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 = { 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) => ( + + ), + }, + { + Header: 'Project ID', + accessor: 'projectId', + sortType: 'alphanumeric', + filterName: 'projectId', + searchable: true, + minWidth: 150, + Cell: ({ value }: any) => ( + + ), + }, + ...(input?.environments?.map((name: string) => { + return { + Header: loading ? () => '' : capitalizeFirst(name), + maxWidth: 140, + id: `environments.${name}`, + align: 'flex-start', + Cell: ({ row }: any) => ( + + ), + }; + }) || []), + { + Header: 'Diff', + minWidth: 150, + id: 'diff', + align: 'left', + Cell: ({ row }: any) => ( + Preview diff + ), + }, + ]; + }, [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 ( + <> + + + {features !== undefined && !loading + ? `Results (${ + rows.length < data.length + ? `${rows.length} of ${data.length}` + : data.length + })` + : 'Results'} + + + + + ( + + {data === undefined + ? 'None of the feature toggles were evaluated yet.' + : 'No results found.'} + + )} + elseShow={() => ( + + + + + 0 + } + show={ + + No feature toggles found matching “ + {searchValue}” + + } + /> + + + No features toggles to display + + } + /> + + )} + /> + + ); +}; diff --git a/frontend/src/component/playground/Playground/LazyPlayground.tsx b/frontend/src/component/playground/Playground/LazyPlayground.tsx index 41ea00f4b3..a8dbc8f87c 100644 --- a/frontend/src/component/playground/Playground/LazyPlayground.tsx +++ b/frontend/src/component/playground/Playground/LazyPlayground.tsx @@ -1,3 +1,15 @@ 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 ; + + return ; +}; diff --git a/frontend/src/component/playground/Playground/Playground.tsx b/frontend/src/component/playground/Playground/Playground.tsx index 99bb1483f8..a68f1b9775 100644 --- a/frontend/src/component/playground/Playground/Playground.tsx +++ b/frontend/src/component/playground/Playground/Playground.tsx @@ -21,11 +21,11 @@ import { PlaygroundGuidancePopper } from './PlaygroundGuidancePopper/PlaygroundG import Loader from '../../common/Loader/Loader'; export const Playground: VFC<{}> = () => { - const { environments } = useEnvironments(); + const { environments: availableEnvironments } = useEnvironments(); const theme = useTheme(); const matches = useMediaQuery(theme.breakpoints.down('lg')); - const [environment, setEnvironment] = useState(''); + const [environments, setEnvironments] = useState([]); const [projects, setProjects] = useState([]); const [context, setContext] = useState(); const [results, setResults] = useState< @@ -36,7 +36,7 @@ export const Playground: VFC<{}> = () => { const { evaluatePlayground, loading } = usePlaygroundApi(); useEffect(() => { - setEnvironment(resolveDefaultEnvironment(environments)); + setEnvironments([resolveDefaultEnvironment(availableEnvironments)]); }, [environments]); useEffect(() => { @@ -44,7 +44,7 @@ export const Playground: VFC<{}> = () => { try { const environmentFromUrl = searchParams.get('environment'); if (environmentFromUrl) { - setEnvironment(environmentFromUrl); + setEnvironments([environmentFromUrl]); } let projectsArray: string[]; @@ -115,7 +115,7 @@ export const Playground: VFC<{}> = () => { event.preventDefault(); await evaluatePlaygroundContext( - environment, + environments[0], projects, context, setURLParameters @@ -124,7 +124,7 @@ export const Playground: VFC<{}> = () => { const setURLParameters = () => { searchParams.set('context', encodeURI(context || '')); // always set because of native validation - searchParams.set('environment', environment); + searchParams.set('environment', environments[0]); if ( Array.isArray(projects) && projects.length > 0 && @@ -182,11 +182,11 @@ export const Playground: VFC<{}> = () => { onSubmit={onSubmit} context={context} setContext={setContext} - environments={environments} + availableEnvironments={availableEnvironments} projects={projects} - environment={environment} + environments={environments} setProjects={setProjects} - setEnvironment={setEnvironment} + setEnvironments={setEnvironments} /> diff --git a/frontend/src/component/playground/Playground/PlaygroundEnvironmentTable/PlaygroundEnvironmentTable.tsx b/frontend/src/component/playground/Playground/PlaygroundEnvironmentTable/PlaygroundEnvironmentTable.tsx index 0a3889576a..b305eeb91e 100644 --- a/frontend/src/component/playground/Playground/PlaygroundEnvironmentTable/PlaygroundEnvironmentTable.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundEnvironmentTable/PlaygroundEnvironmentTable.tsx @@ -18,6 +18,7 @@ import { FeatureStatusCell } from '../PlaygroundResultsTable/FeatureStatusCell/F import { FeatureResultInfoPopoverCell } from '../PlaygroundResultsTable/FeatureResultInfoPopoverCell/FeatureResultInfoPopoverCell'; import { VariantCell } from '../PlaygroundResultsTable/VariantCell/VariantCell'; import { HighlightCell } from '../../../common/Table/cells/HighlightCell/HighlightCell'; +import { capitalizeFirst } from 'utils/capitalizeFirst'; interface IPlaygroundEnvironmentTableProps { features: AdvancedPlaygroundEnvironmentFeatureSchema[]; @@ -32,7 +33,7 @@ export const PlaygroundEnvironmentTable = ({ const dynamicHeaders = Object.keys(features[0].context) .filter(contextField => contextField !== 'appName') .map(contextField => ({ - Header: contextField, + Header: capitalizeFirst(contextField), accessor: `context.${contextField}`, minWidth: 160, Cell: HighlightCell, diff --git a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx index c364bfecb4..b694d3e375 100644 --- a/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundForm/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx @@ -10,11 +10,11 @@ import useProjects from 'hooks/api/getters/useProjects/useProjects'; import { GuidanceIndicator } from 'component/common/GuidanceIndicator/GuidanceIndicator'; interface IPlaygroundConnectionFieldsetProps { - environment: string; + environments: string[]; projects: string[]; setProjects: (projects: string[]) => void; - setEnvironment: (environment: string) => void; - environmentOptions: string[]; + setEnvironments: (environments: string[]) => void; + availableEnvironments: string[]; } interface IOption { @@ -27,11 +27,11 @@ const allOption: IOption = { label: 'ALL', id: '*' }; export const PlaygroundConnectionFieldset: VFC< IPlaygroundConnectionFieldsetProps > = ({ - environment, + environments, projects, setProjects, - setEnvironment, - environmentOptions, + setEnvironments, + availableEnvironments, }) => { const theme = useTheme(); @@ -44,6 +44,13 @@ export const PlaygroundConnectionFieldset: VFC< })), ]; + const environmentOptions = [ + ...availableEnvironments.map(name => ({ + label: name, + id: name, + })), + ]; + const onProjectsChange: ComponentProps['onChange'] = ( event, value, @@ -71,6 +78,23 @@ export const PlaygroundConnectionFieldset: VFC< 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 = projects.length === 0 || (projects.length === 1 && projects[0] === '*'); @@ -89,15 +113,18 @@ export const PlaygroundConnectionFieldset: VFC< ( - + )} - value={environment} - onChange={(event, value) => setEnvironment(value || '')} size="small" + value={environmentOptions.filter(({ id }) => + environments.includes(id) + )} + onChange={onEnvironmentsChange} /> ) => void; - environment: string; + environments: string | string[]; projects: string[]; setProjects: React.Dispatch>; - setEnvironment: React.Dispatch>; + setEnvironments: React.Dispatch>; context: string | undefined; setContext: React.Dispatch>; } export const PlaygroundForm: VFC = ({ + availableEnvironments, environments, - environment, onSubmit, projects, setProjects, - setEnvironment, + setEnvironments, context, setContext, }) => { @@ -39,11 +38,15 @@ export const PlaygroundForm: VFC = ({ }} > name + )} /> { + return ensureArray(envrironments); }; export const resolveDefaultEnvironment = ( @@ -38,7 +44,10 @@ export const getEnvironmentOptions = (environments: IEnvironment[]) => { export const resolveResultsWidth = ( matches: boolean, - results: PlaygroundResponseSchema | undefined + results: + | PlaygroundResponseSchema + | AdvancedPlaygroundResponseSchema + | undefined ) => { if (matches) { return '100%'; diff --git a/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts b/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts index e6b5c439f2..241abf7104 100644 --- a/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts +++ b/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts @@ -1,8 +1,10 @@ import useAPI from '../useApi/useApi'; import { + AdvancedPlaygroundRequestSchema, + AdvancedPlaygroundResponseSchema, PlaygroundRequestSchema, PlaygroundResponseSchema, -} from '../../../../openapi'; +} from 'openapi'; export const usePlaygroundApi = () => { const { makeRequest, createRequest, errors, loading } = useAPI({ @@ -12,8 +14,7 @@ export const usePlaygroundApi = () => { const URI = 'api/admin/playground'; const evaluatePlayground = async (payload: PlaygroundRequestSchema) => { - const path = URI; - const req = createRequest(path, { + const req = createRequest(URI, { method: 'POST', 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; + } catch (error) { + throw error; + } + }; + return { evaluatePlayground, + evaluateAdvancedPlayground, errors, loading, }; diff --git a/frontend/src/utils/capitalizeFirst.ts b/frontend/src/utils/capitalizeFirst.ts new file mode 100644 index 0000000000..bbba60ace6 --- /dev/null +++ b/frontend/src/utils/capitalizeFirst.ts @@ -0,0 +1,3 @@ +export const capitalizeFirst = (string: string) => { + return string.charAt(0).toUpperCase() + string.slice(1); +}; diff --git a/src/lib/util/ensureArray.ts b/src/lib/util/ensureArray.ts new file mode 100644 index 0000000000..e489d2da3f --- /dev/null +++ b/src/lib/util/ensureArray.ts @@ -0,0 +1,3 @@ +export function ensureArray(input: T | T[]): T[] { + return Array.isArray(input) ? input : [input]; +}