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

View File

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

View File

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

View File

@ -1,21 +1,19 @@
import classnames from 'classnames';
import * as jsonpatch from 'fast-json-patch'; import * as jsonpatch from 'fast-json-patch';
import styles from './variants.module.scss';
import { import {
Alert,
Box,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableHead,
TableRow, TableRow,
Typography, useMediaQuery,
} from '@mui/material'; } from '@mui/material';
import { AddVariant } from './AddFeatureVariant/AddFeatureVariant'; 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 { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import AccessContext from 'contexts/AccessContext'; import AccessContext from 'contexts/AccessContext';
import FeatureVariantListItem from './FeatureVariantsListItem/FeatureVariantsListItem';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext'; 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 useToast from 'hooks/useToast';
import { updateWeight } from 'component/common/util'; import { updateWeight } from 'component/common/util';
import cloneDeep from 'lodash.clonedeep'; import cloneDeep from 'lodash.clonedeep';
import useDeleteVariantMarkup from './FeatureVariantsListItem/useDeleteVariantMarkup'; import useDeleteVariantMarkup from './useDeleteVariantMarkup';
import PermissionButton from 'component/common/PermissionButton/PermissionButton'; import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; 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 { hasAccess } = useContext(AccessContext);
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const { feature, refetchFeature } = useFeature(projectId, featureId); const { feature, refetchFeature, loading } = useFeature(
projectId,
featureId
);
const [variants, setVariants] = useState<IFeatureVariant[]>([]); const [variants, setVariants] = useState<IFeatureVariant[]>([]);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const { context } = useUnleashContext(); const { context } = useUnleashContext();
const { setToastData, setToastApiError } = useToast(); const { setToastData, setToastApiError } = useToast();
const { patchFeatureVariants } = useFeatureApi(); const { patchFeatureVariants } = useFeatureApi();
const [editVariant, setEditVariant] = useState({}); const [variantToEdit, setVariantToEdit] = useState({});
const [showAddVariant, setShowAddVariant] = useState(false); const [showAddVariant, setShowAddVariant] = useState(false);
const [stickinessOptions, setStickinessOptions] = useState<string[]>([]); const [stickinessOptions, setStickinessOptions] = useState<string[]>([]);
const [delDialog, setDelDialog] = useState({ name: '', show: false }); const [delDialog, setDelDialog] = useState({ name: '', show: false });
const isMediumScreen = useMediaQuery(theme.breakpoints.down('md'));
const isLargeScreen = useMediaQuery(theme.breakpoints.down('lg'));
useEffect(() => { useEffect(() => {
if (feature) { if (feature) {
setClonedVariants(feature.variants); setClonedVariants(feature.variants);
@ -63,6 +78,146 @@ const FeatureOverviewVariants = () => {
const editable = hasAccess(UPDATE_FEATURE_VARIANTS, projectId); 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 // @ts-expect-error
const setClonedVariants = clonedVariants => const setClonedVariants = clonedVariants =>
setVariants(cloneDeep(clonedVariants)); setVariants(cloneDeep(clonedVariants));
@ -70,26 +225,7 @@ const FeatureOverviewVariants = () => {
const handleCloseAddVariant = () => { const handleCloseAddVariant = () => {
setShowAddVariant(false); setShowAddVariant(false);
setEditing(false); setEditing(false);
setEditVariant({}); setVariantToEdit({});
};
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}
/>
);
});
}; };
const renderStickiness = () => { const renderStickiness = () => {
@ -118,10 +254,7 @@ const FeatureOverviewVariants = () => {
onChange={onChange} onChange={onChange}
/> />
&nbsp;&nbsp; &nbsp;&nbsp;
<small <small style={{ display: 'block', marginTop: '0.5rem' }}>
className={classnames(styles.paragraph, styles.helperText)}
style={{ display: 'block', marginTop: '0.5rem' }}
>
By overriding the stickiness you can control which parameter By overriding the stickiness you can control which parameter
is used to ensure consistent traffic allocation across is used to ensure consistent traffic allocation across
variants.{' '} variants.{' '}
@ -245,55 +378,82 @@ const FeatureOverviewVariants = () => {
return jsonpatch.compare(feature.variants, newVariants); return jsonpatch.compare(feature.variants, newVariants);
}; };
const addVariant = () => {
setEditing(false);
if (variants.length === 0) {
setVariantToEdit({ weight: 1000 });
} else {
setVariantToEdit({
weightType: 'variable',
});
}
setShowAddVariant(true);
};
return ( return (
<section style={{ padding: '16px' }}> <PageContent
<Typography variant="body1"> 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 Variants allows you to return a variant object if the feature
toggle is considered enabled for the current request. When using toggle is considered enabled for the current request. When using
variants you should use the{' '} variants you should use the{' '}
<code style={{ color: 'navy' }}>getVariant()</code> method in <code style={{ fontWeight: 'bold' }}>getVariant()</code> method
the Client SDK. in the Client SDK.
</Typography> </Alert>
<Table {...getTableProps()}>
<ConditionallyRender <SortableTableHeader headerGroups={headerGroups} />
condition={variants?.length > 0} <TableBody {...getTableBodyProps()}>
show={ {rows.map(row => {
<Table className={styles.variantTable}> prepareRow(row);
<TableHead> return (
<TableRow> <TableRow
<TableCell>Variant name</TableCell> hover
<TableCell className={styles.labels} /> {...row.getRowProps()}
<TableCell>Weight</TableCell> style={{ height: '75px' }}
<TableCell>Weight Type</TableCell> >
<TableCell className={styles.actions} /> {row.cells.map(cell => (
<TableCell
{...cell.getCellProps()}
padding="none"
>
{cell.render('Cell')}
</TableCell>
))}
</TableRow> </TableRow>
</TableHead> );
<TableBody>{renderVariants()}</TableBody> })}
</Table> </TableBody>
</Table>
<ConditionallyRender
condition={rows.length === 0}
show={
<TablePlaceholder>
No variants available. Get started by adding one.
</TablePlaceholder>
} }
elseShow={<p>No variants defined.</p>}
/> />
<br /> <br />
<div> <div>
<PermissionButton
onClick={() => {
setEditing(false);
if (variants.length === 0) {
setEditVariant({ weight: 1000 });
} else {
setEditVariant({ weightType: 'variable' });
}
setShowAddVariant(true);
}}
className={styles.addVariantButton}
data-testid={'ADD_VARIANT_BUTTON'}
permission={UPDATE_FEATURE_VARIANTS}
projectId={projectId}
>
New variant
</PermissionButton>
<ConditionallyRender <ConditionallyRender
condition={editable} condition={editable}
show={renderStickiness()} show={renderStickiness()}
@ -314,13 +474,11 @@ const FeatureOverviewVariants = () => {
validateName={validateName} validateName={validateName}
validateWeight={validateWeight} validateWeight={validateWeight}
// @ts-expect-error // @ts-expect-error
editVariant={editVariant} editVariant={variantToEdit}
title={editing ? 'Edit variant' : 'Add variant'} title={editing ? 'Edit variant' : 'Add variant'}
/> />
{delDialogueMarkup} {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;
}