From 3193423d2dd2c1797d114cf41e6854cae6454b9c Mon Sep 17 00:00:00 2001 From: andreas-unleash Date: Fri, 10 Mar 2023 12:28:02 +0200 Subject: [PATCH] feat: Project scoped stickiness (#3289) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Project scoped stickiness 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 Closes # ### Important files ## Discussion points --------- Signed-off-by: andreas-unleash --- .../EnvironmentVariantsModal.tsx | 24 ++++---- .../FlexibleStrategy/FlexibleStrategy.tsx | 56 ++++++++---------- .../StickinessSelect/StickinessSelect.tsx | 57 +++++++++++++++++++ .../Project/CreateProject/CreateProject.tsx | 9 +++ .../Project/EditProject/EditProject.tsx | 15 ++++- .../Project/ProjectForm/ProjectForm.tsx | 33 +++++++++++ .../project/Project/hooks/useProjectForm.ts | 13 ++++- .../src/hooks/useDefaultProjectStickiness.ts | 35 ++++++++++++ frontend/src/hooks/usePlausibleTracker.ts | 1 + frontend/src/interfaces/uiConfig.ts | 1 + .../src/utils/createFeatureStrategy.test.ts | 2 +- frontend/src/utils/createFeatureStrategy.ts | 2 +- .../__snapshots__/create-config.test.ts.snap | 2 + src/lib/types/experimental.ts | 4 ++ src/server-dev.ts | 1 + 15 files changed, 210 insertions(+), 45 deletions(-) create mode 100644 frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx create mode 100644 frontend/src/hooks/useDefaultProjectStickiness.ts diff --git a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx index 71209243a87..465a7d93865 100644 --- a/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureVariants/FeatureEnvironmentVariants/EnvironmentVariantsModal/EnvironmentVariantsModal.tsx @@ -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 (
- + onStickinessChange(e.target.value) + } />
diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx index c2f3904e0da..e6de3eb3b5d 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/FlexibleStrategy.tsx @@ -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 (
- + ); +}; diff --git a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx index bdeef2f32b3..f2587aa6d3e 100644 --- a/frontend/src/component/project/Project/CreateProject/CreateProject.tsx +++ b/frontend/src/component/project/Project/CreateProject/CreateProject.tsx @@ -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} diff --git a/frontend/src/component/project/Project/EditProject/EditProject.tsx b/frontend/src/component/project/Project/EditProject/EditProject.tsx index 4a5ed683ba1..becf94a9efb 100644 --- a/frontend/src/component/project/Project/EditProject/EditProject.tsx +++ b/frontend/src/component/project/Project/EditProject/EditProject.tsx @@ -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" diff --git a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx index 29eb3fcf540..aeb766d6ed3 100644 --- a/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx +++ b/frontend/src/component/project/Project/ProjectForm/ProjectForm.tsx @@ -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>; setProjectId: React.Dispatch>; setProjectName: React.Dispatch>; setProjectDesc: React.Dispatch>; @@ -31,14 +36,19 @@ const ProjectForm: React.FC = ({ projectId, projectName, projectDesc, + projectStickiness, setProjectId, setProjectName, setProjectDesc, + setProjectStickiness, errors, mode, validateProjectId, clearErrors, }) => { + const { uiConfig } = useUiConfig(); + const { projectScopedStickiness } = uiConfig.flags; + return ( @@ -80,6 +90,29 @@ const ProjectForm: React.FC = ({ value={projectDesc} onChange={e => setProjectDesc(e.target.value)} /> + + + + What is the default stickiness for the project? + + + setProjectStickiness && + setProjectStickiness(e.target.value) + } + editable + /> + + } + /> diff --git a/frontend/src/component/project/Project/hooks/useProjectForm.ts b/frontend/src/component/project/Project/hooks/useProjectForm.ts index a1a3dd0f3ca..589b1264418 100644 --- a/frontend/src/component/project/Project/hooks/useProjectForm.ts +++ b/frontend/src/component/project/Project/hooks/useProjectForm.ts @@ -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, diff --git a/frontend/src/hooks/useDefaultProjectStickiness.ts b/frontend/src/hooks/useDefaultProjectStickiness.ts new file mode 100644 index 00000000000..4387a620402 --- /dev/null +++ b/frontend/src/hooks/useDefaultProjectStickiness.ts @@ -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, + }; +}; diff --git a/frontend/src/hooks/usePlausibleTracker.ts b/frontend/src/hooks/usePlausibleTracker.ts index 09f1ad65a28..32c90b5420c 100644 --- a/frontend/src/hooks/usePlausibleTracker.ts +++ b/frontend/src/hooks/usePlausibleTracker.ts @@ -21,6 +21,7 @@ export type CustomEvents = | 'unknown_ui_error' | 'export_import' | 'project_api_tokens' + | 'project_stickiness_set' | 'notifications'; export const usePlausibleTracker = () => { diff --git a/frontend/src/interfaces/uiConfig.ts b/frontend/src/interfaces/uiConfig.ts index ef643b07bbe..1ba9a11bf53 100644 --- a/frontend/src/interfaces/uiConfig.ts +++ b/frontend/src/interfaces/uiConfig.ts @@ -50,6 +50,7 @@ export interface IFlags { loginHistory?: boolean; bulkOperations?: boolean; projectScopedSegments?: boolean; + projectScopedStickiness?: boolean; } export interface IVersionInfo { diff --git a/frontend/src/utils/createFeatureStrategy.test.ts b/frontend/src/utils/createFeatureStrategy.test.ts index bac5ba7f091..2e04e907075 100644 --- a/frontend/src/utils/createFeatureStrategy.test.ts +++ b/frontend/src/utils/createFeatureStrategy.test.ts @@ -69,7 +69,7 @@ test('createFeatureStrategy with parameters', () => { "groupId": "a", "rollout": "50", "s": "", - "stickiness": "default", + "stickiness": "", }, } `); diff --git a/frontend/src/utils/createFeatureStrategy.ts b/frontend/src/utils/createFeatureStrategy.ts index 26366f0fa78..54ca91192c8 100644 --- a/frontend/src/utils/createFeatureStrategy.ts +++ b/frontend/src/utils/createFeatureStrategy.ts @@ -40,7 +40,7 @@ const createFeatureStrategyParameterValue = ( } if (parameter.name === 'stickiness') { - return 'default'; + return ''; } if (parameter.name === 'groupId') { diff --git a/src/lib/__snapshots__/create-config.test.ts.snap b/src/lib/__snapshots__/create-config.test.ts.snap index f2327829947..324e554be26 100644 --- a/src/lib/__snapshots__/create-config.test.ts.snap +++ b/src/lib/__snapshots__/create-config.test.ts.snap @@ -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, diff --git a/src/lib/types/experimental.ts b/src/lib/types/experimental.ts index 6abc207cb9b..84055a83258 100644 --- a/src/lib/types/experimental.ts +++ b/src/lib/types/experimental.ts @@ -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), }; diff --git a/src/server-dev.ts b/src/server-dev.ts index 6a61b09663c..6d8fd8d703f 100644 --- a/src/server-dev.ts +++ b/src/server-dev.ts @@ -43,6 +43,7 @@ process.nextTick(async () => { projectStatusApi: true, showProjectApiAccess: true, projectScopedSegments: true, + projectScopedStickiness: true, }, }, authentication: {