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

feat: Project scoped stickiness (#3289)

Project scoped stickiness 
<!-- Thanks for creating a PR! To make it easier for reviewers and
everyone else to understand what your changes relate to, please add some
relevant content to the headings below. Feel free to ignore or delete
sections that you don't think are relevant. Thank you! ❤️ -->

Adds `projectScopedStickiness` flag to experimental.ts
Refactor Stickiness select for reusability
Modify FlexibleStrategy to respect the setting.
Modify EnvironmentVariantModal to respect the setting

## About the changes
<!-- Describe the changes introduced. What are they and why are they
being introduced? Feel free to also add screenshots or steps to view the
changes if they're visual. -->

<!-- Does it close an issue? Multiple? -->
Closes #

<!-- (For internal contributors): Does it relate to an issue on public
roadmap? -->
<!--
Relates to [roadmap](https://github.com/orgs/Unleash/projects/10) item:
#
-->

### Important files
<!-- PRs can contain a lot of changes, but not all changes are equally
important. Where should a reviewer start looking to get an overview of
the changes? Are any files particularly important? -->


## Discussion points
<!-- Anything about the PR you'd like to discuss before it gets merged?
Got any questions or doubts? -->

---------

Signed-off-by: andreas-unleash <andreas@getunleash.ai>
This commit is contained in:
andreas-unleash 2023-03-10 12:28:02 +02:00 committed by GitHub
parent 99a5b96c20
commit 3193423d2d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 210 additions and 45 deletions

View File

@ -17,8 +17,9 @@ import { UPDATE_FEATURE_ENVIRONMENT_VARIANTS } from 'component/providers/AccessP
import { WeightType } from 'constants/variantTypes';
import { v4 as uuidv4 } from 'uuid';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
import GeneralSelect from 'component/common/GeneralSelect/GeneralSelect';
import { updateWeightEdit } from 'component/common/util';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
const StyledFormSubtitle = styled('div')(({ theme }) => ({
display: 'flex',
@ -65,10 +66,10 @@ const StyledAlert = styled(Alert)(({ theme }) => ({
marginTop: theme.spacing(4),
}));
const StyledVariantForms = styled('div')(({ theme }) => ({
const StyledVariantForms = styled('div')({
display: 'flex',
flexDirection: 'column-reverse',
}));
});
const StyledStickinessContainer = styled('div')(({ theme }) => ({
display: 'flex',
@ -83,7 +84,7 @@ const StyledDescription = styled('p')(({ theme }) => ({
marginBottom: theme.spacing(1.5),
}));
const StyledGeneralSelect = styled(GeneralSelect)(({ theme }) => ({
const StyledStickinessSelect = styled(StickinessSelect)(({ theme }) => ({
minWidth: theme.spacing(20),
width: '100%',
}));
@ -134,6 +135,7 @@ export const EnvironmentVariantsModal = ({
const { uiConfig } = useUiConfig();
const { context } = useUnleashContext();
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
const { isChangeRequestConfigured } = useChangeRequestsEnabled(projectId);
const { data } = usePendingChangeRequests(projectId);
@ -161,7 +163,7 @@ export const EnvironmentVariantsModal = ({
stickiness:
variantsEdit?.length > 0
? variantsEdit[0].stickiness
: 'default',
: defaultStickiness,
new: true,
isValid: false,
id: uuidv4(),
@ -225,7 +227,7 @@ export const EnvironmentVariantsModal = ({
isChangeRequestConfigured(environment?.name || '') &&
uiConfig.flags.crOnVariants;
const stickiness = variants[0]?.stickiness || 'default';
const stickiness = variants[0]?.stickiness || defaultStickiness;
const stickinessOptions = useMemo(
() => [
'default',
@ -258,7 +260,6 @@ export const EnvironmentVariantsModal = ({
setError(apiPayload.error);
}
}, [apiPayload.error]);
return (
<SidebarModal
open={open}
@ -378,10 +379,13 @@ export const EnvironmentVariantsModal = ({
</a>
</StyledDescription>
<div>
<StyledGeneralSelect
options={options}
<StyledStickinessSelect
value={stickiness}
onChange={onStickinessChange}
label={''}
editable
onChange={e =>
onStickinessChange(e.target.value)
}
/>
</div>
</>

View File

@ -1,7 +1,6 @@
import { Typography } from '@mui/material';
import { IFeatureStrategyParameters } from 'interfaces/strategy';
import RolloutSlider from '../RolloutSlider/RolloutSlider';
import Select from 'component/common/select';
import Input from 'component/common/Input/Input';
import {
FLEXIBLE_STRATEGY_GROUP_ID,
@ -12,13 +11,10 @@ import {
parseParameterNumber,
parseParameterString,
} from 'utils/parseParameter';
const builtInStickinessOptions = [
{ key: 'default', label: 'default' },
{ key: 'userId', label: 'userId' },
{ key: 'sessionId', label: 'sessionId' },
{ key: 'random', label: 'random' },
];
import { StickinessSelect } from './StickinessSelect/StickinessSelect';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { useOptionalPathParam } from 'hooks/useOptionalPathParam';
import { useDefaultProjectStickiness } from '../../../../hooks/useDefaultProjectStickiness';
interface IFlexibleStrategyProps {
parameters: IFeatureStrategyParameters;
@ -30,9 +26,11 @@ interface IFlexibleStrategyProps {
const FlexibleStrategy = ({
updateParameter,
parameters,
context,
editable = true,
}: IFlexibleStrategyProps) => {
const projectId = useOptionalPathParam('projectId');
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
const onUpdate = (field: string) => (newValue: string) => {
updateParameter(field, newValue);
};
@ -41,26 +39,25 @@ const FlexibleStrategy = ({
updateParameter('rollout', value.toString());
};
const resolveStickiness = () =>
builtInStickinessOptions.concat(
context
// @ts-expect-error
.filter(c => c.stickiness)
.filter(
// @ts-expect-error
c => !builtInStickinessOptions.find(s => s.key === c.name)
)
// @ts-expect-error
.map(c => ({ key: c.name, label: c.name }))
);
const stickinessOptions = resolveStickiness();
const rollout =
parameters.rollout !== undefined
? parseParameterNumber(parameters.rollout)
: 100;
const resolveStickiness = () => {
if (parameters.stickiness === '') {
return defaultStickiness;
}
return parseParameterString(parameters.stickiness);
};
const stickiness = resolveStickiness();
if (parameters.stickiness === '') {
onUpdate('stickiness')(stickiness);
}
return (
<div>
<RolloutSlider
@ -84,14 +81,11 @@ const FlexibleStrategy = ({
Stickiness
<HelpIcon tooltip="Stickiness defines what parameter should be used to ensure that your users get consistency in features. By default unleash will use the first value present in the context in the order of userId, sessionId and random." />
</Typography>
<Select
id="stickiness-select"
name="stickiness"
<StickinessSelect
label="Stickiness"
options={stickinessOptions}
value={parseParameterString(parameters.stickiness)}
disabled={!editable}
data-testid={FLEXIBLE_STRATEGY_STICKINESS_ID}
value={stickiness}
editable={editable}
dataTestId={FLEXIBLE_STRATEGY_STICKINESS_ID}
onChange={e => onUpdate('stickiness')(e.target.value)}
/>
&nbsp;

View File

@ -0,0 +1,57 @@
import Select from 'component/common/select';
import { SelectChangeEvent } from '@mui/material';
import useUnleashContext from 'hooks/api/getters/useUnleashContext/useUnleashContext';
const builtInStickinessOptions = [
{ key: 'default', label: 'default' },
{ key: 'userId', label: 'userId' },
{ key: 'sessionId', label: 'sessionId' },
{ key: 'random', label: 'random' },
];
interface IStickinessSelectProps {
label: string;
value: string | undefined;
editable: boolean;
onChange: (event: SelectChangeEvent) => void;
dataTestId?: string;
}
export const StickinessSelect = ({
label,
editable,
value,
onChange,
dataTestId,
}: IStickinessSelectProps) => {
const { context } = useUnleashContext();
const resolveStickinessOptions = () =>
builtInStickinessOptions.concat(
context
.filter(contextDefinition => contextDefinition.stickiness)
.filter(
contextDefinition =>
!builtInStickinessOptions.find(
builtInStickinessOption =>
builtInStickinessOption.key ===
contextDefinition.name
)
)
.map(c => ({ key: c.name, label: c.name }))
);
const stickinessOptions = resolveStickinessOptions();
return (
<Select
id="stickiness-select"
name="stickiness"
label={label}
options={stickinessOptions}
value={value}
disabled={!editable}
data-testid={dataTestId}
onChange={onChange}
style={{ width: 'inherit', minWidth: '100%' }}
/>
);
};

View File

@ -10,6 +10,7 @@ import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import useToast from 'hooks/useToast';
import { formatUnknownError } from 'utils/formatUnknownError';
import { GO_BACK } from 'constants/navigate';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
const CreateProject = () => {
const { setToastData, setToastApiError } = useToast();
@ -27,11 +28,16 @@ const CreateProject = () => {
clearErrors,
validateProjectId,
validateName,
setProjectStickiness,
projectStickiness,
errors,
} = useProjectForm();
const { createProject, loading } = useProjectApi();
const { setDefaultProjectStickiness } =
useDefaultProjectStickiness(projectId);
const handleSubmit = async (e: Event) => {
e.preventDefault();
clearErrors();
@ -42,6 +48,7 @@ const CreateProject = () => {
const payload = getProjectPayload();
try {
await createProject(payload);
setDefaultProjectStickiness(payload.projectStickiness);
refetchUser();
navigate(`/projects/${projectId}`);
setToastData({
@ -85,6 +92,8 @@ const CreateProject = () => {
projectId={projectId}
setProjectId={setProjectId}
projectName={projectName}
projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness}
setProjectName={setProjectName}
projectDesc={projectDesc}
setProjectDesc={setProjectDesc}

View File

@ -14,6 +14,7 @@ import { useContext } from 'react';
import AccessContext from 'contexts/AccessContext';
import { Alert } from '@mui/material';
import { GO_BACK } from 'constants/navigate';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
const EditProject = () => {
const { uiConfig } = useUiConfig();
@ -21,21 +22,30 @@ const EditProject = () => {
const { hasAccess } = useContext(AccessContext);
const id = useRequiredPathParam('projectId');
const { project } = useProject(id);
const { defaultStickiness, setDefaultProjectStickiness } =
useDefaultProjectStickiness(id);
const navigate = useNavigate();
const {
projectId,
projectName,
projectDesc,
projectStickiness,
setProjectId,
setProjectName,
setProjectDesc,
setProjectStickiness,
getProjectPayload,
clearErrors,
validateProjectId,
validateName,
errors,
} = useProjectForm(id, project.name, project.description);
} = useProjectForm(
id,
project.name,
project.description,
defaultStickiness
);
const formatApiCode = () => {
return `curl --location --request PUT '${
@ -58,6 +68,7 @@ const EditProject = () => {
if (validName) {
try {
await editProject(id, payload);
setDefaultProjectStickiness(payload.projectStickiness);
refetch();
navigate(`/projects/${id}`);
setToastData({
@ -98,6 +109,8 @@ const EditProject = () => {
setProjectId={setProjectId}
projectName={projectName}
setProjectName={setProjectName}
projectStickiness={projectStickiness}
setProjectStickiness={setProjectStickiness}
projectDesc={projectDesc}
setProjectDesc={setProjectDesc}
mode="Edit"

View File

@ -9,10 +9,15 @@ import {
StyledButtonContainer,
StyledButton,
} from './ProjectForm.styles';
import useUiConfig from 'hooks/api/getters/useUiConfig/useUiConfig';
import { StickinessSelect } from 'component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
interface IProjectForm {
projectId: string;
projectName: string;
projectDesc: string;
projectStickiness?: string;
setProjectStickiness?: React.Dispatch<React.SetStateAction<string>>;
setProjectId: React.Dispatch<React.SetStateAction<string>>;
setProjectName: React.Dispatch<React.SetStateAction<string>>;
setProjectDesc: React.Dispatch<React.SetStateAction<string>>;
@ -31,14 +36,19 @@ const ProjectForm: React.FC<IProjectForm> = ({
projectId,
projectName,
projectDesc,
projectStickiness,
setProjectId,
setProjectName,
setProjectDesc,
setProjectStickiness,
errors,
mode,
validateProjectId,
clearErrors,
}) => {
const { uiConfig } = useUiConfig();
const { projectScopedStickiness } = uiConfig.flags;
return (
<StyledForm onSubmit={handleSubmit}>
<StyledContainer>
@ -80,6 +90,29 @@ const ProjectForm: React.FC<IProjectForm> = ({
value={projectDesc}
onChange={e => setProjectDesc(e.target.value)}
/>
<ConditionallyRender
condition={
Boolean(projectScopedStickiness) &&
setProjectStickiness != null
}
show={
<>
<StyledDescription>
What is the default stickiness for the project?
</StyledDescription>
<StickinessSelect
label="Stickiness"
value={projectStickiness}
onChange={e =>
setProjectStickiness &&
setProjectStickiness(e.target.value)
}
editable
/>
</>
}
/>
</StyledContainer>
<StyledButtonContainer>

View File

@ -1,16 +1,24 @@
import { useEffect, useState } from 'react';
import useProjectApi from 'hooks/api/actions/useProjectApi/useProjectApi';
import { formatUnknownError } from 'utils/formatUnknownError';
import { useDefaultProjectStickiness } from 'hooks/useDefaultProjectStickiness';
const useProjectForm = (
initialProjectId = '',
initialProjectName = '',
initialProjectDesc = ''
initialProjectDesc = '',
initialProjectStickiness = 'default'
) => {
const [projectId, setProjectId] = useState(initialProjectId);
const { defaultStickiness } = useDefaultProjectStickiness(projectId);
const [projectName, setProjectName] = useState(initialProjectName);
const [projectDesc, setProjectDesc] = useState(initialProjectDesc);
const [projectStickiness, setProjectStickiness] = useState(
defaultStickiness || initialProjectStickiness
);
const [errors, setErrors] = useState({});
const { validateId } = useProjectApi();
useEffect(() => {
@ -30,6 +38,7 @@ const useProjectForm = (
id: projectId,
name: projectName,
description: projectDesc,
projectStickiness,
};
};
@ -64,9 +73,11 @@ const useProjectForm = (
projectId,
projectName,
projectDesc,
projectStickiness,
setProjectId,
setProjectName,
setProjectDesc,
setProjectStickiness,
getProjectPayload,
validateName,
validateProjectId,

View File

@ -0,0 +1,35 @@
import useUiConfig from './api/getters/useUiConfig/useUiConfig';
import { usePlausibleTracker } from './usePlausibleTracker';
const DEFAULT_STICKINESS = 'default';
export const useDefaultProjectStickiness = (projectId?: string) => {
const { trackEvent } = usePlausibleTracker();
const { uiConfig } = useUiConfig();
const key = `defaultStickiness.${projectId}`;
const { projectScopedStickiness } = uiConfig.flags;
const projectStickiness = localStorage.getItem(key);
const defaultStickiness =
Boolean(projectScopedStickiness) &&
projectStickiness != null &&
projectId
? projectStickiness
: DEFAULT_STICKINESS;
const setDefaultProjectStickiness = (stickiness: string) => {
if (
Boolean(projectScopedStickiness) &&
projectId &&
stickiness !== ''
) {
localStorage.setItem(key, stickiness);
trackEvent('project_stickiness_set');
}
};
return {
defaultStickiness,
setDefaultProjectStickiness,
};
};

View File

@ -21,6 +21,7 @@ export type CustomEvents =
| 'unknown_ui_error'
| 'export_import'
| 'project_api_tokens'
| 'project_stickiness_set'
| 'notifications';
export const usePlausibleTracker = () => {

View File

@ -50,6 +50,7 @@ export interface IFlags {
loginHistory?: boolean;
bulkOperations?: boolean;
projectScopedSegments?: boolean;
projectScopedStickiness?: boolean;
}
export interface IVersionInfo {

View File

@ -69,7 +69,7 @@ test('createFeatureStrategy with parameters', () => {
"groupId": "a",
"rollout": "50",
"s": "",
"stickiness": "default",
"stickiness": "",
},
}
`);

View File

@ -40,7 +40,7 @@ const createFeatureStrategyParameterValue = (
}
if (parameter.name === 'stickiness') {
return 'default';
return '';
}
if (parameter.name === 'groupId') {

View File

@ -83,6 +83,7 @@ exports[`should create default config 1`] = `
"notifications": false,
"proPlanAutoCharge": false,
"projectScopedSegments": false,
"projectScopedStickiness": false,
"projectStatusApi": false,
"proxyReturnAllToggles": false,
"responseTimeWithAppNameKillSwitch": false,
@ -108,6 +109,7 @@ exports[`should create default config 1`] = `
"notifications": false,
"proPlanAutoCharge": false,
"projectScopedSegments": false,
"projectScopedStickiness": false,
"projectStatusApi": false,
"proxyReturnAllToggles": false,
"responseTimeWithAppNameKillSwitch": false,

View File

@ -72,6 +72,10 @@ const flags = {
process.env.PROJECT_SCOPED_SEGMENTS,
false,
),
projectScopedStickiness: parseEnvVarBoolean(
process.env.PROJECT_SCOPED_STICKINESS,
false,
),
cleanClientApi: parseEnvVarBoolean(process.env.CLEAN_CLIENT_API, false),
};

View File

@ -43,6 +43,7 @@ process.nextTick(async () => {
projectStatusApi: true,
showProjectApiAccess: true,
projectScopedSegments: true,
projectScopedStickiness: true,
},
},
authentication: {