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() || ''),
};