1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-05-22 01:16:07 +02:00

feat: improve variants modal UI/UX (#3307)

https://linear.app/unleash/issue/2-758/add-variant-improve-the-flow


![image](https://user-images.githubusercontent.com/14320932/225064841-7fdb3b23-a06d-4078-b33a-50166e54a8b8.png)

![image](https://user-images.githubusercontent.com/14320932/225063913-ff92a563-7aa8-493f-a0dd-ef16f1474151.png)

### Variants form

- Fix variants edit form to follow natural tab order;
- Update variants form UI to new design with multiple improvements and
fixes, including a sticky header;
- New variants are now added at the bottom of the edit form instead of
at the top, with a smooth scroll and focus;

### Change requests

- On the variants diff, use variant names instead of index;
- Use an object-based diff logic (instead of array-based) for cleaner
diffs on variants (thanks @thomasheartman !);
- Display a table with the new variants data and display the diff on a
`TooltipLink`;
- Adapt strategy CR changes to the new `TooltipLink` logic for
consistency;

### Other

- `TooltipLink` and `Badge` components are now tab-selectable;
- Small enhancements, refactors and improvements;

---------

Co-authored-by: Gastón Fournier <gaston@getunleash.io>
This commit is contained in:
Nuno Góis 2023-03-15 12:22:06 +00:00 committed by GitHub
parent 6aeadfff33
commit 4e36981c96
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 337 additions and 247 deletions

View File

@ -10,9 +10,9 @@ import { ConditionallyRender } from 'component/common/ConditionallyRender/Condit
import { Alert, Box, styled } from '@mui/material'; import { Alert, Box, styled } from '@mui/material';
import { import {
CodeSnippetPopover, StrategyTooltipLink,
PopoverDiff, StrategyDiff,
} from '../../CodeSnippetPopover/CodeSnippetPopover'; } from 'component/changeRequest/ChangeRequest/StrategyTooltipLink/StrategyTooltipLink';
import { StrategyExecution } from '../../../../feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution'; import { StrategyExecution } from '../../../../feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/StrategyDraggableItem/StrategyItem/StrategyExecution/StrategyExecution';
import { ToggleStatusChange } from './ToggleStatusChange'; import { ToggleStatusChange } from './ToggleStatusChange';
import { import {
@ -98,14 +98,14 @@ export const Change: FC<{
{change.action === 'addStrategy' && ( {change.action === 'addStrategy' && (
<> <>
<StrategyAddedChange discard={discard}> <StrategyAddedChange discard={discard}>
<CodeSnippetPopover change={change}> <StrategyTooltipLink change={change}>
<PopoverDiff <StrategyDiff
change={change} change={change}
feature={feature.name} feature={feature.name}
environmentName={changeRequest.environment} environmentName={changeRequest.environment}
project={changeRequest.project} project={changeRequest.project}
/> />
</CodeSnippetPopover> </StrategyTooltipLink>
</StrategyAddedChange> </StrategyAddedChange>
<StrategyExecution strategy={change.payload} /> <StrategyExecution strategy={change.payload} />
</> </>
@ -113,28 +113,28 @@ export const Change: FC<{
{change.action === 'deleteStrategy' && ( {change.action === 'deleteStrategy' && (
<StrategyDeletedChange discard={discard}> <StrategyDeletedChange discard={discard}>
{hasNameField(change.payload) && ( {hasNameField(change.payload) && (
<CodeSnippetPopover change={change}> <StrategyTooltipLink change={change}>
<PopoverDiff <StrategyDiff
change={change} change={change}
feature={feature.name} feature={feature.name}
environmentName={changeRequest.environment} environmentName={changeRequest.environment}
project={changeRequest.project} project={changeRequest.project}
/> />
</CodeSnippetPopover> </StrategyTooltipLink>
)} )}
</StrategyDeletedChange> </StrategyDeletedChange>
)} )}
{change.action === 'updateStrategy' && ( {change.action === 'updateStrategy' && (
<> <>
<StrategyEditedChange discard={discard}> <StrategyEditedChange discard={discard}>
<CodeSnippetPopover change={change}> <StrategyTooltipLink change={change}>
<PopoverDiff <StrategyDiff
change={change} change={change}
feature={feature.name} feature={feature.name}
environmentName={changeRequest.environment} environmentName={changeRequest.environment}
project={changeRequest.project} project={changeRequest.project}
/> />
</CodeSnippetPopover> </StrategyTooltipLink>
</StrategyEditedChange> </StrategyEditedChange>
<StrategyExecution strategy={change.payload} /> <StrategyExecution strategy={change.payload} />
</> </>

View File

@ -1,5 +1,6 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import EventDiff from 'component/events/EventDiff/EventDiff'; import EventDiff from 'component/events/EventDiff/EventDiff';
import { IFeatureVariant } from 'interfaces/featureToggle';
const StyledCodeSection = styled('div')(({ theme }) => ({ const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto', overflowX: 'auto',
@ -13,17 +14,24 @@ const StyledCodeSection = styled('div')(({ theme }) => ({
})); }));
interface IDiffProps { interface IDiffProps {
preData: any; preData: IFeatureVariant[];
data: any; data: IFeatureVariant[];
} }
const variantsArrayToObject = (variants: IFeatureVariant[]) =>
variants.reduce(
(object, { name, ...variant }) => ({ ...object, [name]: variant }),
{}
);
export const Diff = ({ preData, data }: IDiffProps) => ( export const Diff = ({ preData, data }: IDiffProps) => (
<StyledCodeSection> <StyledCodeSection>
<EventDiff <EventDiff
entry={{ entry={{
preData, preData: variantsArrayToObject(preData),
data, data: variantsArrayToObject(data),
}} }}
sort={(a, b) => a.index - b.index}
/> />
</StyledCodeSection> </StyledCodeSection>
); );

View File

@ -1,20 +1,33 @@
import { Box, styled, Typography } from '@mui/material'; import { Box, styled } from '@mui/material';
import { IChangeRequestPatchVariant } from 'component/changeRequest/changeRequest.types'; import { IChangeRequestPatchVariant } from 'component/changeRequest/changeRequest.types';
import { Badge } from 'component/common/Badge/Badge';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { EnvironmentVariantsTable } from 'component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsCard/EnvironmentVariantsTable/EnvironmentVariantsTable';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { ReactNode } from 'react'; import { ReactNode } from 'react';
import { Diff } from './Diff'; import { Diff } from './Diff';
export const ChangeItemCreateEditWrapper = styled(Box)(({ theme }) => ({ const ChangeItemInfo = styled(Box)({
display: 'flex',
flexDirection: 'column',
});
const StyledChangeHeader = styled(Box)(({ theme }) => ({
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
alignItems: 'center', alignItems: 'center',
marginBottom: theme.spacing(2), marginBottom: theme.spacing(2),
lineHeight: theme.spacing(3),
})); }));
const ChangeItemInfo = styled(Box)(({ theme }) => ({ const StyledStickinessContainer = styled('div')(({ theme }) => ({
marginTop: theme.spacing(1.5),
display: 'flex', display: 'flex',
flexDirection: 'column', alignItems: 'center',
gap: theme.spacing(1), gap: theme.spacing(1.5),
marginBottom: theme.spacing(0.5),
fontSize: theme.fontSizes.smallBody,
})); }));
interface IVariantPatchProps { interface IVariantPatchProps {
@ -34,17 +47,44 @@ export const VariantPatch = ({
}: IVariantPatchProps) => { }: IVariantPatchProps) => {
const { feature: featureData } = useFeature(project, feature); const { feature: featureData } = useFeature(project, feature);
const preData = featureData.environments.find( const preData =
({ name }) => environment === name featureData.environments.find(({ name }) => environment === name)
)?.variants; ?.variants ?? [];
return ( return (
<ChangeItemCreateEditWrapper>
<ChangeItemInfo> <ChangeItemInfo>
<Typography>Updating variants:</Typography> <StyledChangeHeader>
<Diff preData={preData} data={change.payload.variants} /> <TooltipLink
</ChangeItemInfo> tooltip={
<Diff
preData={preData}
data={change.payload.variants}
/>
}
tooltipProps={{
maxWidth: 500,
maxHeight: 600,
}}
>
Updating variants to:
</TooltipLink>
{discard} {discard}
</ChangeItemCreateEditWrapper> </StyledChangeHeader>
<EnvironmentVariantsTable variants={change.payload.variants} />
<ConditionallyRender
condition={change.payload.variants.length > 1}
show={
<>
<StyledStickinessContainer>
<p>Stickiness:</p>
<Badge>
{change.payload.variants[0]?.stickiness ||
'default'}
</Badge>
</StyledStickinessContainer>
</>
}
/>
</ChangeItemInfo>
); );
}; };

View File

@ -1,122 +0,0 @@
import {
IChangeRequestAddStrategy,
IChangeRequestDeleteStrategy,
IChangeRequestUpdateStrategy,
} from '../../changeRequest.types';
import React, { FC } from 'react';
import {
formatStrategyName,
GetFeatureStrategyIcon,
} from '../../../../utils/strategyNames';
import { Popover, Typography } from '@mui/material';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { StyledCodeSection } from '../../../events/EventCard/EventCard';
import EventDiff from '../../../events/EventDiff/EventDiff';
import omit from 'lodash.omit';
const useCurrentStrategy = (
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy,
project: string,
feature: string,
environmentName: string
) => {
const currentFeature = useFeature(project, feature);
const currentStrategy = currentFeature.feature?.environments
.find(environment => environment.name === environmentName)
?.strategies.find(
strategy =>
'id' in change.payload && strategy.id === change.payload.id
);
return currentStrategy;
};
export const PopoverDiff: FC<{
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
project: string;
feature: string;
environmentName: string;
}> = ({ change, project, feature, environmentName }) => {
const currentStrategy = useCurrentStrategy(
change,
project,
feature,
environmentName
);
const changeRequestStrategy =
change.action === 'deleteStrategy' ? undefined : change.payload;
return (
<StyledCodeSection>
<EventDiff
entry={{
preData: omit(currentStrategy, 'sortOrder'),
data: changeRequestStrategy,
}}
/>
</StyledCodeSection>
);
};
interface ICodeSnippetPopoverProps {
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
}
// based on: https://mui.com/material-ui/react-popover/#mouse-over-interaction
export const CodeSnippetPopover: FC<ICodeSnippetPopoverProps> = ({
change,
children,
}) => {
const [anchorEl, setAnchorEl] = React.useState<HTMLElement | null>(null);
const handlePopoverOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handlePopoverClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
return (
<>
<GetFeatureStrategyIcon strategyName={change.payload.name} />
<Typography
onMouseEnter={handlePopoverOpen}
onMouseLeave={handlePopoverClose}
>
{formatStrategyName(change.payload.name)}
</Typography>
<Popover
id={String(change.id)}
sx={{
pointerEvents: 'none',
}}
open={open}
anchorEl={anchorEl}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
onClose={handlePopoverClose}
disableRestoreFocus
>
{children}
</Popover>
</>
);
};

View File

@ -0,0 +1,100 @@
import {
IChangeRequestAddStrategy,
IChangeRequestDeleteStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { FC } from 'react';
import {
formatStrategyName,
GetFeatureStrategyIcon,
} from 'utils/strategyNames';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import EventDiff from 'component/events/EventDiff/EventDiff';
import omit from 'lodash.omit';
import { TooltipLink } from 'component/common/TooltipLink/TooltipLink';
import { styled } from '@mui/material';
const StyledCodeSection = styled('div')(({ theme }) => ({
overflowX: 'auto',
'& code': {
wordWrap: 'break-word',
whiteSpace: 'pre-wrap',
fontFamily: 'monospace',
lineHeight: 1.5,
fontSize: theme.fontSizes.smallBody,
},
}));
const useCurrentStrategy = (
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy,
project: string,
feature: string,
environmentName: string
) => {
const currentFeature = useFeature(project, feature);
const currentStrategy = currentFeature.feature?.environments
.find(environment => environment.name === environmentName)
?.strategies.find(
strategy =>
'id' in change.payload && strategy.id === change.payload.id
);
return currentStrategy;
};
export const StrategyDiff: FC<{
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
project: string;
feature: string;
environmentName: string;
}> = ({ change, project, feature, environmentName }) => {
const currentStrategy = useCurrentStrategy(
change,
project,
feature,
environmentName
);
const changeRequestStrategy =
change.action === 'deleteStrategy' ? undefined : change.payload;
return (
<StyledCodeSection>
<EventDiff
entry={{
preData: omit(currentStrategy, 'sortOrder'),
data: changeRequestStrategy,
}}
/>
</StyledCodeSection>
);
};
interface IStrategyTooltipLinkProps {
change:
| IChangeRequestAddStrategy
| IChangeRequestUpdateStrategy
| IChangeRequestDeleteStrategy;
}
export const StrategyTooltipLink: FC<IStrategyTooltipLinkProps> = ({
change,
children,
}) => (
<>
<GetFeatureStrategyIcon strategyName={change.payload.name} />
<TooltipLink
tooltip={children}
tooltipProps={{
maxWidth: 500,
maxHeight: 600,
}}
>
{formatStrategyName(change.payload.name)}
</TooltipLink>
</>
);

View File

@ -80,6 +80,7 @@ export const Badge: FC<IBadgeProps> = forwardRef(
ref: ForwardedRef<HTMLDivElement> ref: ForwardedRef<HTMLDivElement>
) => ( ) => (
<StyledBadge <StyledBadge
tabIndex={0}
color={color} color={color}
icon={icon} icon={icon}
className={className} className={className}

View File

@ -36,7 +36,6 @@ const StyledContainer = styled('section', {
width: '100%', width: '100%',
display: 'flex', display: 'flex',
margin: '0 auto', margin: '0 auto',
overflow: 'hidden',
[theme.breakpoints.down(1100)]: { [theme.breakpoints.down(1100)]: {
flexDirection: 'column', flexDirection: 'column',
minHeight: 0, minHeight: 0,

View File

@ -27,7 +27,7 @@ export const TooltipLink = ({
...props ...props
}: ITooltipLinkProps) => ( }: ITooltipLinkProps) => (
<HtmlTooltip title={tooltip} {...tooltipProps} arrow> <HtmlTooltip title={tooltip} {...tooltipProps} arrow>
<StyledLink highlighted={highlighted} {...props}> <StyledLink tabIndex={0} highlighted={highlighted} {...props}>
{children} {children}
</StyledLink> </StyledLink>
</HtmlTooltip> </HtmlTooltip>

View File

@ -10,11 +10,21 @@ const DIFF_PREFIXES: Record<string, string> = {
N: '+', N: '+',
}; };
interface IEventDiffProps { interface IEventDiffResult {
entry: Partial<IEvent>; key: string;
value: JSX.Element;
index: number;
} }
const EventDiff = ({ entry }: IEventDiffProps) => { interface IEventDiffProps {
entry: Partial<IEvent>;
sort?: (a: IEventDiffResult, b: IEventDiffResult) => number;
}
const EventDiff = ({
entry,
sort = (a, b) => a.key.localeCompare(b.key),
}: IEventDiffProps) => {
const theme = useTheme(); const theme = useTheme();
const styles: Record<string, CSSProperties> = { const styles: Record<string, CSSProperties> = {
@ -48,7 +58,7 @@ const EventDiff = ({ entry }: IEventDiffProps) => {
return change; return change;
}; };
const buildDiff = (diff: any, idx: number) => { const buildDiff = (diff: any, index: number): IEventDiffResult => {
let change; let change;
const key = diff.path?.join('.') ?? diff.index; const key = diff.path?.join('.') ?? diff.index;
@ -66,15 +76,24 @@ const EventDiff = ({ entry }: IEventDiffProps) => {
</div> </div>
); );
} else { } else {
const changeValue = JSON.stringify(diff.rhs || diff.item);
change = ( change = (
<div style={styles[diff.kind]}> <div style={styles[diff.kind]}>
{DIFF_PREFIXES[diff.kind]} {key}:{' '} {DIFF_PREFIXES[diff.kind]} {key}
{JSON.stringify(diff.rhs || diff.item)} {changeValue
? `: ${changeValue}`
: diff.kind === 'D'
? ' (deleted)'
: ''}
</div> </div>
); );
} }
return { key: key.toString(), value: <div key={idx}>{change}</div> }; return {
key: key.toString(),
value: <div key={index}>{change}</div>,
index,
};
}; };
let changes; let changes;
@ -82,7 +101,7 @@ const EventDiff = ({ entry }: IEventDiffProps) => {
if (diffs) { if (diffs) {
changes = diffs changes = diffs
.map(buildDiff) .map(buildDiff)
.sort((a, b) => a.key.localeCompare(b.key)) .sort(sort)
.map(({ value }) => value); .map(({ value }) => value);
} else { } else {
// Just show the data if there is no diff yet. // Just show the data if there is no diff yet.

View File

@ -48,6 +48,10 @@ const StyledDescription = styled('p')(({ theme }) => ({
marginBottom: theme.spacing(1.5), marginBottom: theme.spacing(1.5),
})); }));
const StyledTableContainer = styled('div')(({ theme }) => ({
margin: theme.spacing(3, 0),
}));
const StyledStickinessContainer = styled('div')(({ theme }) => ({ const StyledStickinessContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@ -84,10 +88,12 @@ export const EnvironmentVariantsCard = ({
condition={variants.length > 0} condition={variants.length > 0}
show={ show={
<> <>
<StyledTableContainer>
<EnvironmentVariantsTable <EnvironmentVariantsTable
environment={environment} variants={variants}
searchValue={searchValue} searchValue={searchValue}
/> />
</StyledTableContainer>
<ConditionallyRender <ConditionallyRender
condition={variants.length > 1} condition={variants.length > 1}
show={ show={

View File

@ -1,10 +1,4 @@
import { import { TableBody, TableRow, useMediaQuery, useTheme } from '@mui/material';
styled,
TableBody,
TableRow,
useMediaQuery,
useTheme,
} from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { import {
SortableTableHeader, SortableTableHeader,
@ -18,11 +12,7 @@ import { SearchHighlightProvider } from 'component/common/Table/SearchHighlightC
import { calculateVariantWeight } from 'component/common/util'; import { calculateVariantWeight } from 'component/common/util';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useSearch } from 'hooks/useSearch'; import { useSearch } from 'hooks/useSearch';
import { import { IFeatureVariant, IOverride, IPayload } from 'interfaces/featureToggle';
IFeatureEnvironment,
IOverride,
IPayload,
} from 'interfaces/featureToggle';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useSortBy, useTable } from 'react-table'; import { useSortBy, useTable } from 'react-table';
import { sortTypes } from 'utils/sortTypes'; import { sortTypes } from 'utils/sortTypes';
@ -30,18 +20,14 @@ import { PayloadCell } from './PayloadCell/PayloadCell';
import { OverridesCell } from './OverridesCell/OverridesCell'; import { OverridesCell } from './OverridesCell/OverridesCell';
import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns'; import { useConditionallyHiddenColumns } from 'hooks/useConditionallyHiddenColumns';
const StyledTableContainer = styled('div')(({ theme }) => ({
margin: theme.spacing(3, 0),
}));
interface IEnvironmentVariantsTableProps { interface IEnvironmentVariantsTableProps {
environment: IFeatureEnvironment; variants: IFeatureVariant[];
searchValue: string; searchValue?: string;
} }
export const EnvironmentVariantsTable = ({ export const EnvironmentVariantsTable = ({
environment, variants,
searchValue, searchValue = '',
}: IEnvironmentVariantsTableProps) => { }: IEnvironmentVariantsTableProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -49,8 +35,6 @@ export const EnvironmentVariantsTable = ({
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md')); const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg')); const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
const variants = environment.variants ?? [];
const columns = useMemo( const columns = useMemo(
() => [ () => [
{ {
@ -107,7 +91,7 @@ export const EnvironmentVariantsTable = ({
sortType: 'alphanumeric', sortType: 'alphanumeric',
}, },
], ],
[projectId, variants, environment] [projectId, variants]
); );
const initialState = useMemo( const initialState = useMemo(
@ -156,7 +140,7 @@ export const EnvironmentVariantsTable = ({
); );
return ( return (
<StyledTableContainer> <>
<SearchHighlightProvider value={getSearchText(searchValue)}> <SearchHighlightProvider value={getSearchText(searchValue)}>
<Table {...getTableProps()}> <Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups as any} /> <SortableTableHeader headerGroups={headerGroups as any} />
@ -191,6 +175,6 @@ export const EnvironmentVariantsTable = ({
/> />
} }
/> />
</StyledTableContainer> </>
); );
}; };

View File

@ -1,4 +1,4 @@
import { Alert, Button, styled } from '@mui/material'; import { Alert, Button, Divider, styled } from '@mui/material';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
@ -29,8 +29,14 @@ const StyledFormSubtitle = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
}, },
marginTop: theme.spacing(-1.5), marginTop: theme.spacing(-3.5),
marginBottom: theme.spacing(4), marginBottom: theme.spacing(2),
backgroundColor: theme.palette.background.default,
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
position: 'sticky',
top: 0,
zIndex: 2,
})); }));
const StyledCloudCircle = styled(CloudCircle, { const StyledCloudCircle = styled(CloudCircle, {
@ -68,7 +74,7 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
const StyledVariantForms = styled('div')({ const StyledVariantForms = styled('div')({
display: 'flex', display: 'flex',
flexDirection: 'column-reverse', flexDirection: 'column',
}); });
const StyledStickinessContainer = styled('div')(({ theme }) => ({ const StyledStickinessContainer = styled('div')(({ theme }) => ({
@ -84,6 +90,10 @@ const StyledDescription = styled('p')(({ theme }) => ({
marginBottom: theme.spacing(1.5), marginBottom: theme.spacing(1.5),
})); }));
const StyledDivider = styled(Divider)(({ theme }) => ({
margin: theme.spacing(4, 0),
}));
const StyledStickinessSelect = styled(StickinessSelect)(({ theme }) => ({ const StyledStickinessSelect = styled(StickinessSelect)(({ theme }) => ({
minWidth: theme.spacing(20), minWidth: theme.spacing(20),
width: '100%', width: '100%',
@ -144,6 +154,7 @@ export const EnvironmentVariantsModal = ({
const oldVariants = environment?.variants || []; const oldVariants = environment?.variants || [];
const [variantsEdit, setVariantsEdit] = useState<IFeatureVariantEdit[]>([]); const [variantsEdit, setVariantsEdit] = useState<IFeatureVariantEdit[]>([]);
const [newVariant, setNewVariant] = useState<string>();
useEffect(() => { useEffect(() => {
setVariantsEdit( setVariantsEdit(
@ -183,6 +194,38 @@ export const EnvironmentVariantsModal = ({
); );
}; };
const addVariant = () => {
const id = uuidv4();
setVariantsEdit(variantsEdit => [
...variantsEdit,
{
name: '',
weightType: WeightType.VARIABLE,
weight: 0,
overrides: [],
stickiness:
variantsEdit?.length > 0
? variantsEdit[0].stickiness
: 'default',
new: true,
isValid: false,
id,
},
]);
setNewVariant(id);
};
useEffect(() => {
if (newVariant) {
const element = document.getElementById(
`variant-name-input-${newVariant}`
);
element?.scrollIntoView({ behavior: 'smooth', block: 'center' });
element?.focus({ preventScroll: true });
setNewVariant(undefined);
}
}, [newVariant]);
const variants = variantsEdit.map( const variants = variantsEdit.map(
({ new: _, isValid: __, id: ___, ...rest }) => rest ({ new: _, isValid: __, id: ___, ...rest }) => rest
); );
@ -286,24 +329,7 @@ export const EnvironmentVariantsModal = ({
</div> </div>
<PermissionButton <PermissionButton
data-testid="MODAL_ADD_VARIANT_BUTTON" data-testid="MODAL_ADD_VARIANT_BUTTON"
onClick={() => onClick={addVariant}
setVariantsEdit(variantsEdit => [
...variantsEdit,
{
name: '',
weightType: WeightType.VARIABLE,
weight: 0,
overrides: [],
stickiness:
variantsEdit?.length > 0
? variantsEdit[0].stickiness
: 'default',
new: true,
isValid: false,
id: uuidv4(),
},
])
}
variant="outlined" variant="outlined"
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
projectId={projectId} projectId={projectId}
@ -359,6 +385,16 @@ export const EnvironmentVariantsModal = ({
/> />
))} ))}
</StyledVariantForms> </StyledVariantForms>
<PermissionButton
onClick={addVariant}
variant="outlined"
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
projectId={projectId}
environmentId={environment?.name}
>
Add variant
</PermissionButton>
<StyledDivider />
<ConditionallyRender <ConditionallyRender
condition={variantsEdit.length > 0} condition={variantsEdit.length > 0}
show={ show={

View File

@ -65,6 +65,15 @@ const StyledFormControlLabel = styled(FormControlLabel)(({ theme }) => ({
}, },
})); }));
const StyledFieldColumn = styled('div')(({ theme }) => ({
width: '100%',
gap: theme.spacing(1.5),
display: 'flex',
'& > div': {
width: '100%',
},
}));
const StyledInput = styled(Input)(() => ({ const StyledInput = styled(Input)(() => ({
width: '100%', width: '100%',
})); }));
@ -103,13 +112,15 @@ const StyledTopRow = styled(StyledRow)({
}); });
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({ const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
marginRight: theme.spacing(10), marginRight: theme.spacing(10),
[theme.breakpoints.up('sm')]: {
minWidth: theme.spacing(20),
},
})); }));
const StyledAddOverrideButton = styled(Button)(({ theme }) => ({ const StyledAddOverrideButton = styled(Button)(({ theme }) => ({
width: theme.spacing(20), marginTop: theme.spacing(-1),
maxWidth: '100%', marginLeft: theme.spacing(-1),
})); }));
const payloadOptions = [ const payloadOptions = [
@ -319,8 +330,8 @@ export const VariantForm = ({
This will be used to identify the variant in your code This will be used to identify the variant in your code
</StyledSubLabel> </StyledSubLabel>
<StyledInput <StyledInput
id={`variant-name-input-${variant.id}`}
data-testid="VARIANT_NAME_INPUT" data-testid="VARIANT_NAME_INPUT"
autoFocus
label="Variant name" label="Variant name"
error={Boolean(errors.name)} error={Boolean(errors.name)}
errorText={errors.name} errorText={errors.name}
@ -391,6 +402,7 @@ export const VariantForm = ({
})); }));
}} }}
/> />
<StyledFieldColumn>
<StyledInput <StyledInput
id="variant-payload-value" id="variant-payload-value"
name="variant-payload-value" name="variant-payload-value"
@ -406,12 +418,15 @@ export const VariantForm = ({
})); }));
}} }}
placeholder={ placeholder={
payload.type === 'json' ? '{ "hello": "world" }' : '' payload.type === 'json'
? '{ "hello": "world" }'
: ''
} }
onBlur={() => validatePayload(payload)} onBlur={() => validatePayload(payload)}
error={Boolean(errors.payload)} error={Boolean(errors.payload)}
errorText={errors.payload} errorText={errors.payload}
/> />
</StyledFieldColumn>
</StyledRow> </StyledRow>
<StyledMarginLabel> <StyledMarginLabel>
Overrides Overrides
@ -421,13 +436,15 @@ export const VariantForm = ({
overrides={overrides} overrides={overrides}
overridesDispatch={overridesDispatch} overridesDispatch={overridesDispatch}
/> />
<div>
<StyledAddOverrideButton <StyledAddOverrideButton
onClick={onAddOverride} onClick={onAddOverride}
variant="outlined" variant="text"
color="primary" color="primary"
> >
Add override Add override
</StyledAddOverrideButton> </StyledAddOverrideButton>
</div>
</StyledVariantForm> </StyledVariantForm>
); );
}; };

View File

@ -24,8 +24,10 @@ const StyledRow = styled('div')(({ theme }) => ({
})); }));
const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({ const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
minWidth: theme.spacing(20),
marginRight: theme.spacing(10), marginRight: theme.spacing(10),
[theme.breakpoints.up('sm')]: {
minWidth: theme.spacing(20),
},
})); }));
const StyledFieldColumn = styled('div')(({ theme }) => ({ const StyledFieldColumn = styled('div')(({ theme }) => ({