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 type { IFeatureStrategy, IFeatureStrategyPayload, IStrategy, } from 'interfaces/strategy'; import { UPDATE_FEATURE_STRATEGY } from 'component/providers/AccessProvider/permissions'; import type { 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 type { 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 { FeatureStrategyForm } from '../FeatureStrategyForm/FeatureStrategyForm.tsx'; import { NewStrategyVariants } from 'component/feature/StrategyTypes/NewStrategyVariants'; import { v4 as uuidv4 } from 'uuid'; import { useScheduledChangeRequestsWithStrategy } from 'hooks/api/getters/useScheduledChangeRequestsWithStrategy/useScheduledChangeRequestsWithStrategy'; import { getChangeRequestConflictCreatedData, getChangeRequestConflictCreatedDataFromScheduleData, } from './change-request-conflict-data.ts'; import { constraintId } from 'constants/constraintId.ts'; import { apiPayloadConstraintReplacer } from 'utils/api-payload-constraint-replacer.ts'; 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 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([]); 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({ text: 'Strategy updated', type: 'success', }); }; const onStrategyRequestEdit = async (payload: IFeatureStrategyPayload) => { await addChange(projectId, environmentId, { action: 'updateStrategy', feature: featureId, payload: { ...payload, id: strategyId }, }); // FIXME: segments in change requests setToastData({ text: 'Change added to draft', type: 'success', }); 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, apiPayloadConstraintReplacer, 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 flag is enabled and provides a way to control who will get access to the feature. If any of a feature flag'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';