mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
refactor: remove unused components and rename new (#6357)
This commit is contained in:
parent
9cd324bd7c
commit
20a9e1d725
@ -24,7 +24,7 @@ import {
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import { Delete, Edit, MoreVert } from '@mui/icons-material';
|
||||
import { NewEditChange } from './NewEditChange';
|
||||
import { EditChange } from './EditChange';
|
||||
|
||||
const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => {
|
||||
const { isChangeRequestConfigured } = useChangeRequestsEnabled(
|
||||
@ -149,7 +149,7 @@ export const ChangeActions: FC<{
|
||||
Edit change
|
||||
</Typography>
|
||||
</ListItemText>
|
||||
<NewEditChange
|
||||
<EditChange
|
||||
changeRequestId={changeRequest.id}
|
||||
featureId={feature}
|
||||
change={
|
||||
|
@ -7,7 +7,6 @@ import useToast from 'hooks/useToast';
|
||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||
import { ISegment } from 'interfaces/segment';
|
||||
import { formatStrategyName } from 'utils/strategyNames';
|
||||
import { useFormErrors } from 'hooks/useFormErrors';
|
||||
import { useCollaborateData } from 'hooks/useCollaborateData';
|
||||
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
||||
@ -16,13 +15,17 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
|
||||
import {
|
||||
ChangeRequestAddStrategy,
|
||||
ChangeRequestEditStrategy,
|
||||
IChangeRequestAddStrategy,
|
||||
IChangeRequestUpdateStrategy,
|
||||
} from 'component/changeRequest/changeRequest.types';
|
||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm';
|
||||
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
|
||||
import { FeatureStrategyForm } from '../../../../feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
|
||||
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
interface IEditChangeProps {
|
||||
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
|
||||
@ -34,6 +37,16 @@ interface IEditChangeProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const addIdSymbolToConstraints = (
|
||||
strategy?: ChangeRequestAddStrategy | ChangeRequestEditStrategy,
|
||||
) => {
|
||||
if (!strategy) return;
|
||||
|
||||
return strategy?.constraints.map((constraint) => {
|
||||
return { ...constraint, [constraintId]: uuidv4() };
|
||||
});
|
||||
};
|
||||
|
||||
export const EditChange = ({
|
||||
change,
|
||||
changeRequestId,
|
||||
@ -47,9 +60,12 @@ export const EditChange = ({
|
||||
const { editChange } = useChangeRequestApi();
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>(
|
||||
change.payload,
|
||||
);
|
||||
const constraintsWithId = addIdSymbolToConstraints(change.payload);
|
||||
|
||||
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({
|
||||
...change.payload,
|
||||
constraints: constraintsWithId,
|
||||
});
|
||||
|
||||
const { segments: allSegments } = useSegments();
|
||||
const strategySegments = (allSegments || []).filter((segment) => {
|
||||
@ -134,7 +150,7 @@ export const EditChange = ({
|
||||
>
|
||||
<FormTemplate
|
||||
modal
|
||||
title={formatStrategyName(strategyDefinition.name ?? '')}
|
||||
disablePadding
|
||||
description={featureStrategyHelp}
|
||||
documentationLink={featureStrategyDocsLink}
|
||||
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
||||
@ -148,7 +164,7 @@ export const EditChange = ({
|
||||
)
|
||||
}
|
||||
>
|
||||
<NewFeatureStrategyForm
|
||||
<FeatureStrategyForm
|
||||
projectId={projectId}
|
||||
feature={data}
|
||||
strategy={strategy}
|
||||
@ -165,7 +181,7 @@ export const EditChange = ({
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
StrategyVariants={
|
||||
<StrategyVariants
|
||||
<NewStrategyVariants
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
environment={environment}
|
||||
|
@ -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';
|
@ -1,20 +1,447 @@
|
||||
import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
||||
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { render } from 'utils/testRenderer';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
test('formatAddStrategyApiCode', () => {
|
||||
expect(
|
||||
formatAddStrategyApiCode(
|
||||
'projectId',
|
||||
'featureId',
|
||||
'environmentId',
|
||||
{ id: 'strategyId' },
|
||||
'unleashUrl',
|
||||
import {
|
||||
CREATE_FEATURE_STRATEGY,
|
||||
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||
UPDATE_FEATURE_STRATEGY,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import { FeatureStrategyCreate } from './FeatureStrategyCreate';
|
||||
import {
|
||||
setupProjectEndpoint,
|
||||
setupSegmentsEndpoint,
|
||||
setupStrategyEndpoint,
|
||||
setupFeaturesEndpoint,
|
||||
setupUiConfigEndpoint,
|
||||
setupContextEndpoint,
|
||||
} from './featureStrategyFormTestSetup';
|
||||
|
||||
const featureName = 'my-new-feature';
|
||||
|
||||
const setupComponent = () => {
|
||||
return {
|
||||
wrapper: render(
|
||||
<Routes>
|
||||
<Route
|
||||
path={
|
||||
'/projects/:projectId/features/:featureId/strategies/create'
|
||||
}
|
||||
element={<FeatureStrategyCreate />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
route: `/projects/default/features/${featureName}/strategies/create?environmentId=development&strategyName=flexibleRollout&defaultStrategy=true`,
|
||||
permissions: [
|
||||
{
|
||||
permission: CREATE_FEATURE_STRATEGY,
|
||||
project: 'default',
|
||||
environment: 'development',
|
||||
},
|
||||
{
|
||||
permission: UPDATE_FEATURE_STRATEGY,
|
||||
project: 'default',
|
||||
environment: 'development',
|
||||
},
|
||||
{
|
||||
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||
project: 'default',
|
||||
environment: 'development',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '{
|
||||
"id": "strategyId"
|
||||
}'"
|
||||
`);
|
||||
expectedSegmentName: 'test',
|
||||
expectedGroupId: 'newGroupId',
|
||||
expectedVariantName: 'Blue',
|
||||
expectedSliderValue: '50',
|
||||
expectedConstraintValue: 'new value',
|
||||
expectedMultipleValues: '1234,4141,51515',
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupProjectEndpoint();
|
||||
setupSegmentsEndpoint();
|
||||
setupStrategyEndpoint();
|
||||
setupFeaturesEndpoint(featureName);
|
||||
setupUiConfigEndpoint();
|
||||
setupContextEndpoint();
|
||||
});
|
||||
|
||||
describe('NewFeatureStrategyCreate', () => {
|
||||
test('formatAddStrategyApiCode', () => {
|
||||
expect(
|
||||
formatAddStrategyApiCode(
|
||||
'projectId',
|
||||
'featureId',
|
||||
'environmentId',
|
||||
{ id: 'strategyId' },
|
||||
'unleashUrl',
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
--data-raw '{
|
||||
"id": "strategyId"
|
||||
}'"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should navigate tabs', async () => {
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const slider = await screen.findByRole('slider', { name: /rollout/i });
|
||||
expect(slider).toHaveValue('100');
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const segmentsEl = await screen.findByText('Segments');
|
||||
expect(segmentsEl).toBeInTheDocument();
|
||||
|
||||
const variantEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantEl);
|
||||
|
||||
const addVariantEl = await screen.findByText('Add variant');
|
||||
expect(addVariantEl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should change general settings', async () => {
|
||||
const { expectedGroupId, expectedSliderValue } = setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const slider = await screen.findByRole('slider', { name: /rollout/i });
|
||||
const groupIdInput = await screen.getByLabelText('groupId');
|
||||
|
||||
expect(slider).toHaveValue('100');
|
||||
expect(groupIdInput).toHaveValue(featureName);
|
||||
|
||||
fireEvent.change(slider, { target: { value: expectedSliderValue } });
|
||||
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
|
||||
|
||||
expect(slider).toHaveValue(expectedSliderValue);
|
||||
expect(groupIdInput).toHaveValue(expectedGroupId);
|
||||
});
|
||||
|
||||
test('should change targeting settings', async () => {
|
||||
const { expectedConstraintValue, expectedSegmentName } =
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
fireEvent.click(addConstraintEl);
|
||||
|
||||
const inputElement = screen.getByPlaceholderText(
|
||||
'value1, value2, value3...',
|
||||
);
|
||||
fireEvent.change(inputElement, {
|
||||
target: { value: expectedConstraintValue },
|
||||
});
|
||||
|
||||
const addValueEl = screen.getByText('Add values');
|
||||
fireEvent.click(addValueEl);
|
||||
|
||||
const doneEl = screen.getByText('Done');
|
||||
fireEvent.click(doneEl);
|
||||
|
||||
const selectElement = screen.getByPlaceholderText('Select segments');
|
||||
fireEvent.mouseDown(selectElement);
|
||||
|
||||
const optionElement = await screen.findByText(expectedSegmentName);
|
||||
fireEvent.click(optionElement);
|
||||
|
||||
expect(screen.getByText(expectedSegmentName)).toBeInTheDocument();
|
||||
expect(screen.getByText(expectedConstraintValue)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should change variants settings', async () => {
|
||||
const { expectedVariantName } = setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const variantsEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantsEl);
|
||||
|
||||
const addVariantEl = await screen.findByText('Add variant');
|
||||
fireEvent.click(addVariantEl);
|
||||
|
||||
const inputElement = screen.getAllByRole('textbox')[0];
|
||||
fireEvent.change(inputElement, {
|
||||
target: { value: expectedVariantName },
|
||||
});
|
||||
|
||||
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
|
||||
|
||||
const generalSettingsEl = screen.getByText('General');
|
||||
fireEvent.click(generalSettingsEl);
|
||||
|
||||
await waitFor(() => {
|
||||
const codeSnippet = document.querySelector('pre')?.innerHTML;
|
||||
const variantNameMatches = (
|
||||
codeSnippet!.match(new RegExp(expectedVariantName, 'g')) || []
|
||||
).length;
|
||||
const metaDataMatches = (codeSnippet!.match(/isValid/g) || [])
|
||||
.length;
|
||||
expect(variantNameMatches).toBe(1);
|
||||
expect(metaDataMatches).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('should change variant name after changing tab', async () => {
|
||||
const { expectedVariantName } = setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const variantsEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantsEl);
|
||||
|
||||
const addVariantEl = await screen.findByText('Add variant');
|
||||
fireEvent.click(addVariantEl);
|
||||
|
||||
const inputElement = screen.getAllByRole('textbox')[0];
|
||||
fireEvent.change(inputElement, {
|
||||
target: { value: expectedVariantName },
|
||||
});
|
||||
|
||||
const targetingEl = await screen.findByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
expect(addConstraintEl).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(variantsEl);
|
||||
const inputElement2 = screen.getAllByRole('textbox')[0];
|
||||
|
||||
expect(inputElement2).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test('should remove empty variants when changing tabs', async () => {
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const variantsEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantsEl);
|
||||
|
||||
const addVariantEl = await screen.findByText('Add variant');
|
||||
fireEvent.click(addVariantEl);
|
||||
|
||||
const variants = screen.queryAllByTestId('VARIANT');
|
||||
expect(variants.length).toBe(1);
|
||||
|
||||
const targetingEl = await screen.findByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
expect(addConstraintEl).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(variantsEl);
|
||||
|
||||
const variants2 = screen.queryAllByTestId('VARIANT');
|
||||
expect(variants2.length).toBe(0);
|
||||
});
|
||||
|
||||
test('Should autosave constraint settings when navigating between tabs', async () => {
|
||||
const { expectedMultipleValues } = setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
fireEvent.click(addConstraintEl);
|
||||
|
||||
const inputElement = screen.getByPlaceholderText(
|
||||
'value1, value2, value3...',
|
||||
);
|
||||
fireEvent.change(inputElement, {
|
||||
target: { value: expectedMultipleValues },
|
||||
});
|
||||
|
||||
const addValueEl = await screen.findByText('Add values');
|
||||
fireEvent.click(addValueEl);
|
||||
|
||||
const variantsEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantsEl);
|
||||
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const values = expectedMultipleValues.split(',');
|
||||
|
||||
expect(screen.getByText(values[0])).toBeInTheDocument();
|
||||
expect(screen.getByText(values[1])).toBeInTheDocument();
|
||||
expect(screen.getByText(values[2])).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should update multiple constraints correctly', async () => {
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
fireEvent.click(addConstraintEl);
|
||||
fireEvent.click(addConstraintEl);
|
||||
fireEvent.click(addConstraintEl);
|
||||
|
||||
const inputElements = screen.getAllByPlaceholderText(
|
||||
'value1, value2, value3...',
|
||||
);
|
||||
|
||||
fireEvent.change(inputElements[0], {
|
||||
target: { value: '123' },
|
||||
});
|
||||
fireEvent.change(inputElements[1], {
|
||||
target: { value: '456' },
|
||||
});
|
||||
fireEvent.change(inputElements[2], {
|
||||
target: { value: '789' },
|
||||
});
|
||||
|
||||
const addValueEls = await screen.findAllByText('Add values');
|
||||
fireEvent.click(addValueEls[0]);
|
||||
fireEvent.click(addValueEls[1]);
|
||||
fireEvent.click(addValueEls[2]);
|
||||
|
||||
expect(screen.queryByText('123')).toBeInTheDocument();
|
||||
const deleteBtns = await screen.findAllByTestId('CancelIcon');
|
||||
fireEvent.click(deleteBtns[0]);
|
||||
|
||||
expect(screen.queryByText('123')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('456')).toBeInTheDocument();
|
||||
expect(screen.queryByText('789')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should update multiple constraints with the correct react key', async () => {
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
fireEvent.click(addConstraintEl);
|
||||
fireEvent.click(addConstraintEl);
|
||||
fireEvent.click(addConstraintEl);
|
||||
|
||||
const inputElements = screen.getAllByPlaceholderText(
|
||||
'value1, value2, value3...',
|
||||
);
|
||||
|
||||
fireEvent.change(inputElements[0], {
|
||||
target: { value: '123' },
|
||||
});
|
||||
fireEvent.change(inputElements[1], {
|
||||
target: { value: '456' },
|
||||
});
|
||||
fireEvent.change(inputElements[2], {
|
||||
target: { value: '789' },
|
||||
});
|
||||
|
||||
const addValueEls = await screen.findAllByText('Add values');
|
||||
fireEvent.click(addValueEls[0]);
|
||||
fireEvent.click(addValueEls[1]);
|
||||
fireEvent.click(addValueEls[2]);
|
||||
|
||||
expect(screen.queryByText('123')).toBeInTheDocument();
|
||||
|
||||
const deleteBtns = screen.getAllByTestId('DELETE_CONSTRAINT_BUTTON');
|
||||
fireEvent.click(deleteBtns[0]);
|
||||
|
||||
const inputElements2 = screen.getAllByPlaceholderText(
|
||||
'value1, value2, value3...',
|
||||
);
|
||||
|
||||
fireEvent.change(inputElements2[0], {
|
||||
target: { value: '666' },
|
||||
});
|
||||
const addValueEls2 = screen.getAllByText('Add values');
|
||||
fireEvent.click(addValueEls2[0]);
|
||||
|
||||
expect(screen.queryByText('123')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('456')).toBeInTheDocument();
|
||||
expect(screen.queryByText('789')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should undo changes made to constraints', async () => {
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
fireEvent.click(addConstraintEl);
|
||||
|
||||
const inputEl = screen.getByPlaceholderText(
|
||||
'value1, value2, value3...',
|
||||
);
|
||||
|
||||
fireEvent.change(inputEl, {
|
||||
target: { value: '6, 7, 8' },
|
||||
});
|
||||
|
||||
const addBtn = await screen.findByText('Add values');
|
||||
addBtn.click();
|
||||
|
||||
expect(screen.queryByText('6')).toBeInTheDocument();
|
||||
expect(screen.queryByText('7')).toBeInTheDocument();
|
||||
expect(screen.queryByText('8')).toBeInTheDocument();
|
||||
|
||||
const undoBtn = await screen.findByTestId(
|
||||
'UNDO_CONSTRAINT_CHANGE_BUTTON',
|
||||
);
|
||||
undoBtn.click();
|
||||
|
||||
expect(screen.queryByText('6')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('7')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('8')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should remove constraint when no valid values are set and moving between tabs', async () => {
|
||||
setupComponent();
|
||||
|
||||
const titleEl = await screen.findByText('Gradual rollout');
|
||||
expect(titleEl).toBeInTheDocument();
|
||||
|
||||
const targetingEl = screen.getByText('Targeting');
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const addConstraintEl = await screen.findByText('Add constraint');
|
||||
fireEvent.click(addConstraintEl);
|
||||
|
||||
const variantsEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantsEl);
|
||||
fireEvent.click(targetingEl);
|
||||
|
||||
const seconAddConstraintEl = await screen.findByText('Add constraint');
|
||||
|
||||
expect(seconAddConstraintEl).toBeInTheDocument();
|
||||
expect(screen.queryByText('appName')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
||||
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
|
||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
|
||||
@ -18,7 +17,6 @@ import {
|
||||
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
|
||||
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||
import { ISegment } from 'interfaces/segment';
|
||||
import { formatStrategyName } from 'utils/strategyNames';
|
||||
import { useFormErrors } from 'hooks/useFormErrors';
|
||||
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
|
||||
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
|
||||
@ -33,8 +31,11 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import useQueryParams from 'hooks/useQueryParams';
|
||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||
import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy';
|
||||
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm';
|
||||
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||
|
||||
export const FeatureStrategyCreate = () => {
|
||||
const [tab, setTab] = useState(0);
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const featureId = useRequiredPathParam('featureId');
|
||||
const environmentId = useRequiredQueryParam('environmentId');
|
||||
@ -178,10 +179,10 @@ export const FeatureStrategyCreate = () => {
|
||||
return (
|
||||
<FormTemplate
|
||||
modal
|
||||
title={formatStrategyName(strategyName)}
|
||||
description={featureStrategyHelp}
|
||||
documentationLink={featureStrategyDocsLink}
|
||||
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
||||
disablePadding
|
||||
formatApiCode={() =>
|
||||
formatAddStrategyApiCode(
|
||||
projectId,
|
||||
@ -205,6 +206,17 @@ export const FeatureStrategyCreate = () => {
|
||||
permission={CREATE_FEATURE_STRATEGY}
|
||||
errors={errors}
|
||||
isChangeRequest={isChangeRequestConfigured(environmentId)}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
StrategyVariants={
|
||||
<NewStrategyVariants
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
environment={environmentId}
|
||||
projectId={projectId}
|
||||
editable
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{staleDataNotification}
|
||||
</FormTemplate>
|
||||
|
@ -1,42 +1,112 @@
|
||||
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
|
||||
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||
import { render } from 'utils/testRenderer';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
test('formatUpdateStrategyApiCode', () => {
|
||||
const strategy: IFeatureStrategy = {
|
||||
id: 'a',
|
||||
name: 'b',
|
||||
parameters: {
|
||||
c: 1,
|
||||
b: 2,
|
||||
a: 3,
|
||||
},
|
||||
constraints: [],
|
||||
};
|
||||
import {
|
||||
CREATE_FEATURE_STRATEGY,
|
||||
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||
UPDATE_FEATURE_STRATEGY,
|
||||
} from 'component/providers/AccessProvider/permissions';
|
||||
import { FeatureStrategyEdit } from './FeatureStrategyEdit';
|
||||
import {
|
||||
setupContextEndpoint,
|
||||
setupFeaturesEndpoint,
|
||||
setupProjectEndpoint,
|
||||
setupSegmentsEndpoint,
|
||||
setupStrategyEndpoint,
|
||||
setupUiConfigEndpoint,
|
||||
} from '../FeatureStrategyCreate/featureStrategyFormTestSetup';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const strategyDefinition: IStrategy = {
|
||||
name: 'c',
|
||||
displayName: 'd',
|
||||
description: 'e',
|
||||
editable: false,
|
||||
deprecated: false,
|
||||
parameters: [
|
||||
{ name: 'a', description: '', type: '', required: false },
|
||||
{ name: 'b', description: '', type: '', required: false },
|
||||
{ name: 'c', description: '', type: '', required: false },
|
||||
],
|
||||
};
|
||||
const featureName = 'my-new-feature';
|
||||
const variantName = 'Blue';
|
||||
|
||||
expect(
|
||||
formatUpdateStrategyApiCode(
|
||||
'projectId',
|
||||
'featureId',
|
||||
'environmentId',
|
||||
'strategyId',
|
||||
strategy,
|
||||
strategyDefinition,
|
||||
'unleashUrl',
|
||||
const setupComponent = () => {
|
||||
return {
|
||||
wrapper: render(
|
||||
<Routes>
|
||||
<Route
|
||||
path={
|
||||
'/projects/:projectId/features/:featureId/strategies/edit'
|
||||
}
|
||||
element={<FeatureStrategyEdit />}
|
||||
/>
|
||||
</Routes>,
|
||||
{
|
||||
route: `/projects/default/features/${featureName}/strategies/edit?environmentId=development&strategyId=1`,
|
||||
permissions: [
|
||||
{
|
||||
permission: CREATE_FEATURE_STRATEGY,
|
||||
project: 'default',
|
||||
environment: 'development',
|
||||
},
|
||||
{
|
||||
permission: UPDATE_FEATURE_STRATEGY,
|
||||
project: 'default',
|
||||
environment: 'development',
|
||||
},
|
||||
{
|
||||
permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||
project: 'default',
|
||||
environment: 'development',
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
expectedGroupId: 'newGroupId',
|
||||
expectedVariantName: variantName,
|
||||
expectedSliderValue: '75',
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
setupProjectEndpoint();
|
||||
setupSegmentsEndpoint();
|
||||
setupStrategyEndpoint();
|
||||
setupFeaturesEndpoint(featureName, variantName);
|
||||
setupUiConfigEndpoint();
|
||||
setupContextEndpoint();
|
||||
});
|
||||
|
||||
describe('NewFeatureStrategyEdit', () => {
|
||||
test('formatUpdateStrategyApiCode', () => {
|
||||
const strategy: IFeatureStrategy = {
|
||||
id: 'a',
|
||||
name: 'b',
|
||||
parameters: {
|
||||
c: 1,
|
||||
b: 2,
|
||||
a: 3,
|
||||
},
|
||||
constraints: [],
|
||||
};
|
||||
|
||||
const strategyDefinition: IStrategy = {
|
||||
name: 'c',
|
||||
displayName: 'd',
|
||||
description: 'e',
|
||||
editable: false,
|
||||
deprecated: false,
|
||||
parameters: [
|
||||
{ name: 'a', description: '', type: '', required: false },
|
||||
{ name: 'b', description: '', type: '', required: false },
|
||||
{ name: 'c', description: '', type: '', required: false },
|
||||
],
|
||||
};
|
||||
|
||||
expect(
|
||||
formatUpdateStrategyApiCode(
|
||||
'projectId',
|
||||
'featureId',
|
||||
'environmentId',
|
||||
'strategyId',
|
||||
strategy,
|
||||
strategyDefinition,
|
||||
'unleashUrl',
|
||||
),
|
||||
).toMatchInlineSnapshot(`
|
||||
"curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/strategyId' \\
|
||||
--header 'Authorization: INSERT_API_KEY' \\
|
||||
--header 'Content-Type: application/json' \\
|
||||
@ -51,4 +121,53 @@ test('formatUpdateStrategyApiCode', () => {
|
||||
"constraints": []
|
||||
}'"
|
||||
`);
|
||||
});
|
||||
|
||||
test('should change general settings', async () => {
|
||||
const { expectedGroupId, expectedSliderValue, wrapper } =
|
||||
setupComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const slider = await screen.findByRole('slider', { name: /rollout/i });
|
||||
const groupIdInput = await screen.getByLabelText('groupId');
|
||||
|
||||
expect(slider).toHaveValue('50');
|
||||
expect(groupIdInput).toHaveValue(featureName);
|
||||
const defaultStickiness = await screen.findByText('default');
|
||||
userEvent.click(defaultStickiness);
|
||||
const randomStickiness = await screen.findByText('random');
|
||||
userEvent.click(randomStickiness);
|
||||
|
||||
fireEvent.change(slider, { target: { value: expectedSliderValue } });
|
||||
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
|
||||
|
||||
expect(slider).toHaveValue(expectedSliderValue);
|
||||
expect(groupIdInput).toHaveValue(expectedGroupId);
|
||||
|
||||
await waitFor(() => {
|
||||
const codeSnippet = document.querySelector('pre')?.innerHTML;
|
||||
const count = (codeSnippet!.match(/random/g) || []).length;
|
||||
// strategy stickiness and variant stickiness
|
||||
expect(count).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should not change variant names', async () => {
|
||||
const { expectedVariantName } = setupComponent();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const variantsEl = screen.getByText('Variants');
|
||||
fireEvent.click(variantsEl);
|
||||
|
||||
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
|
||||
|
||||
const inputElement = screen.getAllByRole('textbox')[0];
|
||||
expect(inputElement).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
@ -1,5 +1,4 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
|
||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
||||
@ -16,7 +15,6 @@ import {
|
||||
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||
import { ISegment } from 'interfaces/segment';
|
||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||
import { formatStrategyName } from 'utils/strategyNames';
|
||||
import { useFormErrors } from 'hooks/useFormErrors';
|
||||
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
|
||||
import { sortStrategyParameters } from 'utils/sortStrategyParameters';
|
||||
@ -28,6 +26,10 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm';
|
||||
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
|
||||
import {
|
||||
getChangeRequestConflictCreatedData,
|
||||
@ -80,11 +82,20 @@ const useTitleTracking = () => {
|
||||
};
|
||||
};
|
||||
|
||||
const addIdSymbolToConstraints = (strategy?: IFeatureStrategy) => {
|
||||
if (!strategy) return;
|
||||
|
||||
return strategy?.constraints.map((constraint) => {
|
||||
return { ...constraint, [constraintId]: uuidv4() };
|
||||
});
|
||||
};
|
||||
|
||||
export const FeatureStrategyEdit = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const featureId = useRequiredPathParam('featureId');
|
||||
const environmentId = useRequiredQueryParam('environmentId');
|
||||
const strategyId = useRequiredQueryParam('strategyId');
|
||||
const [tab, setTab] = useState(0);
|
||||
|
||||
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
|
||||
const [segments, setSegments] = useState<ISegment[]>([]);
|
||||
@ -168,7 +179,15 @@ export const FeatureStrategyEdit = () => {
|
||||
const savedStrategy = data?.environments
|
||||
.flatMap((environment) => environment.strategies)
|
||||
.find((strategy) => strategy.id === strategyId);
|
||||
setStrategy((prev) => ({ ...prev, ...savedStrategy }));
|
||||
|
||||
const constraintsWithId = addIdSymbolToConstraints(savedStrategy);
|
||||
|
||||
const formattedStrategy = {
|
||||
...savedStrategy,
|
||||
constraints: constraintsWithId,
|
||||
};
|
||||
|
||||
setStrategy((prev) => ({ ...prev, ...formattedStrategy }));
|
||||
setPreviousTitle(savedStrategy?.title || '');
|
||||
}, [strategyId, data]);
|
||||
|
||||
@ -235,7 +254,7 @@ export const FeatureStrategyEdit = () => {
|
||||
return (
|
||||
<FormTemplate
|
||||
modal
|
||||
title={formatStrategyName(strategy.name ?? '')}
|
||||
disablePadding
|
||||
description={featureStrategyHelp}
|
||||
documentationLink={featureStrategyDocsLink}
|
||||
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
||||
@ -264,6 +283,16 @@ export const FeatureStrategyEdit = () => {
|
||||
permission={UPDATE_FEATURE_STRATEGY}
|
||||
errors={errors}
|
||||
isChangeRequest={isChangeRequestConfigured(environmentId)}
|
||||
tab={tab}
|
||||
setTab={setTab}
|
||||
StrategyVariants={
|
||||
<NewStrategyVariants
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
environment={environmentId}
|
||||
projectId={projectId}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{staleDataNotification}
|
||||
</FormTemplate>
|
||||
|
@ -1,6 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Alert, Button, styled } from '@mui/material';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
styled,
|
||||
Tabs,
|
||||
Tab,
|
||||
Box,
|
||||
Divider,
|
||||
Typography,
|
||||
} from '@mui/material';
|
||||
import {
|
||||
IFeatureStrategy,
|
||||
IFeatureStrategyParameters,
|
||||
@ -31,8 +40,12 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ
|
||||
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
|
||||
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
|
||||
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
|
||||
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
|
||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||
import { formatStrategyName } from 'utils/strategyNames';
|
||||
import { Badge } from 'component/common/Badge/Badge';
|
||||
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
|
||||
import { useFeedback } from 'component/feedbackNew/useFeedback';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
interface IFeatureStrategyFormProps {
|
||||
feature: IFeatureToggle;
|
||||
@ -42,7 +55,7 @@ interface IFeatureStrategyFormProps {
|
||||
onSubmit: () => void;
|
||||
onCancel?: () => void;
|
||||
loading: boolean;
|
||||
isChangeRequest?: boolean;
|
||||
isChangeRequest: boolean;
|
||||
strategy: Partial<IFeatureStrategy>;
|
||||
setStrategy: React.Dispatch<
|
||||
React.SetStateAction<Partial<IFeatureStrategy>>
|
||||
@ -50,28 +63,131 @@ interface IFeatureStrategyFormProps {
|
||||
segments: ISegment[];
|
||||
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
|
||||
errors: IFormErrors;
|
||||
tab: number;
|
||||
setTab: React.Dispatch<React.SetStateAction<number>>;
|
||||
StrategyVariants: JSX.Element;
|
||||
}
|
||||
|
||||
const StyledForm = styled('form')(({ theme }) => ({
|
||||
display: 'grid',
|
||||
gap: theme.spacing(2),
|
||||
const StyledDividerContent = styled(Box)(({ theme }) => ({
|
||||
padding: theme.spacing(0.75, 1),
|
||||
color: theme.palette.text.primary,
|
||||
fontSize: theme.fontSizes.smallerBody,
|
||||
backgroundColor: theme.palette.background.elevation2,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
width: '45px',
|
||||
position: 'absolute',
|
||||
top: '-10px',
|
||||
left: 'calc(50% - 45px)',
|
||||
lineHeight: 1,
|
||||
}));
|
||||
|
||||
const StyledHr = styled('hr')(({ theme }) => ({
|
||||
width: '100%',
|
||||
height: '1px',
|
||||
margin: theme.spacing(2, 0),
|
||||
border: 'none',
|
||||
background: theme.palette.background.elevation2,
|
||||
const StyledForm = styled('form')(({ theme }) => ({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing(2),
|
||||
padding: theme.spacing(6),
|
||||
paddingBottom: theme.spacing(12),
|
||||
paddingTop: theme.spacing(4),
|
||||
overflow: 'auto',
|
||||
height: '100%',
|
||||
}));
|
||||
|
||||
const StyledTitle = styled('h1')(({ theme }) => ({
|
||||
fontWeight: 'normal',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
paddingTop: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledButtons = styled('div')(({ theme }) => ({
|
||||
bottom: 0,
|
||||
right: 0,
|
||||
left: 0,
|
||||
position: 'absolute',
|
||||
display: 'flex',
|
||||
padding: theme.spacing(3),
|
||||
paddingRight: theme.spacing(6),
|
||||
paddingLeft: theme.spacing(6),
|
||||
backgroundColor: theme.palette.background.paper,
|
||||
justifyContent: 'end',
|
||||
gap: theme.spacing(2),
|
||||
paddingBottom: theme.spacing(10),
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
}));
|
||||
|
||||
const StyledTabs = styled(Tabs)(({ theme }) => ({
|
||||
borderTop: `1px solid ${theme.palette.divider}`,
|
||||
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||
paddingLeft: theme.spacing(6),
|
||||
paddingRight: theme.spacing(6),
|
||||
minHeight: '60px',
|
||||
}));
|
||||
|
||||
const StyledBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
position: 'relative',
|
||||
marginTop: theme.spacing(3.5),
|
||||
}));
|
||||
|
||||
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||
width: '100%',
|
||||
}));
|
||||
|
||||
const StyledTargetingHeader = styled('div')(({ theme }) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
marginTop: theme.spacing(1.5),
|
||||
}));
|
||||
|
||||
const StyledHeaderBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
paddingLeft: theme.spacing(6),
|
||||
paddingRight: theme.spacing(6),
|
||||
paddingTop: theme.spacing(2),
|
||||
}));
|
||||
|
||||
const StyledAlertBox = styled(Box)(({ theme }) => ({
|
||||
paddingLeft: theme.spacing(6),
|
||||
paddingRight: theme.spacing(6),
|
||||
'& > *': {
|
||||
marginTop: theme.spacing(2),
|
||||
marginBottom: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
const StyledEnvironmentBox = styled(Box)(({ theme }) => ({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const EnvironmentIconBox = styled(Box)(({ theme }) => ({
|
||||
transform: 'scale(0.9)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
}));
|
||||
|
||||
const EnvironmentTypography = styled(Typography)<{ enabled: boolean }>(
|
||||
({ theme, enabled }) => ({
|
||||
fontWeight: enabled ? 'bold' : 'normal',
|
||||
}),
|
||||
);
|
||||
|
||||
const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({
|
||||
marginRight: theme.spacing(0.5),
|
||||
color: theme.palette.text.secondary,
|
||||
}));
|
||||
|
||||
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||
width: '100px',
|
||||
}));
|
||||
|
||||
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||
marginLeft: theme.spacing(1),
|
||||
}));
|
||||
|
||||
const feedbackCategory = 'newStrategyForm';
|
||||
|
||||
export const FeatureStrategyForm = ({
|
||||
projectId,
|
||||
feature,
|
||||
@ -86,7 +202,14 @@ export const FeatureStrategyForm = ({
|
||||
setSegments,
|
||||
errors,
|
||||
isChangeRequest,
|
||||
tab,
|
||||
setTab,
|
||||
StrategyVariants,
|
||||
}: IFeatureStrategyFormProps) => {
|
||||
const { openFeedback, hasSubmittedFeedback } = useFeedback(
|
||||
feedbackCategory,
|
||||
'manual',
|
||||
);
|
||||
const { trackEvent } = usePlausibleTracker();
|
||||
const [showProdGuard, setShowProdGuard] = useState(false);
|
||||
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
|
||||
@ -97,6 +220,39 @@ export const FeatureStrategyForm = ({
|
||||
environmentId,
|
||||
);
|
||||
const { strategyDefinition } = useStrategy(strategy?.name);
|
||||
const newStrategyConfigurationFeedback = useUiFlag(
|
||||
'newStrategyConfigurationFeedback',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
trackEvent('new-strategy-form', {
|
||||
props: {
|
||||
eventType: 'seen',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const stickiness =
|
||||
strategy?.parameters && 'stickiness' in strategy?.parameters
|
||||
? String(strategy.parameters.stickiness)
|
||||
: 'default';
|
||||
|
||||
useEffect(() => {
|
||||
setStrategy((prev) => ({
|
||||
...prev,
|
||||
variants: (strategy.variants || []).map((variant) => ({
|
||||
stickiness,
|
||||
name: variant.name,
|
||||
weight: variant.weight,
|
||||
payload: variant.payload,
|
||||
weightType: variant.weightType,
|
||||
})),
|
||||
}));
|
||||
}, [stickiness, JSON.stringify(strategy.variants)]);
|
||||
|
||||
const foundEnvironment = feature.environments.find(
|
||||
(environment) => environment.name === environmentId,
|
||||
);
|
||||
|
||||
const { data } = usePendingChangeRequests(feature.project);
|
||||
const { changeRequestInReviewOrApproved, alert } =
|
||||
@ -111,11 +267,7 @@ export const FeatureStrategyForm = ({
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {
|
||||
uiConfig,
|
||||
error: uiConfigError,
|
||||
loading: uiConfigLoading,
|
||||
} = useUiConfig();
|
||||
const { error: uiConfigError, loading: uiConfigLoading } = useUiConfig();
|
||||
|
||||
if (uiConfigError) {
|
||||
throw uiConfigError;
|
||||
@ -159,6 +311,15 @@ export const FeatureStrategyForm = ({
|
||||
navigate(formatFeaturePath(feature.project, feature.name));
|
||||
};
|
||||
|
||||
const createFeedbackContext = () => {
|
||||
openFeedback({
|
||||
title: 'How easy was it to work with the new strategy form?',
|
||||
positiveLabel: 'What do you like most about the new strategy form?',
|
||||
areasForImprovementsLabel:
|
||||
'What should be improved the new strategy form?',
|
||||
});
|
||||
};
|
||||
|
||||
const onSubmitWithValidation = async (event: React.FormEvent) => {
|
||||
if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) {
|
||||
trackEvent('strategy-variants', {
|
||||
@ -172,145 +333,262 @@ export const FeatureStrategyForm = ({
|
||||
return;
|
||||
}
|
||||
|
||||
trackEvent('new-strategy-form', {
|
||||
props: {
|
||||
eventType: 'submitted',
|
||||
},
|
||||
});
|
||||
|
||||
if (enableProdGuard && !isChangeRequest) {
|
||||
setShowProdGuard(true);
|
||||
} else {
|
||||
onSubmit();
|
||||
await onSubmitWithFeedback();
|
||||
}
|
||||
};
|
||||
|
||||
const onSubmitWithFeedback = async () => {
|
||||
try {
|
||||
await onSubmit();
|
||||
|
||||
if (newStrategyConfigurationFeedback && !hasSubmittedFeedback) {
|
||||
createFeedbackContext();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
|
||||
setTab(newValue);
|
||||
};
|
||||
|
||||
const getTargetingCount = () => {
|
||||
const constraintCount = strategy.constraints?.length || 0;
|
||||
const segmentCount = segments.length || 0;
|
||||
|
||||
return constraintCount + segmentCount;
|
||||
};
|
||||
|
||||
const showVariants =
|
||||
strategy.parameters && 'stickiness' in strategy.parameters;
|
||||
|
||||
return (
|
||||
<StyledForm onSubmit={onSubmitWithValidation}>
|
||||
<ConditionallyRender
|
||||
condition={hasChangeRequestInReviewForEnvironment}
|
||||
show={alert}
|
||||
elseShow={
|
||||
<>
|
||||
<StyledHeaderBox>
|
||||
<StyledTitle>
|
||||
{formatStrategyName(strategy.name || '')}
|
||||
<ConditionallyRender
|
||||
condition={strategy.name === 'flexibleRollout'}
|
||||
show={
|
||||
<Badge color='success' sx={{ marginLeft: '1rem' }}>
|
||||
{strategy.parameters?.rollout}%
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
</StyledTitle>
|
||||
{foundEnvironment ? (
|
||||
<StyledEnvironmentBox>
|
||||
<EnvironmentTypographyHeader>
|
||||
Environment:
|
||||
</EnvironmentTypographyHeader>
|
||||
<EnvironmentIconBox>
|
||||
<EnvironmentIcon
|
||||
enabled={foundEnvironment.enabled}
|
||||
/>{' '}
|
||||
<EnvironmentTypography
|
||||
enabled={foundEnvironment.enabled}
|
||||
>
|
||||
{foundEnvironment.name}
|
||||
</EnvironmentTypography>
|
||||
</EnvironmentIconBox>
|
||||
</StyledEnvironmentBox>
|
||||
) : null}
|
||||
</StyledHeaderBox>
|
||||
|
||||
<StyledAlertBox>
|
||||
<ConditionallyRender
|
||||
condition={hasChangeRequestInReviewForEnvironment}
|
||||
show={alert}
|
||||
elseShow={
|
||||
<ConditionallyRender
|
||||
condition={isChangeRequest}
|
||||
show={
|
||||
<FeatureStrategyChangeRequestAlert
|
||||
environment={environmentId}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FeatureStrategyEnabled
|
||||
projectId={feature.project}
|
||||
featureId={feature.name}
|
||||
environmentId={environmentId}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(isChangeRequest)}
|
||||
show={
|
||||
<FeatureStrategyChangeRequestAlert
|
||||
environment={environmentId}
|
||||
/>
|
||||
<Alert severity='success'>
|
||||
This feature toggle is currently enabled in the{' '}
|
||||
<strong>{environmentId}</strong> environment.
|
||||
Any changes made here will be available to users
|
||||
as soon as these changes are approved and
|
||||
applied.
|
||||
</Alert>
|
||||
}
|
||||
elseShow={
|
||||
<Alert severity='success'>
|
||||
This feature toggle is currently enabled in the{' '}
|
||||
<strong>{environmentId}</strong> environment.
|
||||
Any changes made here will be available to users
|
||||
as soon as you hit <strong>save</strong>.
|
||||
</Alert>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FeatureStrategyEnabled
|
||||
projectId={feature.project}
|
||||
featureId={feature.name}
|
||||
environmentId={environmentId}
|
||||
>
|
||||
<ConditionallyRender
|
||||
condition={Boolean(isChangeRequest)}
|
||||
show={
|
||||
<Alert severity='success'>
|
||||
This feature toggle is currently enabled in the{' '}
|
||||
<strong>{environmentId}</strong> environment. Any
|
||||
changes made here will be available to users as soon
|
||||
as these changes are approved and applied.
|
||||
</Alert>
|
||||
}
|
||||
elseShow={
|
||||
<Alert severity='success'>
|
||||
This feature toggle is currently enabled in the{' '}
|
||||
<strong>{environmentId}</strong> environment. Any
|
||||
changes made here will be available to users as soon
|
||||
as you hit <strong>save</strong>.
|
||||
</Alert>
|
||||
</FeatureStrategyEnabled>
|
||||
</StyledAlertBox>
|
||||
|
||||
<StyledTabs value={tab} onChange={handleChange}>
|
||||
<StyledTab label='General' />
|
||||
<Tab
|
||||
data-testid='STRATEGY_TARGETING_TAB'
|
||||
label={
|
||||
<Typography>
|
||||
Targeting
|
||||
<StyledBadge>{getTargetingCount()}</StyledBadge>
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
</FeatureStrategyEnabled>
|
||||
<StyledHr />
|
||||
<FeatureStrategyTitle
|
||||
title={strategy.title || ''}
|
||||
setTitle={(title) => {
|
||||
setStrategy((prev) => ({
|
||||
...prev,
|
||||
title,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
<FeatureStrategySegment
|
||||
segments={segments}
|
||||
setSegments={setSegments}
|
||||
projectId={projectId}
|
||||
/>
|
||||
<FeatureStrategyConstraints
|
||||
projectId={feature.project}
|
||||
environmentId={environmentId}
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
/>
|
||||
<StyledHr />
|
||||
<FeatureStrategyType
|
||||
strategy={strategy}
|
||||
strategyDefinition={strategyDefinition}
|
||||
setStrategy={setStrategy}
|
||||
validateParameter={validateParameter}
|
||||
errors={errors}
|
||||
hasAccess={access}
|
||||
/>
|
||||
<StyledHr />
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
strategy.parameters != null &&
|
||||
'stickiness' in strategy.parameters
|
||||
}
|
||||
show={
|
||||
<StrategyVariants
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
environment={environmentId}
|
||||
projectId={projectId}
|
||||
{showVariants && (
|
||||
<Tab
|
||||
data-testid='STRATEGY_VARIANTS_TAB'
|
||||
label={
|
||||
<Typography>
|
||||
Variants
|
||||
<StyledBadge>
|
||||
{strategy.variants?.length || 0}
|
||||
</StyledBadge>
|
||||
</Typography>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<StyledHr />
|
||||
<FeatureStrategyEnabledDisabled
|
||||
enabled={!strategy?.disabled}
|
||||
onToggleEnabled={() =>
|
||||
setStrategy((strategyState) => ({
|
||||
...strategyState,
|
||||
disabled: !strategyState.disabled,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<StyledHr />
|
||||
<StyledButtons>
|
||||
<PermissionButton
|
||||
permission={permission}
|
||||
projectId={feature.project}
|
||||
environmentId={environmentId}
|
||||
variant='contained'
|
||||
color='primary'
|
||||
type='submit'
|
||||
disabled={
|
||||
loading ||
|
||||
!hasValidConstraints ||
|
||||
errors.hasFormErrors()
|
||||
)}
|
||||
</StyledTabs>
|
||||
<StyledForm onSubmit={onSubmitWithValidation}>
|
||||
<ConditionallyRender
|
||||
condition={tab === 0}
|
||||
show={
|
||||
<>
|
||||
<FeatureStrategyTitle
|
||||
title={strategy.title || ''}
|
||||
setTitle={(title) => {
|
||||
setStrategy((prev) => ({
|
||||
...prev,
|
||||
title,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
|
||||
<FeatureStrategyEnabledDisabled
|
||||
enabled={!strategy?.disabled}
|
||||
onToggleEnabled={() =>
|
||||
setStrategy((strategyState) => ({
|
||||
...strategyState,
|
||||
disabled: !strategyState.disabled,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<FeatureStrategyType
|
||||
strategy={strategy}
|
||||
strategyDefinition={strategyDefinition}
|
||||
setStrategy={setStrategy}
|
||||
validateParameter={validateParameter}
|
||||
errors={errors}
|
||||
hasAccess={access}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
data-testid={STRATEGY_FORM_SUBMIT_ID}
|
||||
>
|
||||
{isChangeRequest
|
||||
? changeRequestButtonText
|
||||
: 'Save strategy'}
|
||||
</PermissionButton>
|
||||
<Button
|
||||
type='button'
|
||||
color='primary'
|
||||
onClick={onCancel ? onCancel : onDefaultCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<FeatureStrategyProdGuard
|
||||
open={showProdGuard}
|
||||
onClose={() => setShowProdGuard(false)}
|
||||
onClick={onSubmit}
|
||||
loading={loading}
|
||||
label='Save strategy'
|
||||
/>
|
||||
</StyledButtons>
|
||||
</StyledForm>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={tab === 1}
|
||||
show={
|
||||
<>
|
||||
<StyledTargetingHeader>
|
||||
Segmentation and constraints allow you to set
|
||||
filters on your strategies, so that they will
|
||||
only be evaluated for users and applications
|
||||
that match the specified preconditions.
|
||||
</StyledTargetingHeader>
|
||||
<FeatureStrategySegment
|
||||
segments={segments}
|
||||
setSegments={setSegments}
|
||||
projectId={projectId}
|
||||
/>
|
||||
|
||||
<StyledBox>
|
||||
<StyledDivider />
|
||||
<StyledDividerContent>AND</StyledDividerContent>
|
||||
</StyledBox>
|
||||
<FeatureStrategyConstraints
|
||||
projectId={feature.project}
|
||||
environmentId={environmentId}
|
||||
strategy={strategy}
|
||||
setStrategy={setStrategy}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
<ConditionallyRender
|
||||
condition={tab === 2}
|
||||
show={
|
||||
<ConditionallyRender
|
||||
condition={
|
||||
strategy.parameters != null &&
|
||||
'stickiness' in strategy.parameters
|
||||
}
|
||||
show={StrategyVariants}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
<StyledButtons>
|
||||
<PermissionButton
|
||||
permission={permission}
|
||||
projectId={feature.project}
|
||||
environmentId={environmentId}
|
||||
variant='contained'
|
||||
color='primary'
|
||||
type='submit'
|
||||
disabled={
|
||||
loading ||
|
||||
!hasValidConstraints ||
|
||||
errors.hasFormErrors()
|
||||
}
|
||||
data-testid={STRATEGY_FORM_SUBMIT_ID}
|
||||
>
|
||||
{isChangeRequest
|
||||
? changeRequestButtonText
|
||||
: 'Save strategy'}
|
||||
</PermissionButton>
|
||||
<Button
|
||||
type='button'
|
||||
color='primary'
|
||||
onClick={onCancel ? onCancel : onDefaultCancel}
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<FeatureStrategyProdGuard
|
||||
open={showProdGuard}
|
||||
onClose={() => setShowProdGuard(false)}
|
||||
onClick={onSubmitWithFeedback}
|
||||
loading={loading}
|
||||
label='Save strategy'
|
||||
/>
|
||||
</StyledButtons>
|
||||
</StyledForm>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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}'`;
|
||||
};
|
@ -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();
|
||||
});
|
||||
});
|
@ -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';
|
@ -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`,
|
||||
},
|
||||
]);
|
||||
});
|
@ -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,
|
||||
})) ?? [];
|
@ -1,7 +1,6 @@
|
||||
import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData';
|
||||
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
|
||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||
import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
@ -9,9 +8,8 @@ import { usePageTitle } from 'hooks/usePageTitle';
|
||||
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
|
||||
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
|
||||
import { styled } from '@mui/material';
|
||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
||||
import { NewFeatureStrategyCreate } from 'component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate';
|
||||
import { NewFeatureStrategyEdit } from 'component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit';
|
||||
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
||||
import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||
|
||||
const StyledContainer = styled('div')(({ theme }) => ({
|
||||
display: 'flex',
|
||||
@ -61,7 +59,7 @@ const FeatureOverview = () => {
|
||||
onClose={onSidebarClose}
|
||||
open
|
||||
>
|
||||
<NewFeatureStrategyCreate />
|
||||
<FeatureStrategyCreate />
|
||||
</SidebarModal>
|
||||
}
|
||||
/>
|
||||
@ -73,7 +71,7 @@ const FeatureOverview = () => {
|
||||
onClose={onSidebarClose}
|
||||
open
|
||||
>
|
||||
<NewFeatureStrategyEdit />
|
||||
<FeatureStrategyEdit />
|
||||
</SidebarModal>
|
||||
}
|
||||
/>
|
||||
|
Loading…
Reference in New Issue
Block a user