1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-09-05 17:53:12 +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[]
) => { patch: Operation[]; error?: string };
onConfirm: (updatedVariants: IFeatureVariant[]) => void;
global?: boolean;
}
export const EnvironmentVariantModal = ({
@ -161,6 +162,7 @@ export const EnvironmentVariantModal = ({
setOpen,
getApiPayload,
onConfirm,
global,
}: IEnvironmentVariantModalProps) => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
@ -259,7 +261,15 @@ export const EnvironmentVariantModal = ({
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
}/api/admin/projects/${projectId}/features/${featureId}/environments/${
environment?.name
@ -300,7 +310,9 @@ export const EnvironmentVariantModal = ({
if (!isNameUnique(name)) {
setError(
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);
@ -345,10 +357,20 @@ export const EnvironmentVariantModal = ({
loading={!open}
>
<StyledFormSubtitle>
<StyledCloudCircle deprecated={!environment?.enabled} />
<ConditionallyRender
condition={Boolean(global)}
show={<span>All environments</span>}
elseShow={
<>
<StyledCloudCircle
deprecated={!environment?.enabled}
/>
<StyledName deprecated={!environment?.enabled}>
{environment?.name}
</StyledName>
</>
}
/>
</StyledFormSubtitle>
<StyledForm onSubmit={handleSubmit}>
<div>

View File

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

View File

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

View File

@ -205,10 +205,12 @@ const useFeatureApi = () => {
const patchFeatureEnvironmentVariants = async (
projectId: 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, {
method: 'PATCH',
body: JSON.stringify(patchPayload),