1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-25 00:07:47 +01:00

feat: adds CR to variants per env UI (#2989)

https://linear.app/unleash/issue/2-585/add-cr-to-variants-per-environment-ui

Adds CR to the variants per environment UI. This is basically the point
where we have CRs integrated but can e.g. only update the weight once
per CR. Adapting the UI to better fit CR logic will come next.


![image](https://user-images.githubusercontent.com/14320932/214563512-664a432f-f2eb-49f7-9721-cbd6785a9320.png)
This commit is contained in:
Nuno Góis 2023-01-25 14:10:35 +00:00 committed by GitHub
parent 247f751fea
commit 4d1a004b5d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 220 additions and 56 deletions

View File

@ -54,7 +54,7 @@ export const ChangeRequestDialogue: FC<IChangeRequestDialogueProps> = ({
show={
<Alert severity="info" sx={{ mb: 2 }}>
Change requests feature is enabled for {environment}.
Your changes needs to be approved before they will be
Your changes need to be approved before they will be
live. All the changes you do now will be added into a
draft that you can submit for review.
</Alert>

View File

@ -28,6 +28,9 @@ import { CloudCircle } from '@mui/icons-material';
import PermissionSwitch from 'component/common/PermissionSwitch/PermissionSwitch';
import { UPDATE_FEATURE_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { WeightType } from 'constants/variantTypes';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
const StyledFormSubtitle = styled('div')(({ theme }) => ({
display: 'flex',
@ -99,6 +102,10 @@ const StyledSelectMenu = styled(SelectMenu)(({ theme }) => ({
marginRight: theme.spacing(10),
}));
const StyledCRAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(2),
}));
const StyledAlert = styled(Alert)(({ theme }) => ({
marginTop: theme.spacing(4),
}));
@ -147,6 +154,11 @@ interface IEnvironmentVariantModalProps {
variants: IFeatureVariant[],
newVariants: IFeatureVariant[]
) => { patch: Operation[]; error?: string };
getCrPayload: (variants: IFeatureVariant[]) => {
feature: string;
action: 'patchVariant';
payload: { variants: IFeatureVariant[] };
};
onConfirm: (updatedVariants: IFeatureVariant[]) => void;
}
@ -156,6 +168,7 @@ export const EnvironmentVariantModal = ({
open,
setOpen,
getApiPayload,
getCrPayload,
onConfirm,
}: IEnvironmentVariantModalProps) => {
const projectId = useRequiredPathParam('projectId');
@ -163,6 +176,11 @@ export const EnvironmentVariantModal = ({
const { uiConfig } = useUiConfig();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { data } = usePendingChangeRequests(projectId);
const { changeRequestInReviewOrApproved, alert } =
useChangeRequestInReviewWarning(data);
const [name, setName] = useState('');
const [customPercentage, setCustomPercentage] = useState(false);
const [percentage, setPercentage] = useState('');
@ -237,6 +255,7 @@ export const EnvironmentVariantModal = ({
};
const apiPayload = getApiPayload(variants, getUpdatedVariants());
const crPayload = getCrPayload(getUpdatedVariants());
useEffect(() => {
clearError(ErrorField.PERCENTAGE);
@ -255,11 +274,21 @@ export const EnvironmentVariantModal = ({
onConfirm(getUpdatedVariants());
};
const formatApiCode = () => `curl --location --request PATCH '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/features/${featureId}/environments/${
environment?.name
}/variants' \\
const formatApiCode = () =>
isChangeRequest
? `curl --location --request POST '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/environments/${
environment?.name
}/change-requests' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(crPayload, undefined, 2)}'`
: `curl --location --request PATCH '${
uiConfig.unleashUrl
}/api/admin/projects/${projectId}/features/${featureId}/environments/${
environment?.name
}/variants' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${JSON.stringify(apiPayload.patch, undefined, 2)}'`;
@ -324,6 +353,15 @@ export const EnvironmentVariantModal = ({
}
};
const hasChangeRequestInReviewForEnvironment =
changeRequestInReviewOrApproved(environment?.name || '');
const changeRequestButtonText = hasChangeRequestInReviewForEnvironment
? 'Add to existing change request'
: 'Add change to draft';
const isChangeRequest = isChangeRequestConfigured(environment?.name || '');
return (
<SidebarModal
open={open}
@ -349,6 +387,29 @@ export const EnvironmentVariantModal = ({
</StyledFormSubtitle>
<StyledForm onSubmit={handleSubmit}>
<div>
<ConditionallyRender
condition={hasChangeRequestInReviewForEnvironment}
show={alert}
elseShow={
<ConditionallyRender
condition={Boolean(isChangeRequest)}
show={
<StyledCRAlert severity="info">
<strong>Change requests</strong> are
enabled
{environment
? ` for ${environment.name}`
: ''}
. Your changes need to be approved
before they will be live. All the
changes you do now will be added
into a draft that you can submit for
review.
</StyledCRAlert>
}
/>
}
/>
<StyledInputDescription>
Variant name
</StyledInputDescription>
@ -486,7 +547,11 @@ export const EnvironmentVariantModal = ({
color="primary"
disabled={!isValid}
>
{editing ? 'Save' : 'Add'} variant
{isChangeRequest
? changeRequestButtonText
: editing
? 'Save variant'
: 'Add variant'}
</Button>
<StyledCancelButton
onClick={() => {

View File

@ -10,8 +10,11 @@ import { updateWeight } from 'component/common/util';
import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessProvider/permissions';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { IFeatureEnvironment, IFeatureVariant } from 'interfaces/featureToggle';
import { useState } from 'react';
import {
IFeatureEnvironmentWithCrEnabled,
IFeatureVariant,
} from 'interfaces/featureToggle';
import { useMemo, useState } from 'react';
import { EnvironmentVariantModal } from './EnvironmentVariantModal/EnvironmentVariantModal';
import { EnvironmentVariantsCard } from './EnvironmentVariantsCard/EnvironmentVariantsCard';
import { VariantDeleteDialog } from './VariantDeleteDialog/VariantDeleteDialog';
@ -20,6 +23,10 @@ import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { EnvironmentVariantsCopyFrom } from './EnvironmentVariantsCopyFrom/EnvironmentVariantsCopyFrom';
import { PushVariantsButton } from './PushVariantsButton/PushVariantsButton';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
const StyledAlert = styled(Alert)(({ theme }) => ({
marginBottom: theme.spacing(4),
@ -34,6 +41,7 @@ const StyledButtonContainer = styled('div')(({ theme }) => ({
}));
export const FeatureEnvironmentVariants = () => {
const { uiConfig } = useUiConfig();
const { setToastData, setToastApiError } = useToast();
const theme = useTheme();
const isSmallScreen = useMediaQuery(theme.breakpoints.down('md'));
@ -46,14 +54,29 @@ export const FeatureEnvironmentVariants = () => {
);
const { patchFeatureEnvironmentVariants, overrideVariantsInEnvironments } =
useFeatureApi();
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { addChange } = useChangeRequestApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const [searchValue, setSearchValue] = useState('');
const [selectedEnvironment, setSelectedEnvironment] =
useState<IFeatureEnvironment>();
useState<IFeatureEnvironmentWithCrEnabled>();
const [selectedVariant, setSelectedVariant] = useState<IFeatureVariant>();
const [modalOpen, setModalOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const environments: IFeatureEnvironmentWithCrEnabled[] = useMemo(
() =>
feature?.environments?.map(environment => ({
...environment,
crEnabled:
uiConfig.flags.crOnVariants &&
isChangeRequestConfigured(environment.name),
})) || [],
[feature.environments, uiConfig.flags.crOnVariants]
);
const createPatch = (
variants: IFeatureVariant[],
newVariants: IFeatureVariant[]
@ -72,38 +95,91 @@ export const FeatureEnvironmentVariants = () => {
return { patch: createPatch(variants, updatedNewVariants) };
};
const getCrPayload = (variants: IFeatureVariant[]) => ({
feature: featureId,
action: 'patchVariant' as const,
payload: { variants },
});
const updateVariants = async (
environment: IFeatureEnvironment,
environment: IFeatureEnvironmentWithCrEnabled,
variants: IFeatureVariant[]
) => {
const environmentVariants = environment.variants ?? [];
const { patch } = getApiPayload(environmentVariants, variants);
if (environment.crEnabled) {
await addChange(
projectId,
environment.name,
getCrPayload(variants)
);
refetchChangeRequests();
} else {
const environmentVariants = environment.variants ?? [];
const { patch } = getApiPayload(environmentVariants, variants);
if (patch.length === 0) return;
if (patch.length === 0) return;
await patchFeatureEnvironmentVariants(
projectId,
featureId,
environment.name,
patch
);
await patchFeatureEnvironmentVariants(
projectId,
featureId,
environment.name,
patch
);
}
refetchFeature();
};
const pushToEnvironments = async (
variants: IFeatureVariant[],
selected: string[]
selected: IFeatureEnvironmentWithCrEnabled[]
) => {
try {
await overrideVariantsInEnvironments(
projectId,
featureId,
variants,
selected
const selectedWithCrEnabled = selected.filter(
({ crEnabled }) => crEnabled
);
const selectedWithCrDisabled = selected.filter(
({ crEnabled }) => !crEnabled
);
if (selectedWithCrEnabled.length) {
await Promise.all(
selectedWithCrEnabled.map(environment =>
addChange(
projectId,
environment.name,
getCrPayload(variants)
)
)
);
}
if (selectedWithCrDisabled.length) {
await overrideVariantsInEnvironments(
projectId,
featureId,
variants,
selectedWithCrDisabled.map(({ name }) => name)
);
}
refetchChangeRequests();
refetchFeature();
const pushTitle = selectedWithCrDisabled.length
? `Variants pushed to ${
selectedWithCrDisabled.length === 1
? selectedWithCrDisabled[0].name
: `${selectedWithCrDisabled.length} environments`
}`
: '';
const draftTitle = selectedWithCrEnabled.length
? `Variants push added to ${
selectedWithCrEnabled.length === 1
? `${selectedWithCrEnabled[0].name} draft`
: `${selectedWithCrEnabled.length} drafts`
}`
: '';
const title = `${pushTitle}${
pushTitle && draftTitle ? '. ' : ''
}${draftTitle}`;
setToastData({
title: `Variants pushed successfully`,
title,
type: 'success',
});
} catch (error: unknown) {
@ -111,14 +187,14 @@ export const FeatureEnvironmentVariants = () => {
}
};
const addVariant = (environment: IFeatureEnvironment) => {
const addVariant = (environment: IFeatureEnvironmentWithCrEnabled) => {
setSelectedEnvironment(environment);
setSelectedVariant(undefined);
setModalOpen(true);
};
const editVariant = (
environment: IFeatureEnvironment,
environment: IFeatureEnvironmentWithCrEnabled,
variant: IFeatureVariant
) => {
setSelectedEnvironment(environment);
@ -127,7 +203,7 @@ export const FeatureEnvironmentVariants = () => {
};
const deleteVariant = (
environment: IFeatureEnvironment,
environment: IFeatureEnvironmentWithCrEnabled,
variant: IFeatureVariant
) => {
setSelectedEnvironment(environment);
@ -147,7 +223,9 @@ export const FeatureEnvironmentVariants = () => {
await updateVariants(selectedEnvironment, updatedVariants);
setDeleteOpen(false);
setToastData({
title: `Variant deleted successfully`,
title: selectedEnvironment.crEnabled
? 'Variant deletion added to draft'
: 'Variant deleted successfully',
type: 'success',
});
} catch (error: unknown) {
@ -162,9 +240,13 @@ export const FeatureEnvironmentVariants = () => {
await updateVariants(selectedEnvironment, updatedVariants);
setModalOpen(false);
setToastData({
title: `Variant ${
selectedVariant ? 'updated' : 'added'
} successfully`,
title: selectedEnvironment.crEnabled
? `Variant ${
selectedVariant ? 'changes' : ''
} added to draft`
: `Variant ${
selectedVariant ? 'updated' : 'added'
} successfully`,
type: 'success',
});
} catch (error: unknown) {
@ -174,14 +256,16 @@ export const FeatureEnvironmentVariants = () => {
};
const onCopyVariantsFrom = async (
fromEnvironment: IFeatureEnvironment,
toEnvironment: IFeatureEnvironment
fromEnvironment: IFeatureEnvironmentWithCrEnabled,
toEnvironment: IFeatureEnvironmentWithCrEnabled
) => {
try {
const variants = fromEnvironment.variants ?? [];
await updateVariants(toEnvironment, variants);
setToastData({
title: 'Variants copied successfully',
title: toEnvironment.crEnabled
? 'Variants copy added to draft'
: 'Variants copied successfully',
type: 'success',
});
} catch (error: unknown) {
@ -190,13 +274,15 @@ export const FeatureEnvironmentVariants = () => {
};
const onUpdateStickiness = async (
environment: IFeatureEnvironment,
environment: IFeatureEnvironmentWithCrEnabled,
updatedVariants: IFeatureVariant[]
) => {
try {
await updateVariants(environment, updatedVariants);
setToastData({
title: 'Variant stickiness updated successfully',
title: environment.crEnabled
? 'Variant stickiness update added to draft'
: 'Variant stickiness updated successfully',
type: 'success',
});
} catch (error: unknown) {
@ -242,8 +328,8 @@ 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(
{environments.map(environment => {
const otherEnvsWithVariants = environments.filter(
({ name, variants }) =>
name !== environment.name && variants?.length
);
@ -266,7 +352,7 @@ export const FeatureEnvironmentVariants = () => {
<StyledButtonContainer>
<PushVariantsButton
current={environment.name}
environments={feature.environments}
environments={environments}
permission={UPDATE_FEATURE_ENVIRONMENT_VARIANTS}
projectId={projectId}
onSubmit={selected =>
@ -303,6 +389,7 @@ export const FeatureEnvironmentVariants = () => {
open={modalOpen}
setOpen={setModalOpen}
getApiPayload={getApiPayload}
getCrPayload={getCrPayload}
onConfirm={onVariantConfirm}
/>
<VariantDeleteDialog

View File

@ -8,7 +8,7 @@ import {
styled,
} from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { IFeatureEnvironment } from 'interfaces/featureToggle';
import { IFeatureEnvironmentWithCrEnabled } from 'interfaces/featureToggle';
import { useState } from 'react';
import { useCheckProjectAccess } from 'hooks/useHasAccess';
@ -30,10 +30,10 @@ const StyledButton = styled(Button)(({ theme }) => ({
interface IPushVariantsButtonProps {
current: string;
environments: IFeatureEnvironment[];
environments: IFeatureEnvironmentWithCrEnabled[];
permission: string;
projectId: string;
onSubmit: (selected: string[]) => void;
onSubmit: (selected: IFeatureEnvironmentWithCrEnabled[]) => void;
}
export const PushVariantsButton = ({
@ -48,9 +48,9 @@ export const PushVariantsButton = ({
);
const pushToOpen = Boolean(pushToAnchorEl);
const [selectedEnvironments, setSelectedEnvironments] = useState<string[]>(
[]
);
const [selectedEnvironments, setSelectedEnvironments] = useState<
IFeatureEnvironmentWithCrEnabled[]
>([]);
const hasAccess = useCheckProjectAccess(projectId);
const hasAccessTo = environments.reduce((acc, env) => {
@ -58,16 +58,22 @@ export const PushVariantsButton = ({
return acc;
}, {} as Record<string, boolean>);
const addSelectedEnvironment = (name: string) => {
const addSelectedEnvironment = (
environment: IFeatureEnvironmentWithCrEnabled
) => {
setSelectedEnvironments(prevSelectedEnvironments => [
...prevSelectedEnvironments,
name,
environment,
]);
};
const removeSelectedEnvironment = (name: string) => {
const removeSelectedEnvironment = (
environment: IFeatureEnvironmentWithCrEnabled
) => {
setSelectedEnvironments(prevSelectedEnvironments =>
prevSelectedEnvironments.filter(env => env !== name)
prevSelectedEnvironments.filter(
({ name }) => name !== environment.name
)
);
};
@ -121,16 +127,16 @@ export const PushVariantsButton = ({
onChange={event => {
if (event.target.checked) {
addSelectedEnvironment(
otherEnvironment.name
otherEnvironment
);
} else {
removeSelectedEnvironment(
otherEnvironment.name
otherEnvironment
);
}
}}
checked={selectedEnvironments.includes(
otherEnvironment.name
otherEnvironment
)}
value={otherEnvironment.name}
/>

View File

@ -7,7 +7,8 @@ export interface IChangeSchema {
| 'updateEnabled'
| 'addStrategy'
| 'updateStrategy'
| 'deleteStrategy';
| 'deleteStrategy'
| 'patchVariant';
payload: string | boolean | object | number;
}

View File

@ -44,6 +44,10 @@ export interface IFeatureEnvironment {
variants?: IFeatureVariant[];
}
export interface IFeatureEnvironmentWithCrEnabled extends IFeatureEnvironment {
crEnabled?: boolean;
}
export interface IFeatureVariant {
name: string;
stickiness: string;

View File

@ -47,6 +47,7 @@ export interface IFlags {
featuresExportImport?: boolean;
newProjectOverview?: boolean;
caseInsensitiveInOperators?: boolean;
crOnVariants?: boolean;
}
export interface IVersionInfo {