1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-07-26 13:48:33 +02:00

refactor: remove unused components and rename new (#6357)

This commit is contained in:
Mateusz Kwasniewski 2024-02-27 12:22:47 +01:00 committed by GitHub
parent 9cd324bd7c
commit 20a9e1d725
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1095 additions and 2458 deletions

View File

@ -24,7 +24,7 @@ import {
Typography,
} from '@mui/material';
import { Delete, Edit, MoreVert } from '@mui/icons-material';
import { NewEditChange } from './NewEditChange';
import { EditChange } from './EditChange';
const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => {
const { isChangeRequestConfigured } = useChangeRequestsEnabled(
@ -149,7 +149,7 @@ export const ChangeActions: FC<{
Edit change
</Typography>
</ListItemText>
<NewEditChange
<EditChange
changeRequestId={changeRequest.id}
featureId={feature}
change={

View File

@ -7,7 +7,6 @@ import useToast from 'hooks/useToast';
import { IFeatureStrategy } from 'interfaces/strategy';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors';
import { useCollaborateData } from 'hooks/useCollaborateData';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
@ -16,13 +15,17 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
import {
ChangeRequestAddStrategy,
ChangeRequestEditStrategy,
IChangeRequestAddStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm';
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
import { FeatureStrategyForm } from '../../../../feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { v4 as uuidv4 } from 'uuid';
interface IEditChangeProps {
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
@ -34,6 +37,16 @@ interface IEditChangeProps {
onClose: () => void;
}
const addIdSymbolToConstraints = (
strategy?: ChangeRequestAddStrategy | ChangeRequestEditStrategy,
) => {
if (!strategy) return;
return strategy?.constraints.map((constraint) => {
return { ...constraint, [constraintId]: uuidv4() };
});
};
export const EditChange = ({
change,
changeRequestId,
@ -47,9 +60,12 @@ export const EditChange = ({
const { editChange } = useChangeRequestApi();
const [tab, setTab] = useState(0);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>(
change.payload,
);
const constraintsWithId = addIdSymbolToConstraints(change.payload);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({
...change.payload,
constraints: constraintsWithId,
});
const { segments: allSegments } = useSegments();
const strategySegments = (allSegments || []).filter((segment) => {
@ -134,7 +150,7 @@ export const EditChange = ({
>
<FormTemplate
modal
title={formatStrategyName(strategyDefinition.name ?? '')}
disablePadding
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
@ -148,7 +164,7 @@ export const EditChange = ({
)
}
>
<NewFeatureStrategyForm
<FeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
@ -165,7 +181,7 @@ export const EditChange = ({
tab={tab}
setTab={setTab}
StrategyVariants={
<StrategyVariants
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environment}

View File

@ -1,227 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { formatUnknownError } from 'utils/formatUnknownError';
import useToast from 'hooks/useToast';
import { IFeatureStrategy } from 'interfaces/strategy';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useFormErrors } from 'hooks/useFormErrors';
import { useCollaborateData } from 'hooks/useCollaborateData';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { IFeatureToggle } from 'interfaces/featureToggle';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
import {
ChangeRequestAddStrategy,
ChangeRequestEditStrategy,
IChangeRequestAddStrategy,
IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { v4 as uuidv4 } from 'uuid';
interface IEditChangeProps {
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
changeRequestId: number;
featureId: string;
environment: string;
open: boolean;
onSubmit: () => void;
onClose: () => void;
}
const addIdSymbolToConstraints = (
strategy?: ChangeRequestAddStrategy | ChangeRequestEditStrategy,
) => {
if (!strategy) return;
return strategy?.constraints.map((constraint) => {
return { ...constraint, [constraintId]: uuidv4() };
});
};
export const NewEditChange = ({
change,
changeRequestId,
environment,
open,
onSubmit,
onClose,
featureId,
}: IEditChangeProps) => {
const projectId = useRequiredPathParam('projectId');
const { editChange } = useChangeRequestApi();
const [tab, setTab] = useState(0);
const constraintsWithId = addIdSymbolToConstraints(change.payload);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({
...change.payload,
constraints: constraintsWithId,
});
const { segments: allSegments } = useSegments();
const strategySegments = (allSegments || []).filter((segment) => {
return change.payload.segments?.includes(segment.id);
});
const [segments, setSegments] = useState<ISegment[]>(strategySegments);
const strategyDefinition = {
parameters: change.payload.parameters,
name: change.payload.name,
};
const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { feature, refetchFeature } = useFeature(projectId, featureId);
const ref = useRef<IFeatureToggle>(feature);
const { data, staleDataNotification, forceRefreshCache } =
useCollaborateData<IFeatureToggle>(
{
unleashGetter: useFeature,
params: [projectId, featureId],
dataKey: 'feature',
refetchFunctionKey: 'refetchFeature',
options: {},
},
feature,
{
afterSubmitAction: refetchFeature,
},
comparisonModerator,
);
useEffect(() => {
if (ref.current.name === '' && feature.name) {
forceRefreshCache(feature);
ref.current = feature;
}
}, [feature]);
const payload = {
...strategy,
segments: segments.map((segment) => segment.id),
};
const onInternalSubmit = async () => {
try {
await editChange(projectId, changeRequestId, change.id, {
action: strategy.id ? 'updateStrategy' : 'addStrategy',
feature: featureId,
payload,
});
onSubmit();
setToastData({
title: 'Change updated',
type: 'success',
});
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
if (!strategyDefinition) {
return null;
}
if (!data) return null;
return (
<SidebarModal
open={open}
onClose={onClose}
label='Edit change'
onClick={(e) => {
e.stopPropagation();
}}
>
<FormTemplate
modal
disablePadding
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
formatApiCode={() =>
formatUpdateStrategyApiCode(
projectId,
changeRequestId,
change.id,
payload,
unleashUrl,
)
}
>
<NewFeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environment}
onSubmit={onInternalSubmit}
onCancel={onClose}
loading={false}
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environment)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environment}
projectId={projectId}
/>
}
/>
{staleDataNotification}
</FormTemplate>
</SidebarModal>
);
};
export const formatUpdateStrategyApiCode = (
projectId: string,
changeRequestId: number,
changeId: number,
strategy: Partial<IFeatureStrategy>,
unleashUrl?: string,
): string => {
if (!unleashUrl) {
return '';
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/change-requests/${changeRequestId}/changes/${changeId}`;
const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};
export const featureStrategyHelp = `
An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature.
If any of a feature toggle's activation strategies returns true, the user will get access.
`;
export const featureStrategyDocsLink =
'https://docs.getunleash.io/reference/activation-strategies';
export const featureStrategyDocsLinkLabel = 'Strategies documentation';

View File

@ -1,20 +1,447 @@
import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
test('formatAddStrategyApiCode', () => {
expect(
formatAddStrategyApiCode(
'projectId',
'featureId',
'environmentId',
{ id: 'strategyId' },
'unleashUrl',
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_FEATURE_STRATEGY,
} from 'component/providers/AccessProvider/permissions';
import { FeatureStrategyCreate } from './FeatureStrategyCreate';
import {
setupProjectEndpoint,
setupSegmentsEndpoint,
setupStrategyEndpoint,
setupFeaturesEndpoint,
setupUiConfigEndpoint,
setupContextEndpoint,
} from './featureStrategyFormTestSetup';
const featureName = 'my-new-feature';
const setupComponent = () => {
return {
wrapper: render(
<Routes>
<Route
path={
'/projects/:projectId/features/:featureId/strategies/create'
}
element={<FeatureStrategyCreate />}
/>
</Routes>,
{
route: `/projects/default/features/${featureName}/strategies/create?environmentId=development&strategyName=flexibleRollout&defaultStrategy=true`,
permissions: [
{
permission: CREATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
project: 'default',
environment: 'development',
},
],
},
),
).toMatchInlineSnapshot(`
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '{
"id": "strategyId"
}'"
`);
expectedSegmentName: 'test',
expectedGroupId: 'newGroupId',
expectedVariantName: 'Blue',
expectedSliderValue: '50',
expectedConstraintValue: 'new value',
expectedMultipleValues: '1234,4141,51515',
};
};
beforeEach(() => {
setupProjectEndpoint();
setupSegmentsEndpoint();
setupStrategyEndpoint();
setupFeaturesEndpoint(featureName);
setupUiConfigEndpoint();
setupContextEndpoint();
});
describe('NewFeatureStrategyCreate', () => {
test('formatAddStrategyApiCode', () => {
expect(
formatAddStrategyApiCode(
'projectId',
'featureId',
'environmentId',
{ id: 'strategyId' },
'unleashUrl',
),
).toMatchInlineSnapshot(`
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '{
"id": "strategyId"
}'"
`);
});
test('should navigate tabs', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const slider = await screen.findByRole('slider', { name: /rollout/i });
expect(slider).toHaveValue('100');
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const segmentsEl = await screen.findByText('Segments');
expect(segmentsEl).toBeInTheDocument();
const variantEl = screen.getByText('Variants');
fireEvent.click(variantEl);
const addVariantEl = await screen.findByText('Add variant');
expect(addVariantEl).toBeInTheDocument();
});
test('should change general settings', async () => {
const { expectedGroupId, expectedSliderValue } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const slider = await screen.findByRole('slider', { name: /rollout/i });
const groupIdInput = await screen.getByLabelText('groupId');
expect(slider).toHaveValue('100');
expect(groupIdInput).toHaveValue(featureName);
fireEvent.change(slider, { target: { value: expectedSliderValue } });
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
expect(slider).toHaveValue(expectedSliderValue);
expect(groupIdInput).toHaveValue(expectedGroupId);
});
test('should change targeting settings', async () => {
const { expectedConstraintValue, expectedSegmentName } =
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const inputElement = screen.getByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElement, {
target: { value: expectedConstraintValue },
});
const addValueEl = screen.getByText('Add values');
fireEvent.click(addValueEl);
const doneEl = screen.getByText('Done');
fireEvent.click(doneEl);
const selectElement = screen.getByPlaceholderText('Select segments');
fireEvent.mouseDown(selectElement);
const optionElement = await screen.findByText(expectedSegmentName);
fireEvent.click(optionElement);
expect(screen.getByText(expectedSegmentName)).toBeInTheDocument();
expect(screen.getByText(expectedConstraintValue)).toBeInTheDocument();
});
test('should change variants settings', async () => {
const { expectedVariantName } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
const addVariantEl = await screen.findByText('Add variant');
fireEvent.click(addVariantEl);
const inputElement = screen.getAllByRole('textbox')[0];
fireEvent.change(inputElement, {
target: { value: expectedVariantName },
});
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
const generalSettingsEl = screen.getByText('General');
fireEvent.click(generalSettingsEl);
await waitFor(() => {
const codeSnippet = document.querySelector('pre')?.innerHTML;
const variantNameMatches = (
codeSnippet!.match(new RegExp(expectedVariantName, 'g')) || []
).length;
const metaDataMatches = (codeSnippet!.match(/isValid/g) || [])
.length;
expect(variantNameMatches).toBe(1);
expect(metaDataMatches).toBe(0);
});
});
test('should change variant name after changing tab', async () => {
const { expectedVariantName } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
const addVariantEl = await screen.findByText('Add variant');
fireEvent.click(addVariantEl);
const inputElement = screen.getAllByRole('textbox')[0];
fireEvent.change(inputElement, {
target: { value: expectedVariantName },
});
const targetingEl = await screen.findByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
expect(addConstraintEl).toBeInTheDocument();
fireEvent.click(variantsEl);
const inputElement2 = screen.getAllByRole('textbox')[0];
expect(inputElement2).not.toBeDisabled();
});
test('should remove empty variants when changing tabs', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
const addVariantEl = await screen.findByText('Add variant');
fireEvent.click(addVariantEl);
const variants = screen.queryAllByTestId('VARIANT');
expect(variants.length).toBe(1);
const targetingEl = await screen.findByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
expect(addConstraintEl).toBeInTheDocument();
fireEvent.click(variantsEl);
const variants2 = screen.queryAllByTestId('VARIANT');
expect(variants2.length).toBe(0);
});
test('Should autosave constraint settings when navigating between tabs', async () => {
const { expectedMultipleValues } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const inputElement = screen.getByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElement, {
target: { value: expectedMultipleValues },
});
const addValueEl = await screen.findByText('Add values');
fireEvent.click(addValueEl);
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
fireEvent.click(targetingEl);
const values = expectedMultipleValues.split(',');
expect(screen.getByText(values[0])).toBeInTheDocument();
expect(screen.getByText(values[1])).toBeInTheDocument();
expect(screen.getByText(values[2])).toBeInTheDocument();
});
test('Should update multiple constraints correctly', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
const inputElements = screen.getAllByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElements[0], {
target: { value: '123' },
});
fireEvent.change(inputElements[1], {
target: { value: '456' },
});
fireEvent.change(inputElements[2], {
target: { value: '789' },
});
const addValueEls = await screen.findAllByText('Add values');
fireEvent.click(addValueEls[0]);
fireEvent.click(addValueEls[1]);
fireEvent.click(addValueEls[2]);
expect(screen.queryByText('123')).toBeInTheDocument();
const deleteBtns = await screen.findAllByTestId('CancelIcon');
fireEvent.click(deleteBtns[0]);
expect(screen.queryByText('123')).not.toBeInTheDocument();
expect(screen.queryByText('456')).toBeInTheDocument();
expect(screen.queryByText('789')).toBeInTheDocument();
});
test('Should update multiple constraints with the correct react key', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
const inputElements = screen.getAllByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElements[0], {
target: { value: '123' },
});
fireEvent.change(inputElements[1], {
target: { value: '456' },
});
fireEvent.change(inputElements[2], {
target: { value: '789' },
});
const addValueEls = await screen.findAllByText('Add values');
fireEvent.click(addValueEls[0]);
fireEvent.click(addValueEls[1]);
fireEvent.click(addValueEls[2]);
expect(screen.queryByText('123')).toBeInTheDocument();
const deleteBtns = screen.getAllByTestId('DELETE_CONSTRAINT_BUTTON');
fireEvent.click(deleteBtns[0]);
const inputElements2 = screen.getAllByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElements2[0], {
target: { value: '666' },
});
const addValueEls2 = screen.getAllByText('Add values');
fireEvent.click(addValueEls2[0]);
expect(screen.queryByText('123')).not.toBeInTheDocument();
expect(screen.queryByText('456')).toBeInTheDocument();
expect(screen.queryByText('789')).toBeInTheDocument();
});
test('Should undo changes made to constraints', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const inputEl = screen.getByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputEl, {
target: { value: '6, 7, 8' },
});
const addBtn = await screen.findByText('Add values');
addBtn.click();
expect(screen.queryByText('6')).toBeInTheDocument();
expect(screen.queryByText('7')).toBeInTheDocument();
expect(screen.queryByText('8')).toBeInTheDocument();
const undoBtn = await screen.findByTestId(
'UNDO_CONSTRAINT_CHANGE_BUTTON',
);
undoBtn.click();
expect(screen.queryByText('6')).not.toBeInTheDocument();
expect(screen.queryByText('7')).not.toBeInTheDocument();
expect(screen.queryByText('8')).not.toBeInTheDocument();
});
test('Should remove constraint when no valid values are set and moving between tabs', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
fireEvent.click(targetingEl);
const seconAddConstraintEl = await screen.findByText('Add constraint');
expect(seconAddConstraintEl).toBeInTheDocument();
expect(screen.queryByText('appName')).not.toBeInTheDocument();
});
});

View File

@ -1,7 +1,6 @@
import { useEffect, useRef, useState } from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
@ -18,7 +17,6 @@ import {
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors';
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
@ -33,8 +31,11 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useQueryParams from 'hooks/useQueryParams';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy';
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
export const FeatureStrategyCreate = () => {
const [tab, setTab] = useState(0);
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId');
@ -178,10 +179,10 @@ export const FeatureStrategyCreate = () => {
return (
<FormTemplate
modal
title={formatStrategyName(strategyName)}
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
disablePadding
formatApiCode={() =>
formatAddStrategyApiCode(
projectId,
@ -205,6 +206,17 @@ export const FeatureStrategyCreate = () => {
permission={CREATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
editable
/>
}
/>
{staleDataNotification}
</FormTemplate>

View File

@ -1,42 +1,112 @@
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
test('formatUpdateStrategyApiCode', () => {
const strategy: IFeatureStrategy = {
id: 'a',
name: 'b',
parameters: {
c: 1,
b: 2,
a: 3,
},
constraints: [],
};
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_FEATURE_STRATEGY,
} from 'component/providers/AccessProvider/permissions';
import { FeatureStrategyEdit } from './FeatureStrategyEdit';
import {
setupContextEndpoint,
setupFeaturesEndpoint,
setupProjectEndpoint,
setupSegmentsEndpoint,
setupStrategyEndpoint,
setupUiConfigEndpoint,
} from '../FeatureStrategyCreate/featureStrategyFormTestSetup';
import userEvent from '@testing-library/user-event';
const strategyDefinition: IStrategy = {
name: 'c',
displayName: 'd',
description: 'e',
editable: false,
deprecated: false,
parameters: [
{ name: 'a', description: '', type: '', required: false },
{ name: 'b', description: '', type: '', required: false },
{ name: 'c', description: '', type: '', required: false },
],
};
const featureName = 'my-new-feature';
const variantName = 'Blue';
expect(
formatUpdateStrategyApiCode(
'projectId',
'featureId',
'environmentId',
'strategyId',
strategy,
strategyDefinition,
'unleashUrl',
const setupComponent = () => {
return {
wrapper: render(
<Routes>
<Route
path={
'/projects/:projectId/features/:featureId/strategies/edit'
}
element={<FeatureStrategyEdit />}
/>
</Routes>,
{
route: `/projects/default/features/${featureName}/strategies/edit?environmentId=development&strategyId=1`,
permissions: [
{
permission: CREATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
project: 'default',
environment: 'development',
},
],
},
),
).toMatchInlineSnapshot(`
expectedGroupId: 'newGroupId',
expectedVariantName: variantName,
expectedSliderValue: '75',
};
};
beforeEach(() => {
setupProjectEndpoint();
setupSegmentsEndpoint();
setupStrategyEndpoint();
setupFeaturesEndpoint(featureName, variantName);
setupUiConfigEndpoint();
setupContextEndpoint();
});
describe('NewFeatureStrategyEdit', () => {
test('formatUpdateStrategyApiCode', () => {
const strategy: IFeatureStrategy = {
id: 'a',
name: 'b',
parameters: {
c: 1,
b: 2,
a: 3,
},
constraints: [],
};
const strategyDefinition: IStrategy = {
name: 'c',
displayName: 'd',
description: 'e',
editable: false,
deprecated: false,
parameters: [
{ name: 'a', description: '', type: '', required: false },
{ name: 'b', description: '', type: '', required: false },
{ name: 'c', description: '', type: '', required: false },
],
};
expect(
formatUpdateStrategyApiCode(
'projectId',
'featureId',
'environmentId',
'strategyId',
strategy,
strategyDefinition,
'unleashUrl',
),
).toMatchInlineSnapshot(`
"curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/strategyId' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
@ -51,4 +121,53 @@ test('formatUpdateStrategyApiCode', () => {
"constraints": []
}'"
`);
});
test('should change general settings', async () => {
const { expectedGroupId, expectedSliderValue, wrapper } =
setupComponent();
await waitFor(() => {
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
});
const slider = await screen.findByRole('slider', { name: /rollout/i });
const groupIdInput = await screen.getByLabelText('groupId');
expect(slider).toHaveValue('50');
expect(groupIdInput).toHaveValue(featureName);
const defaultStickiness = await screen.findByText('default');
userEvent.click(defaultStickiness);
const randomStickiness = await screen.findByText('random');
userEvent.click(randomStickiness);
fireEvent.change(slider, { target: { value: expectedSliderValue } });
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
expect(slider).toHaveValue(expectedSliderValue);
expect(groupIdInput).toHaveValue(expectedGroupId);
await waitFor(() => {
const codeSnippet = document.querySelector('pre')?.innerHTML;
const count = (codeSnippet!.match(/random/g) || []).length;
// strategy stickiness and variant stickiness
expect(count).toBe(2);
});
});
test('should not change variant names', async () => {
const { expectedVariantName } = setupComponent();
await waitFor(() => {
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
});
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
const inputElement = screen.getAllByRole('textbox')[0];
expect(inputElement).toBeDisabled();
});
});

View File

@ -1,5 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
@ -16,7 +15,6 @@ import {
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { sortStrategyParameters } from 'utils/sortStrategyParameters';
@ -28,6 +26,10 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { v4 as uuidv4 } from 'uuid';
import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
import {
getChangeRequestConflictCreatedData,
@ -80,11 +82,20 @@ const useTitleTracking = () => {
};
};
const addIdSymbolToConstraints = (strategy?: IFeatureStrategy) => {
if (!strategy) return;
return strategy?.constraints.map((constraint) => {
return { ...constraint, [constraintId]: uuidv4() };
});
};
export const FeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId');
const strategyId = useRequiredQueryParam('strategyId');
const [tab, setTab] = useState(0);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]);
@ -168,7 +179,15 @@ export const FeatureStrategyEdit = () => {
const savedStrategy = data?.environments
.flatMap((environment) => environment.strategies)
.find((strategy) => strategy.id === strategyId);
setStrategy((prev) => ({ ...prev, ...savedStrategy }));
const constraintsWithId = addIdSymbolToConstraints(savedStrategy);
const formattedStrategy = {
...savedStrategy,
constraints: constraintsWithId,
};
setStrategy((prev) => ({ ...prev, ...formattedStrategy }));
setPreviousTitle(savedStrategy?.title || '');
}, [strategyId, data]);
@ -235,7 +254,7 @@ export const FeatureStrategyEdit = () => {
return (
<FormTemplate
modal
title={formatStrategyName(strategy.name ?? '')}
disablePadding
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
@ -264,6 +283,16 @@ export const FeatureStrategyEdit = () => {
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
/>
}
/>
{staleDataNotification}
</FormTemplate>

View File

@ -1,6 +1,15 @@
import React, { useState } from 'react';
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Alert, Button, styled } from '@mui/material';
import {
Alert,
Button,
styled,
Tabs,
Tab,
Box,
Divider,
Typography,
} from '@mui/material';
import {
IFeatureStrategy,
IFeatureStrategyParameters,
@ -31,8 +40,12 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { formatStrategyName } from 'utils/strategyNames';
import { Badge } from 'component/common/Badge/Badge';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import { useFeedback } from 'component/feedbackNew/useFeedback';
import { useUiFlag } from 'hooks/useUiFlag';
interface IFeatureStrategyFormProps {
feature: IFeatureToggle;
@ -42,7 +55,7 @@ interface IFeatureStrategyFormProps {
onSubmit: () => void;
onCancel?: () => void;
loading: boolean;
isChangeRequest?: boolean;
isChangeRequest: boolean;
strategy: Partial<IFeatureStrategy>;
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
@ -50,28 +63,131 @@ interface IFeatureStrategyFormProps {
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
errors: IFormErrors;
tab: number;
setTab: React.Dispatch<React.SetStateAction<number>>;
StrategyVariants: JSX.Element;
}
const StyledForm = styled('form')(({ theme }) => ({
display: 'grid',
gap: theme.spacing(2),
const StyledDividerContent = styled(Box)(({ theme }) => ({
padding: theme.spacing(0.75, 1),
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallerBody,
backgroundColor: theme.palette.background.elevation2,
borderRadius: theme.shape.borderRadius,
width: '45px',
position: 'absolute',
top: '-10px',
left: 'calc(50% - 45px)',
lineHeight: 1,
}));
const StyledHr = styled('hr')(({ theme }) => ({
width: '100%',
height: '1px',
margin: theme.spacing(2, 0),
border: 'none',
background: theme.palette.background.elevation2,
const StyledForm = styled('form')(({ theme }) => ({
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(6),
paddingBottom: theme.spacing(12),
paddingTop: theme.spacing(4),
overflow: 'auto',
height: '100%',
}));
const StyledTitle = styled('h1')(({ theme }) => ({
fontWeight: 'normal',
display: 'flex',
alignItems: 'center',
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
}));
const StyledButtons = styled('div')(({ theme }) => ({
bottom: 0,
right: 0,
left: 0,
position: 'absolute',
display: 'flex',
padding: theme.spacing(3),
paddingRight: theme.spacing(6),
paddingLeft: theme.spacing(6),
backgroundColor: theme.palette.background.paper,
justifyContent: 'end',
gap: theme.spacing(2),
paddingBottom: theme.spacing(10),
borderTop: `1px solid ${theme.palette.divider}`,
}));
const StyledTabs = styled(Tabs)(({ theme }) => ({
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
minHeight: '60px',
}));
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
position: 'relative',
marginTop: theme.spacing(3.5),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
width: '100%',
}));
const StyledTargetingHeader = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
marginTop: theme.spacing(1.5),
}));
const StyledHeaderBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
paddingTop: theme.spacing(2),
}));
const StyledAlertBox = styled(Box)(({ theme }) => ({
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
'& > *': {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}));
const StyledEnvironmentBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
}));
const EnvironmentIconBox = styled(Box)(({ theme }) => ({
transform: 'scale(0.9)',
display: 'flex',
alignItems: 'center',
}));
const EnvironmentTypography = styled(Typography)<{ enabled: boolean }>(
({ theme, enabled }) => ({
fontWeight: enabled ? 'bold' : 'normal',
}),
);
const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({
marginRight: theme.spacing(0.5),
color: theme.palette.text.secondary,
}));
const StyledTab = styled(Tab)(({ theme }) => ({
width: '100px',
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
const feedbackCategory = 'newStrategyForm';
export const FeatureStrategyForm = ({
projectId,
feature,
@ -86,7 +202,14 @@ export const FeatureStrategyForm = ({
setSegments,
errors,
isChangeRequest,
tab,
setTab,
StrategyVariants,
}: IFeatureStrategyFormProps) => {
const { openFeedback, hasSubmittedFeedback } = useFeedback(
feedbackCategory,
'manual',
);
const { trackEvent } = usePlausibleTracker();
const [showProdGuard, setShowProdGuard] = useState(false);
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
@ -97,6 +220,39 @@ export const FeatureStrategyForm = ({
environmentId,
);
const { strategyDefinition } = useStrategy(strategy?.name);
const newStrategyConfigurationFeedback = useUiFlag(
'newStrategyConfigurationFeedback',
);
useEffect(() => {
trackEvent('new-strategy-form', {
props: {
eventType: 'seen',
},
});
});
const stickiness =
strategy?.parameters && 'stickiness' in strategy?.parameters
? String(strategy.parameters.stickiness)
: 'default';
useEffect(() => {
setStrategy((prev) => ({
...prev,
variants: (strategy.variants || []).map((variant) => ({
stickiness,
name: variant.name,
weight: variant.weight,
payload: variant.payload,
weightType: variant.weightType,
})),
}));
}, [stickiness, JSON.stringify(strategy.variants)]);
const foundEnvironment = feature.environments.find(
(environment) => environment.name === environmentId,
);
const { data } = usePendingChangeRequests(feature.project);
const { changeRequestInReviewOrApproved, alert } =
@ -111,11 +267,7 @@ export const FeatureStrategyForm = ({
const navigate = useNavigate();
const {
uiConfig,
error: uiConfigError,
loading: uiConfigLoading,
} = useUiConfig();
const { error: uiConfigError, loading: uiConfigLoading } = useUiConfig();
if (uiConfigError) {
throw uiConfigError;
@ -159,6 +311,15 @@ export const FeatureStrategyForm = ({
navigate(formatFeaturePath(feature.project, feature.name));
};
const createFeedbackContext = () => {
openFeedback({
title: 'How easy was it to work with the new strategy form?',
positiveLabel: 'What do you like most about the new strategy form?',
areasForImprovementsLabel:
'What should be improved the new strategy form?',
});
};
const onSubmitWithValidation = async (event: React.FormEvent) => {
if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) {
trackEvent('strategy-variants', {
@ -172,145 +333,262 @@ export const FeatureStrategyForm = ({
return;
}
trackEvent('new-strategy-form', {
props: {
eventType: 'submitted',
},
});
if (enableProdGuard && !isChangeRequest) {
setShowProdGuard(true);
} else {
onSubmit();
await onSubmitWithFeedback();
}
};
const onSubmitWithFeedback = async () => {
try {
await onSubmit();
if (newStrategyConfigurationFeedback && !hasSubmittedFeedback) {
createFeedbackContext();
}
} catch (e) {
console.error(e);
}
};
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTab(newValue);
};
const getTargetingCount = () => {
const constraintCount = strategy.constraints?.length || 0;
const segmentCount = segments.length || 0;
return constraintCount + segmentCount;
};
const showVariants =
strategy.parameters && 'stickiness' in strategy.parameters;
return (
<StyledForm onSubmit={onSubmitWithValidation}>
<ConditionallyRender
condition={hasChangeRequestInReviewForEnvironment}
show={alert}
elseShow={
<>
<StyledHeaderBox>
<StyledTitle>
{formatStrategyName(strategy.name || '')}
<ConditionallyRender
condition={strategy.name === 'flexibleRollout'}
show={
<Badge color='success' sx={{ marginLeft: '1rem' }}>
{strategy.parameters?.rollout}%
</Badge>
}
/>
</StyledTitle>
{foundEnvironment ? (
<StyledEnvironmentBox>
<EnvironmentTypographyHeader>
Environment:
</EnvironmentTypographyHeader>
<EnvironmentIconBox>
<EnvironmentIcon
enabled={foundEnvironment.enabled}
/>{' '}
<EnvironmentTypography
enabled={foundEnvironment.enabled}
>
{foundEnvironment.name}
</EnvironmentTypography>
</EnvironmentIconBox>
</StyledEnvironmentBox>
) : null}
</StyledHeaderBox>
<StyledAlertBox>
<ConditionallyRender
condition={hasChangeRequestInReviewForEnvironment}
show={alert}
elseShow={
<ConditionallyRender
condition={isChangeRequest}
show={
<FeatureStrategyChangeRequestAlert
environment={environmentId}
/>
}
/>
}
/>
<FeatureStrategyEnabled
projectId={feature.project}
featureId={feature.name}
environmentId={environmentId}
>
<ConditionallyRender
condition={Boolean(isChangeRequest)}
show={
<FeatureStrategyChangeRequestAlert
environment={environmentId}
/>
<Alert severity='success'>
This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment.
Any changes made here will be available to users
as soon as these changes are approved and
applied.
</Alert>
}
elseShow={
<Alert severity='success'>
This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment.
Any changes made here will be available to users
as soon as you hit <strong>save</strong>.
</Alert>
}
/>
}
/>
<FeatureStrategyEnabled
projectId={feature.project}
featureId={feature.name}
environmentId={environmentId}
>
<ConditionallyRender
condition={Boolean(isChangeRequest)}
show={
<Alert severity='success'>
This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment. Any
changes made here will be available to users as soon
as these changes are approved and applied.
</Alert>
}
elseShow={
<Alert severity='success'>
This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment. Any
changes made here will be available to users as soon
as you hit <strong>save</strong>.
</Alert>
</FeatureStrategyEnabled>
</StyledAlertBox>
<StyledTabs value={tab} onChange={handleChange}>
<StyledTab label='General' />
<Tab
data-testid='STRATEGY_TARGETING_TAB'
label={
<Typography>
Targeting
<StyledBadge>{getTargetingCount()}</StyledBadge>
</Typography>
}
/>
</FeatureStrategyEnabled>
<StyledHr />
<FeatureStrategyTitle
title={strategy.title || ''}
setTitle={(title) => {
setStrategy((prev) => ({
...prev,
title,
}));
}}
/>
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
<FeatureStrategyConstraints
projectId={feature.project}
environmentId={environmentId}
strategy={strategy}
setStrategy={setStrategy}
/>
<StyledHr />
<FeatureStrategyType
strategy={strategy}
strategyDefinition={strategyDefinition}
setStrategy={setStrategy}
validateParameter={validateParameter}
errors={errors}
hasAccess={access}
/>
<StyledHr />
<ConditionallyRender
condition={
strategy.parameters != null &&
'stickiness' in strategy.parameters
}
show={
<StrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
{showVariants && (
<Tab
data-testid='STRATEGY_VARIANTS_TAB'
label={
<Typography>
Variants
<StyledBadge>
{strategy.variants?.length || 0}
</StyledBadge>
</Typography>
}
/>
}
/>
<StyledHr />
<FeatureStrategyEnabledDisabled
enabled={!strategy?.disabled}
onToggleEnabled={() =>
setStrategy((strategyState) => ({
...strategyState,
disabled: !strategyState.disabled,
}))
}
/>
<StyledHr />
<StyledButtons>
<PermissionButton
permission={permission}
projectId={feature.project}
environmentId={environmentId}
variant='contained'
color='primary'
type='submit'
disabled={
loading ||
!hasValidConstraints ||
errors.hasFormErrors()
)}
</StyledTabs>
<StyledForm onSubmit={onSubmitWithValidation}>
<ConditionallyRender
condition={tab === 0}
show={
<>
<FeatureStrategyTitle
title={strategy.title || ''}
setTitle={(title) => {
setStrategy((prev) => ({
...prev,
title,
}));
}}
/>
<FeatureStrategyEnabledDisabled
enabled={!strategy?.disabled}
onToggleEnabled={() =>
setStrategy((strategyState) => ({
...strategyState,
disabled: !strategyState.disabled,
}))
}
/>
<FeatureStrategyType
strategy={strategy}
strategyDefinition={strategyDefinition}
setStrategy={setStrategy}
validateParameter={validateParameter}
errors={errors}
hasAccess={access}
/>
</>
}
data-testid={STRATEGY_FORM_SUBMIT_ID}
>
{isChangeRequest
? changeRequestButtonText
: 'Save strategy'}
</PermissionButton>
<Button
type='button'
color='primary'
onClick={onCancel ? onCancel : onDefaultCancel}
disabled={loading}
>
Cancel
</Button>
<FeatureStrategyProdGuard
open={showProdGuard}
onClose={() => setShowProdGuard(false)}
onClick={onSubmit}
loading={loading}
label='Save strategy'
/>
</StyledButtons>
</StyledForm>
<ConditionallyRender
condition={tab === 1}
show={
<>
<StyledTargetingHeader>
Segmentation and constraints allow you to set
filters on your strategies, so that they will
only be evaluated for users and applications
that match the specified preconditions.
</StyledTargetingHeader>
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
<StyledBox>
<StyledDivider />
<StyledDividerContent>AND</StyledDividerContent>
</StyledBox>
<FeatureStrategyConstraints
projectId={feature.project}
environmentId={environmentId}
strategy={strategy}
setStrategy={setStrategy}
/>
</>
}
/>
<ConditionallyRender
condition={tab === 2}
show={
<ConditionallyRender
condition={
strategy.parameters != null &&
'stickiness' in strategy.parameters
}
show={StrategyVariants}
/>
}
/>
<StyledButtons>
<PermissionButton
permission={permission}
projectId={feature.project}
environmentId={environmentId}
variant='contained'
color='primary'
type='submit'
disabled={
loading ||
!hasValidConstraints ||
errors.hasFormErrors()
}
data-testid={STRATEGY_FORM_SUBMIT_ID}
>
{isChangeRequest
? changeRequestButtonText
: 'Save strategy'}
</PermissionButton>
<Button
type='button'
color='primary'
onClick={onCancel ? onCancel : onDefaultCancel}
disabled={loading}
>
Cancel
</Button>
<FeatureStrategyProdGuard
open={showProdGuard}
onClose={() => setShowProdGuard(false)}
onClick={onSubmitWithFeedback}
loading={loading}
label='Save strategy'
/>
</StyledButtons>
</StyledForm>
</>
);
};

View File

@ -1,594 +0,0 @@
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
styled,
Tabs,
Tab,
Box,
Divider,
Typography,
} from '@mui/material';
import {
IFeatureStrategy,
IFeatureStrategyParameters,
IStrategyParameter,
} from 'interfaces/strategy';
import { FeatureStrategyType } from '../FeatureStrategyType/FeatureStrategyType';
import { FeatureStrategyEnabled } from './FeatureStrategyEnabled/FeatureStrategyEnabled';
import { FeatureStrategyConstraints } from '../FeatureStrategyConstraints/FeatureStrategyConstraints';
import { IFeatureToggle } from 'interfaces/featureToggle';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { STRATEGY_FORM_SUBMIT_ID } from 'utils/testIds';
import { useConstraintsValidation } from 'hooks/api/getters/useConstraintsValidation/useConstraintsValidation';
import PermissionButton from 'component/common/PermissionButton/PermissionButton';
import { FeatureStrategySegment } from 'component/feature/FeatureStrategy/FeatureStrategySegment/FeatureStrategySegment';
import { ISegment } from 'interfaces/segment';
import { IFormErrors } from 'hooks/useFormErrors';
import { validateParameterValue } from 'utils/validateParameterValue';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { FeatureStrategyChangeRequestAlert } from './FeatureStrategyChangeRequestAlert/FeatureStrategyChangeRequestAlert';
import {
FeatureStrategyProdGuard,
useFeatureStrategyProdGuard,
} from '../FeatureStrategyProdGuard/FeatureStrategyProdGuard';
import { formatFeaturePath } from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { useChangeRequestInReviewWarning } from 'hooks/useChangeRequestInReviewWarning';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { formatStrategyName } from 'utils/strategyNames';
import { Badge } from 'component/common/Badge/Badge';
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
import { useFeedback } from 'component/feedbackNew/useFeedback';
import { useUiFlag } from 'hooks/useUiFlag';
interface IFeatureStrategyFormProps {
feature: IFeatureToggle;
projectId: string;
environmentId: string;
permission: string;
onSubmit: () => void;
onCancel?: () => void;
loading: boolean;
isChangeRequest: boolean;
strategy: Partial<IFeatureStrategy>;
setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>>
>;
segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
errors: IFormErrors;
tab: number;
setTab: React.Dispatch<React.SetStateAction<number>>;
StrategyVariants: JSX.Element;
}
const StyledDividerContent = styled(Box)(({ theme }) => ({
padding: theme.spacing(0.75, 1),
color: theme.palette.text.primary,
fontSize: theme.fontSizes.smallerBody,
backgroundColor: theme.palette.background.elevation2,
borderRadius: theme.shape.borderRadius,
width: '45px',
position: 'absolute',
top: '-10px',
left: 'calc(50% - 45px)',
lineHeight: 1,
}));
const StyledForm = styled('form')(({ theme }) => ({
position: 'relative',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
padding: theme.spacing(6),
paddingBottom: theme.spacing(12),
paddingTop: theme.spacing(4),
overflow: 'auto',
height: '100%',
}));
const StyledTitle = styled('h1')(({ theme }) => ({
fontWeight: 'normal',
display: 'flex',
alignItems: 'center',
paddingTop: theme.spacing(2),
paddingBottom: theme.spacing(2),
}));
const StyledButtons = styled('div')(({ theme }) => ({
bottom: 0,
right: 0,
left: 0,
position: 'absolute',
display: 'flex',
padding: theme.spacing(3),
paddingRight: theme.spacing(6),
paddingLeft: theme.spacing(6),
backgroundColor: theme.palette.background.paper,
justifyContent: 'end',
borderTop: `1px solid ${theme.palette.divider}`,
}));
const StyledTabs = styled(Tabs)(({ theme }) => ({
borderTop: `1px solid ${theme.palette.divider}`,
borderBottom: `1px solid ${theme.palette.divider}`,
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
minHeight: '60px',
}));
const StyledBox = styled(Box)(({ theme }) => ({
display: 'flex',
position: 'relative',
marginTop: theme.spacing(3.5),
}));
const StyledDivider = styled(Divider)(({ theme }) => ({
width: '100%',
}));
const StyledTargetingHeader = styled('div')(({ theme }) => ({
color: theme.palette.text.secondary,
marginTop: theme.spacing(1.5),
}));
const StyledHeaderBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
paddingTop: theme.spacing(2),
}));
const StyledAlertBox = styled(Box)(({ theme }) => ({
paddingLeft: theme.spacing(6),
paddingRight: theme.spacing(6),
'& > *': {
marginTop: theme.spacing(2),
marginBottom: theme.spacing(2),
},
}));
const StyledEnvironmentBox = styled(Box)(({ theme }) => ({
display: 'flex',
alignItems: 'center',
}));
const EnvironmentIconBox = styled(Box)(({ theme }) => ({
transform: 'scale(0.9)',
display: 'flex',
alignItems: 'center',
}));
const EnvironmentTypography = styled(Typography)<{ enabled: boolean }>(
({ theme, enabled }) => ({
fontWeight: enabled ? 'bold' : 'normal',
}),
);
const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({
marginRight: theme.spacing(0.5),
color: theme.palette.text.secondary,
}));
const StyledTab = styled(Tab)(({ theme }) => ({
width: '100px',
}));
const StyledBadge = styled(Badge)(({ theme }) => ({
marginLeft: theme.spacing(1),
}));
const feedbackCategory = 'newStrategyForm';
export const NewFeatureStrategyForm = ({
projectId,
feature,
environmentId,
permission,
onSubmit,
onCancel,
loading,
strategy,
setStrategy,
segments,
setSegments,
errors,
isChangeRequest,
tab,
setTab,
StrategyVariants,
}: IFeatureStrategyFormProps) => {
const { openFeedback, hasSubmittedFeedback } = useFeedback(
feedbackCategory,
'manual',
);
const { trackEvent } = usePlausibleTracker();
const [showProdGuard, setShowProdGuard] = useState(false);
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
const enableProdGuard = useFeatureStrategyProdGuard(feature, environmentId);
const access = useHasProjectEnvironmentAccess(
permission,
projectId,
environmentId,
);
const { strategyDefinition } = useStrategy(strategy?.name);
const newStrategyConfigurationFeedback = useUiFlag(
'newStrategyConfigurationFeedback',
);
useEffect(() => {
trackEvent('new-strategy-form', {
props: {
eventType: 'seen',
},
});
});
const stickiness =
strategy?.parameters && 'stickiness' in strategy?.parameters
? String(strategy.parameters.stickiness)
: 'default';
useEffect(() => {
setStrategy((prev) => ({
...prev,
variants: (strategy.variants || []).map((variant) => ({
stickiness,
name: variant.name,
weight: variant.weight,
payload: variant.payload,
weightType: variant.weightType,
})),
}));
}, [stickiness, JSON.stringify(strategy.variants)]);
const foundEnvironment = feature.environments.find(
(environment) => environment.name === environmentId,
);
const { data } = usePendingChangeRequests(feature.project);
const { changeRequestInReviewOrApproved, alert } =
useChangeRequestInReviewWarning(data);
const hasChangeRequestInReviewForEnvironment =
changeRequestInReviewOrApproved(environmentId || '');
const changeRequestButtonText = hasChangeRequestInReviewForEnvironment
? 'Add to existing change request'
: 'Add change to draft';
const navigate = useNavigate();
const { error: uiConfigError, loading: uiConfigLoading } = useUiConfig();
if (uiConfigError) {
throw uiConfigError;
}
if (uiConfigLoading || !strategyDefinition) {
return null;
}
const findParameterDefinition = (name: string): IStrategyParameter => {
return strategyDefinition.parameters.find((parameterDefinition) => {
return parameterDefinition.name === name;
})!;
};
const validateParameter = (
name: string,
value: IFeatureStrategyParameters[string],
): boolean => {
const parameterValueError = validateParameterValue(
findParameterDefinition(name),
value,
);
if (parameterValueError) {
errors.setFormError(name, parameterValueError);
return false;
} else {
errors.removeFormError(name);
return true;
}
};
const validateAllParameters = (): boolean => {
return strategyDefinition.parameters
.map((parameter) => parameter.name)
.map((name) => validateParameter(name, strategy.parameters?.[name]))
.every(Boolean);
};
const onDefaultCancel = () => {
navigate(formatFeaturePath(feature.project, feature.name));
};
const createFeedbackContext = () => {
openFeedback({
title: 'How easy was it to work with the new strategy form?',
positiveLabel: 'What do you like most about the new strategy form?',
areasForImprovementsLabel:
'What should be improved the new strategy form?',
});
};
const onSubmitWithValidation = async (event: React.FormEvent) => {
if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) {
trackEvent('strategy-variants', {
props: {
eventType: 'submitted',
},
});
}
event.preventDefault();
if (!validateAllParameters()) {
return;
}
trackEvent('new-strategy-form', {
props: {
eventType: 'submitted',
},
});
if (enableProdGuard && !isChangeRequest) {
setShowProdGuard(true);
} else {
await onSubmitWithFeedback();
}
};
const onSubmitWithFeedback = async () => {
try {
await onSubmit();
if (newStrategyConfigurationFeedback && !hasSubmittedFeedback) {
createFeedbackContext();
}
} catch (e) {
console.error(e);
}
};
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
setTab(newValue);
};
const getTargetingCount = () => {
const constraintCount = strategy.constraints?.length || 0;
const segmentCount = segments.length || 0;
return constraintCount + segmentCount;
};
const showVariants =
strategy.parameters && 'stickiness' in strategy.parameters;
return (
<>
<StyledHeaderBox>
<StyledTitle>
{formatStrategyName(strategy.name || '')}
<ConditionallyRender
condition={strategy.name === 'flexibleRollout'}
show={
<Badge color='success' sx={{ marginLeft: '1rem' }}>
{strategy.parameters?.rollout}%
</Badge>
}
/>
</StyledTitle>
{foundEnvironment ? (
<StyledEnvironmentBox>
<EnvironmentTypographyHeader>
Environment:
</EnvironmentTypographyHeader>
<EnvironmentIconBox>
<EnvironmentIcon
enabled={foundEnvironment.enabled}
/>{' '}
<EnvironmentTypography
enabled={foundEnvironment.enabled}
>
{foundEnvironment.name}
</EnvironmentTypography>
</EnvironmentIconBox>
</StyledEnvironmentBox>
) : null}
</StyledHeaderBox>
<StyledAlertBox>
<ConditionallyRender
condition={hasChangeRequestInReviewForEnvironment}
show={alert}
elseShow={
<ConditionallyRender
condition={isChangeRequest}
show={
<FeatureStrategyChangeRequestAlert
environment={environmentId}
/>
}
/>
}
/>
<FeatureStrategyEnabled
projectId={feature.project}
featureId={feature.name}
environmentId={environmentId}
>
<ConditionallyRender
condition={Boolean(isChangeRequest)}
show={
<Alert severity='success'>
This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment.
Any changes made here will be available to users
as soon as these changes are approved and
applied.
</Alert>
}
elseShow={
<Alert severity='success'>
This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment.
Any changes made here will be available to users
as soon as you hit <strong>save</strong>.
</Alert>
}
/>
</FeatureStrategyEnabled>
</StyledAlertBox>
<StyledTabs value={tab} onChange={handleChange}>
<StyledTab label='General' />
<Tab
data-testid='STRATEGY_TARGETING_TAB'
label={
<Typography>
Targeting
<StyledBadge>{getTargetingCount()}</StyledBadge>
</Typography>
}
/>
{showVariants && (
<Tab
data-testid='STRATEGY_VARIANTS_TAB'
label={
<Typography>
Variants
<StyledBadge>
{strategy.variants?.length || 0}
</StyledBadge>
</Typography>
}
/>
)}
</StyledTabs>
<StyledForm onSubmit={onSubmitWithValidation}>
<ConditionallyRender
condition={tab === 0}
show={
<>
<FeatureStrategyTitle
title={strategy.title || ''}
setTitle={(title) => {
setStrategy((prev) => ({
...prev,
title,
}));
}}
/>
<FeatureStrategyEnabledDisabled
enabled={!strategy?.disabled}
onToggleEnabled={() =>
setStrategy((strategyState) => ({
...strategyState,
disabled: !strategyState.disabled,
}))
}
/>
<FeatureStrategyType
strategy={strategy}
strategyDefinition={strategyDefinition}
setStrategy={setStrategy}
validateParameter={validateParameter}
errors={errors}
hasAccess={access}
/>
</>
}
/>
<ConditionallyRender
condition={tab === 1}
show={
<>
<StyledTargetingHeader>
Segmentation and constraints allow you to set
filters on your strategies, so that they will
only be evaluated for users and applications
that match the specified preconditions.
</StyledTargetingHeader>
<FeatureStrategySegment
segments={segments}
setSegments={setSegments}
projectId={projectId}
/>
<StyledBox>
<StyledDivider />
<StyledDividerContent>AND</StyledDividerContent>
</StyledBox>
<FeatureStrategyConstraints
projectId={feature.project}
environmentId={environmentId}
strategy={strategy}
setStrategy={setStrategy}
/>
</>
}
/>
<ConditionallyRender
condition={tab === 2}
show={
<ConditionallyRender
condition={
strategy.parameters != null &&
'stickiness' in strategy.parameters
}
show={StrategyVariants}
/>
}
/>
<StyledButtons>
<PermissionButton
permission={permission}
projectId={feature.project}
environmentId={environmentId}
variant='contained'
color='primary'
type='submit'
disabled={
loading ||
!hasValidConstraints ||
errors.hasFormErrors()
}
data-testid={STRATEGY_FORM_SUBMIT_ID}
>
{isChangeRequest
? changeRequestButtonText
: 'Save strategy'}
</PermissionButton>
<Button
type='button'
color='primary'
onClick={onCancel ? onCancel : onDefaultCancel}
disabled={loading}
>
Cancel
</Button>
<FeatureStrategyProdGuard
open={showProdGuard}
onClose={() => setShowProdGuard(false)}
onClick={onSubmitWithFeedback}
loading={loading}
label='Save strategy'
/>
</StyledButtons>
</StyledForm>
</>
);
};

View File

@ -1,447 +0,0 @@
import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_FEATURE_STRATEGY,
} from 'component/providers/AccessProvider/permissions';
import { NewFeatureStrategyCreate } from './NewFeatureStrategyCreate';
import {
setupProjectEndpoint,
setupSegmentsEndpoint,
setupStrategyEndpoint,
setupFeaturesEndpoint,
setupUiConfigEndpoint,
setupContextEndpoint,
} from './featureStrategyFormTestSetup';
const featureName = 'my-new-feature';
const setupComponent = () => {
return {
wrapper: render(
<Routes>
<Route
path={
'/projects/:projectId/features/:featureId/strategies/create'
}
element={<NewFeatureStrategyCreate />}
/>
</Routes>,
{
route: `/projects/default/features/${featureName}/strategies/create?environmentId=development&strategyName=flexibleRollout&defaultStrategy=true`,
permissions: [
{
permission: CREATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
project: 'default',
environment: 'development',
},
],
},
),
expectedSegmentName: 'test',
expectedGroupId: 'newGroupId',
expectedVariantName: 'Blue',
expectedSliderValue: '50',
expectedConstraintValue: 'new value',
expectedMultipleValues: '1234,4141,51515',
};
};
beforeEach(() => {
setupProjectEndpoint();
setupSegmentsEndpoint();
setupStrategyEndpoint();
setupFeaturesEndpoint(featureName);
setupUiConfigEndpoint();
setupContextEndpoint();
});
describe('NewFeatureStrategyCreate', () => {
test('formatAddStrategyApiCode', () => {
expect(
formatAddStrategyApiCode(
'projectId',
'featureId',
'environmentId',
{ id: 'strategyId' },
'unleashUrl',
),
).toMatchInlineSnapshot(`
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '{
"id": "strategyId"
}'"
`);
});
test('should navigate tabs', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const slider = await screen.findByRole('slider', { name: /rollout/i });
expect(slider).toHaveValue('100');
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const segmentsEl = await screen.findByText('Segments');
expect(segmentsEl).toBeInTheDocument();
const variantEl = screen.getByText('Variants');
fireEvent.click(variantEl);
const addVariantEl = await screen.findByText('Add variant');
expect(addVariantEl).toBeInTheDocument();
});
test('should change general settings', async () => {
const { expectedGroupId, expectedSliderValue } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const slider = await screen.findByRole('slider', { name: /rollout/i });
const groupIdInput = await screen.getByLabelText('groupId');
expect(slider).toHaveValue('100');
expect(groupIdInput).toHaveValue(featureName);
fireEvent.change(slider, { target: { value: expectedSliderValue } });
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
expect(slider).toHaveValue(expectedSliderValue);
expect(groupIdInput).toHaveValue(expectedGroupId);
});
test('should change targeting settings', async () => {
const { expectedConstraintValue, expectedSegmentName } =
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const inputElement = screen.getByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElement, {
target: { value: expectedConstraintValue },
});
const addValueEl = screen.getByText('Add values');
fireEvent.click(addValueEl);
const doneEl = screen.getByText('Done');
fireEvent.click(doneEl);
const selectElement = screen.getByPlaceholderText('Select segments');
fireEvent.mouseDown(selectElement);
const optionElement = await screen.findByText(expectedSegmentName);
fireEvent.click(optionElement);
expect(screen.getByText(expectedSegmentName)).toBeInTheDocument();
expect(screen.getByText(expectedConstraintValue)).toBeInTheDocument();
});
test('should change variants settings', async () => {
const { expectedVariantName } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
const addVariantEl = await screen.findByText('Add variant');
fireEvent.click(addVariantEl);
const inputElement = screen.getAllByRole('textbox')[0];
fireEvent.change(inputElement, {
target: { value: expectedVariantName },
});
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
const generalSettingsEl = screen.getByText('General');
fireEvent.click(generalSettingsEl);
await waitFor(() => {
const codeSnippet = document.querySelector('pre')?.innerHTML;
const variantNameMatches = (
codeSnippet!.match(new RegExp(expectedVariantName, 'g')) || []
).length;
const metaDataMatches = (codeSnippet!.match(/isValid/g) || [])
.length;
expect(variantNameMatches).toBe(1);
expect(metaDataMatches).toBe(0);
});
});
test('should change variant name after changing tab', async () => {
const { expectedVariantName } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
const addVariantEl = await screen.findByText('Add variant');
fireEvent.click(addVariantEl);
const inputElement = screen.getAllByRole('textbox')[0];
fireEvent.change(inputElement, {
target: { value: expectedVariantName },
});
const targetingEl = await screen.findByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
expect(addConstraintEl).toBeInTheDocument();
fireEvent.click(variantsEl);
const inputElement2 = screen.getAllByRole('textbox')[0];
expect(inputElement2).not.toBeDisabled();
});
test('should remove empty variants when changing tabs', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
const addVariantEl = await screen.findByText('Add variant');
fireEvent.click(addVariantEl);
const variants = screen.queryAllByTestId('VARIANT');
expect(variants.length).toBe(1);
const targetingEl = await screen.findByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
expect(addConstraintEl).toBeInTheDocument();
fireEvent.click(variantsEl);
const variants2 = screen.queryAllByTestId('VARIANT');
expect(variants2.length).toBe(0);
});
test('Should autosave constraint settings when navigating between tabs', async () => {
const { expectedMultipleValues } = setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const inputElement = screen.getByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElement, {
target: { value: expectedMultipleValues },
});
const addValueEl = await screen.findByText('Add values');
fireEvent.click(addValueEl);
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
fireEvent.click(targetingEl);
const values = expectedMultipleValues.split(',');
expect(screen.getByText(values[0])).toBeInTheDocument();
expect(screen.getByText(values[1])).toBeInTheDocument();
expect(screen.getByText(values[2])).toBeInTheDocument();
});
test('Should update multiple constraints correctly', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
const inputElements = screen.getAllByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElements[0], {
target: { value: '123' },
});
fireEvent.change(inputElements[1], {
target: { value: '456' },
});
fireEvent.change(inputElements[2], {
target: { value: '789' },
});
const addValueEls = await screen.findAllByText('Add values');
fireEvent.click(addValueEls[0]);
fireEvent.click(addValueEls[1]);
fireEvent.click(addValueEls[2]);
expect(screen.queryByText('123')).toBeInTheDocument();
const deleteBtns = await screen.findAllByTestId('CancelIcon');
fireEvent.click(deleteBtns[0]);
expect(screen.queryByText('123')).not.toBeInTheDocument();
expect(screen.queryByText('456')).toBeInTheDocument();
expect(screen.queryByText('789')).toBeInTheDocument();
});
test('Should update multiple constraints with the correct react key', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
fireEvent.click(addConstraintEl);
const inputElements = screen.getAllByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElements[0], {
target: { value: '123' },
});
fireEvent.change(inputElements[1], {
target: { value: '456' },
});
fireEvent.change(inputElements[2], {
target: { value: '789' },
});
const addValueEls = await screen.findAllByText('Add values');
fireEvent.click(addValueEls[0]);
fireEvent.click(addValueEls[1]);
fireEvent.click(addValueEls[2]);
expect(screen.queryByText('123')).toBeInTheDocument();
const deleteBtns = screen.getAllByTestId('DELETE_CONSTRAINT_BUTTON');
fireEvent.click(deleteBtns[0]);
const inputElements2 = screen.getAllByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputElements2[0], {
target: { value: '666' },
});
const addValueEls2 = screen.getAllByText('Add values');
fireEvent.click(addValueEls2[0]);
expect(screen.queryByText('123')).not.toBeInTheDocument();
expect(screen.queryByText('456')).toBeInTheDocument();
expect(screen.queryByText('789')).toBeInTheDocument();
});
test('Should undo changes made to constraints', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const inputEl = screen.getByPlaceholderText(
'value1, value2, value3...',
);
fireEvent.change(inputEl, {
target: { value: '6, 7, 8' },
});
const addBtn = await screen.findByText('Add values');
addBtn.click();
expect(screen.queryByText('6')).toBeInTheDocument();
expect(screen.queryByText('7')).toBeInTheDocument();
expect(screen.queryByText('8')).toBeInTheDocument();
const undoBtn = await screen.findByTestId(
'UNDO_CONSTRAINT_CHANGE_BUTTON',
);
undoBtn.click();
expect(screen.queryByText('6')).not.toBeInTheDocument();
expect(screen.queryByText('7')).not.toBeInTheDocument();
expect(screen.queryByText('8')).not.toBeInTheDocument();
});
test('Should remove constraint when no valid values are set and moving between tabs', async () => {
setupComponent();
const titleEl = await screen.findByText('Gradual rollout');
expect(titleEl).toBeInTheDocument();
const targetingEl = screen.getByText('Targeting');
fireEvent.click(targetingEl);
const addConstraintEl = await screen.findByText('Add constraint');
fireEvent.click(addConstraintEl);
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
fireEvent.click(targetingEl);
const seconAddConstraintEl = await screen.findByText('Add constraint');
expect(seconAddConstraintEl).toBeInTheDocument();
expect(screen.queryByText('appName')).not.toBeInTheDocument();
});
});

View File

@ -1,260 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useNavigate } from 'react-router-dom';
import useToast from 'hooks/useToast';
import { IFeatureStrategy, IFeatureStrategyPayload } from 'interfaces/strategy';
import {
createStrategyPayload,
featureStrategyDocsLink,
featureStrategyDocsLinkLabel,
featureStrategyHelp,
formatFeaturePath,
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useFormErrors } from 'hooks/useFormErrors';
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { useCollaborateData } from 'hooks/useCollaborateData';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { IFeatureToggle } from 'interfaces/featureToggle';
import { comparisonModerator } from '../featureStrategy.utils';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useQueryParams from 'hooks/useQueryParams';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy';
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
export const NewFeatureStrategyCreate = () => {
const [tab, setTab] = useState(0);
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId');
const strategyName = useRequiredQueryParam('strategyName');
const { strategy: defaultStrategy, defaultStrategyFallback } =
useDefaultStrategy(projectId, environmentId);
const shouldUseDefaultStrategy: boolean = JSON.parse(
useQueryParams().get('defaultStrategy') || 'false',
);
const { segments: allSegments } = useSegments();
const strategySegments = (allSegments || []).filter((segment) => {
return defaultStrategy?.segments?.includes(segment.id);
});
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>(
shouldUseDefaultStrategy ? strategySegments : [],
);
const { strategyDefinition } = useStrategy(strategyName);
const errors = useFormErrors();
const { addStrategyToFeature, loading } = useFeatureStrategyApi();
const { addChange } = useChangeRequestApi();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const navigate = useNavigate();
const { feature, refetchFeature } = useFeature(projectId, featureId);
const ref = useRef<IFeatureToggle>(feature);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { trackEvent } = usePlausibleTracker();
const { data, staleDataNotification, forceRefreshCache } =
useCollaborateData<IFeatureToggle>(
{
unleashGetter: useFeature,
params: [projectId, featureId],
dataKey: 'feature',
refetchFunctionKey: 'refetchFeature',
options: {},
},
feature,
{
afterSubmitAction: refetchFeature,
},
comparisonModerator,
);
useEffect(() => {
if (ref.current.name === '' && feature.name) {
forceRefreshCache(feature);
ref.current = feature;
}
}, [feature.name]);
useEffect(() => {
if (shouldUseDefaultStrategy) {
const strategyTemplate = defaultStrategy || defaultStrategyFallback;
if (strategyTemplate.parameters?.groupId === '' && featureId) {
setStrategy({
...strategyTemplate,
parameters: {
...strategyTemplate.parameters,
groupId: featureId,
},
} as any);
} else {
setStrategy(strategyTemplate as any);
}
} else if (strategyDefinition) {
setStrategy(createFeatureStrategy(featureId, strategyDefinition));
}
}, [
featureId,
JSON.stringify(strategyDefinition),
shouldUseDefaultStrategy,
]);
const onAddStrategy = async (payload: IFeatureStrategyPayload) => {
await addStrategyToFeature(
projectId,
featureId,
environmentId,
payload,
);
setToastData({
title: 'Strategy created',
type: 'success',
confetti: true,
});
};
const onStrategyRequestAdd = async (payload: IFeatureStrategyPayload) => {
await addChange(projectId, environmentId, {
action: 'addStrategy',
feature: featureId,
payload,
});
// FIXME: segments in change requests
setToastData({
title: 'Strategy added to draft',
type: 'success',
confetti: true,
});
refetchChangeRequests();
};
const payload = createStrategyPayload(strategy, segments);
const onSubmit = async () => {
trackEvent('strategyTitle', {
props: {
hasTitle: Boolean(strategy.title),
on: 'create',
},
});
try {
if (isChangeRequestConfigured(environmentId)) {
await onStrategyRequestAdd(payload);
} else {
await onAddStrategy(payload);
}
refetchFeature();
navigate(formatFeaturePath(projectId, featureId));
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const emptyFeature = !data || !data.project;
if (emptyFeature) return null;
return (
<FormTemplate
modal
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
disablePadding
formatApiCode={() =>
formatAddStrategyApiCode(
projectId,
featureId,
environmentId,
payload,
unleashUrl,
)
}
>
<NewFeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}
permission={CREATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
editable
/>
}
/>
{staleDataNotification}
</FormTemplate>
);
};
export const formatCreateStrategyPath = (
projectId: string,
featureId: string,
environmentId: string,
strategyName: string,
defaultStrategy: boolean = false,
): string => {
const params = new URLSearchParams({
environmentId,
strategyName,
defaultStrategy: String(defaultStrategy),
});
return `/projects/${projectId}/features/${featureId}/strategies/create?${params}`;
};
export const formatAddStrategyApiCode = (
projectId: string,
featureId: string,
environmentId: string,
strategy: Partial<IFeatureStrategy>,
unleashUrl?: string,
): string => {
if (!unleashUrl) {
return '';
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request POST '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};

View File

@ -1,173 +0,0 @@
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
import { screen, waitFor, fireEvent } from '@testing-library/react';
import { render } from 'utils/testRenderer';
import { Route, Routes } from 'react-router-dom';
import {
CREATE_FEATURE_STRATEGY,
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
UPDATE_FEATURE_STRATEGY,
} from 'component/providers/AccessProvider/permissions';
import { NewFeatureStrategyEdit } from './NewFeatureStrategyEdit';
import {
setupContextEndpoint,
setupFeaturesEndpoint,
setupProjectEndpoint,
setupSegmentsEndpoint,
setupStrategyEndpoint,
setupUiConfigEndpoint,
} from '../NewFeatureStrategyCreate/featureStrategyFormTestSetup';
import userEvent from '@testing-library/user-event';
const featureName = 'my-new-feature';
const variantName = 'Blue';
const setupComponent = () => {
return {
wrapper: render(
<Routes>
<Route
path={
'/projects/:projectId/features/:featureId/strategies/edit'
}
element={<NewFeatureStrategyEdit />}
/>
</Routes>,
{
route: `/projects/default/features/${featureName}/strategies/edit?environmentId=development&strategyId=1`,
permissions: [
{
permission: CREATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_STRATEGY,
project: 'default',
environment: 'development',
},
{
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
project: 'default',
environment: 'development',
},
],
},
),
expectedGroupId: 'newGroupId',
expectedVariantName: variantName,
expectedSliderValue: '75',
};
};
beforeEach(() => {
setupProjectEndpoint();
setupSegmentsEndpoint();
setupStrategyEndpoint();
setupFeaturesEndpoint(featureName, variantName);
setupUiConfigEndpoint();
setupContextEndpoint();
});
describe('NewFeatureStrategyEdit', () => {
test('formatUpdateStrategyApiCode', () => {
const strategy: IFeatureStrategy = {
id: 'a',
name: 'b',
parameters: {
c: 1,
b: 2,
a: 3,
},
constraints: [],
};
const strategyDefinition: IStrategy = {
name: 'c',
displayName: 'd',
description: 'e',
editable: false,
deprecated: false,
parameters: [
{ name: 'a', description: '', type: '', required: false },
{ name: 'b', description: '', type: '', required: false },
{ name: 'c', description: '', type: '', required: false },
],
};
expect(
formatUpdateStrategyApiCode(
'projectId',
'featureId',
'environmentId',
'strategyId',
strategy,
strategyDefinition,
'unleashUrl',
),
).toMatchInlineSnapshot(`
"curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/strategyId' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '{
"id": "a",
"name": "b",
"parameters": {
"a": 3,
"b": 2,
"c": 1
},
"constraints": []
}'"
`);
});
test('should change general settings', async () => {
const { expectedGroupId, expectedSliderValue, wrapper } =
setupComponent();
await waitFor(() => {
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
});
const slider = await screen.findByRole('slider', { name: /rollout/i });
const groupIdInput = await screen.getByLabelText('groupId');
expect(slider).toHaveValue('50');
expect(groupIdInput).toHaveValue(featureName);
const defaultStickiness = await screen.findByText('default');
userEvent.click(defaultStickiness);
const randomStickiness = await screen.findByText('random');
userEvent.click(randomStickiness);
fireEvent.change(slider, { target: { value: expectedSliderValue } });
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
expect(slider).toHaveValue(expectedSliderValue);
expect(groupIdInput).toHaveValue(expectedGroupId);
await waitFor(() => {
const codeSnippet = document.querySelector('pre')?.innerHTML;
const count = (codeSnippet!.match(/random/g) || []).length;
// strategy stickiness and variant stickiness
expect(count).toBe(2);
});
});
test('should not change variant names', async () => {
const { expectedVariantName } = setupComponent();
await waitFor(() => {
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
});
const variantsEl = screen.getByText('Variants');
fireEvent.click(variantsEl);
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
const inputElement = screen.getAllByRole('textbox')[0];
expect(inputElement).toBeDisabled();
});
});

View File

@ -1,373 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useNavigate } from 'react-router-dom';
import useToast from 'hooks/useToast';
import {
IFeatureStrategy,
IFeatureStrategyPayload,
IStrategy,
} from 'interfaces/strategy';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useFormErrors } from 'hooks/useFormErrors';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { sortStrategyParameters } from 'utils/sortStrategyParameters';
import { useCollaborateData } from 'hooks/useCollaborateData';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
import { IFeatureToggle } from 'interfaces/featureToggle';
import { comparisonModerator } from '../featureStrategy.utils';
import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm';
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { v4 as uuidv4 } from 'uuid';
import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
import {
getChangeRequestConflictCreatedData,
getChangeRequestConflictCreatedDataFromScheduleData,
} from './change-request-conflict-data';
const useTitleTracking = () => {
const [previousTitle, setPreviousTitle] = useState<string>('');
const { trackEvent } = usePlausibleTracker();
const trackTitle = (title: string = '') => {
// don't expose the title, just if it was added, removed, or edited
if (title === previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'none',
on: 'edit',
},
});
}
if (previousTitle === '' && title !== '') {
trackEvent('strategyTitle', {
props: {
action: 'added',
on: 'edit',
},
});
}
if (previousTitle !== '' && title === '') {
trackEvent('strategyTitle', {
props: {
action: 'removed',
on: 'edit',
},
});
}
if (previousTitle !== '' && title !== '' && title !== previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'edited',
on: 'edit',
},
});
}
};
return {
setPreviousTitle,
trackTitle,
};
};
const addIdSymbolToConstraints = (strategy?: IFeatureStrategy) => {
if (!strategy) return;
return strategy?.constraints.map((constraint) => {
return { ...constraint, [constraintId]: uuidv4() };
});
};
export const NewFeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId');
const strategyId = useRequiredQueryParam('strategyId');
const [tab, setTab] = useState(0);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]);
const { updateStrategyOnFeature, loading } = useFeatureStrategyApi();
const { strategyDefinition } = useStrategy(strategy.name);
const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const navigate = useNavigate();
const { addChange } = useChangeRequestApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests, data: pendingChangeRequests } =
usePendingChangeRequests(projectId);
const { setPreviousTitle } = useTitleTracking();
const { feature, refetchFeature } = useFeature(projectId, featureId);
const ref = useRef<IFeatureToggle>(feature);
const { data, staleDataNotification, forceRefreshCache } =
useCollaborateData<IFeatureToggle>(
{
unleashGetter: useFeature,
params: [projectId, featureId],
dataKey: 'feature',
refetchFunctionKey: 'refetchFeature',
options: {},
},
feature,
{
afterSubmitAction: refetchFeature,
},
comparisonModerator,
);
useEffect(() => {
if (ref.current.name === '' && feature.name) {
forceRefreshCache(feature);
ref.current = feature;
}
}, [feature]);
const { trackEvent } = usePlausibleTracker();
const { changeRequests: scheduledChangeRequestThatUseStrategy } =
useScheduledChangeRequestsWithStrategy(projectId, strategyId);
const pendingCrsUsingThisStrategy = getChangeRequestConflictCreatedData(
pendingChangeRequests,
featureId,
strategyId,
uiConfig,
);
const scheduledCrsUsingThisStrategy =
getChangeRequestConflictCreatedDataFromScheduleData(
scheduledChangeRequestThatUseStrategy,
uiConfig,
);
const emitConflictsCreatedEvents = (): void =>
[
...pendingCrsUsingThisStrategy,
...scheduledCrsUsingThisStrategy,
].forEach((data) =>
trackEvent('change_request', {
props: {
...data,
action: 'edit-strategy',
eventType: 'conflict-created',
},
}),
);
const {
segments: savedStrategySegments,
refetchSegments: refetchSavedStrategySegments,
} = useSegments(strategyId);
useEffect(() => {
const savedStrategy = data?.environments
.flatMap((environment) => environment.strategies)
.find((strategy) => strategy.id === strategyId);
const constraintsWithId = addIdSymbolToConstraints(savedStrategy);
const formattedStrategy = {
...savedStrategy,
constraints: constraintsWithId,
};
setStrategy((prev) => ({ ...prev, ...formattedStrategy }));
setPreviousTitle(savedStrategy?.title || '');
}, [strategyId, data]);
useEffect(() => {
// Fill in the selected segments once they've been fetched.
savedStrategySegments && setSegments(savedStrategySegments);
}, [JSON.stringify(savedStrategySegments)]);
const payload = createStrategyPayload(strategy, segments);
const onStrategyEdit = async (payload: IFeatureStrategyPayload) => {
await updateStrategyOnFeature(
projectId,
featureId,
environmentId,
strategyId,
payload,
);
await refetchSavedStrategySegments();
setToastData({
title: 'Strategy updated',
type: 'success',
confetti: true,
});
};
const onStrategyRequestEdit = async (payload: IFeatureStrategyPayload) => {
await addChange(projectId, environmentId, {
action: 'updateStrategy',
feature: featureId,
payload: { ...payload, id: strategyId },
});
// FIXME: segments in change requests
setToastData({
title: 'Change added to draft',
type: 'success',
confetti: true,
});
refetchChangeRequests();
};
const onSubmit = async () => {
try {
if (isChangeRequestConfigured(environmentId)) {
await onStrategyRequestEdit(payload);
} else {
await onStrategyEdit(payload);
}
emitConflictsCreatedEvents();
refetchFeature();
navigate(formatFeaturePath(projectId, featureId));
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
if (!strategy.id || !strategyDefinition) {
return null;
}
if (!data) return null;
return (
<FormTemplate
modal
disablePadding
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
formatApiCode={() =>
formatUpdateStrategyApiCode(
projectId,
featureId,
environmentId,
strategyId,
payload,
strategyDefinition,
unleashUrl,
)
}
>
<NewFeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
/>
}
/>
{staleDataNotification}
</FormTemplate>
);
};
export const createStrategyPayload = (
strategy: Partial<IFeatureStrategy>,
segments: ISegment[],
): IFeatureStrategyPayload => ({
name: strategy.name,
title: strategy.title,
constraints: strategy.constraints ?? [],
parameters: strategy.parameters ?? {},
variants: strategy.variants ?? [],
segments: segments.map((segment) => segment.id),
disabled: strategy.disabled ?? false,
});
export const formatFeaturePath = (
projectId: string,
featureId: string,
): string => {
return `/projects/${projectId}/features/${featureId}`;
};
export const formatEditStrategyPath = (
projectId: string,
featureId: string,
environmentId: string,
strategyId: string,
): string => {
const params = new URLSearchParams({ environmentId, strategyId });
return `/projects/${projectId}/features/${featureId}/strategies/edit?${params}`;
};
export const formatUpdateStrategyApiCode = (
projectId: string,
featureId: string,
environmentId: string,
strategyId: string,
strategy: Partial<IFeatureStrategy>,
strategyDefinition: IStrategy,
unleashUrl?: string,
): string => {
if (!unleashUrl) {
return '';
}
// Sort the strategy parameters payload so that they match
// the order of the input fields in the form, for usability.
const sortedStrategy = {
...strategy,
parameters: sortStrategyParameters(
strategy.parameters ?? {},
strategyDefinition,
),
};
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
const payload = JSON.stringify(sortedStrategy, undefined, 2);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};
export const featureStrategyHelp = `
An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature.
If any of a feature toggle's activation strategies returns true, the user will get access.
`;
export const featureStrategyDocsLink =
'https://docs.getunleash.io/reference/activation-strategies';
export const featureStrategyDocsLinkLabel = 'Strategies documentation';

