diff --git a/frontend/src/assets/icons/isenabled-false.svg b/frontend/src/assets/icons/isenabled-false.svg new file mode 100644 index 0000000000..51edee56cc --- /dev/null +++ b/frontend/src/assets/icons/isenabled-false.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/assets/icons/isenabled-true.svg b/frontend/src/assets/icons/isenabled-true.svg new file mode 100644 index 0000000000..52e560d6d7 --- /dev/null +++ b/frontend/src/assets/icons/isenabled-true.svg @@ -0,0 +1,5 @@ + + + diff --git a/frontend/src/component/common/PageContent/PageContent.tsx b/frontend/src/component/common/PageContent/PageContent.tsx index df64a71205..3238d01d2e 100644 --- a/frontend/src/component/common/PageContent/PageContent.tsx +++ b/frontend/src/component/common/PageContent/PageContent.tsx @@ -17,9 +17,23 @@ interface IPageContentProps extends PaperProps { * @deprecated fix feature event log and remove */ disableBorder?: boolean; + disableLoading?: boolean; bodyClass?: string; } +const PageContentLoading: FC<{ isLoading: boolean }> = ({ + children, + isLoading, +}) => { + const ref = useLoading(isLoading); + + return ( +
+ {children} +
+ ); +}; + export const PageContent: FC = ({ children, header, @@ -27,11 +41,11 @@ export const PageContent: FC = ({ disableBorder = false, bodyClass = '', isLoading = false, + disableLoading = false, className, ...rest }) => { const { classes: styles } = useStyles(); - const ref = useLoading(isLoading); const headerClasses = classnames(styles.headerContainer, { [styles.paddingDisabled]: disablePadding, @@ -48,27 +62,33 @@ export const PageContent: FC = ({ const paperProps = disableBorder ? { elevation: 0 } : {}; + const content = ( + + + } + elseShow={header} + /> + + } + /> +
{children}
+
+ ); + + if (disableLoading) { + return content; + } + return ( -
- - - } - elseShow={header} - /> -
- } - /> -
{children}
- - + {content} ); }; diff --git a/frontend/src/component/common/Search/Search.tsx b/frontend/src/component/common/Search/Search.tsx index 24c2421fcb..544f49e6ca 100644 --- a/frontend/src/component/common/Search/Search.tsx +++ b/frontend/src/component/common/Search/Search.tsx @@ -15,6 +15,7 @@ interface ISearchProps { className?: string; placeholder?: string; hasFilters?: boolean; + disabled?: boolean; getSearchContext?: () => IGetSearchContextOutput; } @@ -24,6 +25,7 @@ export const Search = ({ className, placeholder: customPlaceholder, hasFilters, + disabled, getSearchContext, }: ISearchProps) => { const ref = useRef(); @@ -79,6 +81,7 @@ export const Search = ({ onChange={e => onSearchChange(e.target.value)} onFocus={() => setShowSuggestions(true)} onBlur={() => setShowSuggestions(false)} + disabled={disabled} />
= () => { const theme = useTheme(); + const [environment, onSetEnvironment] = useState(''); + const [projects, onSetProjects] = useState([]); const [context, setContext] = useState(); - const [contextObject, setContextObject] = useState(); + const [results, setResults] = useState< + PlaygroundResponseSchema | undefined + >(); const { setToastData } = useToast(); + const [searchParams, setSearchParams] = useSearchParams(); + const { evaluatePlayground, loading } = usePlaygroundApi(); - const onSubmit: FormEventHandler = event => { + useEffect(() => { + // Load initial values from URL + try { + const environmentFromUrl = searchParams.get('environment'); + if (environmentFromUrl) { + onSetEnvironment(environmentFromUrl); + } + const projectsFromUrl = searchParams.get('projects'); + if (projectsFromUrl) { + onSetProjects(projectsFromUrl.split(',')); + } + const contextFromUrl = searchParams.get('context'); + if (contextFromUrl) { + setContext(decodeURI(contextFromUrl)); + } + } catch (error) { + setToastData({ + type: 'error', + title: `Failed to parse URL parameters: ${formatUnknownError( + error + )}`, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const onSubmit: FormEventHandler = async event => { event.preventDefault(); try { - setContextObject( - JSON.stringify(JSON.parse(context || '{}'), null, 2) - ); + const parsedContext = JSON.parse(context || '{}'); + const response = await evaluatePlayground({ + environment, + projects: + !projects || + projects.length === 0 || + (projects.length === 1 && projects[0] === '*') + ? '*' + : projects, + context: { + appName: 'playground', + ...parsedContext, + }, + }); + + // Set URL search parameters + searchParams.set('context', encodeURI(context || '')); // always set because of native validation + searchParams.set('environment', environment); + if ( + Array.isArray(projects) && + projects.length > 0 && + !(projects.length === 1 && projects[0] === '*') + ) { + searchParams.set('projects', projects.join(',')); + } else { + searchParams.delete('projects'); + } + setSearchParams(searchParams); + + // Display results + setResults(response); } catch (error: unknown) { setToastData({ type: 'error', @@ -38,12 +104,18 @@ export const Playground: VFC = () => { }; return ( - }> + } + disableLoading + bodyClass={'no-padding'} + > @@ -55,7 +127,12 @@ export const Playground: VFC = () => { > Configure playground - + = () => { - {Boolean(contextObject) && ( - - TODO: Request -
{contextObject}
-
- )} + + + + + } + /> + +
); }; diff --git a/frontend/src/component/playground/Playground/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx index f2d1f17164..1388dd4f1b 100644 --- a/frontend/src/component/playground/Playground/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundCodeFieldset/PlaygroundCodeFieldset.tsx @@ -33,12 +33,15 @@ export const PlaygroundCodeFieldset: VFC = ({ }) => { const theme = useTheme(); const { setToastData } = useToast(); - const { context } = useUnleashContext(); - const contextOptions = context + const { context: contextData } = useUnleashContext(); + const contextOptions = contextData .sort((a, b) => a.sortOrder - b.sortOrder) .map(({ name }) => name); const [error, setError] = useState(); - const debounceSetError = useMemo( + const [fieldExist, setFieldExist] = useState(false); + const [contextField, setContextField] = useState(''); + const [contextValue, setContextValue] = useState(''); + const debounceJsonParsing = useMemo( () => debounce((input?: string) => { if (!input) { @@ -46,22 +49,22 @@ export const PlaygroundCodeFieldset: VFC = ({ } try { - JSON.parse(input); + const contextValue = JSON.parse(input); + + setFieldExist(contextValue[contextField] !== undefined); } catch (error: unknown) { return setError(formatUnknownError(error)); } return setError(undefined); }, 250), - [setError] + [setError, contextField, setFieldExist] ); useEffect(() => { - debounceSetError(value); - }, [debounceSetError, value]); + debounceJsonParsing(value); + }, [debounceJsonParsing, value]); - const [contextField, setContextField] = useState(''); - const [contextValue, setContextValue] = useState(''); const onAddField = () => { try { const currentValue = JSON.parse(value || '{}'); @@ -75,6 +78,7 @@ export const PlaygroundCodeFieldset: VFC = ({ 2 ) ); + setContextValue(''); } catch (error) { setToastData({ type: 'error', @@ -154,7 +158,11 @@ export const PlaygroundCodeFieldset: VFC = ({ disabled={!contextField || Boolean(error)} onClick={onAddField} > - Add context field + {`${ + !fieldExist + ? 'Add context field' + : 'Replace context field value' + } `} diff --git a/frontend/src/component/playground/Playground/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx b/frontend/src/component/playground/Playground/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx index 84ec89e74b..aa368906cd 100644 --- a/frontend/src/component/playground/Playground/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundConnectionFieldset/PlaygroundConnectionFieldset.tsx @@ -1,4 +1,4 @@ -import { ComponentProps, useState, VFC } from 'react'; +import { ComponentProps, VFC } from 'react'; import { Autocomplete, Box, @@ -9,7 +9,12 @@ import { import { useEnvironments } from 'hooks/api/getters/useEnvironments/useEnvironments'; import useProjects from 'hooks/api/getters/useProjects/useProjects'; -interface IPlaygroundConnectionFieldsetProps {} +interface IPlaygroundConnectionFieldsetProps { + environment: string; + projects: string[]; + setProjects: (projects: string[]) => void; + setEnvironment: (environment: string) => void; +} interface IOption { label: string; @@ -20,7 +25,7 @@ const allOption: IOption = { label: 'ALL', id: '*' }; export const PlaygroundConnectionFieldset: VFC< IPlaygroundConnectionFieldsetProps -> = () => { +> = ({ environment, projects, setProjects, setEnvironment }) => { const theme = useTheme(); const { environments } = useEnvironments(); const environmentOptions = environments @@ -36,7 +41,6 @@ export const PlaygroundConnectionFieldset: VFC< id, })), ]; - const [projects, setProjects] = useState(allOption); const onProjectsChange: ComponentProps['onChange'] = ( event, @@ -45,26 +49,29 @@ export const PlaygroundConnectionFieldset: VFC< ) => { const newProjects = value as IOption | IOption[]; if (reason === 'clear' || newProjects === null) { - return setProjects(allOption); + return setProjects([allOption.id]); } if (Array.isArray(newProjects)) { if (newProjects.length === 0) { - return setProjects(allOption); + return setProjects([allOption.id]); } if ( newProjects.find(({ id }) => id === allOption.id) !== undefined ) { - return setProjects(allOption); + return setProjects([allOption.id]); } - return setProjects(newProjects); + return setProjects(newProjects.map(({ id }) => id)); } if (newProjects.id === allOption.id) { - return setProjects(allOption); + return setProjects([allOption.id]); } - return setProjects([newProjects]); + return setProjects([newProjects.id]); }; + const isAllProjects = + projects.length === 0 || (projects.length === 1 && projects[0] === '*'); + return ( ( )} + value={environment} + onChange={(event, value) => setEnvironment(value || '')} size="small" /> ( )} size="small" - value={projects} + value={ + isAllProjects + ? allOption + : projectsOptions.filter(({ id }) => + projects.includes(id) + ) + } onChange={onProjectsChange} /> diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/ContextBanner/ContextBanner.tsx b/frontend/src/component/playground/Playground/PlaygroundResultsTable/ContextBanner/ContextBanner.tsx index 7880929d98..00383eb524 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/ContextBanner/ContextBanner.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/ContextBanner/ContextBanner.tsx @@ -1,24 +1,37 @@ import { colors } from 'themes/colors'; import { Alert, styled } from '@mui/material'; -import { SdkContextSchema } from '../../playground.model'; +import { SdkContextSchema } from 'hooks/api/actions/usePlayground/playground.model'; interface IContextBannerProps { + environment: string; + projects?: string | string[]; context: SdkContextSchema; } const StyledContextFieldList = styled('ul')(({ theme }) => ({ color: colors.black, listStyleType: 'none', - paddingInlineStart: theme.spacing(16), + padding: theme.spacing(2), })); -export const ContextBanner = ({ context }: IContextBannerProps) => { +export const ContextBanner = ({ + environment, + projects = [], + context, +}: IContextBannerProps) => { return ( - - Your results are generated based on this configuration + + Your results are generated based on this configuration: +
  • environment: {environment}
  • +
  • + projects:{' '} + {Array.isArray(projects) ? projects.join(', ') : projects} +
  • {Object.entries(context).map(([key, value]) => ( -
  • {`${key}: ${value}`}
  • +
  • + {key}: {value} +
  • ))}
    diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureStatusCell/FeatureStatusCell.tsx b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureStatusCell/FeatureStatusCell.tsx index c39825a741..9d7f99462c 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureStatusCell/FeatureStatusCell.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/FeatureStatusCell/FeatureStatusCell.tsx @@ -1,9 +1,8 @@ import React from 'react'; -import { TextCell } from 'component/common/Table/cells/TextCell/TextCell'; import { colors } from 'themes/colors'; import { ReactComponent as FeatureEnabledIcon } from 'assets/icons/isenabled-true.svg'; import { ReactComponent as FeatureDisabledIcon } from 'assets/icons/isenabled-false.svg'; -import { Chip, styled, useTheme } from '@mui/material'; +import { Box, Chip, styled, useTheme } from '@mui/material'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; interface IFeatureStatusCellProps { @@ -36,6 +35,16 @@ const StyledTrueChip = styled(Chip)(({ theme }) => ({ }, })); +const StyledCellBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + padding: theme.spacing(1, 2), +})); + +const StyledChipWrapper = styled(Box)(() => ({ + marginRight: 'auto', +})); + export const FeatureStatusCell = ({ enabled }: IFeatureStatusCellProps) => { const theme = useTheme(); const icon = ( @@ -43,13 +52,13 @@ export const FeatureStatusCell = ({ enabled }: IFeatureStatusCellProps) => { condition={enabled} show={ } elseShow={ } @@ -59,12 +68,14 @@ export const FeatureStatusCell = ({ enabled }: IFeatureStatusCellProps) => { const label = enabled ? 'True' : 'False'; return ( - - } - elseShow={} - /> - + + + } + elseShow={} + /> + + ); }; diff --git a/frontend/src/component/playground/Playground/PlaygroundResultsTable/PlaygroundResultsTable.tsx b/frontend/src/component/playground/Playground/PlaygroundResultsTable/PlaygroundResultsTable.tsx index 3688c92858..b86dcc3c08 100644 --- a/frontend/src/component/playground/Playground/PlaygroundResultsTable/PlaygroundResultsTable.tsx +++ b/frontend/src/component/playground/Playground/PlaygroundResultsTable/PlaygroundResultsTable.tsx @@ -20,7 +20,7 @@ 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 } from '../playground.model'; +import { PlaygroundFeatureSchema } from 'hooks/api/actions/usePlayground/playground.model'; const defaultSort: SortingRule = { id: 'name' }; const { value, setValue } = createLocalStorage( @@ -53,9 +53,9 @@ export const PlaygroundResultsTable = ({ return loading ? Array(5).fill({ name: 'Feature name', - project: 'Feature Project', - variant: 'Feature variant', - enabled: 'Feature state', + projectId: 'FeatureProject', + variant: { name: 'FeatureVariant' }, + enabled: true, }) : searchedData; }, [searchedData, loading]); @@ -109,6 +109,8 @@ export const PlaygroundResultsTable = ({ } if (searchValue) { tableState.search = searchValue; + } else { + delete tableState.search; } setSearchParams(tableState, { @@ -138,6 +140,7 @@ export const PlaygroundResultsTable = ({ onChange={setSearchValue} hasFilters getSearchContext={getSearchContext} + disabled={loading} /> } /> @@ -145,10 +148,12 @@ export const PlaygroundResultsTable = ({ isLoading={loading} > ( - None of the feature toggles were evaluated yet. + {data === undefined + ? 'None of the feature toggles were evaluated yet.' + : 'No results found.'} )} elseShow={() => ( @@ -203,8 +208,11 @@ const COLUMNS = [ accessor: 'name', searchable: true, width: '60%', - Cell: ({ value }: any) => ( - + Cell: ({ value, row: { original } }: any) => ( + ), }, { @@ -226,7 +234,12 @@ const COLUMNS = [ filterName: 'variant', searchable: true, maxWidth: 170, - Cell: ({ value }: any) => , + Cell: ({ + value, + row: { + original: { variant }, + }, + }: any) => , }, { Header: 'isEnabled', @@ -234,5 +247,6 @@ const COLUMNS = [ maxWidth: 170, Cell: ({ value }: any) => , sortType: 'boolean', + sortInverted: true, }, ]; diff --git a/frontend/src/component/playground/Playground/playground.model.ts b/frontend/src/hooks/api/actions/usePlayground/playground.model.ts similarity index 99% rename from frontend/src/component/playground/Playground/playground.model.ts rename to frontend/src/hooks/api/actions/usePlayground/playground.model.ts index fdf501124e..3b954902de 100644 --- a/frontend/src/component/playground/Playground/playground.model.ts +++ b/frontend/src/hooks/api/actions/usePlayground/playground.model.ts @@ -1,3 +1,5 @@ +// TODO: replace with auto-generated openapi code + export enum PlaygroundFeatureSchemaVariantPayloadTypeEnum { Json = 'json', Csv = 'csv', diff --git a/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts b/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts new file mode 100644 index 0000000000..5351b673ec --- /dev/null +++ b/frontend/src/hooks/api/actions/usePlayground/usePlayground.ts @@ -0,0 +1,37 @@ +import useAPI from '../useApi/useApi'; +import { + PlaygroundRequestSchema, + PlaygroundResponseSchema, +} from './playground.model'; + +const usePlaygroundApi = () => { + const { makeRequest, createRequest, errors, loading } = useAPI({ + propagateErrors: true, + }); + + const URI = 'api/admin/playground'; + + const evaluatePlayground = async (payload: PlaygroundRequestSchema) => { + const path = URI; + 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, + errors, + loading, + }; +}; + +export default usePlaygroundApi; diff --git a/frontend/src/hooks/useSearch.ts b/frontend/src/hooks/useSearch.ts index 95fadd517a..c8bce302c9 100644 --- a/frontend/src/hooks/useSearch.ts +++ b/frontend/src/hooks/useSearch.ts @@ -112,7 +112,7 @@ export const getColumnValues = (column: any, row: any) => { : column.accessor.includes('.') ? column.accessor .split('.') - .reduce((object: any, key: string) => object[key], row) + .reduce((object: any, key: string) => object?.[key], row) : row[column.accessor]; if (column.filterParsing) { diff --git a/frontend/src/utils/sortTypes.ts b/frontend/src/utils/sortTypes.ts index 2fc43e3702..42313e4c49 100644 --- a/frontend/src/utils/sortTypes.ts +++ b/frontend/src/utils/sortTypes.ts @@ -15,7 +15,7 @@ export const sortTypes = { return a === b ? 0 : a ? 1 : -1; }, alphanumeric: (a: any, b: any, id: string) => - a?.values?.[id] + (a?.values?.[id] || '') ?.toLowerCase() - .localeCompare(b?.values?.[id]?.toLowerCase()), + .localeCompare(b?.values?.[id]?.toLowerCase() || ''), };