1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-10 17:53:36 +02:00

feat: toggleable variants per env

This commit is contained in:
Nuno Góis 2023-01-02 17:06:13 +00:00
parent 8a8cd1bf27
commit 070fedf83f
No known key found for this signature in database
GPG Key ID: 71ECC689F1091765
4 changed files with 238 additions and 98 deletions

View File

@ -152,6 +152,7 @@ interface IEnvironmentVariantModalProps {
newVariants: IFeatureVariant[] newVariants: IFeatureVariant[]
) => { patch: Operation[]; error?: string }; ) => { patch: Operation[]; error?: string };
onConfirm: (updatedVariants: IFeatureVariant[]) => void; onConfirm: (updatedVariants: IFeatureVariant[]) => void;
global?: boolean;
} }
export const EnvironmentVariantModal = ({ export const EnvironmentVariantModal = ({
@ -161,6 +162,7 @@ export const EnvironmentVariantModal = ({
setOpen, setOpen,
getApiPayload, getApiPayload,
onConfirm, onConfirm,
global,
}: IEnvironmentVariantModalProps) => { }: IEnvironmentVariantModalProps) => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
@ -259,7 +261,15 @@ export const EnvironmentVariantModal = ({
onConfirm(getUpdatedVariants()); onConfirm(getUpdatedVariants());
}; };
const formatApiCode = () => `curl --location --request PATCH '${ const formatApiCode = () =>
global
? `curl --location --request PATCH '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/features/${featureId}/variants' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(apiPayload.patch, undefined, 2)}'`
: `curl --location --request PATCH '${
uiConfig.unleashUrl uiConfig.unleashUrl
}/api/admin/projects/${projectId}/features/${featureId}/environments/${ }/api/admin/projects/${projectId}/features/${featureId}/environments/${
environment?.name environment?.name
@ -300,7 +310,9 @@ export const EnvironmentVariantModal = ({
if (!isNameUnique(name)) { if (!isNameUnique(name)) {
setError( setError(
ErrorField.NAME, ErrorField.NAME,
'A variant with that name already exists for this environment.' global
? 'A variant with that name already exists.'
: 'A variant with that name already exists for this environment.'
); );
} }
setName(name); setName(name);
@ -345,10 +357,20 @@ export const EnvironmentVariantModal = ({
loading={!open} loading={!open}
> >
<StyledFormSubtitle> <StyledFormSubtitle>
<StyledCloudCircle deprecated={!environment?.enabled} /> <ConditionallyRender
condition={Boolean(global)}
show={<span>All environments</span>}
elseShow={
<>
<StyledCloudCircle
deprecated={!environment?.enabled}
/>
<StyledName deprecated={!environment?.enabled}> <StyledName deprecated={!environment?.enabled}>
{environment?.name} {environment?.name}
</StyledName> </StyledName>
</>
}
/>
</StyledFormSubtitle> </StyledFormSubtitle>
<StyledForm onSubmit={handleSubmit}> <StyledForm onSubmit={handleSubmit}>
<div> <div>

View File

@ -59,6 +59,7 @@ interface IEnvironmentVariantsCardProps {
onEditVariant: (variant: IFeatureVariant) => void; onEditVariant: (variant: IFeatureVariant) => void;
onDeleteVariant: (variant: IFeatureVariant) => void; onDeleteVariant: (variant: IFeatureVariant) => void;
onUpdateStickiness: (variant: IFeatureVariant[]) => void; onUpdateStickiness: (variant: IFeatureVariant[]) => void;
global?: boolean;
children?: React.ReactNode; children?: React.ReactNode;
} }
@ -68,6 +69,7 @@ export const EnvironmentVariantsCard = ({
onEditVariant, onEditVariant,
onDeleteVariant, onDeleteVariant,
onUpdateStickiness, onUpdateStickiness,
global,
children, children,
}: IEnvironmentVariantsCardProps) => { }: IEnvironmentVariantsCardProps) => {
const { context } = useUnleashContext(); const { context } = useUnleashContext();
@ -104,10 +106,20 @@ export const EnvironmentVariantsCard = ({
<StyledCard> <StyledCard>
<StyledHeader> <StyledHeader>
<div> <div>
<StyledCloudCircle deprecated={!environment.enabled} /> <ConditionallyRender
condition={Boolean(global)}
show={<StyledName>All environments</StyledName>}
elseShow={
<>
<StyledCloudCircle
deprecated={!environment.enabled}
/>
<StyledName deprecated={!environment.enabled}> <StyledName deprecated={!environment.enabled}>
{environment.name} {environment.name}
</StyledName> </StyledName>
</>
}
/>
</div> </div>
{children} {children}
</StyledHeader> </StyledHeader>

View File

@ -1,6 +1,13 @@
import * as jsonpatch from 'fast-json-patch'; import * as jsonpatch from 'fast-json-patch';
import { Alert, styled, useMediaQuery, useTheme } from '@mui/material'; import {
Alert,
FormControlLabel,
styled,
Switch,
useMediaQuery,
useTheme,
} from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { PageContent } from 'component/common/PageContent/PageContent'; import { PageContent } from 'component/common/PageContent/PageContent';
import { PageHeader } from 'component/common/PageHeader/PageHeader'; import { PageHeader } from 'component/common/PageHeader/PageHeader';
@ -11,7 +18,7 @@ import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessP
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle'; import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal'; import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard'; import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog'; import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog';
@ -19,6 +26,7 @@ import useFeatureApi from 'hooks/api/actions/useFeatureApi/useFeatureApi';
import { formatUnknownError } from 'utils/formatUnknownError'; import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom'; import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom';
import { dequal } from 'dequal';
const StyledAlert = styled(Alert)(({ theme }) => ({ const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4), marginBottom: theme.spacing(4),
@ -52,6 +60,20 @@ export const FeatureEnvironmentVariants = () => {
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [perEnvironment, setPerEnvironment] = useState(false);
const envSpecificVariants = !feature.environments.reduce(
(acc, { variants }) =>
acc && dequal(feature.environments[0].variants, variants),
true
);
useEffect(() => {
if (envSpecificVariants) {
setPerEnvironment(envSpecificVariants);
}
}, [envSpecificVariants]);
const createPatch = ( const createPatch = (
variants: IFeatureVariant[], variants: IFeatureVariant[],
newVariants: IFeatureVariant[] newVariants: IFeatureVariant[]
@ -75,10 +97,13 @@ export const FeatureEnvironmentVariants = () => {
}; };
const updateVariants = async ( const updateVariants = async (
environment: IFeatureEnvironment, variants: IFeatureVariant[],
variants: IFeatureVariant[] environment?: IFeatureEnvironment
) => { ) => {
const environmentVariants = environment.variants ?? []; const environmentVariants =
(environment
? environment.variants
: feature.environments[0].variants) ?? [];
const { patch } = getApiPayload(environmentVariants, variants); const { patch } = getApiPayload(environmentVariants, variants);
if (patch.length === 0) return; if (patch.length === 0) return;
@ -86,21 +111,21 @@ export const FeatureEnvironmentVariants = () => {
await patchFeatureEnvironmentVariants( await patchFeatureEnvironmentVariants(
projectId, projectId,
featureId, featureId,
environment.name, patch,
patch environment?.name
); );
refetchFeature(); refetchFeature();
}; };
const addVariant = (environment: IFeatureEnvironment) => { const addVariant = (environment?: IFeatureEnvironment) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
setSelectedVariant(undefined); setSelectedVariant(undefined);
setModalOpen(true); setModalOpen(true);
}; };
const editVariant = ( const editVariant = (
environment: IFeatureEnvironment, variant: IFeatureVariant,
variant: IFeatureVariant environment?: IFeatureEnvironment
) => { ) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
setSelectedVariant(variant); setSelectedVariant(variant);
@ -108,8 +133,8 @@ export const FeatureEnvironmentVariants = () => {
}; };
const deleteVariant = ( const deleteVariant = (
environment: IFeatureEnvironment, variant: IFeatureVariant,
variant: IFeatureVariant environment?: IFeatureEnvironment
) => { ) => {
setSelectedEnvironment(environment); setSelectedEnvironment(environment);
setSelectedVariant(variant); setSelectedVariant(variant);
@ -117,15 +142,18 @@ export const FeatureEnvironmentVariants = () => {
}; };
const onDeleteConfirm = async () => { const onDeleteConfirm = async () => {
if (selectedEnvironment && selectedVariant) { if (selectedVariant) {
const variants = selectedEnvironment.variants ?? []; const variants =
(selectedEnvironment
? selectedEnvironment.variants
: feature.environments[0].variants) ?? [];
const updatedVariants = variants.filter( const updatedVariants = variants.filter(
({ name }) => name !== selectedVariant.name ({ name }) => name !== selectedVariant.name
); );
try { try {
await updateVariants(selectedEnvironment, updatedVariants); await updateVariants(updatedVariants, selectedEnvironment);
setDeleteOpen(false); setDeleteOpen(false);
setToastData({ setToastData({
title: `Variant deleted successfully`, title: `Variant deleted successfully`,
@ -138,9 +166,8 @@ export const FeatureEnvironmentVariants = () => {
}; };
const onVariantConfirm = async (updatedVariants: IFeatureVariant[]) => { const onVariantConfirm = async (updatedVariants: IFeatureVariant[]) => {
if (selectedEnvironment) {
try { try {
await updateVariants(selectedEnvironment, updatedVariants); await updateVariants(updatedVariants, selectedEnvironment);
setModalOpen(false); setModalOpen(false);
setToastData({ setToastData({
title: `Variant ${ title: `Variant ${
@ -151,7 +178,6 @@ export const FeatureEnvironmentVariants = () => {
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }
}
}; };
const onCopyVariantsFrom = async ( const onCopyVariantsFrom = async (
@ -160,7 +186,7 @@ export const FeatureEnvironmentVariants = () => {
) => { ) => {
try { try {
const variants = fromEnvironment.variants ?? []; const variants = fromEnvironment.variants ?? [];
await updateVariants(toEnvironment, variants); await updateVariants(variants, toEnvironment);
setToastData({ setToastData({
title: 'Variants copied successfully', title: 'Variants copied successfully',
type: 'success', type: 'success',
@ -171,11 +197,11 @@ export const FeatureEnvironmentVariants = () => {
}; };
const onUpdateStickiness = async ( const onUpdateStickiness = async (
environment: IFeatureEnvironment, updatedVariants: IFeatureVariant[],
updatedVariants: IFeatureVariant[] environment?: IFeatureEnvironment
) => { ) => {
try { try {
await updateVariants(environment, updatedVariants); await updateVariants(updatedVariants, environment);
setToastData({ setToastData({
title: 'Variant stickiness updated successfully', title: 'Variant stickiness updated successfully',
type: 'success', type: 'success',
@ -192,17 +218,32 @@ export const FeatureEnvironmentVariants = () => {
<PageHeader <PageHeader
title="Variants" title="Variants"
actions={ actions={
<>
<ConditionallyRender <ConditionallyRender
condition={!isSmallScreen} condition={!isSmallScreen}
show={ show={
<>
<Search <Search
initialValue={searchValue} initialValue={searchValue}
onChange={setSearchValue} onChange={setSearchValue}
/> />
</>
} }
/> />
<FormControlLabel
data-loading
label="Per environment"
labelPlacement="start"
control={
<Switch
checked={perEnvironment}
onChange={() =>
setPerEnvironment(!perEnvironment)
}
color="primary"
disabled={envSpecificVariants}
/>
}
/>
</>
} }
> >
<ConditionallyRender <ConditionallyRender
@ -223,10 +264,17 @@ export const FeatureEnvironmentVariants = () => {
variants you should use the <code>getVariant()</code> method in variants you should use the <code>getVariant()</code> method in
the Client SDK. the Client SDK.
</StyledAlert> </StyledAlert>
{feature.environments.map(environment => { <ConditionallyRender
const otherEnvsWithVariants = feature.environments.filter( condition={Boolean(feature.environments.length)}
show={
<ConditionallyRender
condition={perEnvironment}
show={feature.environments.map(environment => {
const otherEnvsWithVariants =
feature.environments.filter(
({ name, variants }) => ({ name, variants }) =>
name !== environment.name && variants?.length name !== environment.name &&
variants?.length
); );
return ( return (
@ -235,28 +283,43 @@ export const FeatureEnvironmentVariants = () => {
environment={environment} environment={environment}
searchValue={searchValue} searchValue={searchValue}
onEditVariant={(variant: IFeatureVariant) => onEditVariant={(variant: IFeatureVariant) =>
editVariant(environment, variant) editVariant(variant, environment)
} }
onDeleteVariant={(variant: IFeatureVariant) => onDeleteVariant={(
deleteVariant(environment, variant) variant: IFeatureVariant
} ) => deleteVariant(variant, environment)}
onUpdateStickiness={(variants: IFeatureVariant[]) => onUpdateStickiness={(
onUpdateStickiness(environment, variants) variants: IFeatureVariant[]
) =>
onUpdateStickiness(
variants,
environment
)
} }
> >
<StyledButtonContainer> <StyledButtonContainer>
<EnvironmentVariantsCopyFrom <EnvironmentVariantsCopyFrom
environment={environment} environment={environment}
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} permission={
UPDATE_FEATURE_ENVIRONMENT_VARIANTS
}
projectId={projectId} projectId={projectId}
environmentId={environment.name} environmentId={environment.name}
onCopyVariantsFrom={onCopyVariantsFrom} onCopyVariantsFrom={
otherEnvsWithVariants={otherEnvsWithVariants} onCopyVariantsFrom
}
otherEnvsWithVariants={
otherEnvsWithVariants
}
/> />
<PermissionButton <PermissionButton
onClick={() => addVariant(environment)} onClick={() =>
addVariant(environment)
}
variant="outlined" variant="outlined"
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS} permission={
UPDATE_FEATURE_ENVIRONMENT_VARIANTS
}
projectId={projectId} projectId={projectId}
environmentId={environment.name} environmentId={environment.name}
> >
@ -266,8 +329,49 @@ export const FeatureEnvironmentVariants = () => {
</EnvironmentVariantsCard> </EnvironmentVariantsCard>
); );
})} })}
elseShow={
<EnvironmentVariantsCard
global
environment={feature.environments[0]}
searchValue={searchValue}
onEditVariant={(variant: IFeatureVariant) =>
editVariant(variant)
}
onDeleteVariant={(variant: IFeatureVariant) =>
deleteVariant(variant)
}
onUpdateStickiness={(
variants: IFeatureVariant[]
) => onUpdateStickiness(variants)}
>
<StyledButtonContainer>
<PermissionButton
onClick={() => addVariant()}
variant="outlined"
permission={
UPDATE_FEATURE_ENVIRONMENT_VARIANTS
}
projectId={projectId}
environmentId={
feature.environments[0]?.name
}
>
Add variant
</PermissionButton>
</StyledButtonContainer>
</EnvironmentVariantsCard>
}
/>
}
elseShow={
<StyledAlert severity="info" data-loading>
Variants needs at least one environment.
</StyledAlert>
}
/>
<EnvironmentVariantModal <EnvironmentVariantModal
environment={selectedEnvironment} global={!Boolean(selectedEnvironment)}
environment={selectedEnvironment ?? feature.environments[0]}
variant={selectedVariant} variant={selectedVariant}
open={modalOpen} open={modalOpen}
setOpen={setModalOpen} setOpen={setModalOpen}

View File

@ -205,10 +205,12 @@ const useFeatureApi = () => {
const patchFeatureEnvironmentVariants = async ( const patchFeatureEnvironmentVariants = async (
projectId: string, projectId: string,
featureId: string, featureId: string,
environmentName: string, patchPayload: Operation[],
patchPayload: Operation[] environmentName?: string
) => { ) => {
const path = `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentName}/variants`; const path = environmentName
? `api/admin/projects/${projectId}/features/${featureId}/environments/${environmentName}/variants`
: `api/admin/projects/${projectId}/features/${featureId}/variants`;
const req = createRequest(path, { const req = createRequest(path, {
method: 'PATCH', method: 'PATCH',
body: JSON.stringify(patchPayload), body: JSON.stringify(patchPayload),