1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: new variants table (#1025)

* fix: cleanup

* fix: text

* fix: stable references

* refactor: fix VARIANT_WEIGH test id

* refactor: fix variant element selection in e2e test

* fix: update variants table

* fix: refactor action cell

Co-authored-by: olav <mail@olav.io>
This commit is contained in:
Fredrik Strand Oseberg 2022-05-31 13:45:04 +02:00 committed by GitHub
parent f4d02e37b7
commit 7c52f0fcc8
9 changed files with 334 additions and 287 deletions

View File

@ -6,6 +6,8 @@ const ENTERPRISE = Boolean(Cypress.env('ENTERPRISE'));
const randomId = String(Math.random()).split('.')[1];
const featureToggleName = `unleash-e2e-${randomId}`;
const baseUrl = Cypress.config().baseUrl;
const variant1 = 'variant1';
const variant2 = 'variant2';
let strategyId = '';
// Disable the prod guard modal by marking it as seen.
@ -233,9 +235,6 @@ describe('feature', () => {
});
it('can add two variant to the feature', () => {
const variantName = 'my-new-variant';
const secondVariantName = 'my-second-variant';
cy.visit(`/projects/default/features/${featureToggleName}/variants`);
cy.intercept(
@ -245,26 +244,26 @@ describe('feature', () => {
if (req.body.length === 1) {
expect(req.body[0].op).to.equal('add');
expect(req.body[0].path).to.match(/\//);
expect(req.body[0].value.name).to.equal(variantName);
expect(req.body[0].value.name).to.equal(variant1);
} else if (req.body.length === 2) {
expect(req.body[0].op).to.equal('replace');
expect(req.body[0].path).to.match(/weight/);
expect(req.body[0].value).to.equal(500);
expect(req.body[1].op).to.equal('add');
expect(req.body[1].path).to.match(/\//);
expect(req.body[1].value.name).to.equal(secondVariantName);
expect(req.body[1].value.name).to.equal(variant2);
}
}
).as('variantCreation');
cy.get('[data-testid=ADD_VARIANT_BUTTON]').click();
cy.wait(1000);
cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variantName);
cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant1);
cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@variantCreation');
cy.get('[data-testid=ADD_VARIANT_BUTTON]').click();
cy.wait(1000);
cy.get('[data-testid=VARIANT_NAME_INPUT]').type(secondVariantName);
cy.get('[data-testid=VARIANT_NAME_INPUT]').type(variant2);
cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@variantCreation');
});
@ -272,7 +271,7 @@ describe('feature', () => {
it('can set weight to fixed value for one of the variants', () => {
cy.visit(`/projects/default/features/${featureToggleName}/variants`);
cy.get('[data-testid=VARIANT_EDIT_BUTTON]').first().click();
cy.get(`[data-testid=VARIANT_EDIT_BUTTON_${variant1}]`).click();
cy.wait(1000);
cy.get('[data-testid=VARIANT_NAME_INPUT]')
.children()
@ -299,9 +298,10 @@ describe('feature', () => {
cy.get('[data-testid=DIALOGUE_CONFIRM_ID]').click();
cy.wait('@variantUpdate');
cy.get('[data-testid=VARIANT_WEIGHT]')
.first()
.should('have.text', '15 %');
cy.get(`[data-testid=VARIANT_WEIGHT_${variant1}]`).should(
'have.text',
'15 %'
);
});
it('can delete variant', () => {

View File

@ -5,18 +5,22 @@ import { useStyles } from './TextCell.styles';
interface ITextCellProps {
value?: string | null;
lineClamp?: number;
'data-testid'?: string;
}
export const TextCell: FC<ITextCellProps> = ({
value,
children,
lineClamp,
'data-testid': testid,
}) => {
const { classes } = useStyles({ lineClamp });
return (
<Box className={classes.wrapper}>
<span data-loading>{children ?? value}</span>
<span data-loading="true" data-testid={testid}>
{children ?? value}
</span>
</Box>
);
};

View File

@ -1,17 +1,10 @@
import { useStyles } from './FeatureVariants.styles';
import FeatureOverviewVariants from './FeatureVariantsList/FeatureVariantsList';
import { FeatureVariantsList } from './FeatureVariantsList/FeatureVariantsList';
import { usePageTitle } from 'hooks/usePageTitle';
const FeatureVariants = () => {
usePageTitle('Variants');
const { classes: styles } = useStyles();
return (
<div className={styles.container}>
<FeatureOverviewVariants />
</div>
);
return <FeatureVariantsList />;
};
export default FeatureVariants;

View File

@ -1,21 +1,19 @@
import classnames from 'classnames';
import * as jsonpatch from 'fast-json-patch';
import styles from './variants.module.scss';
import {
Alert,
Box,
Table,
TableBody,
TableCell,
TableHead,
TableRow,
Typography,
useMediaQuery,
} from '@mui/material';
import { AddVariant } from './AddFeatureVariant/AddFeatureVariant';
import { useContext, useEffect, useState } from 'react';
import { useContext, useEffect, useState, useMemo, useCallback } from 'react';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import AccessContext from 'contexts/AccessContext';
import FeatureVariantListItem from './FeatureVariantsListItem/FeatureVariantsListItem';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
@ -25,26 +23,43 @@ import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import useToast from 'hooks/useToast';
import { updateWeight } from 'component/common/util';
import cloneDeep from 'lodash.clonedeep';
import useDeleteVariantMarkup from './FeatureVariantsListItem/useDeleteVariantMarkup';
import useDeleteVariantMarkup from './useDeleteVariantMarkup';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { Edit, Delete } from '@mui/icons-material';
import { useTable, useSortBy, useGlobalFilter } from 'react-table';
import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader';
import { SortableTableHeader, TablePlaceholder } from 'component/common/Table';
import { sortTypes } from 'utils/sortTypes';
import { PayloadOverridesCell } from './PayloadOverridesCell/PayloadOverridesCell';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import theme from 'themes/theme';
import { VariantsActionCell } from './VariantsActionsCell/VariantsActionsCell';
const FeatureOverviewVariants = () => {
export const FeatureVariantsList = () => {
const { hasAccess } = useContext(AccessContext);
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature } = useFeature(projectId, featureId);
const { feature, refetchFeature, loading } = useFeature(
projectId,
featureId
);
const [variants, setVariants] = useState<IFeatureVariant[]>([]);
const [editing, setEditing] = useState(false);
const { context } = useUnleashContext();
const { setToastData, setToastApiError } = useToast();
const { patchFeatureVariants } = useFeatureApi();
const [editVariant, setEditVariant] = useState({});
const [variantToEdit, setVariantToEdit] = useState({});
const [showAddVariant, setShowAddVariant] = useState(false);
const [stickinessOptions, setStickinessOptions] = useState<string[]>([]);
const [delDialog, setDelDialog] = useState({ name: '', show: false });
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
useEffect(() => {
if (feature) {
setClonedVariants(feature.variants);
@ -63,6 +78,146 @@ const FeatureOverviewVariants = () => {
const editable = hasAccess(UPDATE_FEATURE_VARIANTS, projectId);
const data = useMemo(() => {
if (loading) {
return Array(5).fill({
name: 'Context name',
description: 'Context description when loading',
});
}
return feature.variants;
}, [feature.variants, loading]);
const editVariant = useCallback(
(name: string) => {
const variant = {
...variants.find(variant => variant.name === name),
};
setVariantToEdit(variant);
setEditing(true);
setShowAddVariant(true);
},
[variants, setVariantToEdit, setEditing, setShowAddVariant]
);
const columns = useMemo(
() => [
{
Header: 'Name',
accessor: 'name',
width: '25%',
Cell: ({
row: {
original: { name },
},
}: any) => {
return <TextCell data-loading>{name}</TextCell>;
},
sortType: 'alphanumeric',
},
{
Header: 'Payload/Overrides',
accessor: 'data',
Cell: ({
row: {
original: { overrides, payload },
},
}: any) => {
return (
<PayloadOverridesCell
overrides={overrides}
payload={payload}
/>
);
},
disableSortBy: true,
},
{
Header: 'Weight',
accessor: 'weight',
width: '20%',
Cell: ({
row: {
original: { name, weight },
},
}: any) => {
return (
<TextCell data-testid={`VARIANT_WEIGHT_${name}`}>
{weight / 10.0} %
</TextCell>
);
},
sortType: 'number',
},
{
Header: 'Type',
accessor: 'weightType',
width: '20%',
Cell: ({
row: {
original: { weightType },
},
}: any) => {
return <TextCell>{weightType}</TextCell>;
},
sortType: 'alphanumeric',
},
{
Header: 'Actions',
id: 'Actions',
align: 'right',
Cell: ({ row: { original } }: any) => (
<VariantsActionCell
editVariant={editVariant}
setDelDialog={setDelDialog}
variant={original as IFeatureVariant}
projectId={projectId}
/>
),
width: 150,
disableSortBy: true,
},
],
[projectId, editVariant]
);
const initialState = useMemo(
() => ({
sortBy: [{ id: 'name', desc: false }],
}),
[]
);
const {
getTableProps,
getTableBodyProps,
headerGroups,
rows,
prepareRow,
setHiddenColumns,
} = useTable(
{
columns: columns as any[],
data: data as any[],
initialState,
sortTypes,
autoResetGlobalFilter: false,
autoResetSortBy: false,
disableSortRemove: true,
},
useGlobalFilter,
useSortBy
);
useEffect(() => {
if (isMediumScreen) {
setHiddenColumns(['weightType', 'data']);
} else if (isLargeScreen) {
setHiddenColumns(['weightType']);
}
}, [setHiddenColumns, isMediumScreen, isLargeScreen]);
// @ts-expect-error
const setClonedVariants = clonedVariants =>
setVariants(cloneDeep(clonedVariants));
@ -70,26 +225,7 @@ const FeatureOverviewVariants = () => {
const handleCloseAddVariant = () => {
setShowAddVariant(false);
setEditing(false);
setEditVariant({});
};
const renderVariants = () => {
return variants.map(variant => {
return (
<FeatureVariantListItem
key={variant.name}
variant={variant}
editVariant={(name: string) => {
const v = { ...variants.find(v => v.name === name) };
setEditVariant(v);
setEditing(true);
setShowAddVariant(true);
}}
setDelDialog={setDelDialog}
editable={editable}
/>
);
});
setVariantToEdit({});
};
const renderStickiness = () => {
@ -118,10 +254,7 @@ const FeatureOverviewVariants = () => {
onChange={onChange}
/>
&nbsp;&nbsp;
<small
className={classnames(styles.paragraph, styles.helperText)}
style={{ display: 'block', marginTop: '0.5rem' }}
>
<small style={{ display: 'block', marginTop: '0.5rem' }}>
By overriding the stickiness you can control which parameter
is used to ensure consistent traffic allocation across
variants.{' '}
@ -245,55 +378,82 @@ const FeatureOverviewVariants = () => {
return jsonpatch.compare(feature.variants, newVariants);
};
return (
<section style={{ padding: '16px' }}>
<Typography variant="body1">
Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using
variants you should use the{' '}
<code style={{ color: 'navy' }}>getVariant()</code> method in
the Client SDK.
</Typography>
<ConditionallyRender
condition={variants?.length > 0}
show={
<Table className={styles.variantTable}>
<TableHead>
<TableRow>
<TableCell>Variant name</TableCell>
<TableCell className={styles.labels} />
<TableCell>Weight</TableCell>
<TableCell>Weight Type</TableCell>
<TableCell className={styles.actions} />
</TableRow>
</TableHead>
<TableBody>{renderVariants()}</TableBody>
</Table>
}
elseShow={<p>No variants defined.</p>}
/>
<br />
<div>
<PermissionButton
onClick={() => {
const addVariant = () => {
setEditing(false);
if (variants.length === 0) {
setEditVariant({ weight: 1000 });
setVariantToEdit({ weight: 1000 });
} else {
setEditVariant({ weightType: 'variable' });
setVariantToEdit({
weightType: 'variable',
});
}
setShowAddVariant(true);
}}
className={styles.addVariantButton}
};
return (
<PageContent
isLoading={loading}
header={
<PageHeader
title="Variants"
actions={
<>
<PermissionButton
onClick={addVariant}
data-testid={'ADD_VARIANT_BUTTON'}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
>
New variant
</PermissionButton>
</>
}
/>
}
>
<Alert severity="info" sx={{ marginBottom: '1rem' }}>
Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using
variants you should use the{' '}
<code style={{ fontWeight: 'bold' }}>getVariant()</code> method
in the Client SDK.
</Alert>
<Table {...getTableProps()}>
<SortableTableHeader headerGroups={headerGroups} />
<TableBody {...getTableBodyProps()}>
{rows.map(row => {
prepareRow(row);
return (
<TableRow
hover
{...row.getRowProps()}
style={{ height: '75px' }}
>
{row.cells.map(cell => (
<TableCell
{...cell.getCellProps()}
padding="none"
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow>
);
})}
</TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<TablePlaceholder>
No variants available. Get started by adding one.
</TablePlaceholder>
}
/>
<br />
<div>
<ConditionallyRender
condition={editable}
show={renderStickiness()}
@ -314,13 +474,11 @@ const FeatureOverviewVariants = () => {
validateName={validateName}
validateWeight={validateWeight}
// @ts-expect-error
editVariant={editVariant}
editVariant={variantToEdit}
title={editing ? 'Edit variant' : 'Add variant'}
/>
{delDialogueMarkup}
</section>
</PageContent>
);
};
export default FeatureOverviewVariants;

View File

@ -1,90 +0,0 @@
import { Chip, IconButton, TableCell, TableRow, Tooltip } from '@mui/material';
import { Delete, Edit } from '@mui/icons-material';
import styles from '../variants.module.scss';
import { IFeatureVariant } from 'interfaces/featureToggle';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { weightTypes } from '../AddFeatureVariant/enums';
interface IFeatureVariantListItem {
variant: IFeatureVariant;
editVariant: any;
setDelDialog: any;
editable: boolean;
}
const FeatureVariantListItem = ({
variant,
editVariant,
setDelDialog,
editable,
}: IFeatureVariantListItem) => {
const { FIX } = weightTypes;
return (
<TableRow>
<TableCell data-testid={'VARIANT_NAME'}>{variant.name}</TableCell>
<TableCell className={styles.chipContainer}>
<ConditionallyRender
condition={Boolean(variant.payload)}
show={<Chip label="Payload" />}
/>
<ConditionallyRender
condition={
variant.overrides && variant.overrides.length > 0
}
show={
<Chip
style={{
backgroundColor: 'rgba(173, 216, 230, 0.2)',
}}
label="Overrides"
/>
}
/>
</TableCell>
<TableCell data-testid={'VARIANT_WEIGHT'}>
{variant.weight / 10.0} %
</TableCell>
<TableCell data-testid={'VARIANT_WEIGHT_TYPE'}>
{variant.weightType === FIX ? 'Fix' : 'Variable'}
</TableCell>
<ConditionallyRender
condition={editable}
show={
<TableCell className={styles.actions}>
<div className={styles.actionsContainer}>
<Tooltip title="Edit variant" arrow>
<IconButton
data-testid={'VARIANT_EDIT_BUTTON'}
onClick={() => editVariant(variant.name)}
size="large"
>
<Edit />
</IconButton>
</Tooltip>
<Tooltip title="Delete variant" arrow>
<IconButton
data-testid={`VARIANT_DELETE_BUTTON_${variant.name}`}
onClick={e => {
e.stopPropagation();
setDelDialog({
show: true,
name: variant.name,
});
}}
size="large"
>
<Delete />
</IconButton>
</Tooltip>
</div>
</TableCell>
}
elseShow={<TableCell className={styles.actions} />}
/>
</TableRow>
);
};
export default FeatureVariantListItem;

View File

@ -0,0 +1,27 @@
import { Chip } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { TextCell } from 'component/common/Table/cells/TextCell/TextCell';
import { IOverride, IPayload } from 'interfaces/featureToggle';
interface IPayloadOverridesCellProps {
payload: IPayload;
overrides: IOverride[];
}
export const PayloadOverridesCell = ({
payload,
overrides,
}: IPayloadOverridesCellProps) => {
return (
<>
<ConditionallyRender
condition={Boolean(payload)}
show={<TextCell>Payload</TextCell>}
/>
<ConditionallyRender
condition={overrides && overrides.length > 0}
show={<TextCell>Overrides</TextCell>}
/>
</>
);
};

View File

@ -0,0 +1,55 @@
import { Edit, Delete } from '@mui/icons-material';
import { Box } from '@mui/material';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { IFeatureVariant } from 'interfaces/featureToggle';
interface IVarintsActionCellProps {
projectId: string;
editVariant: (name: string) => void;
setDelDialog: React.Dispatch<
React.SetStateAction<{
name: string;
show: boolean;
}>
>;
variant: IFeatureVariant;
}
export const VariantsActionCell = ({
projectId,
setDelDialog,
variant,
editVariant,
}: IVarintsActionCellProps) => {
return (
<Box
style={{ display: 'flex', justifyContent: 'flex-end' }}
data-loading
>
<PermissionIconButton
size="large"
data-testid={`VARIANT_EDIT_BUTTON_${variant.name}`}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
onClick={() => editVariant(variant.name)}
>
<Edit />
</PermissionIconButton>
<PermissionIconButton
size="large"
permission={UPDATE_FEATURE_VARIANTS}
data-testid={`VARIANT_DELETE_BUTTON_${variant.name}`}
projectId={projectId}
onClick={() =>
setDelDialog({
show: true,
name: variant.name,
})
}
>
<Delete />
</PermissionIconButton>
</Box>
);
};

View File

@ -1,100 +0,0 @@
.variantTable {
width: 100%;
max-width: 700px;
th,
td {
text-align: center;
}
th:first-of-type,
td:first-of-type {
text-align: left;
width: 100%;
}
tbody tr:hover {
background-color: rgba(173, 216, 230, 0.2);
}
}
@media (max-width: 600px) {
th.labels {
display: none;
}
td.labels {
display: none;
}
}
th.labels {
text-align: right;
}
td.labels {
text-align: right;
vertical-align: top;
}
th.actions {
text-align: right;
}
td.actions {
height: 100%;
text-align: right;
vertical-align: top;
}
.actionsContainer {
display: flex;
align-items: center;
}
.modal {
max-width: 90%;
width: 600px;
position: absolute !important;
}
@media (max-width: 600px) {
.modal {
top: 0 !important;
}
}
.tooltip {
i {
font-size: 18px;
}
}
.inputWeight {
text-align: right;
}
.flexCenter {
display: flex;
justify-content: center;
align-items: center;
}
.flex {
display: flex;
align-items: center;
}
.marginL10 {
margin-left: 10px;
}
.addVariantButton {
margin: 1rem 0;
}
.paragraph {
max-width: 400px;
}
.helperText {
display: block;
margin-top: 0.5rem;
}