1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-08-09 13:47:13 +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, Typography,
} from '@mui/material'; } from '@mui/material';
import { Delete, Edit, MoreVert } from '@mui/icons-material'; import { Delete, Edit, MoreVert } from '@mui/icons-material';
import { NewEditChange } from './NewEditChange'; import { EditChange } from './EditChange';
const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => { const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => {
const { isChangeRequestConfigured } = useChangeRequestsEnabled( const { isChangeRequestConfigured } = useChangeRequestsEnabled(
@ -149,7 +149,7 @@ export const ChangeActions: FC<{
Edit change Edit change
</Typography> </Typography>
</ListItemText> </ListItemText>
<NewEditChange <EditChange
changeRequestId={changeRequest.id} changeRequestId={changeRequest.id}
featureId={feature} featureId={feature}
change={ change={

View File

@ -7,7 +7,6 @@ import useToast from 'hooks/useToast';
import { IFeatureStrategy } from 'interfaces/strategy'; import { IFeatureStrategy } from 'interfaces/strategy';
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment'; import { ISegment } from 'interfaces/segment';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors'; import { useFormErrors } from 'hooks/useFormErrors';
import { useCollaborateData } from 'hooks/useCollaborateData'; import { useCollaborateData } from 'hooks/useCollaborateData';
import { useFeature } from 'hooks/api/getters/useFeature/useFeature'; 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 { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils'; import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
import { import {
ChangeRequestAddStrategy,
ChangeRequestEditStrategy,
IChangeRequestAddStrategy, IChangeRequestAddStrategy,
IChangeRequestUpdateStrategy, IChangeRequestUpdateStrategy,
} from 'component/changeRequest/changeRequest.types'; } from 'component/changeRequest/changeRequest.types';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm'; import { FeatureStrategyForm } from '../../../../feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants'; import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
import { v4 as uuidv4 } from 'uuid';
interface IEditChangeProps { interface IEditChangeProps {
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy; change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
@ -34,6 +37,16 @@ interface IEditChangeProps {
onClose: () => void; onClose: () => void;
} }
const addIdSymbolToConstraints = (
strategy?: ChangeRequestAddStrategy | ChangeRequestEditStrategy,
) => {
if (!strategy) return;
return strategy?.constraints.map((constraint) => {
return { ...constraint, [constraintId]: uuidv4() };
});
};
export const EditChange = ({ export const EditChange = ({
change, change,
changeRequestId, changeRequestId,
@ -47,9 +60,12 @@ export const EditChange = ({
const { editChange } = useChangeRequestApi(); const { editChange } = useChangeRequestApi();
const [tab, setTab] = useState(0); const [tab, setTab] = useState(0);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>( const constraintsWithId = addIdSymbolToConstraints(change.payload);
change.payload,
); const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({
...change.payload,
constraints: constraintsWithId,
});
const { segments: allSegments } = useSegments(); const { segments: allSegments } = useSegments();
const strategySegments = (allSegments || []).filter((segment) => { const strategySegments = (allSegments || []).filter((segment) => {
@ -134,7 +150,7 @@ export const EditChange = ({
> >
<FormTemplate <FormTemplate
modal modal
title={formatStrategyName(strategyDefinition.name ?? '')} disablePadding
description={featureStrategyHelp} description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink} documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel} documentationLinkLabel={featureStrategyDocsLinkLabel}
@ -148,7 +164,7 @@ export const EditChange = ({
) )
} }
> >
<NewFeatureStrategyForm <FeatureStrategyForm
projectId={projectId} projectId={projectId}
feature={data} feature={data}
strategy={strategy} strategy={strategy}
@ -165,7 +181,7 @@ export const EditChange = ({
tab={tab} tab={tab}
setTab={setTab} setTab={setTab}
StrategyVariants={ StrategyVariants={
<StrategyVariants <NewStrategyVariants
strategy={strategy} strategy={strategy}
setStrategy={setStrategy} setStrategy={setStrategy}
environment={environment} 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,6 +1,77 @@
import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; 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', () => { 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',
},
],
},
),
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( expect(
formatAddStrategyApiCode( formatAddStrategyApiCode(
'projectId', 'projectId',
@ -17,4 +88,360 @@ test('formatAddStrategyApiCode', () => {
"id": "strategyId" "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 { useEffect, useRef, useState } from 'react';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
@ -18,7 +17,6 @@ import {
} from '../FeatureStrategyEdit/FeatureStrategyEdit'; } from '../FeatureStrategyEdit/FeatureStrategyEdit';
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment'; import { ISegment } from 'interfaces/segment';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors'; import { useFormErrors } from 'hooks/useFormErrors';
import { createFeatureStrategy } from 'utils/createFeatureStrategy'; import { createFeatureStrategy } from 'utils/createFeatureStrategy';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
@ -33,8 +31,11 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import useQueryParams from 'hooks/useQueryParams'; import useQueryParams from 'hooks/useQueryParams';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy'; 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 = () => { export const FeatureStrategyCreate = () => {
const [tab, setTab] = useState(0);
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId'); const environmentId = useRequiredQueryParam('environmentId');
@ -178,10 +179,10 @@ export const FeatureStrategyCreate = () => {
return ( return (
<FormTemplate <FormTemplate
modal modal
title={formatStrategyName(strategyName)}
description={featureStrategyHelp} description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink} documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel} documentationLinkLabel={featureStrategyDocsLinkLabel}
disablePadding
formatApiCode={() => formatApiCode={() =>
formatAddStrategyApiCode( formatAddStrategyApiCode(
projectId, projectId,
@ -205,6 +206,17 @@ export const FeatureStrategyCreate = () => {
permission={CREATE_FEATURE_STRATEGY} permission={CREATE_FEATURE_STRATEGY}
errors={errors} errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)} isChangeRequest={isChangeRequestConfigured(environmentId)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
editable
/>
}
/> />
{staleDataNotification} {staleDataNotification}
</FormTemplate> </FormTemplate>

View File

@ -1,7 +1,77 @@
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy'; 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', () => { 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 featureName = 'my-new-feature';
const variantName = 'Blue';
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',
},
],
},
),
expectedGroupId: 'newGroupId',
expectedVariantName: variantName,
expectedSliderValue: '75',
};
};
beforeEach(() => {
setupProjectEndpoint();
setupSegmentsEndpoint();
setupStrategyEndpoint();
setupFeaturesEndpoint(featureName, variantName);
setupUiConfigEndpoint();
setupContextEndpoint();
});
describe('NewFeatureStrategyEdit', () => {
test('formatUpdateStrategyApiCode', () => {
const strategy: IFeatureStrategy = { const strategy: IFeatureStrategy = {
id: 'a', id: 'a',
name: 'b', name: 'b',
@ -51,4 +121,53 @@ test('formatUpdateStrategyApiCode', () => {
"constraints": [] "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 { useEffect, useRef, useState } from 'react';
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import FormTemplate from 'component/common/FormTemplate/FormTemplate';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
@ -16,7 +15,6 @@ import {
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
import { ISegment } from 'interfaces/segment'; import { ISegment } from 'interfaces/segment';
import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
import { formatStrategyName } from 'utils/strategyNames';
import { useFormErrors } from 'hooks/useFormErrors'; import { useFormErrors } from 'hooks/useFormErrors';
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
import { sortStrategyParameters } from 'utils/sortStrategyParameters'; import { sortStrategyParameters } from 'utils/sortStrategyParameters';
@ -28,6 +26,10 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; 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 { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
import { import {
getChangeRequestConflictCreatedData, 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 = () => { export const FeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId'); const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId'); const environmentId = useRequiredQueryParam('environmentId');
const strategyId = useRequiredQueryParam('strategyId'); const strategyId = useRequiredQueryParam('strategyId');
const [tab, setTab] = useState(0);
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({}); const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]); const [segments, setSegments] = useState<ISegment[]>([]);
@ -168,7 +179,15 @@ export const FeatureStrategyEdit = () => {
const savedStrategy = data?.environments const savedStrategy = data?.environments
.flatMap((environment) => environment.strategies) .flatMap((environment) => environment.strategies)
.find((strategy) => strategy.id === strategyId); .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 || ''); setPreviousTitle(savedStrategy?.title || '');
}, [strategyId, data]); }, [strategyId, data]);
@ -235,7 +254,7 @@ export const FeatureStrategyEdit = () => {
return ( return (
<FormTemplate <FormTemplate
modal modal
title={formatStrategyName(strategy.name ?? '')} disablePadding
description={featureStrategyHelp} description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink} documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel} documentationLinkLabel={featureStrategyDocsLinkLabel}
@ -264,6 +283,16 @@ export const FeatureStrategyEdit = () => {
permission={UPDATE_FEATURE_STRATEGY} permission={UPDATE_FEATURE_STRATEGY}
errors={errors} errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)} isChangeRequest={isChangeRequestConfigured(environmentId)}
tab={tab}
setTab={setTab}
StrategyVariants={
<NewStrategyVariants
strategy={strategy}
setStrategy={setStrategy}
environment={environmentId}
projectId={projectId}
/>
}
/> />
{staleDataNotification} {staleDataNotification}
</FormTemplate> </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 { 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 { import {
IFeatureStrategy, IFeatureStrategy,
IFeatureStrategyParameters, IFeatureStrategyParameters,
@ -31,8 +40,12 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess'; import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle'; import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled'; import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; 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 { interface IFeatureStrategyFormProps {
feature: IFeatureToggle; feature: IFeatureToggle;
@ -42,7 +55,7 @@ interface IFeatureStrategyFormProps {
onSubmit: () => void; onSubmit: () => void;
onCancel?: () => void; onCancel?: () => void;
loading: boolean; loading: boolean;
isChangeRequest?: boolean; isChangeRequest: boolean;
strategy: Partial<IFeatureStrategy>; strategy: Partial<IFeatureStrategy>;
setStrategy: React.Dispatch< setStrategy: React.Dispatch<
React.SetStateAction<Partial<IFeatureStrategy>> React.SetStateAction<Partial<IFeatureStrategy>>
@ -50,28 +63,131 @@ interface IFeatureStrategyFormProps {
segments: ISegment[]; segments: ISegment[];
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>; setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
errors: IFormErrors; errors: IFormErrors;
tab: number;
setTab: React.Dispatch<React.SetStateAction<number>>;
StrategyVariants: JSX.Element;
} }
const StyledForm = styled('form')(({ theme }) => ({ const StyledDividerContent = styled(Box)(({ theme }) => ({
display: 'grid', padding: theme.spacing(0.75, 1),
gap: theme.spacing(2), 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 }) => ({ const StyledForm = styled('form')(({ theme }) => ({
width: '100%', position: 'relative',
height: '1px', display: 'flex',
margin: theme.spacing(2, 0), flexDirection: 'column',
border: 'none', gap: theme.spacing(2),
background: theme.palette.background.elevation2, 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 }) => ({ const StyledButtons = styled('div')(({ theme }) => ({
bottom: 0,
right: 0,
left: 0,
position: 'absolute',
display: 'flex', display: 'flex',
padding: theme.spacing(3),
paddingRight: theme.spacing(6),
paddingLeft: theme.spacing(6),
backgroundColor: theme.palette.background.paper,
justifyContent: 'end', justifyContent: 'end',
gap: theme.spacing(2), borderTop: `1px solid ${theme.palette.divider}`,
paddingBottom: theme.spacing(10),
})); }));
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 = ({ export const FeatureStrategyForm = ({
projectId, projectId,
feature, feature,
@ -86,7 +202,14 @@ export const FeatureStrategyForm = ({
setSegments, setSegments,
errors, errors,
isChangeRequest, isChangeRequest,
tab,
setTab,
StrategyVariants,
}: IFeatureStrategyFormProps) => { }: IFeatureStrategyFormProps) => {
const { openFeedback, hasSubmittedFeedback } = useFeedback(
feedbackCategory,
'manual',
);
const { trackEvent } = usePlausibleTracker(); const { trackEvent } = usePlausibleTracker();
const [showProdGuard, setShowProdGuard] = useState(false); const [showProdGuard, setShowProdGuard] = useState(false);
const hasValidConstraints = useConstraintsValidation(strategy.constraints); const hasValidConstraints = useConstraintsValidation(strategy.constraints);
@ -97,6 +220,39 @@ export const FeatureStrategyForm = ({
environmentId, environmentId,
); );
const { strategyDefinition } = useStrategy(strategy?.name); 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 { data } = usePendingChangeRequests(feature.project);
const { changeRequestInReviewOrApproved, alert } = const { changeRequestInReviewOrApproved, alert } =
@ -111,11 +267,7 @@ export const FeatureStrategyForm = ({
const navigate = useNavigate(); const navigate = useNavigate();
const { const { error: uiConfigError, loading: uiConfigLoading } = useUiConfig();
uiConfig,
error: uiConfigError,
loading: uiConfigLoading,
} = useUiConfig();
if (uiConfigError) { if (uiConfigError) {
throw uiConfigError; throw uiConfigError;
@ -159,6 +311,15 @@ export const FeatureStrategyForm = ({
navigate(formatFeaturePath(feature.project, feature.name)); 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) => { const onSubmitWithValidation = async (event: React.FormEvent) => {
if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) { if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) {
trackEvent('strategy-variants', { trackEvent('strategy-variants', {
@ -172,21 +333,85 @@ export const FeatureStrategyForm = ({
return; return;
} }
trackEvent('new-strategy-form', {
props: {
eventType: 'submitted',
},
});
if (enableProdGuard && !isChangeRequest) { if (enableProdGuard && !isChangeRequest) {
setShowProdGuard(true); setShowProdGuard(true);
} else { } 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 ( return (
<StyledForm onSubmit={onSubmitWithValidation}> <>
<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 <ConditionallyRender
condition={hasChangeRequestInReviewForEnvironment} condition={hasChangeRequestInReviewForEnvironment}
show={alert} show={alert}
elseShow={ elseShow={
<ConditionallyRender <ConditionallyRender
condition={Boolean(isChangeRequest)} condition={isChangeRequest}
show={ show={
<FeatureStrategyChangeRequestAlert <FeatureStrategyChangeRequestAlert
environment={environmentId} environment={environmentId}
@ -205,22 +430,54 @@ export const FeatureStrategyForm = ({
show={ show={
<Alert severity='success'> <Alert severity='success'>
This feature toggle is currently enabled in the{' '} This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment. Any <strong>{environmentId}</strong> environment.
changes made here will be available to users as soon Any changes made here will be available to users
as these changes are approved and applied. as soon as these changes are approved and
applied.
</Alert> </Alert>
} }
elseShow={ elseShow={
<Alert severity='success'> <Alert severity='success'>
This feature toggle is currently enabled in the{' '} This feature toggle is currently enabled in the{' '}
<strong>{environmentId}</strong> environment. Any <strong>{environmentId}</strong> environment.
changes made here will be available to users as soon Any changes made here will be available to users
as you hit <strong>save</strong>. as soon as you hit <strong>save</strong>.
</Alert> </Alert>
} }
/> />
</FeatureStrategyEnabled> </FeatureStrategyEnabled>
<StyledHr /> </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 <FeatureStrategyTitle
title={strategy.title || ''} title={strategy.title || ''}
setTitle={(title) => { setTitle={(title) => {
@ -230,42 +487,7 @@ export const FeatureStrategyForm = ({
})); }));
}} }}
/> />
<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}
/>
}
/>
<StyledHr />
<FeatureStrategyEnabledDisabled <FeatureStrategyEnabledDisabled
enabled={!strategy?.disabled} enabled={!strategy?.disabled}
onToggleEnabled={() => onToggleEnabled={() =>
@ -275,7 +497,62 @@ export const FeatureStrategyForm = ({
})) }))
} }
/> />
<StyledHr />
<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> <StyledButtons>
<PermissionButton <PermissionButton
permission={permission} permission={permission}
@ -306,11 +583,12 @@ export const FeatureStrategyForm = ({
<FeatureStrategyProdGuard <FeatureStrategyProdGuard
open={showProdGuard} open={showProdGuard}
onClose={() => setShowProdGuard(false)} onClose={() => setShowProdGuard(false)}
onClick={onSubmit} onClick={onSubmitWithFeedback}
loading={loading} loading={loading}
label='Save strategy' label='Save strategy'
/> />
</StyledButtons> </StyledButtons>
</StyledForm> </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 FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData';
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments'; import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
import { Route, Routes, useNavigate } from 'react-router-dom'; import { Route, Routes, useNavigate } from 'react-router-dom';
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
@ -9,9 +8,8 @@ import { usePageTitle } from 'hooks/usePageTitle';
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments'; import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
import { NewFeatureStrategyCreate } from 'component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate'; import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
import { NewFeatureStrategyEdit } from 'component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit';
const StyledContainer = styled('div')(({ theme }) => ({ const StyledContainer = styled('div')(({ theme }) => ({
display: 'flex', display: 'flex',
@ -61,7 +59,7 @@ const FeatureOverview = () => {
onClose={onSidebarClose} onClose={onSidebarClose}
open open
> >
<NewFeatureStrategyCreate /> <FeatureStrategyCreate />
</SidebarModal> </SidebarModal>
} }
/> />
@ -73,7 +71,7 @@ const FeatureOverview = () => {
onClose={onSidebarClose} onClose={onSidebarClose}
open open
> >
<NewFeatureStrategyEdit /> <FeatureStrategyEdit />
</SidebarModal> </SidebarModal>
} }
/> />