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:
parent
8a8cd1bf27
commit
070fedf83f
@ -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>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -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),
|
||||
|
Loading…
Reference in New Issue
Block a user