View File

@ -1,113 +0,0 @@
import { IUiConfig } from 'interfaces/uiConfig';
import {
getChangeRequestConflictCreatedData,
getChangeRequestConflictCreatedDataFromScheduleData,
} from './change-request-conflict-data';
const uiConfig: Pick<IUiConfig, 'baseUriPath' | 'versionInfo'> = {
baseUriPath: '/some-base-uri',
};
const unleashIdentifier = uiConfig.baseUriPath;
const featureId = 'flag-with-deleted-scheduler';
const strategyId = 'ed2ffa14-004c-4ed1-931b-78761681c54a';
const changeRequestWithStrategy = {
id: 105,
features: [
{
name: featureId,
changes: [
{
action: 'updateStrategy' as const,
payload: {
id: strategyId,
},
},
],
},
],
state: 'In review' as const,
};
const changeRequestWithoutStrategy = {
id: 106,
features: [
{
name: featureId,
changes: [
{
action: 'deleteStrategy' as const,
payload: {
id: strategyId,
},
},
],
},
{
name: featureId,
changes: [
{
action: 'addStrategy' as const,
payload: {},
},
],
},
],
state: 'In review' as const,
};
test('it finds crs that update a strategy', () => {
const results = getChangeRequestConflictCreatedData(
[changeRequestWithStrategy],
featureId,
strategyId,
uiConfig,
);
expect(results).toStrictEqual([
{
state: changeRequestWithStrategy.state,
changeRequest: `${unleashIdentifier}#${changeRequestWithStrategy.id}`,
},
]);
});
test('it does not return crs that do not update a strategy', () => {
const results = getChangeRequestConflictCreatedData(
[changeRequestWithoutStrategy],
featureId,
strategyId,
uiConfig,
);
expect(results).toStrictEqual([]);
});
test('it maps scheduled change request data', () => {
const scheduledChanges = [
{
id: 103,
environment: 'development',
},
{
id: 104,
environment: 'development',
},
];
const results = getChangeRequestConflictCreatedDataFromScheduleData(
scheduledChanges,
uiConfig,
);
expect(results).toStrictEqual([
{
state: 'Scheduled',
changeRequest: `${unleashIdentifier}#103`,
},
{
state: 'Scheduled',
changeRequest: `${unleashIdentifier}#104`,
},
]);
});

