1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: initial setup (#5583)

This PR sets up the feature flag for the new strategy configuration and
duplicates the components for the new setup
This commit is contained in:
Fredrik Strand Oseberg 2023-12-11 12:23:18 +01:00 committed by GitHub
parent 2322e1149a
commit ec670450fd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 656 additions and 3 deletions

View File

@ -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\\"
}'"
`);
});

View File

@ -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<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>(
shouldUseDefaultStrategy ? strategySegments : [],
);
const { strategyDefinition } = useStrategy(strategyName);
const errors = useFormErrors();
const { addStrategyToFeature, loading } = useFeatureStrategyApi();
const { addChange } = useChangeRequestApi();
const { setToastData, setToastApiError } = useToast();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const navigate = useNavigate();
const { feature, refetchFeature } = useFeature(projectId, featureId);
const ref = useRef<IFeatureToggle>(feature);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { trackEvent } = usePlausibleTracker();
const { data, staleDataNotification, forceRefreshCache } =
useCollaborateData<IFeatureToggle>(
{
unleashGetter: useFeature,
params: [projectId, featureId],
dataKey: 'feature',
refetchFunctionKey: 'refetchFeature',
options: {},
},
feature,
{
afterSubmitAction: refetchFeature,
},
comparisonModerator,
);
useEffect(() => {
if (ref.current.name === '' && feature.name) {
forceRefreshCache(feature);
ref.current = feature;
}
}, [feature.name]);
useEffect(() => {
if (shouldUseDefaultStrategy) {
const strategyTemplate = defaultStrategy || defaultStrategyFallback;
if (strategyTemplate.parameters?.groupId === '' && featureId) {
setStrategy({
...strategyTemplate,
parameters: {
...strategyTemplate.parameters,
groupId: featureId,
},
} as any);
} else {
setStrategy(strategyTemplate as any);
}
} else if (strategyDefinition) {
setStrategy(createFeatureStrategy(featureId, strategyDefinition));
}
}, [
featureId,
JSON.stringify(strategyDefinition),
shouldUseDefaultStrategy,
]);
const onAddStrategy = async (payload: IFeatureStrategyPayload) => {
await addStrategyToFeature(
projectId,
featureId,
environmentId,
payload,
);
setToastData({
title: 'Strategy created',
type: 'success',
confetti: true,
});
};
const onStrategyRequestAdd = async (payload: IFeatureStrategyPayload) => {
await addChange(projectId, environmentId, {
action: 'addStrategy',
feature: featureId,
payload,
});
// FIXME: segments in change requests
setToastData({
title: 'Strategy added to draft',
type: 'success',
confetti: true,
});
refetchChangeRequests();
};
const payload = createStrategyPayload(strategy, segments);
const onSubmit = async () => {
trackEvent('strategyTitle', {
props: {
hasTitle: Boolean(strategy.title),
on: 'create',
},
});
try {
if (isChangeRequestConfigured(environmentId)) {
await onStrategyRequestAdd(payload);
} else {
await onAddStrategy(payload);
}
refetchFeature();
navigate(formatFeaturePath(projectId, featureId));
} catch (error: unknown) {
setToastApiError(formatUnknownError(error));
}
};
const emptyFeature = !data || !data.project;
if (emptyFeature) return null;
return (
<FormTemplate
modal
title={formatStrategyName(strategyName)}
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
formatApiCode={() =>
formatAddStrategyApiCode(
projectId,
featureId,
environmentId,
payload,
unleashUrl,
)
}
>
<h1>NEW CREATE FORM</h1>
<FeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}
permission={CREATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)}
/>
{staleDataNotification}
</FormTemplate>
);
};
export const formatCreateStrategyPath = (
projectId: string,
featureId: string,
environmentId: string,
strategyName: string,
defaultStrategy: boolean = false,
): string => {
const params = new URLSearchParams({
environmentId,
strategyName,
defaultStrategy: String(defaultStrategy),
});
return `/projects/${projectId}/features/${featureId}/strategies/create?${params}`;
};
export const formatAddStrategyApiCode = (
projectId: string,
featureId: string,
environmentId: string,
strategy: Partial<IFeatureStrategy>,
unleashUrl?: string,
): string => {
if (!unleashUrl) {
return '';
}
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies`;
const payload = JSON.stringify(strategy, undefined, 2);
return `curl --location --request POST '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};

View File

@ -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\\": []
}'"
`);
});

View File

