mirror of
https://github.com/Unleash/unleash.git
synced 2025-08-04 13:48:56 +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,
|
Typography,
|
||||||
} from '@mui/material';
|
} from '@mui/material';
|
||||||
import { Delete, Edit, MoreVert } from '@mui/icons-material';
|
import { Delete, Edit, MoreVert } from '@mui/icons-material';
|
||||||
import { NewEditChange } from './NewEditChange';
|
import { EditChange } from './EditChange';
|
||||||
|
|
||||||
const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => {
|
const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => {
|
||||||
const { isChangeRequestConfigured } = useChangeRequestsEnabled(
|
const { isChangeRequestConfigured } = useChangeRequestsEnabled(
|
||||||
@ -149,7 +149,7 @@ export const ChangeActions: FC<{
|
|||||||
Edit change
|
Edit change
|
||||||
</Typography>
|
</Typography>
|
||||||
</ListItemText>
|
</ListItemText>
|
||||||
<NewEditChange
|
<EditChange
|
||||||
changeRequestId={changeRequest.id}
|
changeRequestId={changeRequest.id}
|
||||||
featureId={feature}
|
featureId={feature}
|
||||||
change={
|
change={
|
||||||
|
@ -7,7 +7,6 @@ import useToast from 'hooks/useToast';
|
|||||||
import { IFeatureStrategy } from 'interfaces/strategy';
|
import { IFeatureStrategy } from 'interfaces/strategy';
|
||||||
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||||
import { ISegment } from 'interfaces/segment';
|
import { ISegment } from 'interfaces/segment';
|
||||||
import { formatStrategyName } from 'utils/strategyNames';
|
|
||||||
import { useFormErrors } from 'hooks/useFormErrors';
|
import { useFormErrors } from 'hooks/useFormErrors';
|
||||||
import { useCollaborateData } from 'hooks/useCollaborateData';
|
import { useCollaborateData } from 'hooks/useCollaborateData';
|
||||||
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
import { useFeature } from 'hooks/api/getters/useFeature/useFeature';
|
||||||
@ -16,13 +15,17 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
|||||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||||
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
|
import { comparisonModerator } from 'component/feature/FeatureStrategy/featureStrategy.utils';
|
||||||
import {
|
import {
|
||||||
|
ChangeRequestAddStrategy,
|
||||||
|
ChangeRequestEditStrategy,
|
||||||
IChangeRequestAddStrategy,
|
IChangeRequestAddStrategy,
|
||||||
IChangeRequestUpdateStrategy,
|
IChangeRequestUpdateStrategy,
|
||||||
} from 'component/changeRequest/changeRequest.types';
|
} from 'component/changeRequest/changeRequest.types';
|
||||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||||
import { NewFeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm';
|
import { FeatureStrategyForm } from '../../../../feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
|
||||||
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
|
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||||
|
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
|
|
||||||
interface IEditChangeProps {
|
interface IEditChangeProps {
|
||||||
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
|
change: IChangeRequestAddStrategy | IChangeRequestUpdateStrategy;
|
||||||
@ -34,6 +37,16 @@ interface IEditChangeProps {
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const addIdSymbolToConstraints = (
|
||||||
|
strategy?: ChangeRequestAddStrategy | ChangeRequestEditStrategy,
|
||||||
|
) => {
|
||||||
|
if (!strategy) return;
|
||||||
|
|
||||||
|
return strategy?.constraints.map((constraint) => {
|
||||||
|
return { ...constraint, [constraintId]: uuidv4() };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const EditChange = ({
|
export const EditChange = ({
|
||||||
change,
|
change,
|
||||||
changeRequestId,
|
changeRequestId,
|
||||||
@ -47,9 +60,12 @@ export const EditChange = ({
|
|||||||
const { editChange } = useChangeRequestApi();
|
const { editChange } = useChangeRequestApi();
|
||||||
const [tab, setTab] = useState(0);
|
const [tab, setTab] = useState(0);
|
||||||
|
|
||||||
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>(
|
const constraintsWithId = addIdSymbolToConstraints(change.payload);
|
||||||
change.payload,
|
|
||||||
);
|
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({
|
||||||
|
...change.payload,
|
||||||
|
constraints: constraintsWithId,
|
||||||
|
});
|
||||||
|
|
||||||
const { segments: allSegments } = useSegments();
|
const { segments: allSegments } = useSegments();
|
||||||
const strategySegments = (allSegments || []).filter((segment) => {
|
const strategySegments = (allSegments || []).filter((segment) => {
|
||||||
@ -134,7 +150,7 @@ export const EditChange = ({
|
|||||||
>
|
>
|
||||||
<FormTemplate
|
<FormTemplate
|
||||||
modal
|
modal
|
||||||
title={formatStrategyName(strategyDefinition.name ?? '')}
|
disablePadding
|
||||||
description={featureStrategyHelp}
|
description={featureStrategyHelp}
|
||||||
documentationLink={featureStrategyDocsLink}
|
documentationLink={featureStrategyDocsLink}
|
||||||
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
||||||
@ -148,7 +164,7 @@ export const EditChange = ({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<NewFeatureStrategyForm
|
<FeatureStrategyForm
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
feature={data}
|
feature={data}
|
||||||
strategy={strategy}
|
strategy={strategy}
|
||||||
@ -165,7 +181,7 @@ export const EditChange = ({
|
|||||||
tab={tab}
|
tab={tab}
|
||||||
setTab={setTab}
|
setTab={setTab}
|
||||||
StrategyVariants={
|
StrategyVariants={
|
||||||
<StrategyVariants
|
<NewStrategyVariants
|
||||||
strategy={strategy}
|
strategy={strategy}
|
||||||
setStrategy={setStrategy}
|
setStrategy={setStrategy}
|
||||||
environment={environment}
|
environment={environment}
|
||||||
|
@ -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 { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
||||||
|
import { screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import { render } from 'utils/testRenderer';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
test('formatAddStrategyApiCode', () => {
|
import {
|
||||||
expect(
|
CREATE_FEATURE_STRATEGY,
|
||||||
formatAddStrategyApiCode(
|
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||||
'projectId',
|
UPDATE_FEATURE_STRATEGY,
|
||||||
'featureId',
|
} from 'component/providers/AccessProvider/permissions';
|
||||||
'environmentId',
|
import { FeatureStrategyCreate } from './FeatureStrategyCreate';
|
||||||
{ id: 'strategyId' },
|
import {
|
||||||
'unleashUrl',
|
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(`
|
expectedSegmentName: 'test',
|
||||||
"curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\
|
expectedGroupId: 'newGroupId',
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
expectedVariantName: 'Blue',
|
||||||
--header 'Content-Type: application/json' \\
|
expectedSliderValue: '50',
|
||||||
--data-raw '{
|
expectedConstraintValue: 'new value',
|
||||||
"id": "strategyId"
|
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 { useEffect, useRef, useState } from 'react';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
||||||
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
|
|
||||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
|
import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi';
|
||||||
@ -18,7 +17,6 @@ import {
|
|||||||
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
|
} from '../FeatureStrategyEdit/FeatureStrategyEdit';
|
||||||
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||||
import { ISegment } from 'interfaces/segment';
|
import { ISegment } from 'interfaces/segment';
|
||||||
import { formatStrategyName } from 'utils/strategyNames';
|
|
||||||
import { useFormErrors } from 'hooks/useFormErrors';
|
import { useFormErrors } from 'hooks/useFormErrors';
|
||||||
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
|
import { createFeatureStrategy } from 'utils/createFeatureStrategy';
|
||||||
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
|
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
|
||||||
@ -33,8 +31,11 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
|||||||
import useQueryParams from 'hooks/useQueryParams';
|
import useQueryParams from 'hooks/useQueryParams';
|
||||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||||
import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy';
|
import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy';
|
||||||
|
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm';
|
||||||
|
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||||
|
|
||||||
export const FeatureStrategyCreate = () => {
|
export const FeatureStrategyCreate = () => {
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const featureId = useRequiredPathParam('featureId');
|
const featureId = useRequiredPathParam('featureId');
|
||||||
const environmentId = useRequiredQueryParam('environmentId');
|
const environmentId = useRequiredQueryParam('environmentId');
|
||||||
@ -178,10 +179,10 @@ export const FeatureStrategyCreate = () => {
|
|||||||
return (
|
return (
|
||||||
<FormTemplate
|
<FormTemplate
|
||||||
modal
|
modal
|
||||||
title={formatStrategyName(strategyName)}
|
|
||||||
description={featureStrategyHelp}
|
description={featureStrategyHelp}
|
||||||
documentationLink={featureStrategyDocsLink}
|
documentationLink={featureStrategyDocsLink}
|
||||||
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
||||||
|
disablePadding
|
||||||
formatApiCode={() =>
|
formatApiCode={() =>
|
||||||
formatAddStrategyApiCode(
|
formatAddStrategyApiCode(
|
||||||
projectId,
|
projectId,
|
||||||
@ -205,6 +206,17 @@ export const FeatureStrategyCreate = () => {
|
|||||||
permission={CREATE_FEATURE_STRATEGY}
|
permission={CREATE_FEATURE_STRATEGY}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
isChangeRequest={isChangeRequestConfigured(environmentId)}
|
isChangeRequest={isChangeRequestConfigured(environmentId)}
|
||||||
|
tab={tab}
|
||||||
|
setTab={setTab}
|
||||||
|
StrategyVariants={
|
||||||
|
<NewStrategyVariants
|
||||||
|
strategy={strategy}
|
||||||
|
setStrategy={setStrategy}
|
||||||
|
environment={environmentId}
|
||||||
|
projectId={projectId}
|
||||||
|
editable
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{staleDataNotification}
|
{staleDataNotification}
|
||||||
</FormTemplate>
|
</FormTemplate>
|
||||||
|
@ -1,42 +1,112 @@
|
|||||||
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||||
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
|
import { IFeatureStrategy, IStrategy } from 'interfaces/strategy';
|
||||||
|
import { screen, waitFor, fireEvent } from '@testing-library/react';
|
||||||
|
import { render } from 'utils/testRenderer';
|
||||||
|
import { Route, Routes } from 'react-router-dom';
|
||||||
|
|
||||||
test('formatUpdateStrategyApiCode', () => {
|
import {
|
||||||
const strategy: IFeatureStrategy = {
|
CREATE_FEATURE_STRATEGY,
|
||||||
id: 'a',
|
UPDATE_FEATURE_ENVIRONMENT_VARIANTS,
|
||||||
name: 'b',
|
UPDATE_FEATURE_STRATEGY,
|
||||||
parameters: {
|
} from 'component/providers/AccessProvider/permissions';
|
||||||
c: 1,
|
import { FeatureStrategyEdit } from './FeatureStrategyEdit';
|
||||||
b: 2,
|
import {
|
||||||
a: 3,
|
setupContextEndpoint,
|
||||||
},
|
setupFeaturesEndpoint,
|
||||||
constraints: [],
|
setupProjectEndpoint,
|
||||||
};
|
setupSegmentsEndpoint,
|
||||||
|
setupStrategyEndpoint,
|
||||||
|
setupUiConfigEndpoint,
|
||||||
|
} from '../FeatureStrategyCreate/featureStrategyFormTestSetup';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
|
||||||
const strategyDefinition: IStrategy = {
|
const featureName = 'my-new-feature';
|
||||||
name: 'c',
|
const variantName = 'Blue';
|
||||||
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(
|
const setupComponent = () => {
|
||||||
formatUpdateStrategyApiCode(
|
return {
|
||||||
'projectId',
|
wrapper: render(
|
||||||
'featureId',
|
<Routes>
|
||||||
'environmentId',
|
<Route
|
||||||
'strategyId',
|
path={
|
||||||
strategy,
|
'/projects/:projectId/features/:featureId/strategies/edit'
|
||||||
strategyDefinition,
|
}
|
||||||
'unleashUrl',
|
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' \\
|
"curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/strategyId' \\
|
||||||
--header 'Authorization: INSERT_API_KEY' \\
|
--header 'Authorization: INSERT_API_KEY' \\
|
||||||
--header 'Content-Type: application/json' \\
|
--header 'Content-Type: application/json' \\
|
||||||
@ -51,4 +121,53 @@ test('formatUpdateStrategyApiCode', () => {
|
|||||||
"constraints": []
|
"constraints": []
|
||||||
}'"
|
}'"
|
||||||
`);
|
`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should change general settings', async () => {
|
||||||
|
const { expectedGroupId, expectedSliderValue, wrapper } =
|
||||||
|
setupComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const slider = await screen.findByRole('slider', { name: /rollout/i });
|
||||||
|
const groupIdInput = await screen.getByLabelText('groupId');
|
||||||
|
|
||||||
|
expect(slider).toHaveValue('50');
|
||||||
|
expect(groupIdInput).toHaveValue(featureName);
|
||||||
|
const defaultStickiness = await screen.findByText('default');
|
||||||
|
userEvent.click(defaultStickiness);
|
||||||
|
const randomStickiness = await screen.findByText('random');
|
||||||
|
userEvent.click(randomStickiness);
|
||||||
|
|
||||||
|
fireEvent.change(slider, { target: { value: expectedSliderValue } });
|
||||||
|
fireEvent.change(groupIdInput, { target: { value: expectedGroupId } });
|
||||||
|
|
||||||
|
expect(slider).toHaveValue(expectedSliderValue);
|
||||||
|
expect(groupIdInput).toHaveValue(expectedGroupId);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const codeSnippet = document.querySelector('pre')?.innerHTML;
|
||||||
|
const count = (codeSnippet!.match(/random/g) || []).length;
|
||||||
|
// strategy stickiness and variant stickiness
|
||||||
|
expect(count).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should not change variant names', async () => {
|
||||||
|
const { expectedVariantName } = setupComponent();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Gradual rollout')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const variantsEl = screen.getByText('Variants');
|
||||||
|
fireEvent.click(variantsEl);
|
||||||
|
|
||||||
|
expect(screen.getByText(expectedVariantName)).toBeInTheDocument();
|
||||||
|
|
||||||
|
const inputElement = screen.getAllByRole('textbox')[0];
|
||||||
|
expect(inputElement).toBeDisabled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,5 +1,4 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm';
|
|
||||||
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
import FormTemplate from 'component/common/FormTemplate/FormTemplate';
|
||||||
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
|
||||||
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam';
|
||||||
@ -16,7 +15,6 @@ import {
|
|||||||
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions';
|
||||||
import { ISegment } from 'interfaces/segment';
|
import { ISegment } from 'interfaces/segment';
|
||||||
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
import { useSegments } from 'hooks/api/getters/useSegments/useSegments';
|
||||||
import { formatStrategyName } from 'utils/strategyNames';
|
|
||||||
import { useFormErrors } from 'hooks/useFormErrors';
|
import { useFormErrors } from 'hooks/useFormErrors';
|
||||||
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
|
import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy';
|
||||||
import { sortStrategyParameters } from 'utils/sortStrategyParameters';
|
import { sortStrategyParameters } from 'utils/sortStrategyParameters';
|
||||||
@ -28,6 +26,10 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled';
|
|||||||
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi';
|
||||||
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests';
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
|
import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm';
|
||||||
|
import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants';
|
||||||
|
import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint';
|
||||||
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
|
import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy';
|
||||||
import {
|
import {
|
||||||
getChangeRequestConflictCreatedData,
|
getChangeRequestConflictCreatedData,
|
||||||
@ -80,11 +82,20 @@ const useTitleTracking = () => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addIdSymbolToConstraints = (strategy?: IFeatureStrategy) => {
|
||||||
|
if (!strategy) return;
|
||||||
|
|
||||||
|
return strategy?.constraints.map((constraint) => {
|
||||||
|
return { ...constraint, [constraintId]: uuidv4() };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
export const FeatureStrategyEdit = () => {
|
export const FeatureStrategyEdit = () => {
|
||||||
const projectId = useRequiredPathParam('projectId');
|
const projectId = useRequiredPathParam('projectId');
|
||||||
const featureId = useRequiredPathParam('featureId');
|
const featureId = useRequiredPathParam('featureId');
|
||||||
const environmentId = useRequiredQueryParam('environmentId');
|
const environmentId = useRequiredQueryParam('environmentId');
|
||||||
const strategyId = useRequiredQueryParam('strategyId');
|
const strategyId = useRequiredQueryParam('strategyId');
|
||||||
|
const [tab, setTab] = useState(0);
|
||||||
|
|
||||||
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
|
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
|
||||||
const [segments, setSegments] = useState<ISegment[]>([]);
|
const [segments, setSegments] = useState<ISegment[]>([]);
|
||||||
@ -168,7 +179,15 @@ export const FeatureStrategyEdit = () => {
|
|||||||
const savedStrategy = data?.environments
|
const savedStrategy = data?.environments
|
||||||
.flatMap((environment) => environment.strategies)
|
.flatMap((environment) => environment.strategies)
|
||||||
.find((strategy) => strategy.id === strategyId);
|
.find((strategy) => strategy.id === strategyId);
|
||||||
setStrategy((prev) => ({ ...prev, ...savedStrategy }));
|
|
||||||
|
const constraintsWithId = addIdSymbolToConstraints(savedStrategy);
|
||||||
|
|
||||||
|
const formattedStrategy = {
|
||||||
|
...savedStrategy,
|
||||||
|
constraints: constraintsWithId,
|
||||||
|
};
|
||||||
|
|
||||||
|
setStrategy((prev) => ({ ...prev, ...formattedStrategy }));
|
||||||
setPreviousTitle(savedStrategy?.title || '');
|
setPreviousTitle(savedStrategy?.title || '');
|
||||||
}, [strategyId, data]);
|
}, [strategyId, data]);
|
||||||
|
|
||||||
@ -235,7 +254,7 @@ export const FeatureStrategyEdit = () => {
|
|||||||
return (
|
return (
|
||||||
<FormTemplate
|
<FormTemplate
|
||||||
modal
|
modal
|
||||||
title={formatStrategyName(strategy.name ?? '')}
|
disablePadding
|
||||||
description={featureStrategyHelp}
|
description={featureStrategyHelp}
|
||||||
documentationLink={featureStrategyDocsLink}
|
documentationLink={featureStrategyDocsLink}
|
||||||
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
documentationLinkLabel={featureStrategyDocsLinkLabel}
|
||||||
@ -264,6 +283,16 @@ export const FeatureStrategyEdit = () => {
|
|||||||
permission={UPDATE_FEATURE_STRATEGY}
|
permission={UPDATE_FEATURE_STRATEGY}
|
||||||
errors={errors}
|
errors={errors}
|
||||||
isChangeRequest={isChangeRequestConfigured(environmentId)}
|
isChangeRequest={isChangeRequestConfigured(environmentId)}
|
||||||
|
tab={tab}
|
||||||
|
setTab={setTab}
|
||||||
|
StrategyVariants={
|
||||||
|
<NewStrategyVariants
|
||||||
|
strategy={strategy}
|
||||||
|
setStrategy={setStrategy}
|
||||||
|
environment={environmentId}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
{staleDataNotification}
|
{staleDataNotification}
|
||||||
</FormTemplate>
|
</FormTemplate>
|
||||||
|
@ -1,6 +1,15 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Alert, Button, styled } from '@mui/material';
|
import {
|
||||||
|
Alert,
|
||||||
|
Button,
|
||||||
|
styled,
|
||||||
|
Tabs,
|
||||||
|
Tab,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material';
|
||||||
import {
|
import {
|
||||||
IFeatureStrategy,
|
IFeatureStrategy,
|
||||||
IFeatureStrategyParameters,
|
IFeatureStrategyParameters,
|
||||||
@ -31,8 +40,12 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ
|
|||||||
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
|
import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess';
|
||||||
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
|
import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle';
|
||||||
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
|
import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled';
|
||||||
import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants';
|
|
||||||
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
|
||||||
|
import { formatStrategyName } from 'utils/strategyNames';
|
||||||
|
import { Badge } from 'component/common/Badge/Badge';
|
||||||
|
import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon';
|
||||||
|
import { useFeedback } from 'component/feedbackNew/useFeedback';
|
||||||
|
import { useUiFlag } from 'hooks/useUiFlag';
|
||||||
|
|
||||||
interface IFeatureStrategyFormProps {
|
interface IFeatureStrategyFormProps {
|
||||||
feature: IFeatureToggle;
|
feature: IFeatureToggle;
|
||||||
@ -42,7 +55,7 @@ interface IFeatureStrategyFormProps {
|
|||||||
onSubmit: () => void;
|
onSubmit: () => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
isChangeRequest?: boolean;
|
isChangeRequest: boolean;
|
||||||
strategy: Partial<IFeatureStrategy>;
|
strategy: Partial<IFeatureStrategy>;
|
||||||
setStrategy: React.Dispatch<
|
setStrategy: React.Dispatch<
|
||||||
React.SetStateAction<Partial<IFeatureStrategy>>
|
React.SetStateAction<Partial<IFeatureStrategy>>
|
||||||
@ -50,28 +63,131 @@ interface IFeatureStrategyFormProps {
|
|||||||
segments: ISegment[];
|
segments: ISegment[];
|
||||||
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
|
setSegments: React.Dispatch<React.SetStateAction<ISegment[]>>;
|
||||||
errors: IFormErrors;
|
errors: IFormErrors;
|
||||||
|
tab: number;
|
||||||
|
setTab: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
StrategyVariants: JSX.Element;
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledForm = styled('form')(({ theme }) => ({
|
const StyledDividerContent = styled(Box)(({ theme }) => ({
|
||||||
display: 'grid',
|
padding: theme.spacing(0.75, 1),
|
||||||
gap: theme.spacing(2),
|
color: theme.palette.text.primary,
|
||||||
|
fontSize: theme.fontSizes.smallerBody,
|
||||||
|
backgroundColor: theme.palette.background.elevation2,
|
||||||
|
borderRadius: theme.shape.borderRadius,
|
||||||
|
width: '45px',
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-10px',
|
||||||
|
left: 'calc(50% - 45px)',
|
||||||
|
lineHeight: 1,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledHr = styled('hr')(({ theme }) => ({
|
const StyledForm = styled('form')(({ theme }) => ({
|
||||||
width: '100%',
|
position: 'relative',
|
||||||
height: '1px',
|
display: 'flex',
|
||||||
margin: theme.spacing(2, 0),
|
flexDirection: 'column',
|
||||||
border: 'none',
|
gap: theme.spacing(2),
|
||||||
background: theme.palette.background.elevation2,
|
padding: theme.spacing(6),
|
||||||
|
paddingBottom: theme.spacing(12),
|
||||||
|
paddingTop: theme.spacing(4),
|
||||||
|
overflow: 'auto',
|
||||||
|
height: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTitle = styled('h1')(({ theme }) => ({
|
||||||
|
fontWeight: 'normal',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingTop: theme.spacing(2),
|
||||||
|
paddingBottom: theme.spacing(2),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const StyledButtons = styled('div')(({ theme }) => ({
|
const StyledButtons = styled('div')(({ theme }) => ({
|
||||||
|
bottom: 0,
|
||||||
|
right: 0,
|
||||||
|
left: 0,
|
||||||
|
position: 'absolute',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
paddingRight: theme.spacing(6),
|
||||||
|
paddingLeft: theme.spacing(6),
|
||||||
|
backgroundColor: theme.palette.background.paper,
|
||||||
justifyContent: 'end',
|
justifyContent: 'end',
|
||||||
gap: theme.spacing(2),
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
paddingBottom: theme.spacing(10),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const StyledTabs = styled(Tabs)(({ theme }) => ({
|
||||||
|
borderTop: `1px solid ${theme.palette.divider}`,
|
||||||
|
borderBottom: `1px solid ${theme.palette.divider}`,
|
||||||
|
paddingLeft: theme.spacing(6),
|
||||||
|
paddingRight: theme.spacing(6),
|
||||||
|
minHeight: '60px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
position: 'relative',
|
||||||
|
marginTop: theme.spacing(3.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledDivider = styled(Divider)(({ theme }) => ({
|
||||||
|
width: '100%',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTargetingHeader = styled('div')(({ theme }) => ({
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
marginTop: theme.spacing(1.5),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledHeaderBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
paddingLeft: theme.spacing(6),
|
||||||
|
paddingRight: theme.spacing(6),
|
||||||
|
paddingTop: theme.spacing(2),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledAlertBox = styled(Box)(({ theme }) => ({
|
||||||
|
paddingLeft: theme.spacing(6),
|
||||||
|
paddingRight: theme.spacing(6),
|
||||||
|
'& > *': {
|
||||||
|
marginTop: theme.spacing(2),
|
||||||
|
marginBottom: theme.spacing(2),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledEnvironmentBox = styled(Box)(({ theme }) => ({
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const EnvironmentIconBox = styled(Box)(({ theme }) => ({
|
||||||
|
transform: 'scale(0.9)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const EnvironmentTypography = styled(Typography)<{ enabled: boolean }>(
|
||||||
|
({ theme, enabled }) => ({
|
||||||
|
fontWeight: enabled ? 'bold' : 'normal',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({
|
||||||
|
marginRight: theme.spacing(0.5),
|
||||||
|
color: theme.palette.text.secondary,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledTab = styled(Tab)(({ theme }) => ({
|
||||||
|
width: '100px',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const StyledBadge = styled(Badge)(({ theme }) => ({
|
||||||
|
marginLeft: theme.spacing(1),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const feedbackCategory = 'newStrategyForm';
|
||||||
|
|
||||||
export const FeatureStrategyForm = ({
|
export const FeatureStrategyForm = ({
|
||||||
projectId,
|
projectId,
|
||||||
feature,
|
feature,
|
||||||
@ -86,7 +202,14 @@ export const FeatureStrategyForm = ({
|
|||||||
setSegments,
|
setSegments,
|
||||||
errors,
|
errors,
|
||||||
isChangeRequest,
|
isChangeRequest,
|
||||||
|
tab,
|
||||||
|
setTab,
|
||||||
|
StrategyVariants,
|
||||||
}: IFeatureStrategyFormProps) => {
|
}: IFeatureStrategyFormProps) => {
|
||||||
|
const { openFeedback, hasSubmittedFeedback } = useFeedback(
|
||||||
|
feedbackCategory,
|
||||||
|
'manual',
|
||||||
|
);
|
||||||
const { trackEvent } = usePlausibleTracker();
|
const { trackEvent } = usePlausibleTracker();
|
||||||
const [showProdGuard, setShowProdGuard] = useState(false);
|
const [showProdGuard, setShowProdGuard] = useState(false);
|
||||||
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
|
const hasValidConstraints = useConstraintsValidation(strategy.constraints);
|
||||||
@ -97,6 +220,39 @@ export const FeatureStrategyForm = ({
|
|||||||
environmentId,
|
environmentId,
|
||||||
);
|
);
|
||||||
const { strategyDefinition } = useStrategy(strategy?.name);
|
const { strategyDefinition } = useStrategy(strategy?.name);
|
||||||
|
const newStrategyConfigurationFeedback = useUiFlag(
|
||||||
|
'newStrategyConfigurationFeedback',
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
trackEvent('new-strategy-form', {
|
||||||
|
props: {
|
||||||
|
eventType: 'seen',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const stickiness =
|
||||||
|
strategy?.parameters && 'stickiness' in strategy?.parameters
|
||||||
|
? String(strategy.parameters.stickiness)
|
||||||
|
: 'default';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setStrategy((prev) => ({
|
||||||
|
...prev,
|
||||||
|
variants: (strategy.variants || []).map((variant) => ({
|
||||||
|
stickiness,
|
||||||
|
name: variant.name,
|
||||||
|
weight: variant.weight,
|
||||||
|
payload: variant.payload,
|
||||||
|
weightType: variant.weightType,
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
}, [stickiness, JSON.stringify(strategy.variants)]);
|
||||||
|
|
||||||
|
const foundEnvironment = feature.environments.find(
|
||||||
|
(environment) => environment.name === environmentId,
|
||||||
|
);
|
||||||
|
|
||||||
const { data } = usePendingChangeRequests(feature.project);
|
const { data } = usePendingChangeRequests(feature.project);
|
||||||
const { changeRequestInReviewOrApproved, alert } =
|
const { changeRequestInReviewOrApproved, alert } =
|
||||||
@ -111,11 +267,7 @@ export const FeatureStrategyForm = ({
|
|||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const {
|
const { error: uiConfigError, loading: uiConfigLoading } = useUiConfig();
|
||||||
uiConfig,
|
|
||||||
error: uiConfigError,
|
|
||||||
loading: uiConfigLoading,
|
|
||||||
} = useUiConfig();
|
|
||||||
|
|
||||||
if (uiConfigError) {
|
if (uiConfigError) {
|
||||||
throw uiConfigError;
|
throw uiConfigError;
|
||||||
@ -159,6 +311,15 @@ export const FeatureStrategyForm = ({
|
|||||||
navigate(formatFeaturePath(feature.project, feature.name));
|
navigate(formatFeaturePath(feature.project, feature.name));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createFeedbackContext = () => {
|
||||||
|
openFeedback({
|
||||||
|
title: 'How easy was it to work with the new strategy form?',
|
||||||
|
positiveLabel: 'What do you like most about the new strategy form?',
|
||||||
|
areasForImprovementsLabel:
|
||||||
|
'What should be improved the new strategy form?',
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const onSubmitWithValidation = async (event: React.FormEvent) => {
|
const onSubmitWithValidation = async (event: React.FormEvent) => {
|
||||||
if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) {
|
if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) {
|
||||||
trackEvent('strategy-variants', {
|
trackEvent('strategy-variants', {
|
||||||
@ -172,145 +333,262 @@ export const FeatureStrategyForm = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
trackEvent('new-strategy-form', {
|
||||||
|
props: {
|
||||||
|
eventType: 'submitted',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
if (enableProdGuard && !isChangeRequest) {
|
if (enableProdGuard && !isChangeRequest) {
|
||||||
setShowProdGuard(true);
|
setShowProdGuard(true);
|
||||||
} else {
|
} else {
|
||||||
onSubmit();
|
await onSubmitWithFeedback();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onSubmitWithFeedback = async () => {
|
||||||
|
try {
|
||||||
|
await onSubmit();
|
||||||
|
|
||||||
|
if (newStrategyConfigurationFeedback && !hasSubmittedFeedback) {
|
||||||
|
createFeedbackContext();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => {
|
||||||
|
setTab(newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTargetingCount = () => {
|
||||||
|
const constraintCount = strategy.constraints?.length || 0;
|
||||||
|
const segmentCount = segments.length || 0;
|
||||||
|
|
||||||
|
return constraintCount + segmentCount;
|
||||||
|
};
|
||||||
|
|
||||||
|
const showVariants =
|
||||||
|
strategy.parameters && 'stickiness' in strategy.parameters;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledForm onSubmit={onSubmitWithValidation}>
|
<>
|
||||||
<ConditionallyRender
|
<StyledHeaderBox>
|
||||||
condition={hasChangeRequestInReviewForEnvironment}
|
<StyledTitle>
|
||||||
show={alert}
|
{formatStrategyName(strategy.name || '')}
|
||||||
elseShow={
|
<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
|
<ConditionallyRender
|
||||||
condition={Boolean(isChangeRequest)}
|
condition={Boolean(isChangeRequest)}
|
||||||
show={
|
show={
|
||||||
<FeatureStrategyChangeRequestAlert
|
<Alert severity='success'>
|
||||||
environment={environmentId}
|
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>
|
||||||
<FeatureStrategyEnabled
|
|
||||||
projectId={feature.project}
|
<StyledTabs value={tab} onChange={handleChange}>
|
||||||
featureId={feature.name}
|
<StyledTab label='General' />
|
||||||
environmentId={environmentId}
|
<Tab
|
||||||
>
|
data-testid='STRATEGY_TARGETING_TAB'
|
||||||
<ConditionallyRender
|
label={
|
||||||
condition={Boolean(isChangeRequest)}
|
<Typography>
|
||||||
show={
|
Targeting
|
||||||
<Alert severity='success'>
|
<StyledBadge>{getTargetingCount()}</StyledBadge>
|
||||||
This feature toggle is currently enabled in the{' '}
|
</Typography>
|
||||||
<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>
|
{showVariants && (
|
||||||
<StyledHr />
|
<Tab
|
||||||
<FeatureStrategyTitle
|
data-testid='STRATEGY_VARIANTS_TAB'
|
||||||
title={strategy.title || ''}
|
label={
|
||||||
setTitle={(title) => {
|
<Typography>
|
||||||
setStrategy((prev) => ({
|
Variants
|
||||||
...prev,
|
<StyledBadge>
|
||||||
title,
|
{strategy.variants?.length || 0}
|
||||||
}));
|
</StyledBadge>
|
||||||
}}
|
</Typography>
|
||||||
/>
|
}
|
||||||
<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}
|
|
||||||
/>
|
/>
|
||||||
}
|
)}
|
||||||
/>
|
</StyledTabs>
|
||||||
<StyledHr />
|
<StyledForm onSubmit={onSubmitWithValidation}>
|
||||||
<FeatureStrategyEnabledDisabled
|
<ConditionallyRender
|
||||||
enabled={!strategy?.disabled}
|
condition={tab === 0}
|
||||||
onToggleEnabled={() =>
|
show={
|
||||||
setStrategy((strategyState) => ({
|
<>
|
||||||
...strategyState,
|
<FeatureStrategyTitle
|
||||||
disabled: !strategyState.disabled,
|
title={strategy.title || ''}
|
||||||
}))
|
setTitle={(title) => {
|
||||||
}
|
setStrategy((prev) => ({
|
||||||
/>
|
...prev,
|
||||||
<StyledHr />
|
title,
|
||||||
<StyledButtons>
|
}));
|
||||||
<PermissionButton
|
}}
|
||||||
permission={permission}
|
/>
|
||||||
projectId={feature.project}
|
|
||||||
environmentId={environmentId}
|
<FeatureStrategyEnabledDisabled
|
||||||
variant='contained'
|
enabled={!strategy?.disabled}
|
||||||
color='primary'
|
onToggleEnabled={() =>
|
||||||
type='submit'
|
setStrategy((strategyState) => ({
|
||||||
disabled={
|
...strategyState,
|
||||||
loading ||
|
disabled: !strategyState.disabled,
|
||||||
!hasValidConstraints ||
|
}))
|
||||||
errors.hasFormErrors()
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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 FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData';
|
||||||
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
|
import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments';
|
||||||
import { Route, Routes, useNavigate } from 'react-router-dom';
|
import { Route, Routes, useNavigate } from 'react-router-dom';
|
||||||
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
|
||||||
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
import { SidebarModal } from 'component/common/SidebarModal/SidebarModal';
|
||||||
import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||||
@ -9,9 +8,8 @@ import { usePageTitle } from 'hooks/usePageTitle';
|
|||||||
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
|
import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel';
|
||||||
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
|
import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments';
|
||||||
import { styled } from '@mui/material';
|
import { styled } from '@mui/material';
|
||||||
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
|
import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate';
|
||||||
import { NewFeatureStrategyCreate } from 'component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate';
|
import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit';
|
||||||
import { NewFeatureStrategyEdit } from 'component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit';
|
|
||||||
|
|
||||||
const StyledContainer = styled('div')(({ theme }) => ({
|
const StyledContainer = styled('div')(({ theme }) => ({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@ -61,7 +59,7 @@ const FeatureOverview = () => {
|
|||||||
onClose={onSidebarClose}
|
onClose={onSidebarClose}
|
||||||
open
|
open
|
||||||
>
|
>
|
||||||
<NewFeatureStrategyCreate />
|
<FeatureStrategyCreate />
|
||||||
</SidebarModal>
|
</SidebarModal>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@ -73,7 +71,7 @@ const FeatureOverview = () => {
|
|||||||
onClose={onSidebarClose}
|
onClose={onSidebarClose}
|
||||||
open
|
open
|
||||||
>
|
>
|
||||||
<NewFeatureStrategyEdit />
|
<FeatureStrategyEdit />
|
||||||
</SidebarModal>
|
</SidebarModal>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
Loading…
Reference in New Issue
Block a user