From 20a9e1d72504601f6c0fbe6cfc54827e6d650516 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Tue, 27 Feb 2024 12:22:47 +0100 Subject: [PATCH] refactor: remove unused components and rename new (#6357) --- .../Changes/Change/ChangeActions.tsx | 4 +- .../Changes/Change/EditChange.tsx | 34 +- .../Changes/Change/NewEditChange.tsx | 227 ------- .../FeatureStrategyCreate.test.tsx | 459 +++++++++++++- .../FeatureStrategyCreate.tsx | 18 +- .../featureStrategyFormTestSetup.ts | 0 .../FeatureStrategyEdit.test.tsx | 185 +++++- .../FeatureStrategyEdit.tsx | 37 +- .../FeatureStrategyForm.tsx | 564 ++++++++++++----- .../NewFeatureStrategyForm.tsx | 594 ------------------ .../NewFeatureStrategyCreate.test.tsx | 447 ------------- .../NewFeatureStrategyCreate.tsx | 260 -------- .../NewFeatureStrategyEdit.test.tsx | 173 ----- .../NewFeatureStrategyEdit.tsx | 373 ----------- .../change-request-conflict-data.test.ts | 113 ---- .../change-request-conflict-data.ts | 55 -- .../FeatureOverview/FeatureOverview.tsx | 10 +- 17 files changed, 1095 insertions(+), 2458 deletions(-) delete mode 100644 frontend/src/component/changeRequest/ChangeRequest/Changes/Change/NewEditChange.tsx rename frontend/src/component/feature/FeatureStrategy/{NewFeatureStrategyCreate => FeatureStrategyCreate}/featureStrategyFormTestSetup.ts (100%) delete mode 100644 frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm.tsx delete mode 100644 frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx delete mode 100644 frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx delete mode 100644 frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.test.tsx delete mode 100644 frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx delete mode 100644 frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.test.ts delete mode 100644 frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.ts diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx index 51ea30be6e..2c97eee66e 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/ChangeActions.tsx @@ -24,7 +24,7 @@ import { Typography, } from '@mui/material'; import { Delete, Edit, MoreVert } from '@mui/icons-material'; -import { NewEditChange } from './NewEditChange'; +import { EditChange } from './EditChange'; const useShowActions = (changeRequest: ChangeRequestType, change: IChange) => { const { isChangeRequestConfigured } = useChangeRequestsEnabled( @@ -149,7 +149,7 @@ export const ChangeActions: FC<{ Edit change - void; } +const addIdSymbolToConstraints = ( + strategy?: ChangeRequestAddStrategy | ChangeRequestEditStrategy, +) => { + if (!strategy) return; + + return strategy?.constraints.map((constraint) => { + return { ...constraint, [constraintId]: uuidv4() }; + }); +}; + export const EditChange = ({ change, changeRequestId, @@ -47,9 +60,12 @@ export const EditChange = ({ const { editChange } = useChangeRequestApi(); const [tab, setTab] = useState(0); - const [strategy, setStrategy] = useState>( - change.payload, - ); + const constraintsWithId = addIdSymbolToConstraints(change.payload); + + const [strategy, setStrategy] = useState>({ + ...change.payload, + constraints: constraintsWithId, + }); const { segments: allSegments } = useSegments(); const strategySegments = (allSegments || []).filter((segment) => { @@ -134,7 +150,7 @@ export const EditChange = ({ > - 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>({ - ...change.payload, - constraints: constraintsWithId, - }); - - const { segments: allSegments } = useSegments(); - const strategySegments = (allSegments || []).filter((segment) => { - return change.payload.segments?.includes(segment.id); - }); - - const [segments, setSegments] = useState(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(feature); - - const { data, staleDataNotification, forceRefreshCache } = - useCollaborateData( - { - 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 ( - { - e.stopPropagation(); - }} - > - - formatUpdateStrategyApiCode( - projectId, - changeRequestId, - change.id, - payload, - unleashUrl, - ) - } - > - - } - /> - - {staleDataNotification} - - - ); -}; - -export const formatUpdateStrategyApiCode = ( - projectId: string, - changeRequestId: number, - changeId: number, - strategy: Partial, - 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'; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx index fee20b1fe5..3aa583f97d 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.test.tsx @@ -1,20 +1,447 @@ import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; +import { screen, fireEvent, waitFor } from '@testing-library/react'; +import { render } from 'utils/testRenderer'; +import { Route, Routes } from 'react-router-dom'; -test('formatAddStrategyApiCode', () => { - expect( - formatAddStrategyApiCode( - 'projectId', - 'featureId', - 'environmentId', - { id: 'strategyId' }, - 'unleashUrl', +import { + CREATE_FEATURE_STRATEGY, + UPDATE_FEATURE_ENVIRONMENT_VARIANTS, + UPDATE_FEATURE_STRATEGY, +} from 'component/providers/AccessProvider/permissions'; +import { FeatureStrategyCreate } from './FeatureStrategyCreate'; +import { + setupProjectEndpoint, + setupSegmentsEndpoint, + setupStrategyEndpoint, + setupFeaturesEndpoint, + setupUiConfigEndpoint, + setupContextEndpoint, +} from './featureStrategyFormTestSetup'; + +const featureName = 'my-new-feature'; + +const setupComponent = () => { + return { + wrapper: render( + + } + /> + , + { + route: `/projects/default/features/${featureName}/strategies/create?environmentId=development&strategyName=flexibleRollout&defaultStrategy=true`, + permissions: [ + { + permission: CREATE_FEATURE_STRATEGY, + project: 'default', + environment: 'development', + }, + { + permission: UPDATE_FEATURE_STRATEGY, + project: 'default', + environment: 'development', + }, + { + permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS, + project: 'default', + environment: 'development', + }, + ], + }, ), - ).toMatchInlineSnapshot(` - "curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\ - --header 'Authorization: INSERT_API_KEY' \\ - --header 'Content-Type: application/json' \\ - --data-raw '{ - "id": "strategyId" - }'" - `); + expectedSegmentName: 'test', + expectedGroupId: 'newGroupId', + expectedVariantName: 'Blue', + expectedSliderValue: '50', + expectedConstraintValue: 'new value', + expectedMultipleValues: '1234,4141,51515', + }; +}; + +beforeEach(() => { + setupProjectEndpoint(); + setupSegmentsEndpoint(); + setupStrategyEndpoint(); + setupFeaturesEndpoint(featureName); + setupUiConfigEndpoint(); + setupContextEndpoint(); +}); + +describe('NewFeatureStrategyCreate', () => { + test('formatAddStrategyApiCode', () => { + expect( + formatAddStrategyApiCode( + 'projectId', + 'featureId', + 'environmentId', + { id: 'strategyId' }, + 'unleashUrl', + ), + ).toMatchInlineSnapshot(` + "curl --location --request POST 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies' \\ + --header 'Authorization: INSERT_API_KEY' \\ + --header 'Content-Type: application/json' \\ + --data-raw '{ + "id": "strategyId" + }'" + `); + }); + + test('should navigate tabs', async () => { + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const slider = await screen.findByRole('slider', { name: /rollout/i }); + expect(slider).toHaveValue('100'); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const segmentsEl = await screen.findByText('Segments'); + expect(segmentsEl).toBeInTheDocument(); + + const variantEl = screen.getByText('Variants'); + fireEvent.click(variantEl); + + const addVariantEl = await screen.findByText('Add variant'); + expect(addVariantEl).toBeInTheDocument(); + }); + + test('should change general settings', async () => { + const { expectedGroupId, expectedSliderValue } = setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const slider = await screen.findByRole('slider', { name: /rollout/i }); + const groupIdInput = await screen.getByLabelText('groupId'); + + expect(slider).toHaveValue('100'); + expect(groupIdInput).toHaveValue(featureName); + + fireEvent.change(slider, { target: { value: expectedSliderValue } }); + fireEvent.change(groupIdInput, { target: { value: expectedGroupId } }); + + expect(slider).toHaveValue(expectedSliderValue); + expect(groupIdInput).toHaveValue(expectedGroupId); + }); + + test('should change targeting settings', async () => { + const { expectedConstraintValue, expectedSegmentName } = + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + fireEvent.click(addConstraintEl); + + const inputElement = screen.getByPlaceholderText( + 'value1, value2, value3...', + ); + fireEvent.change(inputElement, { + target: { value: expectedConstraintValue }, + }); + + const addValueEl = screen.getByText('Add values'); + fireEvent.click(addValueEl); + + const doneEl = screen.getByText('Done'); + fireEvent.click(doneEl); + + const selectElement = screen.getByPlaceholderText('Select segments'); + fireEvent.mouseDown(selectElement); + + const optionElement = await screen.findByText(expectedSegmentName); + fireEvent.click(optionElement); + + expect(screen.getByText(expectedSegmentName)).toBeInTheDocument(); + expect(screen.getByText(expectedConstraintValue)).toBeInTheDocument(); + }); + + test('should change variants settings', async () => { + const { expectedVariantName } = setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const variantsEl = screen.getByText('Variants'); + fireEvent.click(variantsEl); + + const addVariantEl = await screen.findByText('Add variant'); + fireEvent.click(addVariantEl); + + const inputElement = screen.getAllByRole('textbox')[0]; + fireEvent.change(inputElement, { + target: { value: expectedVariantName }, + }); + + expect(screen.getByText(expectedVariantName)).toBeInTheDocument(); + + const generalSettingsEl = screen.getByText('General'); + fireEvent.click(generalSettingsEl); + + await waitFor(() => { + const codeSnippet = document.querySelector('pre')?.innerHTML; + const variantNameMatches = ( + codeSnippet!.match(new RegExp(expectedVariantName, 'g')) || [] + ).length; + const metaDataMatches = (codeSnippet!.match(/isValid/g) || []) + .length; + expect(variantNameMatches).toBe(1); + expect(metaDataMatches).toBe(0); + }); + }); + + test('should change variant name after changing tab', async () => { + const { expectedVariantName } = setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const variantsEl = screen.getByText('Variants'); + fireEvent.click(variantsEl); + + const addVariantEl = await screen.findByText('Add variant'); + fireEvent.click(addVariantEl); + + const inputElement = screen.getAllByRole('textbox')[0]; + fireEvent.change(inputElement, { + target: { value: expectedVariantName }, + }); + + const targetingEl = await screen.findByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + expect(addConstraintEl).toBeInTheDocument(); + + fireEvent.click(variantsEl); + const inputElement2 = screen.getAllByRole('textbox')[0]; + + expect(inputElement2).not.toBeDisabled(); + }); + + test('should remove empty variants when changing tabs', async () => { + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const variantsEl = screen.getByText('Variants'); + fireEvent.click(variantsEl); + + const addVariantEl = await screen.findByText('Add variant'); + fireEvent.click(addVariantEl); + + const variants = screen.queryAllByTestId('VARIANT'); + expect(variants.length).toBe(1); + + const targetingEl = await screen.findByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + expect(addConstraintEl).toBeInTheDocument(); + + fireEvent.click(variantsEl); + + const variants2 = screen.queryAllByTestId('VARIANT'); + expect(variants2.length).toBe(0); + }); + + test('Should autosave constraint settings when navigating between tabs', async () => { + const { expectedMultipleValues } = setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + fireEvent.click(addConstraintEl); + + const inputElement = screen.getByPlaceholderText( + 'value1, value2, value3...', + ); + fireEvent.change(inputElement, { + target: { value: expectedMultipleValues }, + }); + + const addValueEl = await screen.findByText('Add values'); + fireEvent.click(addValueEl); + + const variantsEl = screen.getByText('Variants'); + fireEvent.click(variantsEl); + + fireEvent.click(targetingEl); + + const values = expectedMultipleValues.split(','); + + expect(screen.getByText(values[0])).toBeInTheDocument(); + expect(screen.getByText(values[1])).toBeInTheDocument(); + expect(screen.getByText(values[2])).toBeInTheDocument(); + }); + + test('Should update multiple constraints correctly', async () => { + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + fireEvent.click(addConstraintEl); + fireEvent.click(addConstraintEl); + fireEvent.click(addConstraintEl); + + const inputElements = screen.getAllByPlaceholderText( + 'value1, value2, value3...', + ); + + fireEvent.change(inputElements[0], { + target: { value: '123' }, + }); + fireEvent.change(inputElements[1], { + target: { value: '456' }, + }); + fireEvent.change(inputElements[2], { + target: { value: '789' }, + }); + + const addValueEls = await screen.findAllByText('Add values'); + fireEvent.click(addValueEls[0]); + fireEvent.click(addValueEls[1]); + fireEvent.click(addValueEls[2]); + + expect(screen.queryByText('123')).toBeInTheDocument(); + const deleteBtns = await screen.findAllByTestId('CancelIcon'); + fireEvent.click(deleteBtns[0]); + + expect(screen.queryByText('123')).not.toBeInTheDocument(); + expect(screen.queryByText('456')).toBeInTheDocument(); + expect(screen.queryByText('789')).toBeInTheDocument(); + }); + + test('Should update multiple constraints with the correct react key', async () => { + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + fireEvent.click(addConstraintEl); + fireEvent.click(addConstraintEl); + fireEvent.click(addConstraintEl); + + const inputElements = screen.getAllByPlaceholderText( + 'value1, value2, value3...', + ); + + fireEvent.change(inputElements[0], { + target: { value: '123' }, + }); + fireEvent.change(inputElements[1], { + target: { value: '456' }, + }); + fireEvent.change(inputElements[2], { + target: { value: '789' }, + }); + + const addValueEls = await screen.findAllByText('Add values'); + fireEvent.click(addValueEls[0]); + fireEvent.click(addValueEls[1]); + fireEvent.click(addValueEls[2]); + + expect(screen.queryByText('123')).toBeInTheDocument(); + + const deleteBtns = screen.getAllByTestId('DELETE_CONSTRAINT_BUTTON'); + fireEvent.click(deleteBtns[0]); + + const inputElements2 = screen.getAllByPlaceholderText( + 'value1, value2, value3...', + ); + + fireEvent.change(inputElements2[0], { + target: { value: '666' }, + }); + const addValueEls2 = screen.getAllByText('Add values'); + fireEvent.click(addValueEls2[0]); + + expect(screen.queryByText('123')).not.toBeInTheDocument(); + expect(screen.queryByText('456')).toBeInTheDocument(); + expect(screen.queryByText('789')).toBeInTheDocument(); + }); + + test('Should undo changes made to constraints', async () => { + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + fireEvent.click(addConstraintEl); + + const inputEl = screen.getByPlaceholderText( + 'value1, value2, value3...', + ); + + fireEvent.change(inputEl, { + target: { value: '6, 7, 8' }, + }); + + const addBtn = await screen.findByText('Add values'); + addBtn.click(); + + expect(screen.queryByText('6')).toBeInTheDocument(); + expect(screen.queryByText('7')).toBeInTheDocument(); + expect(screen.queryByText('8')).toBeInTheDocument(); + + const undoBtn = await screen.findByTestId( + 'UNDO_CONSTRAINT_CHANGE_BUTTON', + ); + undoBtn.click(); + + expect(screen.queryByText('6')).not.toBeInTheDocument(); + expect(screen.queryByText('7')).not.toBeInTheDocument(); + expect(screen.queryByText('8')).not.toBeInTheDocument(); + }); + + test('Should remove constraint when no valid values are set and moving between tabs', async () => { + setupComponent(); + + const titleEl = await screen.findByText('Gradual rollout'); + expect(titleEl).toBeInTheDocument(); + + const targetingEl = screen.getByText('Targeting'); + fireEvent.click(targetingEl); + + const addConstraintEl = await screen.findByText('Add constraint'); + fireEvent.click(addConstraintEl); + + const variantsEl = screen.getByText('Variants'); + fireEvent.click(variantsEl); + fireEvent.click(targetingEl); + + const seconAddConstraintEl = await screen.findByText('Add constraint'); + + expect(seconAddConstraintEl).toBeInTheDocument(); + expect(screen.queryByText('appName')).not.toBeInTheDocument(); + }); }); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx index 07ab29a349..fa0590cb45 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate.tsx @@ -1,7 +1,6 @@ import { useEffect, useRef, useState } from 'react'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; -import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import useFeatureStrategyApi from 'hooks/api/actions/useFeatureStrategyApi/useFeatureStrategyApi'; @@ -18,7 +17,6 @@ import { } from '../FeatureStrategyEdit/FeatureStrategyEdit'; import { CREATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { ISegment } from 'interfaces/segment'; -import { formatStrategyName } from 'utils/strategyNames'; import { useFormErrors } from 'hooks/useFormErrors'; import { createFeatureStrategy } from 'utils/createFeatureStrategy'; import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; @@ -33,8 +31,11 @@ import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import useQueryParams from 'hooks/useQueryParams'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; import { useDefaultStrategy } from '../../../project/Project/ProjectSettings/ProjectDefaultStrategySettings/ProjectEnvironment/ProjectEnvironmentDefaultStrategy/EditDefaultStrategy'; +import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm'; +import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants'; export const FeatureStrategyCreate = () => { + const [tab, setTab] = useState(0); const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const environmentId = useRequiredQueryParam('environmentId'); @@ -178,10 +179,10 @@ export const FeatureStrategyCreate = () => { return ( formatAddStrategyApiCode( projectId, @@ -205,6 +206,17 @@ export const FeatureStrategyCreate = () => { permission={CREATE_FEATURE_STRATEGY} errors={errors} isChangeRequest={isChangeRequestConfigured(environmentId)} + tab={tab} + setTab={setTab} + StrategyVariants={ + + } /> {staleDataNotification} diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/featureStrategyFormTestSetup.ts b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/featureStrategyFormTestSetup.ts similarity index 100% rename from frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/featureStrategyFormTestSetup.ts rename to frontend/src/component/feature/FeatureStrategy/FeatureStrategyCreate/featureStrategyFormTestSetup.ts diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx index 02c20c547e..19a542924f 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.test.tsx @@ -1,42 +1,112 @@ import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { IFeatureStrategy, IStrategy } from 'interfaces/strategy'; +import { screen, waitFor, fireEvent } from '@testing-library/react'; +import { render } from 'utils/testRenderer'; +import { Route, Routes } from 'react-router-dom'; -test('formatUpdateStrategyApiCode', () => { - const strategy: IFeatureStrategy = { - id: 'a', - name: 'b', - parameters: { - c: 1, - b: 2, - a: 3, - }, - constraints: [], - }; +import { + CREATE_FEATURE_STRATEGY, + UPDATE_FEATURE_ENVIRONMENT_VARIANTS, + UPDATE_FEATURE_STRATEGY, +} from 'component/providers/AccessProvider/permissions'; +import { FeatureStrategyEdit } from './FeatureStrategyEdit'; +import { + setupContextEndpoint, + setupFeaturesEndpoint, + setupProjectEndpoint, + setupSegmentsEndpoint, + setupStrategyEndpoint, + setupUiConfigEndpoint, +} from '../FeatureStrategyCreate/featureStrategyFormTestSetup'; +import userEvent from '@testing-library/user-event'; - const strategyDefinition: IStrategy = { - name: 'c', - displayName: 'd', - description: 'e', - editable: false, - deprecated: false, - parameters: [ - { name: 'a', description: '', type: '', required: false }, - { name: 'b', description: '', type: '', required: false }, - { name: 'c', description: '', type: '', required: false }, - ], - }; +const featureName = 'my-new-feature'; +const variantName = 'Blue'; - expect( - formatUpdateStrategyApiCode( - 'projectId', - 'featureId', - 'environmentId', - 'strategyId', - strategy, - strategyDefinition, - 'unleashUrl', +const setupComponent = () => { + return { + wrapper: render( + + } + /> + , + { + route: `/projects/default/features/${featureName}/strategies/edit?environmentId=development&strategyId=1`, + permissions: [ + { + permission: CREATE_FEATURE_STRATEGY, + project: 'default', + environment: 'development', + }, + { + permission: UPDATE_FEATURE_STRATEGY, + project: 'default', + environment: 'development', + }, + { + permission: UPDATE_FEATURE_ENVIRONMENT_VARIANTS, + project: 'default', + environment: 'development', + }, + ], + }, ), - ).toMatchInlineSnapshot(` + expectedGroupId: 'newGroupId', + expectedVariantName: variantName, + expectedSliderValue: '75', + }; +}; + +beforeEach(() => { + setupProjectEndpoint(); + setupSegmentsEndpoint(); + setupStrategyEndpoint(); + setupFeaturesEndpoint(featureName, variantName); + setupUiConfigEndpoint(); + setupContextEndpoint(); +}); + +describe('NewFeatureStrategyEdit', () => { + test('formatUpdateStrategyApiCode', () => { + const strategy: IFeatureStrategy = { + id: 'a', + name: 'b', + parameters: { + c: 1, + b: 2, + a: 3, + }, + constraints: [], + }; + + const strategyDefinition: IStrategy = { + name: 'c', + displayName: 'd', + description: 'e', + editable: false, + deprecated: false, + parameters: [ + { name: 'a', description: '', type: '', required: false }, + { name: 'b', description: '', type: '', required: false }, + { name: 'c', description: '', type: '', required: false }, + ], + }; + + expect( + formatUpdateStrategyApiCode( + 'projectId', + 'featureId', + 'environmentId', + 'strategyId', + strategy, + strategyDefinition, + 'unleashUrl', + ), + ).toMatchInlineSnapshot(` "curl --location --request PUT 'unleashUrl/api/admin/projects/projectId/features/featureId/environments/environmentId/strategies/strategyId' \\ --header 'Authorization: INSERT_API_KEY' \\ --header 'Content-Type: application/json' \\ @@ -51,4 +121,53 @@ test('formatUpdateStrategyApiCode', () => { "constraints": [] }'" `); + }); + + test('should change general settings', async () => { + const { expectedGroupId, expectedSliderValue, wrapper } = + setupComponent(); + + await waitFor(() => { + expect(screen.getByText('Gradual rollout')).toBeInTheDocument(); + }); + + const slider = await screen.findByRole('slider', { name: /rollout/i }); + const groupIdInput = await screen.getByLabelText('groupId'); + + expect(slider).toHaveValue('50'); + expect(groupIdInput).toHaveValue(featureName); + const defaultStickiness = await screen.findByText('default'); + userEvent.click(defaultStickiness); + const randomStickiness = await screen.findByText('random'); + userEvent.click(randomStickiness); + + fireEvent.change(slider, { target: { value: expectedSliderValue } }); + fireEvent.change(groupIdInput, { target: { value: expectedGroupId } }); + + expect(slider).toHaveValue(expectedSliderValue); + expect(groupIdInput).toHaveValue(expectedGroupId); + + await waitFor(() => { + const codeSnippet = document.querySelector('pre')?.innerHTML; + const count = (codeSnippet!.match(/random/g) || []).length; + // strategy stickiness and variant stickiness + expect(count).toBe(2); + }); + }); + + test('should not change variant names', async () => { + const { expectedVariantName } = setupComponent(); + + await waitFor(() => { + expect(screen.getByText('Gradual rollout')).toBeInTheDocument(); + }); + + const variantsEl = screen.getByText('Variants'); + fireEvent.click(variantsEl); + + expect(screen.getByText(expectedVariantName)).toBeInTheDocument(); + + const inputElement = screen.getAllByRole('textbox')[0]; + expect(inputElement).toBeDisabled(); + }); }); diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx index 5a617ab7db..85d6ee8873 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit.tsx @@ -1,5 +1,4 @@ import { useEffect, useRef, useState } from 'react'; -import { FeatureStrategyForm } from 'component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm'; import FormTemplate from 'component/common/FormTemplate/FormTemplate'; import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig'; import { useRequiredQueryParam } from 'hooks/useRequiredQueryParam'; @@ -16,7 +15,6 @@ import { import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import { ISegment } from 'interfaces/segment'; import { useSegments } from 'hooks/api/getters/useSegments/useSegments'; -import { formatStrategyName } from 'utils/strategyNames'; import { useFormErrors } from 'hooks/useFormErrors'; import { useStrategy } from 'hooks/api/getters/useStrategy/useStrategy'; import { sortStrategyParameters } from 'utils/sortStrategyParameters'; @@ -28,6 +26,10 @@ import { useChangeRequestsEnabled } from 'hooks/useChangeRequestsEnabled'; import { useChangeRequestApi } from 'hooks/api/actions/useChangeRequestApi/useChangeRequestApi'; import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequests/usePendingChangeRequests'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm'; +import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants'; +import { constraintId } from 'component/common/ConstraintAccordion/ConstraintAccordionList/createEmptyConstraint'; +import { v4 as uuidv4 } from 'uuid'; import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy'; import { getChangeRequestConflictCreatedData, @@ -80,11 +82,20 @@ const useTitleTracking = () => { }; }; +const addIdSymbolToConstraints = (strategy?: IFeatureStrategy) => { + if (!strategy) return; + + return strategy?.constraints.map((constraint) => { + return { ...constraint, [constraintId]: uuidv4() }; + }); +}; + export const FeatureStrategyEdit = () => { const projectId = useRequiredPathParam('projectId'); const featureId = useRequiredPathParam('featureId'); const environmentId = useRequiredQueryParam('environmentId'); const strategyId = useRequiredQueryParam('strategyId'); + const [tab, setTab] = useState(0); const [strategy, setStrategy] = useState>({}); const [segments, setSegments] = useState([]); @@ -168,7 +179,15 @@ export const FeatureStrategyEdit = () => { const savedStrategy = data?.environments .flatMap((environment) => environment.strategies) .find((strategy) => strategy.id === strategyId); - setStrategy((prev) => ({ ...prev, ...savedStrategy })); + + const constraintsWithId = addIdSymbolToConstraints(savedStrategy); + + const formattedStrategy = { + ...savedStrategy, + constraints: constraintsWithId, + }; + + setStrategy((prev) => ({ ...prev, ...formattedStrategy })); setPreviousTitle(savedStrategy?.title || ''); }, [strategyId, data]); @@ -235,7 +254,7 @@ export const FeatureStrategyEdit = () => { return ( { permission={UPDATE_FEATURE_STRATEGY} errors={errors} isChangeRequest={isChangeRequestConfigured(environmentId)} + tab={tab} + setTab={setTab} + StrategyVariants={ + + } /> {staleDataNotification} diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx index ecc5f94268..d29ad41408 100644 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx +++ b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/FeatureStrategyForm.tsx @@ -1,6 +1,15 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { Alert, Button, styled } from '@mui/material'; +import { + Alert, + Button, + styled, + Tabs, + Tab, + Box, + Divider, + Typography, +} from '@mui/material'; import { IFeatureStrategy, IFeatureStrategyParameters, @@ -31,8 +40,12 @@ import { usePendingChangeRequests } from 'hooks/api/getters/usePendingChangeRequ import { useHasProjectEnvironmentAccess } from 'hooks/useHasAccess'; import { FeatureStrategyTitle } from './FeatureStrategyTitle/FeatureStrategyTitle'; import { FeatureStrategyEnabledDisabled } from './FeatureStrategyEnabledDisabled/FeatureStrategyEnabledDisabled'; -import { StrategyVariants } from 'component/feature/StrategyTypes/StrategyVariants'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { formatStrategyName } from 'utils/strategyNames'; +import { Badge } from 'component/common/Badge/Badge'; +import EnvironmentIcon from 'component/common/EnvironmentIcon/EnvironmentIcon'; +import { useFeedback } from 'component/feedbackNew/useFeedback'; +import { useUiFlag } from 'hooks/useUiFlag'; interface IFeatureStrategyFormProps { feature: IFeatureToggle; @@ -42,7 +55,7 @@ interface IFeatureStrategyFormProps { onSubmit: () => void; onCancel?: () => void; loading: boolean; - isChangeRequest?: boolean; + isChangeRequest: boolean; strategy: Partial; setStrategy: React.Dispatch< React.SetStateAction> @@ -50,28 +63,131 @@ interface IFeatureStrategyFormProps { segments: ISegment[]; setSegments: React.Dispatch>; errors: IFormErrors; + tab: number; + setTab: React.Dispatch>; + StrategyVariants: JSX.Element; } -const StyledForm = styled('form')(({ theme }) => ({ - display: 'grid', - gap: theme.spacing(2), +const StyledDividerContent = styled(Box)(({ theme }) => ({ + padding: theme.spacing(0.75, 1), + color: theme.palette.text.primary, + fontSize: theme.fontSizes.smallerBody, + backgroundColor: theme.palette.background.elevation2, + borderRadius: theme.shape.borderRadius, + width: '45px', + position: 'absolute', + top: '-10px', + left: 'calc(50% - 45px)', + lineHeight: 1, })); -const StyledHr = styled('hr')(({ theme }) => ({ - width: '100%', - height: '1px', - margin: theme.spacing(2, 0), - border: 'none', - background: theme.palette.background.elevation2, +const StyledForm = styled('form')(({ theme }) => ({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + padding: theme.spacing(6), + paddingBottom: theme.spacing(12), + paddingTop: theme.spacing(4), + overflow: 'auto', + height: '100%', +})); + +const StyledTitle = styled('h1')(({ theme }) => ({ + fontWeight: 'normal', + display: 'flex', + alignItems: 'center', + paddingTop: theme.spacing(2), + paddingBottom: theme.spacing(2), })); const StyledButtons = styled('div')(({ theme }) => ({ + bottom: 0, + right: 0, + left: 0, + position: 'absolute', display: 'flex', + padding: theme.spacing(3), + paddingRight: theme.spacing(6), + paddingLeft: theme.spacing(6), + backgroundColor: theme.palette.background.paper, justifyContent: 'end', - gap: theme.spacing(2), - paddingBottom: theme.spacing(10), + borderTop: `1px solid ${theme.palette.divider}`, })); +const StyledTabs = styled(Tabs)(({ theme }) => ({ + borderTop: `1px solid ${theme.palette.divider}`, + borderBottom: `1px solid ${theme.palette.divider}`, + paddingLeft: theme.spacing(6), + paddingRight: theme.spacing(6), + minHeight: '60px', +})); + +const StyledBox = styled(Box)(({ theme }) => ({ + display: 'flex', + position: 'relative', + marginTop: theme.spacing(3.5), +})); + +const StyledDivider = styled(Divider)(({ theme }) => ({ + width: '100%', +})); + +const StyledTargetingHeader = styled('div')(({ theme }) => ({ + color: theme.palette.text.secondary, + marginTop: theme.spacing(1.5), +})); + +const StyledHeaderBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + paddingLeft: theme.spacing(6), + paddingRight: theme.spacing(6), + paddingTop: theme.spacing(2), +})); + +const StyledAlertBox = styled(Box)(({ theme }) => ({ + paddingLeft: theme.spacing(6), + paddingRight: theme.spacing(6), + '& > *': { + marginTop: theme.spacing(2), + marginBottom: theme.spacing(2), + }, +})); + +const StyledEnvironmentBox = styled(Box)(({ theme }) => ({ + display: 'flex', + alignItems: 'center', +})); + +const EnvironmentIconBox = styled(Box)(({ theme }) => ({ + transform: 'scale(0.9)', + display: 'flex', + alignItems: 'center', +})); + +const EnvironmentTypography = styled(Typography)<{ enabled: boolean }>( + ({ theme, enabled }) => ({ + fontWeight: enabled ? 'bold' : 'normal', + }), +); + +const EnvironmentTypographyHeader = styled(Typography)(({ theme }) => ({ + marginRight: theme.spacing(0.5), + color: theme.palette.text.secondary, +})); + +const StyledTab = styled(Tab)(({ theme }) => ({ + width: '100px', +})); + +const StyledBadge = styled(Badge)(({ theme }) => ({ + marginLeft: theme.spacing(1), +})); + +const feedbackCategory = 'newStrategyForm'; + export const FeatureStrategyForm = ({ projectId, feature, @@ -86,7 +202,14 @@ export const FeatureStrategyForm = ({ setSegments, errors, isChangeRequest, + tab, + setTab, + StrategyVariants, }: IFeatureStrategyFormProps) => { + const { openFeedback, hasSubmittedFeedback } = useFeedback( + feedbackCategory, + 'manual', + ); const { trackEvent } = usePlausibleTracker(); const [showProdGuard, setShowProdGuard] = useState(false); const hasValidConstraints = useConstraintsValidation(strategy.constraints); @@ -97,6 +220,39 @@ export const FeatureStrategyForm = ({ environmentId, ); const { strategyDefinition } = useStrategy(strategy?.name); + const newStrategyConfigurationFeedback = useUiFlag( + 'newStrategyConfigurationFeedback', + ); + + useEffect(() => { + trackEvent('new-strategy-form', { + props: { + eventType: 'seen', + }, + }); + }); + + const stickiness = + strategy?.parameters && 'stickiness' in strategy?.parameters + ? String(strategy.parameters.stickiness) + : 'default'; + + useEffect(() => { + setStrategy((prev) => ({ + ...prev, + variants: (strategy.variants || []).map((variant) => ({ + stickiness, + name: variant.name, + weight: variant.weight, + payload: variant.payload, + weightType: variant.weightType, + })), + })); + }, [stickiness, JSON.stringify(strategy.variants)]); + + const foundEnvironment = feature.environments.find( + (environment) => environment.name === environmentId, + ); const { data } = usePendingChangeRequests(feature.project); const { changeRequestInReviewOrApproved, alert } = @@ -111,11 +267,7 @@ export const FeatureStrategyForm = ({ const navigate = useNavigate(); - const { - uiConfig, - error: uiConfigError, - loading: uiConfigLoading, - } = useUiConfig(); + const { error: uiConfigError, loading: uiConfigLoading } = useUiConfig(); if (uiConfigError) { throw uiConfigError; @@ -159,6 +311,15 @@ export const FeatureStrategyForm = ({ navigate(formatFeaturePath(feature.project, feature.name)); }; + const createFeedbackContext = () => { + openFeedback({ + title: 'How easy was it to work with the new strategy form?', + positiveLabel: 'What do you like most about the new strategy form?', + areasForImprovementsLabel: + 'What should be improved the new strategy form?', + }); + }; + const onSubmitWithValidation = async (event: React.FormEvent) => { if (Array.isArray(strategy.variants) && strategy.variants?.length > 0) { trackEvent('strategy-variants', { @@ -172,145 +333,262 @@ export const FeatureStrategyForm = ({ return; } + trackEvent('new-strategy-form', { + props: { + eventType: 'submitted', + }, + }); + if (enableProdGuard && !isChangeRequest) { setShowProdGuard(true); } else { - onSubmit(); + await onSubmitWithFeedback(); } }; + const onSubmitWithFeedback = async () => { + try { + await onSubmit(); + + if (newStrategyConfigurationFeedback && !hasSubmittedFeedback) { + createFeedbackContext(); + } + } catch (e) { + console.error(e); + } + }; + + const handleChange = (event: React.ChangeEvent<{}>, newValue: number) => { + setTab(newValue); + }; + + const getTargetingCount = () => { + const constraintCount = strategy.constraints?.length || 0; + const segmentCount = segments.length || 0; + + return constraintCount + segmentCount; + }; + + const showVariants = + strategy.parameters && 'stickiness' in strategy.parameters; + return ( - - + + + {formatStrategyName(strategy.name || '')} + + {strategy.parameters?.rollout}% + + } + /> + + {foundEnvironment ? ( + + + Environment: + + + {' '} + + {foundEnvironment.name} + + + + ) : null} + + + + + } + /> + } + /> + + + This feature toggle is currently enabled in the{' '} + {environmentId} environment. + Any changes made here will be available to users + as soon as these changes are approved and + applied. + + } + elseShow={ + + This feature toggle is currently enabled in the{' '} + {environmentId} environment. + Any changes made here will be available to users + as soon as you hit save. + } /> - } - /> - - - This feature toggle is currently enabled in the{' '} - {environmentId} environment. Any - changes made here will be available to users as soon - as these changes are approved and applied. - - } - elseShow={ - - This feature toggle is currently enabled in the{' '} - {environmentId} environment. Any - changes made here will be available to users as soon - as you hit save. - + + + + + + + Targeting + {getTargetingCount()} + } /> - - - { - setStrategy((prev) => ({ - ...prev, - title, - })); - }} - /> - - - - - - + Variants + + {strategy.variants?.length || 0} + + + } /> - } - /> - - - setStrategy((strategyState) => ({ - ...strategyState, - disabled: !strategyState.disabled, - })) - } - /> - - - + + + { + setStrategy((prev) => ({ + ...prev, + title, + })); + }} + /> + + + setStrategy((strategyState) => ({ + ...strategyState, + disabled: !strategyState.disabled, + })) + } + /> + + + } - data-testid={STRATEGY_FORM_SUBMIT_ID} - > - {isChangeRequest - ? changeRequestButtonText - : 'Save strategy'} - - - setShowProdGuard(false)} - onClick={onSubmit} - loading={loading} - label='Save strategy' /> - - + + + + 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. + + + + + + AND + + + + } + /> + + + } + /> + + + + {isChangeRequest + ? changeRequestButtonText + : 'Save strategy'} + + + setShowProdGuard(false)} + onClick={onSubmitWithFeedback} + loading={loading} + label='Save strategy' + /> + + + ); }; diff --git a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm.tsx b/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm.tsx deleted file mode 100644 index 6c1c1967bd..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/FeatureStrategyForm/NewFeatureStrategyForm.tsx +++ /dev/null @@ -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; - setStrategy: React.Dispatch< - React.SetStateAction> - >; - segments: ISegment[]; - setSegments: React.Dispatch>; - errors: IFormErrors; - tab: number; - setTab: React.Dispatch>; - 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 ( - <> - - - {formatStrategyName(strategy.name || '')} - - {strategy.parameters?.rollout}% - - } - /> - - {foundEnvironment ? ( - - - Environment: - - - {' '} - - {foundEnvironment.name} - - - - ) : null} - - - - - } - /> - } - /> - - - This feature toggle is currently enabled in the{' '} - {environmentId} environment. - Any changes made here will be available to users - as soon as these changes are approved and - applied. - - } - elseShow={ - - This feature toggle is currently enabled in the{' '} - {environmentId} environment. - Any changes made here will be available to users - as soon as you hit save. - - } - /> - - - - - - - Targeting - {getTargetingCount()} - - } - /> - {showVariants && ( - - Variants - - {strategy.variants?.length || 0} - - - } - /> - )} - - - - { - setStrategy((prev) => ({ - ...prev, - title, - })); - }} - /> - - - setStrategy((strategyState) => ({ - ...strategyState, - disabled: !strategyState.disabled, - })) - } - /> - - - - } - /> - - - - 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. - - - - - - AND - - - - } - /> - - - } - /> - - - - {isChangeRequest - ? changeRequestButtonText - : 'Save strategy'} - - - setShowProdGuard(false)} - onClick={onSubmitWithFeedback} - loading={loading} - label='Save strategy' - /> - - - - ); -}; diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx deleted file mode 100644 index e7f20993a9..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx +++ /dev/null @@ -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( - - } - /> - , - { - 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(); - }); -}); diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx deleted file mode 100644 index 5e4759444f..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx +++ /dev/null @@ -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>({}); - - const [segments, setSegments] = useState( - 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(feature); - const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId); - const { refetch: refetchChangeRequests } = - usePendingChangeRequests(projectId); - const { trackEvent } = usePlausibleTracker(); - - const { data, staleDataNotification, forceRefreshCache } = - useCollaborateData( - { - 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 ( - - formatAddStrategyApiCode( - projectId, - featureId, - environmentId, - payload, - unleashUrl, - ) - } - > - - } - /> - {staleDataNotification} - - ); -}; - -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, - 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}'`; -}; diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.test.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.test.tsx deleted file mode 100644 index 4da46408a6..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.test.tsx +++ /dev/null @@ -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( - - } - /> - , - { - 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(); - }); -}); diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx deleted file mode 100644 index 5f5bceb51c..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx +++ /dev/null @@ -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(''); - 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>({}); - const [segments, setSegments] = useState([]); - 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(feature); - - const { data, staleDataNotification, forceRefreshCache } = - useCollaborateData( - { - 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 ( - - formatUpdateStrategyApiCode( - projectId, - featureId, - environmentId, - strategyId, - payload, - strategyDefinition, - unleashUrl, - ) - } - > - - } - /> - {staleDataNotification} - - ); -}; - -export const createStrategyPayload = ( - strategy: Partial, - 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, - 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'; diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.test.ts b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.test.ts deleted file mode 100644 index 262b20bf4d..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { IUiConfig } from 'interfaces/uiConfig'; -import { - getChangeRequestConflictCreatedData, - getChangeRequestConflictCreatedDataFromScheduleData, -} from './change-request-conflict-data'; - -const uiConfig: Pick = { - 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`, - }, - ]); -}); diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.ts b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.ts deleted file mode 100644 index 8df88432e7..0000000000 --- a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/change-request-conflict-data.ts +++ /dev/null @@ -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 & { - payload: { id?: number | string }; - })[]; - }[]; - }[] - | undefined, - featureId: string, - strategyId: string, - uiConfig: Pick, -): 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[] | undefined, - uiConfig: Pick, -): ChangeRequestConflictCreatedData[] => - changeRequests?.map((cr) => ({ - changeRequest: getUniqueChangeRequestId(uiConfig, cr.id), - state: 'Scheduled' as const, - })) ?? []; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index d459cb6764..6b87db9ea8 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -1,7 +1,6 @@ import FeatureOverviewMetaData from './FeatureOverviewMetaData/FeatureOverviewMetaData'; import FeatureOverviewEnvironments from './FeatureOverviewEnvironments/FeatureOverviewEnvironments'; import { Route, Routes, useNavigate } from 'react-router-dom'; -import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; import { SidebarModal } from 'component/common/SidebarModal/SidebarModal'; import { formatFeaturePath } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; @@ -9,9 +8,8 @@ import { usePageTitle } from 'hooks/usePageTitle'; import { FeatureOverviewSidePanel } from 'component/feature/FeatureView/FeatureOverview/FeatureOverviewSidePanel/FeatureOverviewSidePanel'; import { useHiddenEnvironments } from 'hooks/useHiddenEnvironments'; import { styled } from '@mui/material'; -import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; -import { NewFeatureStrategyCreate } from 'component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate'; -import { NewFeatureStrategyEdit } from 'component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit'; +import { FeatureStrategyCreate } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; +import { FeatureStrategyEdit } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; const StyledContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -61,7 +59,7 @@ const FeatureOverview = () => { onClose={onSidebarClose} open > - + } /> @@ -73,7 +71,7 @@ const FeatureOverview = () => { onClose={onSidebarClose} open > - + } />