View File

@ -1,55 +0,0 @@
import {
ChangeRequestState,
ChangeRequestType,
IChangeRequestFeature,
IFeatureChange,
} from 'component/changeRequest/changeRequest.types';
import { ScheduledChangeRequestViewModel } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
import { IUiConfig } from 'interfaces/uiConfig';
import { getUniqueChangeRequestId } from 'utils/unique-change-request-id';
type ChangeRequestConflictCreatedData = {
changeRequest: string;
state: ChangeRequestState;
};
export const getChangeRequestConflictCreatedData = (
changeRequests:
| {
state: ChangeRequestType['state'];
id: ChangeRequestType['id'];
features: {
name: IChangeRequestFeature['name'];
changes: (Pick<IFeatureChange, 'action'> & {
payload: { id?: number | string };
})[];
}[];
}[]
| undefined,
featureId: string,
strategyId: string,
uiConfig: Pick<IUiConfig, 'baseUriPath' | 'versionInfo'>,
): ChangeRequestConflictCreatedData[] =>
changeRequests
?.filter((cr) =>
cr.features
.find((feature) => feature.name === featureId)
?.changes.some(
(change) =>
change.action === 'updateStrategy' &&
change.payload.id === strategyId,
),
)
.map((cr) => ({
changeRequest: getUniqueChangeRequestId(uiConfig, cr.id),
state: cr.state,
})) ?? [];
export const getChangeRequestConflictCreatedDataFromScheduleData = (
changeRequests: Pick<ScheduledChangeRequestViewModel, 'id'>[] | undefined,
uiConfig: Pick<IUiConfig, 'baseUriPath' | 'versionInfo'>,
): ChangeRequestConflictCreatedData[] =>
changeRequests?.map((cr) => ({
changeRequest: getUniqueChangeRequestId(uiConfig, cr.id),
state: 'Scheduled' as const,
})) ?? [];

View File

@ -1,7 +1,6 @@
import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData';
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { Route, Routes, useNavigate } from 'react-router-dom';
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
@ -9,9 +8,8 @@ import { usePageTitle } from 'hooks/usePageTitle';
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { NewFeatureStrategyCreate } from 'component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate';
import { NewFeatureStrategyEdit } from 'component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit';
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -61,7 +59,7 @@ const FeatureOverview = () => {
onClose={onSidebarClose}
open
>
<NewFeatureStrategyCreate />
<FeatureStrategyCreate />
</SidebarModal>
}
/>
@ -73,7 +71,7 @@ const FeatureOverview = () => {
onClose={onSidebarClose}
open
>
<NewFeatureStrategyEdit />
<FeatureStrategyEdit />
</SidebarModal>
}
/>