mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-20 00:08:02 +01:00
feat: context field usage backend (#3921)
This commit is contained in:
parent
209017e421
commit
ec6e4d70b5
@ -645,6 +645,23 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
return rows.map(mapRow);
|
return rows.map(mapRow);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStrategiesByContextField(
|
||||||
|
contextFieldName: string,
|
||||||
|
): Promise<IFeatureStrategy[]> {
|
||||||
|
const stopTimer = this.timer('getStrategiesByContextField');
|
||||||
|
const rows = await this.db
|
||||||
|
.select(this.prefixColumns())
|
||||||
|
.from<IFeatureStrategiesTable>(T.featureStrategies)
|
||||||
|
.where(
|
||||||
|
this.db.raw(
|
||||||
|
"EXISTS (SELECT 1 FROM jsonb_array_elements(constraints) AS elem WHERE elem ->> 'contextName' = ?)",
|
||||||
|
contextFieldName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
stopTimer();
|
||||||
|
return rows.map(mapRow);
|
||||||
|
}
|
||||||
|
|
||||||
prefixColumns(): string[] {
|
prefixColumns(): string[] {
|
||||||
return COLUMNS.map((c) => `${T.featureStrategies}.${c}`);
|
return COLUMNS.map((c) => `${T.featureStrategies}.${c}`);
|
||||||
}
|
}
|
||||||
|
@ -72,6 +72,7 @@ export const createFakeExportImportTogglesService = (
|
|||||||
projectStore,
|
projectStore,
|
||||||
eventStore,
|
eventStore,
|
||||||
contextFieldStore,
|
contextFieldStore,
|
||||||
|
featureStrategiesStore,
|
||||||
},
|
},
|
||||||
{ getLogger },
|
{ getLogger },
|
||||||
);
|
);
|
||||||
@ -166,6 +167,7 @@ export const createExportImportTogglesService = (
|
|||||||
projectStore,
|
projectStore,
|
||||||
eventStore,
|
eventStore,
|
||||||
contextFieldStore,
|
contextFieldStore,
|
||||||
|
featureStrategiesStore,
|
||||||
},
|
},
|
||||||
{ getLogger },
|
{ getLogger },
|
||||||
);
|
);
|
||||||
|
@ -152,6 +152,7 @@ import { clientMetricsEnvSchema } from './spec/client-metrics-env-schema';
|
|||||||
import { updateTagsSchema } from './spec/update-tags-schema';
|
import { updateTagsSchema } from './spec/update-tags-schema';
|
||||||
import { batchStaleSchema } from './spec/batch-stale-schema';
|
import { batchStaleSchema } from './spec/batch-stale-schema';
|
||||||
import { createApplicationSchema } from './spec/create-application-schema';
|
import { createApplicationSchema } from './spec/create-application-schema';
|
||||||
|
import { contextFieldStrategiesSchema } from './spec/context-field-strategies-schema';
|
||||||
|
|
||||||
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
// Schemas must have an $id property on the form "#/components/schemas/mySchema".
|
||||||
export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
|
export type SchemaId = typeof schemas[keyof typeof schemas]['$id'];
|
||||||
@ -329,6 +330,7 @@ export const schemas: UnleashSchemas = {
|
|||||||
importTogglesSchema,
|
importTogglesSchema,
|
||||||
importTogglesValidateSchema,
|
importTogglesValidateSchema,
|
||||||
importTogglesValidateItemSchema,
|
importTogglesValidateItemSchema,
|
||||||
|
contextFieldStrategiesSchema,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
// Remove JSONSchema keys that would result in an invalid OpenAPI spec.
|
||||||
|
@ -12,6 +12,7 @@ export const contextFieldSchema = {
|
|||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
nullable: true,
|
||||||
},
|
},
|
||||||
stickiness: {
|
stickiness: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
@ -24,6 +25,20 @@ export const contextFieldSchema = {
|
|||||||
format: 'date-time',
|
format: 'date-time',
|
||||||
nullable: true,
|
nullable: true,
|
||||||
},
|
},
|
||||||
|
usedInFeatures: {
|
||||||
|
type: 'number',
|
||||||
|
description:
|
||||||
|
'Number of projects where this context field is used in',
|
||||||
|
example: 3,
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
|
usedInProjects: {
|
||||||
|
type: 'number',
|
||||||
|
description:
|
||||||
|
'Number of projects where this context field is used in',
|
||||||
|
example: 2,
|
||||||
|
nullable: true,
|
||||||
|
},
|
||||||
legalValues: {
|
legalValues: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
|
57
src/lib/openapi/spec/context-field-strategies-schema.ts
Normal file
57
src/lib/openapi/spec/context-field-strategies-schema.ts
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
import { FromSchema } from 'json-schema-to-ts';
|
||||||
|
|
||||||
|
export const contextFieldStrategiesSchema = {
|
||||||
|
$id: '#/components/schemas/segmentStrategiesSchema',
|
||||||
|
type: 'object',
|
||||||
|
description:
|
||||||
|
'A wrapper object containing all for strategies using a specific context field',
|
||||||
|
required: ['strategies'],
|
||||||
|
properties: {
|
||||||
|
strategies: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'List of strategies using the context field',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
required: [
|
||||||
|
'id',
|
||||||
|
'featureName',
|
||||||
|
'projectId',
|
||||||
|
'environment',
|
||||||
|
'strategyName',
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
id: {
|
||||||
|
type: 'string',
|
||||||
|
example: '433ae8d9-dd69-4ad0-bc46-414aedbe9c55',
|
||||||
|
description: 'The ID of the strategy.',
|
||||||
|
},
|
||||||
|
featureName: {
|
||||||
|
type: 'string',
|
||||||
|
example: 'best-feature',
|
||||||
|
description:
|
||||||
|
'The name of the feature that contains this strategy.',
|
||||||
|
},
|
||||||
|
projectId: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'The ID of the project that contains this feature.',
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
type: 'string',
|
||||||
|
description:
|
||||||
|
'The ID of the environment where this strategy is in.',
|
||||||
|
},
|
||||||
|
strategyName: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'The name of the strategy.',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
components: {},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type ContextFieldStrategiesSchema = FromSchema<
|
||||||
|
typeof contextFieldStrategiesSchema
|
||||||
|
>;
|
@ -32,6 +32,7 @@ import { serializeDates } from '../../types/serialize-dates';
|
|||||||
import NotFoundError from '../../error/notfound-error';
|
import NotFoundError from '../../error/notfound-error';
|
||||||
import { NameSchema } from '../../openapi/spec/name-schema';
|
import { NameSchema } from '../../openapi/spec/name-schema';
|
||||||
import { emptyResponse } from '../../openapi/util/standard-responses';
|
import { emptyResponse } from '../../openapi/util/standard-responses';
|
||||||
|
import { contextFieldStrategiesSchema } from '../../openapi/spec/context-field-strategies-schema';
|
||||||
|
|
||||||
interface ContextParam {
|
interface ContextParam {
|
||||||
contextField: string;
|
contextField: string;
|
||||||
@ -88,6 +89,24 @@ export class ContextController extends Controller {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route({
|
||||||
|
method: 'get',
|
||||||
|
path: '/:contextField/strategies',
|
||||||
|
handler: this.getStrategiesByContextField,
|
||||||
|
permission: NONE,
|
||||||
|
middleware: [
|
||||||
|
openApiService.validPath({
|
||||||
|
tags: ['Strategies'],
|
||||||
|
operationId: 'getStrategiesByContextField',
|
||||||
|
responses: {
|
||||||
|
200: createResponseSchema(
|
||||||
|
'contextFieldStrategiesSchema',
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
this.route({
|
this.route({
|
||||||
method: 'post',
|
method: 'post',
|
||||||
path: '',
|
path: '',
|
||||||
@ -243,4 +262,20 @@ export class ContextController extends Controller {
|
|||||||
await this.contextService.validateName(name);
|
await this.contextService.validateName(name);
|
||||||
res.status(200).end();
|
res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStrategiesByContextField(
|
||||||
|
req: IAuthRequest<{ contextField: string }>,
|
||||||
|
res: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const { contextField } = req.params;
|
||||||
|
const contextFields =
|
||||||
|
await this.contextService.getStrategiesByContextField(contextField);
|
||||||
|
|
||||||
|
this.openApiService.respondWithValidation(
|
||||||
|
200,
|
||||||
|
res,
|
||||||
|
contextFieldStrategiesSchema.$id,
|
||||||
|
serializeDates(contextFields),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -6,8 +6,9 @@ import {
|
|||||||
} from '../types/stores/context-field-store';
|
} from '../types/stores/context-field-store';
|
||||||
import { IEventStore } from '../types/stores/event-store';
|
import { IEventStore } from '../types/stores/event-store';
|
||||||
import { IProjectStore } from '../types/stores/project-store';
|
import { IProjectStore } from '../types/stores/project-store';
|
||||||
import { IUnleashStores } from '../types/stores';
|
import { IFeatureStrategiesStore, IUnleashStores } from '../types/stores';
|
||||||
import { IUnleashConfig } from '../types/option';
|
import { IUnleashConfig } from '../types/option';
|
||||||
|
import { ContextFieldStrategiesSchema } from '../openapi/spec/context-field-strategies-schema';
|
||||||
|
|
||||||
const { contextSchema, nameSchema } = require('./context-schema');
|
const { contextSchema, nameSchema } = require('./context-schema');
|
||||||
const NameExistsError = require('../error/name-exists-error');
|
const NameExistsError = require('../error/name-exists-error');
|
||||||
@ -25,6 +26,8 @@ class ContextService {
|
|||||||
|
|
||||||
private contextFieldStore: IContextFieldStore;
|
private contextFieldStore: IContextFieldStore;
|
||||||
|
|
||||||
|
private featureStrategiesStore: IFeatureStrategiesStore;
|
||||||
|
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@ -32,15 +35,20 @@ class ContextService {
|
|||||||
projectStore,
|
projectStore,
|
||||||
eventStore,
|
eventStore,
|
||||||
contextFieldStore,
|
contextFieldStore,
|
||||||
|
featureStrategiesStore,
|
||||||
}: Pick<
|
}: Pick<
|
||||||
IUnleashStores,
|
IUnleashStores,
|
||||||
'projectStore' | 'eventStore' | 'contextFieldStore'
|
| 'projectStore'
|
||||||
|
| 'eventStore'
|
||||||
|
| 'contextFieldStore'
|
||||||
|
| 'featureStrategiesStore'
|
||||||
>,
|
>,
|
||||||
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
{ getLogger }: Pick<IUnleashConfig, 'getLogger'>,
|
||||||
) {
|
) {
|
||||||
this.projectStore = projectStore;
|
this.projectStore = projectStore;
|
||||||
this.eventStore = eventStore;
|
this.eventStore = eventStore;
|
||||||
this.contextFieldStore = contextFieldStore;
|
this.contextFieldStore = contextFieldStore;
|
||||||
|
this.featureStrategiesStore = featureStrategiesStore;
|
||||||
this.logger = getLogger('services/context-service.js');
|
this.logger = getLogger('services/context-service.js');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,6 +60,22 @@ class ContextService {
|
|||||||
return this.contextFieldStore.get(name);
|
return this.contextFieldStore.get(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStrategiesByContextField(
|
||||||
|
name: string,
|
||||||
|
): Promise<ContextFieldStrategiesSchema> {
|
||||||
|
const strategies =
|
||||||
|
await this.featureStrategiesStore.getStrategiesByContextField(name);
|
||||||
|
return {
|
||||||
|
strategies: strategies.map((strategy) => ({
|
||||||
|
id: strategy.id,
|
||||||
|
projectId: strategy.projectId,
|
||||||
|
featureName: strategy.featureName,
|
||||||
|
strategyName: strategy.strategyName,
|
||||||
|
environment: strategy.environment,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async createContextField(
|
async createContextField(
|
||||||
value: IContextFieldDto,
|
value: IContextFieldDto,
|
||||||
userName: string,
|
userName: string,
|
||||||
|
@ -58,6 +58,9 @@ export interface IFeatureStrategiesStore
|
|||||||
newProjectId: string,
|
newProjectId: string,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>;
|
getStrategiesBySegment(segmentId: number): Promise<IFeatureStrategy[]>;
|
||||||
|
getStrategiesByContextField(
|
||||||
|
contextFieldName: string,
|
||||||
|
): Promise<IFeatureStrategy[]>;
|
||||||
updateSortOrder(id: string, sortOrder: number): Promise<void>;
|
updateSortOrder(id: string, sortOrder: number): Promise<void>;
|
||||||
getAllByFeatures(
|
getAllByFeatures(
|
||||||
features: string[],
|
features: string[],
|
||||||
|
@ -1,5 +1,8 @@
|
|||||||
import dbInit from '../../helpers/database-init';
|
import dbInit from '../../helpers/database-init';
|
||||||
import { IUnleashTest, setupApp } from '../../helpers/test-helper';
|
import {
|
||||||
|
IUnleashTest,
|
||||||
|
setupAppWithCustomConfig,
|
||||||
|
} from '../../helpers/test-helper';
|
||||||
import getLogger from '../../../fixtures/no-logger';
|
import getLogger from '../../../fixtures/no-logger';
|
||||||
|
|
||||||
let db;
|
let db;
|
||||||
@ -7,7 +10,13 @@ let app: IUnleashTest;
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
db = await dbInit('context_api_serial', getLogger);
|
db = await dbInit('context_api_serial', getLogger);
|
||||||
app = await setupApp(db.stores);
|
app = await setupAppWithCustomConfig(db.stores, {
|
||||||
|
experimental: {
|
||||||
|
flags: {
|
||||||
|
strictSchemaValidation: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@ -225,3 +234,51 @@ test('should update context field with stickiness', async () => {
|
|||||||
expect(contextField.description).toBe('asd');
|
expect(contextField.description).toBe('asd');
|
||||||
expect(contextField.stickiness).toBe(true);
|
expect(contextField.stickiness).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should show context field usage', async () => {
|
||||||
|
const context = 'appName';
|
||||||
|
const feature = 'contextFeature';
|
||||||
|
await app.request
|
||||||
|
.post('/api/admin/projects/default/features')
|
||||||
|
.send({
|
||||||
|
name: feature,
|
||||||
|
enabled: false,
|
||||||
|
strategies: [{ name: 'default' }],
|
||||||
|
})
|
||||||
|
.set('Content-Type', 'application/json')
|
||||||
|
.expect(201);
|
||||||
|
await app.request
|
||||||
|
.post(
|
||||||
|
`/api/admin/projects/default/features/${feature}/environments/default/strategies`,
|
||||||
|
)
|
||||||
|
.send({
|
||||||
|
name: 'default',
|
||||||
|
parameters: {
|
||||||
|
userId: '14',
|
||||||
|
},
|
||||||
|
constraints: [
|
||||||
|
{
|
||||||
|
contextName: context,
|
||||||
|
operator: 'IN',
|
||||||
|
values: ['test'],
|
||||||
|
caseInsensitive: false,
|
||||||
|
inverted: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await app.request.post('/api/admin/context').send({
|
||||||
|
name: context,
|
||||||
|
description: context,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body } = await app.request.get(
|
||||||
|
`/api/admin/context/${context}/strategies`,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(body.strategies).toHaveLength(1);
|
||||||
|
expect(body).toMatchObject({
|
||||||
|
strategies: [{ environment: 'default', featureName: 'contextFeature' }],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
@ -1391,6 +1391,7 @@ The provider you choose for your addon dictates what properties the \`parameters
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"description": {
|
"description": {
|
||||||
|
"nullable": true,
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"legalValues": {
|
"legalValues": {
|
||||||
@ -1408,12 +1409,71 @@ The provider you choose for your addon dictates what properties the \`parameters
|
|||||||
"stickiness": {
|
"stickiness": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
},
|
},
|
||||||
|
"usedInFeatures": {
|
||||||
|
"description": "Number of projects where this context field is used in",
|
||||||
|
"example": 3,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
|
"usedInProjects": {
|
||||||
|
"description": "Number of projects where this context field is used in",
|
||||||
|
"example": 2,
|
||||||
|
"nullable": true,
|
||||||
|
"type": "number",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"name",
|
"name",
|
||||||
],
|
],
|
||||||
"type": "object",
|
"type": "object",
|
||||||
},
|
},
|
||||||
|
"contextFieldStrategiesSchema": {
|
||||||
|
"description": "A wrapper object containing all for strategies using a specific context field",
|
||||||
|
"properties": {
|
||||||
|
"strategies": {
|
||||||
|
"description": "List of strategies using the context field",
|
||||||
|
"items": {
|
||||||
|
"properties": {
|
||||||
|
"environment": {
|
||||||
|
"description": "The ID of the environment where this strategy is in.",
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"featureName": {
|
||||||
|
"description": "The name of the feature that contains this strategy.",
|
||||||
|
"example": "best-feature",
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"id": {
|
||||||
|
"description": "The ID of the strategy.",
|
||||||
|
"example": "433ae8d9-dd69-4ad0-bc46-414aedbe9c55",
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"projectId": {
|
||||||
|
"description": "The ID of the project that contains this feature.",
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
"strategyName": {
|
||||||
|
"description": "The name of the strategy.",
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"featureName",
|
||||||
|
"projectId",
|
||||||
|
"environment",
|
||||||
|
"strategyName",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
"type": "array",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"strategies",
|
||||||
|
],
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
"contextFieldsSchema": {
|
"contextFieldsSchema": {
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/components/schemas/contextFieldSchema",
|
"$ref": "#/components/schemas/contextFieldSchema",
|
||||||
@ -6712,6 +6772,36 @@ Note: passing \`null\` as a value for the description property will set it to an
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/admin/context/{contextField}/strategies": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "getStrategiesByContextField",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "contextField",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/contextFieldStrategiesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"description": "contextFieldStrategiesSchema",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"Strategies",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/admin/environments": {
|
"/api/admin/environments": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "Retrieves all environments that exist in this Unleash instance.",
|
"description": "Retrieves all environments that exist in this Unleash instance.",
|
||||||
|
@ -35,6 +35,17 @@ export default class FakeFeatureStrategiesStore
|
|||||||
return Promise.resolve(newStrat);
|
return Promise.resolve(newStrat);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getStrategiesByContextField(
|
||||||
|
contextFieldName: string,
|
||||||
|
): Promise<IFeatureStrategy[]> {
|
||||||
|
const strategies = this.featureStrategies.filter((strategy) =>
|
||||||
|
strategy.constraints.some(
|
||||||
|
(constraint) => constraint.contextName === contextFieldName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Promise.resolve(strategies);
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
|
||||||
async createFeature(feature: any): Promise<void> {
|
async createFeature(feature: any): Promise<void> {
|
||||||
this.featureToggles.push({
|
this.featureToggles.push({
|
||||||
|
Loading…
Reference in New Issue
Block a user