diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx new file mode 100644 index 0000000000..b606f8169a --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.test.tsx @@ -0,0 +1,20 @@ +import { formatAddStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyCreate/FeatureStrategyCreate'; + +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\\" + }'" + `); +}); diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx new file mode 100644 index 0000000000..59b9a83440 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate.tsx @@ -0,0 +1,249 @@ +import React, { 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'; +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 { formatStrategyName } from 'utils/strategyNames'; +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'; + +export const NewFeatureStrategyCreate = () => { + 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, + ) + } + > +

NEW CREATE FORM

+ + {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 new file mode 100644 index 0000000000..8a44fa6c5d --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.test.tsx @@ -0,0 +1,54 @@ +import { formatUpdateStrategyApiCode } from 'component/feature/FeatureStrategy/FeatureStrategyEdit/FeatureStrategyEdit'; +import { IFeatureStrategy, IStrategy } from 'interfaces/strategy'; + +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\\": [] + }'" + `); +}); diff --git a/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx new file mode 100644 index 0000000000..6c3dddcc60 --- /dev/null +++ b/frontend/src/component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit.tsx @@ -0,0 +1,308 @@ +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'; +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 { formatStrategyName } from 'utils/strategyNames'; +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'; + +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, + }; +}; + +export const NewFeatureStrategyEdit = () => { + const projectId = useRequiredPathParam('projectId'); + const featureId = useRequiredPathParam('featureId'); + const environmentId = useRequiredQueryParam('environmentId'); + const strategyId = useRequiredQueryParam('strategyId'); + + 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 } = + 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 { + segments: savedStrategySegments, + refetchSegments: refetchSavedStrategySegments, + } = useSegments(strategyId); + + useEffect(() => { + const savedStrategy = data?.environments + .flatMap((environment) => environment.strategies) + .find((strategy) => strategy.id === strategyId); + setStrategy((prev) => ({ ...prev, ...savedStrategy })); + 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); + } + 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, + ) + } + > +

NEW EDIT FORM

+ + {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/FeatureView/FeatureOverview/FeatureOverview.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx index be7cfbda6b..60e232a7ec 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverview.tsx @@ -12,6 +12,10 @@ 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 { useUiFlag } from 'hooks/useUiFlag'; +import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; +import { NewFeatureStrategyCreate } from 'component/feature/FeatureStrategy/NewFeatureStrategyCreate/NewFeatureStrategyCreate'; +import { NewFeatureStrategyEdit } from 'component/feature/FeatureStrategy/NewFeatureStrategyEdit/NewFeatureStrategyEdit'; const StyledContainer = styled('div')(({ theme }) => ({ display: 'flex', @@ -40,6 +44,8 @@ const FeatureOverview = () => { const onSidebarClose = () => navigate(featurePath); usePageTitle(featureId); + const newStrategyConfiguration = useUiFlag('newStrategyConfiguration'); + return (
@@ -61,7 +67,11 @@ const FeatureOverview = () => { onClose={onSidebarClose} open > - + } + elseShow={} + /> } /> @@ -73,7 +83,11 @@ const FeatureOverview = () => { onClose={onSidebarClose} open > - + } + elseShow={} + /> } /> diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index ea518783e9..7863dabd42 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -67,6 +67,7 @@ export type UiFlags = { scheduledConfigurationChanges?: boolean; featureSearchAPI?: boolean; featureSearchFrontend?: boolean; + newStrategyConfiguration?: boolean; }; export interface IVersionInfo { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index 4085c204fe..fad85b5523 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -96,6 +96,7 @@ exports[`should create default config 1`] = ` }, }, "migrationLock": true, + "newStrategyConfiguration": false, "personalAccessTokensKillSwitch": false, "privateProjects": false, "proPlanAutoCharge": false, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index a7701de814..d5321a964a 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -30,7 +30,8 @@ export type IFlagKey = | 'featureSearchFrontend' | 'scheduledConfigurationChanges' | 'detectSegmentUsageInChangeRequests' - | 'stripClientHeadersOn304'; + | 'stripClientHeadersOn304' + | 'newStrategyConfiguration'; export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>; @@ -136,6 +137,10 @@ const flags: IFlags = { .UNLEASH_EXPERIMENTAL_DETECT_SEGMENT_USAGE_IN_CHANGE_REQUESTS, false, ), + newStrategyConfiguration: parseEnvVarBoolean( + process.env.UNLEASH_EXPERIMENTAL_NEW_STRATEGY_CONFIGURATION, + false, + ), }; export const defaultExperimentalOptions: IExperimentalOptions = { diff --git a/src/server-dev.ts b/src/server-dev.ts index 202eb41b8f..e0b8dffed8 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -41,6 +41,7 @@ process.nextTick(async () => { featureSearchAPI: true, featureSearchFrontend: true, stripClientHeadersOn304: true, + newStrategyConfiguration: true, }, }, authentication: {