@ -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<string>('');
const { trackEvent } = usePlausibleTracker();
const trackTitle = (title: string = '') => {
// don't expose the title, just if it was added, removed, or edited
if (title === previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'none',
on: 'edit',
},
});
}
if (previousTitle === '' && title !== '') {
trackEvent('strategyTitle', {
props: {
action: 'added',
on: 'edit',
},
});
}
if (previousTitle !== '' && title === '') {
trackEvent('strategyTitle', {
props: {
action: 'removed',
on: 'edit',
},
});
}
if (previousTitle !== '' && title !== '' && title !== previousTitle) {
trackEvent('strategyTitle', {
props: {
action: 'edited',
on: 'edit',
},
});
}
};
return {
setPreviousTitle,
trackTitle,
};
};
export const NewFeatureStrategyEdit = () => {
const projectId = useRequiredPathParam('projectId');
const featureId = useRequiredPathParam('featureId');
const environmentId = useRequiredQueryParam('environmentId');
const strategyId = useRequiredQueryParam('strategyId');
const [strategy, setStrategy] = useState<Partial<IFeatureStrategy>>({});
const [segments, setSegments] = useState<ISegment[]>([]);
const { updateStrategyOnFeature, loading } = useFeatureStrategyApi();
const { strategyDefinition } = useStrategy(strategy.name);
const { setToastData, setToastApiError } = useToast();
const errors = useFormErrors();
const { uiConfig } = useUiConfig();
const { unleashUrl } = uiConfig;
const navigate = useNavigate();
const { addChange } = useChangeRequestApi();
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { refetch: refetchChangeRequests } =
usePendingChangeRequests(projectId);
const { setPreviousTitle } = useTitleTracking();
const { feature, refetchFeature } = useFeature(projectId, featureId);
const ref = useRef<IFeatureToggle>(feature);
const { data, staleDataNotification, forceRefreshCache } =
useCollaborateData<IFeatureToggle>(
{
unleashGetter: useFeature,
params: [projectId, featureId],
dataKey: 'feature',
refetchFunctionKey: 'refetchFeature',
options: {},
},
feature,
{
afterSubmitAction: refetchFeature,
},
comparisonModerator,
);
useEffect(() => {
if (ref.current.name === '' && feature.name) {
forceRefreshCache(feature);
ref.current = feature;
}
}, [feature]);
const {
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 (
<FormTemplate
modal
title={formatStrategyName(strategy.name ?? '')}
description={featureStrategyHelp}
documentationLink={featureStrategyDocsLink}
documentationLinkLabel={featureStrategyDocsLinkLabel}
formatApiCode={() =>
formatUpdateStrategyApiCode(
projectId,
featureId,
environmentId,
strategyId,
payload,
strategyDefinition,
unleashUrl,
)
}
>
<h1>NEW EDIT FORM</h1>
<FeatureStrategyForm
projectId={projectId}
feature={data}
strategy={strategy}
setStrategy={setStrategy}
segments={segments}
setSegments={setSegments}
environmentId={environmentId}
onSubmit={onSubmit}
loading={loading}
permission={UPDATE_FEATURE_STRATEGY}
errors={errors}
isChangeRequest={isChangeRequestConfigured(environmentId)}
/>
{staleDataNotification}
</FormTemplate>
);
};
export const createStrategyPayload = (
strategy: Partial<IFeatureStrategy>,
segments: ISegment[],
): IFeatureStrategyPayload => ({
name: strategy.name,
title: strategy.title,
constraints: strategy.constraints ?? [],
parameters: strategy.parameters ?? {},
variants: strategy.variants ?? [],
segments: segments.map((segment) => segment.id),
disabled: strategy.disabled ?? false,
});
export const formatFeaturePath = (
projectId: string,
featureId: string,
): string => {
return `/projects/${projectId}/features/${featureId}`;
};
export const formatEditStrategyPath = (
projectId: string,
featureId: string,
environmentId: string,
strategyId: string,
): string => {
const params = new URLSearchParams({ environmentId, strategyId });
return `/projects/${projectId}/features/${featureId}/strategies/edit?${params}`;
};
export const formatUpdateStrategyApiCode = (
projectId: string,
featureId: string,
environmentId: string,
strategyId: string,
strategy: Partial<IFeatureStrategy>,
strategyDefinition: IStrategy,
unleashUrl?: string,
): string => {
if (!unleashUrl) {
return '';
}
// Sort the strategy parameters payload so that they match
// the order of the input fields in the form, for usability.
const sortedStrategy = {
...strategy,
parameters: sortStrategyParameters(
strategy.parameters ?? {},
strategyDefinition,
),
};
const url = `${unleashUrl}/api/admin/projects/${projectId}/features/${featureId}/environments/${environmentId}/strategies/${strategyId}`;
const payload = JSON.stringify(sortedStrategy, undefined, 2);
return `curl --location --request PUT '${url}' \\
--header 'Authorization: INSERT_API_KEY' \\
--header 'Content-Type: application/json' \\
--data-raw '${payload}'`;
};
export const featureStrategyHelp = `
An activation strategy will only run when a feature toggle is enabled and provides a way to control who will get access to the feature.
If any of a feature toggle's activation strategies returns true, the user will get access.
`;
export const featureStrategyDocsLink =
'https://docs.getunleash.io/reference/activation-strategies';
export const featureStrategyDocsLinkLabel = 'Strategies documentation';

View File

@ -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 (
<StyledContainer>
<div>
@ -61,7 +67,11 @@ const FeatureOverview = () => {
onClose={onSidebarClose}
open
>
<FeatureStrategyCreate />
<ConditionallyRender
condition={newStrategyConfiguration}
show={<NewFeatureStrategyCreate />}
elseShow={<FeatureStrategyCreate />}
/>
</SidebarModal>
}
/>
@ -73,7 +83,11 @@ const FeatureOverview = () => {
onClose={onSidebarClose}
open
>
<FeatureStrategyEdit />
<ConditionallyRender
condition={newStrategyConfiguration}
show={<NewFeatureStrategyEdit />}
elseShow={<FeatureStrategyEdit />}
/>
</SidebarModal>
}
/>

View File

@ -67,6 +67,7 @@ export type UiFlags = {
scheduledConfigurationChanges?: boolean;
featureSearchAPI?: boolean;
featureSearchFrontend?: boolean;
newStrategyConfiguration?: boolean;
};
export interface IVersionInfo {

View File

@ -96,6 +96,7 @@ exports[`should create default config 1`] = `
},
},
"migrationLock": true,
"newStrategyConfiguration": false,
"personalAccessTokensKillSwitch": false,
"privateProjects": false,
"proPlanAutoCharge": false,

View File

@ -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 = {

View File

@ -41,6 +41,7 @@ process.nextTick(async () => {
featureSearchAPI: true,
featureSearchFrontend: true,
stripClientHeadersOn304: true,
newStrategyConfiguration: true,
},
},
authentication: {