diff --git a/.github/workflows/e2e.frontend.yaml b/.github/workflows/e2e.frontend.yaml index 2b3917ced6..e95db86798 100644 --- a/.github/workflows/e2e.frontend.yaml +++ b/.github/workflows/e2e.frontend.yaml @@ -11,7 +11,6 @@ jobs: - groups/groups.spec.ts - projects/access.spec.ts - projects/overview.spec.ts - - projects/settings.spec.ts - projects/notifications.spec.ts - segments/segments.spec.ts - import/import.spec.ts diff --git a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx index 8565e13fe7..7eea39686e 100644 --- a/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx +++ b/frontend/src/component/feature/StrategyTypes/FlexibleStrategy/StickinessSelect/StickinessSelect.tsx @@ -1,12 +1,11 @@ import Select from 'component/common/select'; import { SelectChangeEvent, useTheme } 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' }, -]; + +type OptionType = { key: string; label: string }; + +const DEFAULT_RANDOM_OPTION = 'random'; +const DEFAULT_STICKINESS_OPTION = 'default'; interface IStickinessSelectProps { label: string; @@ -25,20 +24,27 @@ export const StickinessSelect = ({ const { context } = useUnleashContext(); const theme = useTheme(); - 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 resolveStickinessOptions = () => { + const options = context + .filter(field => field.stickiness) + .map(c => ({ key: c.name, label: c.name })) as OptionType[]; + + if ( + !options.find(option => option.key === 'default') && + !context.find(field => field.name === DEFAULT_STICKINESS_OPTION) + ) { + options.push({ key: 'default', label: 'default' }); + } + + if ( + !options.find(option => option.key === 'random') && + !context.find(field => field.name === DEFAULT_RANDOM_OPTION) + ) { + options.push({ key: 'random', label: 'random' }); + } + + return options; + }; const stickinessOptions = resolveStickinessOptions(); return ( diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index 14b6c4bfc6..faeba55c7e 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -227,6 +227,16 @@ class FeatureToggleService { 'You can not change the featureName for an activation strategy.', ); } + + if ( + strategy.parameters && + 'stickiness' in strategy.parameters && + strategy.parameters.stickiness === '' + ) { + throw new InvalidOperationError( + 'You can not have an empty string for stickiness.', + ); + } } async validateProjectCanAccessSegments( @@ -411,6 +421,14 @@ class FeatureToggleService { ); } + if ( + strategyConfig.parameters && + 'stickiness' in strategyConfig.parameters && + strategyConfig.parameters.stickiness === '' + ) { + strategyConfig.parameters.stickiness = 'default'; + } + try { const newFeatureStrategy = await this.featureStrategiesStore.createStrategyFeatureEnv({ diff --git a/src/migrations/20230420211308-update-context-fields-add-sessionId.js b/src/migrations/20230420211308-update-context-fields-add-sessionId.js new file mode 100644 index 0000000000..16344dc09f --- /dev/null +++ b/src/migrations/20230420211308-update-context-fields-add-sessionId.js @@ -0,0 +1,28 @@ +'use strict'; + +exports.up = function (db, callback) { + db.runSql( + ` + INSERT INTO context_fields(name, description, sort_order, stickiness) VALUES('sessionId', 'Allows you to constrain on sessionId', 4, true); + + UPDATE context_fields + SET stickiness = true + WHERE name LIKE 'userId' AND stickiness is null; + `, + callback, + ); +}; + +exports.down = function (db, callback) { + db.runSql( + ` + DELETE FROM context_fields + WHERE name LIKE 'sessionId'; + + UPDATE context_fields + SET stickiness = null + WHERE name LIKE 'userId'; + `, + callback, + ); +}; diff --git a/src/test/e2e/api/admin/feature.e2e.test.ts b/src/test/e2e/api/admin/feature.e2e.test.ts index 4583c21c91..430c526ed3 100644 --- a/src/test/e2e/api/admin/feature.e2e.test.ts +++ b/src/test/e2e/api/admin/feature.e2e.test.ts @@ -17,8 +17,11 @@ let app: IUnleashTest; let db: ITestDb; const defaultStrategy = { - name: 'default', - parameters: {}, + name: 'flexibleRollout', + parameters: { + rollout: '100', + stickiness: '', + }, constraints: [], }; @@ -844,3 +847,77 @@ test('Can add and remove tags at the same time', async () => { expect(res.body.tags).toHaveLength(1); }); }); + +test('Should return "default" for stickiness when creating a flexibleRollout strategy with "" for stickiness', async () => { + const username = 'toggle-feature'; + const feature = { + name: 'test-featureA', + description: 'the #1 feature', + }; + const projectId = 'default'; + + await app.services.featureToggleServiceV2.createFeatureToggle( + projectId, + feature, + username, + ); + await app.services.featureToggleServiceV2.createStrategy( + defaultStrategy, + { projectId, featureName: feature.name, environment: DEFAULT_ENV }, + username, + ); + + await app.request + .get( + `/api/admin/projects/${projectId}/features/${feature.name}/environments/${DEFAULT_ENV}`, + ) + .expect((res) => { + const toggle = res.body; + expect(toggle.strategies).toHaveLength(1); + expect(toggle.strategies[0].parameters.stickiness).toBe('default'); + }); + + await app.request + .get(`/api/admin/features/${feature.name}`) + .expect((res) => { + const toggle = res.body; + expect(toggle.strategies).toHaveLength(1); + expect(toggle.strategies[0].parameters.stickiness).toBe('default'); + }); +}); + +test('Should throw error when updating a flexibleRollout strategy with "" for stickiness', async () => { + const username = 'toggle-feature'; + const feature = { + name: 'test-featureB', + description: 'the #1 feature', + }; + const projectId = 'default'; + + await app.services.featureToggleServiceV2.createFeatureToggle( + projectId, + feature, + username, + ); + await app.services.featureToggleServiceV2.createStrategy( + defaultStrategy, + { projectId, featureName: feature.name, environment: DEFAULT_ENV }, + username, + ); + + const featureToggle = + await app.services.featureToggleServiceV2.getFeatureToggle( + feature.name, + ); + + await app.request + .patch( + `/api/admin/projects/${projectId}/features/${feature.name}/environments/${DEFAULT_ENV}/strategies/${featureToggle.environments[0].strategies[0].id}`, + ) + .send(defaultStrategy) + .expect((res) => { + const result = res.body; + expect(res.status).toBe(400); + expect(result.error).toBe('Request validation failed'); + }); +}); diff --git a/website/docs/reference/projects.md b/website/docs/reference/projects.md index 645d65db3c..f439bdbf61 100644 --- a/website/docs/reference/projects.md +++ b/website/docs/reference/projects.md @@ -43,13 +43,15 @@ The UI shows the currently available projects. To create a new project, use the The configuration of a new Project is now available. the following input is available to create the new Project. -![A project creation form. The form fields are labeled "project ID", "name", and "description". The "Create" button is highlighted.](/img/projects_save_new_project.png) +![A project creation form. The "Create" button is highlighted.](/img/projects_save_new_project_v2.png) -| Item | Description | -| ------------ | ---------------------------------- | -| Project Id | Id for this Project | -| Project name | The name of the Project. | -| Description | A short description of the project | +| Item | Description | +|--------------------|---------------------------------------------------------------------------------------------| +| Project Id | Id for this Project | +| Project name | The name of the Project. | +| Description | A short description of the project | +| Mode | The project [collaboration mode](/reference/project-collaboration-mode.md) | +| Default Stickiness | The default stickiness for the project. This setting controls the default stickiness value for variants and for the gradual rollout strategy. | ## Deleting an existing project {#deleting-an-existing-project} diff --git a/website/static/img/projects_save_new_project.png b/website/static/img/projects_save_new_project.png deleted file mode 100644 index dda3786d26..0000000000 Binary files a/website/static/img/projects_save_new_project.png and /dev/null differ diff --git a/website/static/img/projects_save_new_project_v2.png b/website/static/img/projects_save_new_project_v2.png new file mode 100644 index 0000000000..e0f099c575 Binary files /dev/null and b/website/static/img/projects_save_new_project_v2.png differ