diff --git a/frontend/cypress/integration/feature/feature.spec.ts b/frontend/cypress/integration/feature/feature.spec.ts index c2bc9d6b86..8f2b4f478c 100644 --- a/frontend/cypress/integration/feature/feature.spec.ts +++ b/frontend/cypress/integration/feature/feature.spec.ts @@ -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', () => { diff --git a/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx b/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx index bf5944d941..10335a76de 100644 --- a/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx +++ b/frontend/src/component/common/Table/cells/TextCell/TextCell.tsx @@ -5,18 +5,22 @@ import { useStyles } from './TextCell.styles'; interface ITextCellProps { value?: string | null; lineClamp?: number; + 'data-testid'?: string; } export const TextCell: FC = ({ value, children, lineClamp, + 'data-testid': testid, }) => { const { classes } = useStyles({ lineClamp }); return ( - {children ?? value} + + {children ?? value} + ); }; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx index 488783ccb9..cc842ca49d 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariants.tsx @@ -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 ( -
- -
- ); + return ; }; export default FeatureVariants; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx index 8d3deca8b5..51bef46c1b 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsList.tsx @@ -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([]); 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([]); 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 {name}; + }, + sortType: 'alphanumeric', + }, + { + Header: 'Payload/Overrides', + accessor: 'data', + Cell: ({ + row: { + original: { overrides, payload }, + }, + }: any) => { + return ( + + ); + }, + disableSortBy: true, + }, + { + Header: 'Weight', + accessor: 'weight', + width: '20%', + Cell: ({ + row: { + original: { name, weight }, + }, + }: any) => { + return ( + + {weight / 10.0} % + + ); + }, + sortType: 'number', + }, + { + Header: 'Type', + accessor: 'weightType', + width: '20%', + Cell: ({ + row: { + original: { weightType }, + }, + }: any) => { + return {weightType}; + }, + sortType: 'alphanumeric', + }, + { + Header: 'Actions', + id: 'Actions', + align: 'right', + Cell: ({ row: { original } }: any) => ( + + ), + 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 ( - { - 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} />    - + 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); }; + const addVariant = () => { + setEditing(false); + if (variants.length === 0) { + setVariantToEdit({ weight: 1000 }); + } else { + setVariantToEdit({ + weightType: 'variable', + }); + } + setShowAddVariant(true); + }; + return ( -
- + + + New variant + + + } + /> + } + > + 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{' '} - getVariant() method in - the Client SDK. - - - 0} - show={ - - - - Variant name - - Weight - Weight Type - + getVariant() method + in the Client SDK. + +
+ + + {rows.map(row => { + prepareRow(row); + return ( + + {row.cells.map(cell => ( + + {cell.render('Cell')} + + ))} - - {renderVariants()} -
+ ); + })} + + + + No variants available. Get started by adding one. + } - elseShow={

No variants defined.

} />
- { - 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 - { validateName={validateName} validateWeight={validateWeight} // @ts-expect-error - editVariant={editVariant} + editVariant={variantToEdit} title={editing ? 'Edit variant' : 'Add variant'} /> {delDialogueMarkup} -
+ ); }; - -export default FeatureOverviewVariants; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/FeatureVariantsListItem.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/FeatureVariantsListItem.tsx deleted file mode 100644 index 240e09ccbe..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/FeatureVariantsListItem.tsx +++ /dev/null @@ -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 ( - - {variant.name} - - } - /> - 0 - } - show={ - - } - /> - - - {variant.weight / 10.0} % - - - {variant.weightType === FIX ? 'Fix' : 'Variable'} - - -
- - editVariant(variant.name)} - size="large" - > - - - - - { - e.stopPropagation(); - setDelDialog({ - show: true, - name: variant.name, - }); - }} - size="large" - > - - - -
- - } - elseShow={} - /> -
- ); -}; - -export default FeatureVariantListItem; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/PayloadOverridesCell/PayloadOverridesCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/PayloadOverridesCell/PayloadOverridesCell.tsx new file mode 100644 index 0000000000..a2ae4f9ff1 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/PayloadOverridesCell/PayloadOverridesCell.tsx @@ -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 ( + <> + Payload} + /> + 0} + show={Overrides} + /> + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/VariantsActionsCell/VariantsActionsCell.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/VariantsActionsCell/VariantsActionsCell.tsx new file mode 100644 index 0000000000..4fa0c4a516 --- /dev/null +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/VariantsActionsCell/VariantsActionsCell.tsx @@ -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 ( + + editVariant(variant.name)} + > + + + + setDelDialog({ + show: true, + name: variant.name, + }) + } + > + + + + ); +}; diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/useDeleteVariantMarkup.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/useDeleteVariantMarkup.tsx similarity index 100% rename from frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/FeatureVariantsListItem/useDeleteVariantMarkup.tsx rename to frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/useDeleteVariantMarkup.tsx diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/variants.module.scss b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/variants.module.scss deleted file mode 100644 index 8110253b45..0000000000 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureVariantsList/variants.module.scss +++ /dev/null @@ -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; -}