diff --git a/src/lib/db/feature-strategy-store.ts b/src/lib/db/feature-strategy-store.ts index 65fc0e69f2..bd5331c2f6 100644 --- a/src/lib/db/feature-strategy-store.ts +++ b/src/lib/db/feature-strategy-store.ts @@ -385,6 +385,12 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { throw new NotFoundError(`Could not find strategy with id: ${id}`); } + async updateSortOrder(id: string, sortOrder: number): Promise { + await this.db(T.featureStrategies) + .where({ id }) + .update({ sort_order: sortOrder }); + } + async updateStrategy( id: string, updates: Partial, diff --git a/src/lib/openapi/index.ts b/src/lib/openapi/index.ts index 35bfa20b48..31309970b1 100644 --- a/src/lib/openapi/index.ts +++ b/src/lib/openapi/index.ts @@ -62,8 +62,8 @@ import { patchesSchema } from './spec/patches-schema'; import { patchSchema } from './spec/patch-schema'; import { permissionSchema } from './spec/permission-schema'; import { playgroundFeatureSchema } from './spec/playground-feature-schema'; -import { playgroundResponseSchema } from './spec/playground-response-schema'; import { playgroundRequestSchema } from './spec/playground-request-schema'; +import { playgroundResponseSchema } from './spec/playground-response-schema'; import { projectEnvironmentSchema } from './spec/project-environment-schema'; import { projectSchema } from './spec/project-schema'; import { projectsSchema } from './spec/projects-schema'; @@ -71,6 +71,7 @@ import { resetPasswordSchema } from './spec/reset-password-schema'; import { roleSchema } from './spec/role-schema'; import { sdkContextSchema } from './spec/sdk-context-schema'; import { segmentSchema } from './spec/segment-schema'; +import { setStrategySortOrderSchema } from './spec/set-strategy-sort-order-schema'; import { sortOrderSchema } from './spec/sort-order-schema'; import { splashSchema } from './spec/splash-schema'; import { stateSchema } from './spec/state-schema'; @@ -171,8 +172,8 @@ export const schemas = { patchSchema, permissionSchema, playgroundFeatureSchema, - playgroundResponseSchema, playgroundRequestSchema, + playgroundResponseSchema, projectEnvironmentSchema, projectSchema, projectsSchema, @@ -180,6 +181,7 @@ export const schemas = { roleSchema, sdkContextSchema, segmentSchema, + setStrategySortOrderSchema, sortOrderSchema, splashSchema, stateSchema, diff --git a/src/lib/openapi/spec/__snapshots__/set-strategy-sort-order-schema.test.ts.snap b/src/lib/openapi/spec/__snapshots__/set-strategy-sort-order-schema.test.ts.snap new file mode 100644 index 0000000000..029ef827b4 --- /dev/null +++ b/src/lib/openapi/spec/__snapshots__/set-strategy-sort-order-schema.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`setStrategySortOrderSchema missing id 1`] = ` +Object { + "errors": Array [ + Object { + "instancePath": "/0", + "keyword": "required", + "message": "must have required property 'id'", + "params": Object { + "missingProperty": "id", + }, + "schemaPath": "#/items/required", + }, + ], + "schema": "#/components/schemas/setStrategySortOrderSchema", +} +`; + +exports[`setStrategySortOrderSchema missing sortOrder 1`] = ` +Object { + "errors": Array [ + Object { + "instancePath": "/0", + "keyword": "required", + "message": "must have required property 'sortOrder'", + "params": Object { + "missingProperty": "sortOrder", + }, + "schemaPath": "#/items/required", + }, + ], + "schema": "#/components/schemas/setStrategySortOrderSchema", +} +`; + +exports[`setStrategySortOrderSchema no additional parameters 1`] = ` +Object { + "errors": Array [ + Object { + "instancePath": "/1", + "keyword": "additionalProperties", + "message": "must NOT have additional properties", + "params": Object { + "additionalProperty": "extra", + }, + "schemaPath": "#/items/additionalProperties", + }, + ], + "schema": "#/components/schemas/setStrategySortOrderSchema", +} +`; + +exports[`setStrategySortOrderSchema wrong sortOrder type 1`] = ` +Object { + "errors": Array [ + Object { + "instancePath": "/0/sortOrder", + "keyword": "type", + "message": "must be number", + "params": Object { + "type": "number", + }, + "schemaPath": "#/items/properties/sortOrder/type", + }, + ], + "schema": "#/components/schemas/setStrategySortOrderSchema", +} +`; diff --git a/src/lib/openapi/spec/set-strategy-sort-order-schema.test.ts b/src/lib/openapi/spec/set-strategy-sort-order-schema.test.ts new file mode 100644 index 0000000000..83ff4330cb --- /dev/null +++ b/src/lib/openapi/spec/set-strategy-sort-order-schema.test.ts @@ -0,0 +1,49 @@ +import { validateSchema } from '../validate'; +import { SetStrategySortOrderSchema } from './set-strategy-sort-order-schema'; + +test('setStrategySortOrderSchema', () => { + const data: SetStrategySortOrderSchema = [ + { id: 'strategy-1', sortOrder: 1 }, + { id: 'strategy-2', sortOrder: 2 }, + { id: 'strategy-3', sortOrder: 3 }, + ]; + + expect( + validateSchema('#/components/schemas/setStrategySortOrderSchema', data), + ).toBeUndefined(); +}); + +test('setStrategySortOrderSchema missing sortOrder', () => { + expect( + validateSchema('#/components/schemas/setStrategySortOrderSchema', [ + { id: 'strategy-1' }, + ]), + ).toMatchSnapshot(); +}); + +test('setStrategySortOrderSchema missing id', () => { + expect( + validateSchema('#/components/schemas/setStrategySortOrderSchema', [ + { sortOrder: 123 }, + { sortOrder: 7 }, + ]), + ).toMatchSnapshot(); +}); + +test('setStrategySortOrderSchema wrong sortOrder type', () => { + expect( + validateSchema('#/components/schemas/setStrategySortOrderSchema', [ + { id: 'strategy-1', sortOrder: 'test' }, + ]), + ).toMatchSnapshot(); +}); + +test('setStrategySortOrderSchema no additional parameters', () => { + expect( + validateSchema('#/components/schemas/setStrategySortOrderSchema', [ + { id: 'strategy-1', sortOrder: 1 }, + { id: 'strategy-2', sortOrder: 2, extra: 'test' }, + { id: 'strategy-3', sortOrder: 3 }, + ]), + ).toMatchSnapshot(); +}); diff --git a/src/lib/openapi/spec/set-strategy-sort-order-schema.ts b/src/lib/openapi/spec/set-strategy-sort-order-schema.ts new file mode 100644 index 0000000000..e2574e81f9 --- /dev/null +++ b/src/lib/openapi/spec/set-strategy-sort-order-schema.ts @@ -0,0 +1,24 @@ +import { FromSchema } from 'json-schema-to-ts'; + +export const setStrategySortOrderSchema = { + $id: '#/components/schemas/setStrategySortOrderSchema', + type: 'array', + items: { + type: 'object', + additionalProperties: false, + required: ['id', 'sortOrder'], + properties: { + id: { + type: 'string', + }, + sortOrder: { + type: 'number', + }, + }, + }, + components: {}, +} as const; + +export type SetStrategySortOrderSchema = FromSchema< + typeof setStrategySortOrderSchema +>; diff --git a/src/lib/routes/admin-api/project/features.ts b/src/lib/routes/admin-api/project/features.ts index 5f7cc388ea..7e411146d7 100644 --- a/src/lib/routes/admin-api/project/features.ts +++ b/src/lib/routes/admin-api/project/features.ts @@ -36,6 +36,8 @@ import { OpenApiService } from '../../../services/openapi-service'; import { createRequestSchema } from '../../../openapi/util/create-request-schema'; import { createResponseSchema } from '../../../openapi/util/create-response-schema'; import { FeatureEnvironmentSchema } from '../../../openapi/spec/feature-environment-schema'; +import { SetStrategySortOrderSchema } from '../../../openapi/spec/set-strategy-sort-order-schema'; + import { emptyResponse } from '../../../openapi/util/standard-responses'; interface FeatureStrategyParams { @@ -182,6 +184,25 @@ export default class ProjectFeaturesController extends Controller { ], }); + this.route({ + method: 'post', + path: `${PATH_STRATEGIES}/set-sort-order`, + handler: this.setStrategiesSortOrder, + permission: UPDATE_FEATURE_STRATEGY, + middleware: [ + openApiService.validPath({ + tags: ['admin'], + operationId: 'setStrategySortOrder', + requestBody: createRequestSchema( + 'setStrategySortOrderSchema', + ), + responses: { + 200: emptyResponse, + }, + }), + ], + }); + this.route({ method: 'put', path: PATH_STRATEGY, @@ -217,6 +238,7 @@ export default class ProjectFeaturesController extends Controller { }), ], }); + this.route({ method: 'delete', path: PATH_STRATEGY, @@ -556,6 +578,24 @@ export default class ProjectFeaturesController extends Controller { res.status(200).json(featureStrategies); } + async setStrategiesSortOrder( + req: Request< + FeatureStrategyParams, + any, + SetStrategySortOrderSchema, + any + >, + res: Response, + ): Promise { + const { featureName } = req.params; + await this.featureService.updateStrategiesSortOrder( + featureName, + req.body, + ); + + res.status(200).send(); + } + async updateFeatureStrategy( req: IAuthRequest, res: Response, diff --git a/src/lib/services/feature-toggle-service.ts b/src/lib/services/feature-toggle-service.ts index caaeabbfbd..eeb1156445 100644 --- a/src/lib/services/feature-toggle-service.ts +++ b/src/lib/services/feature-toggle-service.ts @@ -70,6 +70,7 @@ import { import { IContextFieldStore } from 'lib/types/stores/context-field-store'; import { Saved, Unsaved } from '../types/saved'; import { SegmentService } from './segment-service'; +import { SetStrategySortOrderSchema } from 'lib/openapi/spec/set-strategy-sort-order-schema'; interface IFeatureContext { featureName: string; @@ -282,6 +283,17 @@ class FeatureToggleService { }; } + async updateStrategiesSortOrder( + featureName: string, + sortOrders: SetStrategySortOrderSchema, + ): Promise> { + await Promise.all( + sortOrders.map(({ id, sortOrder }) => { + this.featureStrategiesStore.updateSortOrder(id, sortOrder); + }), + ); + } + async createStrategy( strategyConfig: Unsaved, context: IFeatureStrategyContext, diff --git a/src/lib/types/stores/feature-strategies-store.ts b/src/lib/types/stores/feature-strategies-store.ts index 28e86a6d4c..33c00bc28a 100644 --- a/src/lib/types/stores/feature-strategies-store.ts +++ b/src/lib/types/stores/feature-strategies-store.ts @@ -51,4 +51,5 @@ export interface IFeatureStrategiesStore newProjectId: string, ): Promise; getStrategiesBySegment(segmentId: number): Promise; + updateSortOrder(id: string, sortOrder: number): Promise; } diff --git a/src/test/e2e/api/admin/project/features.e2e.test.ts b/src/test/e2e/api/admin/project/features.e2e.test.ts index 3a35f33916..e0d1f5ea0b 100644 --- a/src/test/e2e/api/admin/project/features.e2e.test.ts +++ b/src/test/e2e/api/admin/project/features.e2e.test.ts @@ -2276,3 +2276,210 @@ test('should allow long parameter values', async () => { .send(strategy) .expect(200); }); + +test('should change strategy sort order when payload is valid', async () => { + const toggle = { name: uuidv4(), impressionData: false }; + await app.request + .post('/api/admin/projects/default/features') + .send({ + name: toggle.name, + }) + .expect(201); + + const { body: strategyOne } = await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ) + .send({ + name: 'default', + parameters: { + userId: 'string', + }, + }) + .expect(200); + + const { body: strategyTwo } = await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ) + .send({ + name: 'gradualrollout', + parameters: { + userId: 'string', + }, + }) + .expect(200); + + const { body: strategies } = await app.request.get( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ); + + expect(strategies[0].sortOrder).toBe(9999); + expect(strategies[0].id).toBe(strategyOne.id); + expect(strategies[1].sortOrder).toBe(9999); + expect(strategies[1].id).toBe(strategyTwo.id); + + await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies/set-sort-order`, + ) + .send([ + { + id: strategyOne.id, + sortOrder: 2, + }, + { + id: strategyTwo.id, + sortOrder: 1, + }, + ]) + .expect(200); + + const { body: strategiesOrdered } = await app.request.get( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ); + + expect(strategiesOrdered[0].sortOrder).toBe(1); + expect(strategiesOrdered[0].id).toBe(strategyTwo.id); + expect(strategiesOrdered[1].sortOrder).toBe(2); + expect(strategiesOrdered[1].id).toBe(strategyOne.id); +}); + +test('should reject set sort order request when payload is invalid', async () => { + const toggle = { name: uuidv4(), impressionData: false }; + + await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies/set-sort-order`, + ) + .send([ + { + id: '213141', + }, + { + id: '341123', + }, + ]) + .expect(400); +}); + +test('should return strategies in correct order when new strategies are added', async () => { + const toggle = { name: uuidv4(), impressionData: false }; + await app.request + .post('/api/admin/projects/default/features') + .send({ + name: toggle.name, + }) + .expect(201); + + const { body: strategyOne } = await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ) + .send({ + name: 'default', + parameters: { + userId: 'string', + }, + }) + .expect(200); + + const { body: strategyTwo } = await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ) + .send({ + name: 'gradualrollout', + parameters: { + userId: 'string', + }, + }) + .expect(200); + + const { body: strategies } = await app.request.get( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ); + + expect(strategies[0].sortOrder).toBe(9999); + expect(strategies[0].id).toBe(strategyOne.id); + expect(strategies[1].sortOrder).toBe(9999); + expect(strategies[1].id).toBe(strategyTwo.id); + + await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies/set-sort-order`, + ) + .send([ + { + id: strategyOne.id, + sortOrder: 2, + }, + { + id: strategyTwo.id, + sortOrder: 1, + }, + ]) + .expect(200); + + const { body: strategyThree } = await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ) + .send({ + name: 'gradualrollout', + parameters: { + userId: 'string', + }, + }) + .expect(200); + + const { body: strategyFour } = await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ) + .send({ + name: 'gradualrollout', + parameters: { + userId: 'string', + }, + }) + .expect(200); + + const { body: strategiesOrdered } = await app.request.get( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ); + + expect(strategiesOrdered[0].sortOrder).toBe(1); + expect(strategiesOrdered[0].id).toBe(strategyTwo.id); + expect(strategiesOrdered[1].sortOrder).toBe(2); + expect(strategiesOrdered[1].id).toBe(strategyOne.id); + expect(strategiesOrdered[2].id).toBe(strategyThree.id); + expect(strategiesOrdered[3].id).toBe(strategyFour.id); + + await app.request + .post( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies/set-sort-order`, + ) + .send([ + { + id: strategyFour.id, + sortOrder: 0, + }, + ]) + .expect(200); + + const { body: strategiesReOrdered } = await app.request.get( + `/api/admin/projects/default/features/${toggle.name}/environments/default/strategies`, + ); + + // This block checks the order of the strategies retrieved from the endpoint. After partial update, the order should + // change because the new element received a lower sort order than the previous objects. + expect(strategiesReOrdered[0].sortOrder).toBe(0); + expect(strategiesReOrdered[0].id).toBe(strategyFour.id); + expect(strategiesReOrdered[1].sortOrder).toBe(1); + expect(strategiesReOrdered[1].id).toBe(strategyTwo.id); + expect(strategiesReOrdered[2].sortOrder).toBe(2); + expect(strategiesReOrdered[2].id).toBe(strategyOne.id); + expect(strategiesReOrdered[3].sortOrder).toBe(9999); + expect(strategiesReOrdered[3].id).toBe(strategyThree.id); +}); diff --git a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap index 54178a2264..0b497a619f 100644 --- a/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap +++ b/src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap @@ -2161,6 +2161,25 @@ Object { ], "type": "object", }, + "setStrategySortOrderSchema": Object { + "items": Object { + "additionalProperties": false, + "properties": Object { + "id": Object { + "type": "string", + }, + "sortOrder": Object { + "type": "number", + }, + }, + "required": Array [ + "id", + "sortOrder", + ], + "type": "object", + }, + "type": "array", + }, "sortOrderSchema": Object { "additionalProperties": Object { "type": "number", @@ -4677,6 +4696,56 @@ If the provided project does not exist, the list of events will be empty.", ], }, }, + "/api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategies/set-sort-order": Object { + "post": Object { + "operationId": "setStrategySortOrder", + "parameters": Array [ + Object { + "in": "path", + "name": "projectId", + "required": true, + "schema": Object { + "type": "string", + }, + }, + Object { + "in": "path", + "name": "featureName", + "required": true, + "schema": Object { + "type": "string", + }, + }, + Object { + "in": "path", + "name": "environment", + "required": true, + "schema": Object { + "type": "string", + }, + }, + ], + "requestBody": Object { + "content": Object { + "application/json": Object { + "schema": Object { + "$ref": "#/components/schemas/setStrategySortOrderSchema", + }, + }, + }, + "description": "setStrategySortOrderSchema", + "required": true, + }, + "responses": Object { + "200": Object { + "description": "This response has no body.", + }, + }, + "tags": Array [ + "admin", + ], + }, + }, "/api/admin/projects/{projectId}/features/{featureName}/environments/{environment}/strategies/{strategyId}": Object { "delete": Object { "operationId": "deleteFeatureStrategy", diff --git a/src/test/fixtures/fake-feature-strategies-store.ts b/src/test/fixtures/fake-feature-strategies-store.ts index a343a11188..99c1848c5f 100644 --- a/src/test/fixtures/fake-feature-strategies-store.ts +++ b/src/test/fixtures/fake-feature-strategies-store.ts @@ -73,6 +73,15 @@ export default class FakeFeatureStrategiesStore this.featureStrategies = []; } + // FIXME: implement + async updateSortOrder(id: string, sortOrder: number): Promise { + const found = this.featureStrategies.find((item) => item.id === id); + + if (found) { + found.sortOrder = sortOrder; + } + } + destroy(): void { throw new Error('Method not implemented.'); }