Skip to content

Commit

Permalink
feat: export by tags (#3635)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Apr 27, 2023
1 parent 4f12361 commit 70a8ab4
Show file tree
Hide file tree
Showing 9 changed files with 177 additions and 56 deletions.
8 changes: 8 additions & 0 deletions src/lib/db/feature-tag-store.ts
Expand Up @@ -110,6 +110,14 @@ class FeatureTagStore implements IFeatureTagStore {
}
}

async getAllFeaturesForTag(tagValue: string): Promise<string[]> {
const rows = await this.db
.select('feature_name')
.from<FeatureTagTable>(TABLE)
.where({ tag_value: tagValue });
return rows.map(({ feature_name }) => feature_name);
}

async featureExists(featureName: string): Promise<boolean> {
const result = await this.db.raw(
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
Expand Down
81 changes: 44 additions & 37 deletions src/lib/features/export-import-toggles/export-import-service.ts
Expand Up @@ -169,7 +169,7 @@ export default class ExportImportService {
const errors = ImportValidationMessages.compileErrors(
dto.project,
unsupportedStrategies,
unsupportedContextFields,
unsupportedContextFields || [],
[],
otherProjectFeatures,
false,
Expand Down Expand Up @@ -226,7 +226,7 @@ export default class ExportImportService {

private async importToggleStatuses(dto: ImportTogglesSchema, user: User) {
await Promise.all(
dto.data.featureEnvironments?.map((featureEnvironment) =>
(dto.data.featureEnvironments || []).map((featureEnvironment) =>
this.featureToggleService.updateEnabled(
dto.project,
featureEnvironment.name,
Expand Down Expand Up @@ -281,13 +281,15 @@ export default class ExportImportService {
dto.data.features.map((feature) => feature.name),
);
return Promise.all(
dto.data.featureTags?.map((tag) =>
this.featureTagService.addTag(
tag.featureName,
{ type: tag.tagType, value: tag.tagValue },
extractUsernameFromUser(user),
),
),
(dto.data.featureTags || []).map((tag) => {
return tag.tagType
? this.featureTagService.addTag(
tag.featureName,
{ type: tag.tagType, value: tag.tagValue },
extractUsernameFromUser(user),
)
: Promise.resolve();
}),
);
}

Expand All @@ -311,12 +313,14 @@ export default class ExportImportService {
private async importTagTypes(dto: ImportTogglesSchema, user: User) {
const newTagTypes = await this.getNewTagTypes(dto);
return Promise.all(
newTagTypes.map((tagType) =>
this.tagTypeService.createTagType(
tagType,
extractUsernameFromUser(user),
),
),
newTagTypes.map((tagType) => {
return tagType
? this.tagTypeService.createTagType(
tagType,
extractUsernameFromUser(user),
)
: Promise.resolve();
}),
);
}

Expand All @@ -328,15 +332,17 @@ export default class ExportImportService {
featureEnvironment.variants.length > 0,
) || [];
await Promise.all(
featureEnvsWithVariants.map((featureEnvironment) =>
this.featureToggleService.saveVariantsOnEnv(
dto.project,
featureEnvironment.featureName,
dto.environment,
featureEnvironment.variants as IVariant[],
user,
),
),
featureEnvsWithVariants.map((featureEnvironment) => {
return featureEnvironment.featureName
? this.featureToggleService.saveVariantsOnEnv(
dto.project,
featureEnvironment.featureName,
dto.environment,
featureEnvironment.variants as IVariant[],
user,
)
: Promise.resolve();
}),
);
}

Expand Down Expand Up @@ -395,11 +401,10 @@ export default class ExportImportService {

private async cleanData(dto: ImportTogglesSchema) {
const removedFeaturesDto = await this.removeArchivedFeatures(dto);
const remappedDto = this.remapSegments(removedFeaturesDto);
return remappedDto;
return ExportImportService.remapSegments(removedFeaturesDto);
}

private async remapSegments(dto: ImportTogglesSchema) {
private static async remapSegments(dto: ImportTogglesSchema) {
return {
...dto,
data: {
Expand Down Expand Up @@ -533,14 +538,12 @@ export default class ExportImportService {
const existingTagTypes = (await this.tagTypeService.getAll()).map(
(tagType) => tagType.name,
);
const newTagTypes =
dto.data.tagTypes?.filter(
(tagType) => !existingTagTypes.includes(tagType.name),
) || [];
const uniqueTagTypes = [
const newTagTypes = (dto.data.tagTypes || []).filter(
(tagType) => !existingTagTypes.includes(tagType.name),
);
return [
...new Map(newTagTypes.map((item) => [item.name, item])).values(),
];
return uniqueTagTypes;
}

private async getNewContextFields(dto: ImportTogglesSchema) {
Expand All @@ -559,6 +562,10 @@ export default class ExportImportService {
query: ExportQuerySchema,
userName: string,
): Promise<ExportResultSchema> {
const featureNames =
typeof query.tag === 'string'
? await this.featureTagService.listFeatures(query.tag)
: (query.features as string[]) || [];
const [
features,
featureEnvironments,
Expand All @@ -569,18 +576,18 @@ export default class ExportImportService {
segments,
tagTypes,
] = await Promise.all([
this.toggleStore.getAllByNames(query.features),
this.toggleStore.getAllByNames(featureNames),
await this.featureEnvironmentStore.getAllByFeatures(
query.features,
featureNames,
query.environment,
),
this.featureStrategiesStore.getAllByFeatures(
query.features,
featureNames,
query.environment,
),
this.segmentStore.getAllFeatureStrategySegments(),
this.contextFieldStore.getAll(),
this.featureTagStore.getAllByFeatures(query.features),
this.featureTagStore.getAllByFeatures(featureNames),
this.segmentStore.getAll(),
this.tagTypeStore.getAll(),
]);
Expand Down
56 changes: 56 additions & 0 deletions src/lib/features/export-import-toggles/export-import.e2e.test.ts
Expand Up @@ -295,6 +295,62 @@ test('exports features', async () => {
});
});

test('exports features by tag', async () => {
await createProjects();
const strategy = {
name: 'default',
parameters: { rollout: '100', stickiness: 'default' },
constraints: [
{
contextName: 'appName',
values: ['test'],
operator: 'IN' as const,
},
],
};
await createToggle(
{
name: 'first_feature',
description: 'the #1 feature',
},
strategy,
['mytag'],
);
await createToggle(
{
name: 'second_feature',
description: 'the #1 feature',
},
strategy,
['anothertag'],
);
const { body } = await app.request
.post('/api/admin/features-batch/export')
.send({
tag: 'mytag',
environment: 'default',
})
.set('Content-Type', 'application/json')
.expect(200);

const { name, ...resultStrategy } = strategy;
expect(body).toMatchObject({
features: [
{
name: 'first_feature',
},
],
featureStrategies: [resultStrategy],
featureEnvironments: [
{
enabled: false,
environment: 'default',
featureName: 'first_feature',
},
],
});
});

test('should export custom context fields from strategies and variants', async () => {
await createProjects();
const strategyContext = {
Expand Down
36 changes: 27 additions & 9 deletions src/lib/openapi/spec/export-query-schema.ts
Expand Up @@ -3,23 +3,41 @@ import { FromSchema } from 'json-schema-to-ts';
export const exportQuerySchema = {
$id: '#/components/schemas/exportQuerySchema',
type: 'object',
additionalProperties: false,
required: ['features', 'environment'],
additionalProperties: true,
required: ['environment'],
properties: {
features: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
},
environment: {
type: 'string',
},
downloadFile: {
type: 'boolean',
},
},
oneOf: [
{
required: ['features'],
properties: {
features: {
type: 'array',
items: {
type: 'string',
minLength: 1,
},
description: 'Selects features to export by name.',
},
},
},
{
required: ['tag'],
properties: {
tag: {
type: 'string',
description:
'Selects features to export by tag. Takes precedence over the features field.',
},
},
},
],
components: {
schemas: {},
},
Expand Down
4 changes: 4 additions & 0 deletions src/lib/services/feature-tag-service.ts
Expand Up @@ -47,6 +47,10 @@ class FeatureTagService {
return this.featureTagStore.getAllTagsForFeature(featureName);
}

async listFeatures(tagValue: string): Promise<string[]> {
return this.featureTagStore.getAllFeaturesForTag(tagValue);
}

// TODO: add project Id
async addTag(
featureName: string,
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/stores/feature-tag-store.ts
Expand Up @@ -13,6 +13,7 @@ export interface IFeatureAndTag {
}
export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
getAllTagsForFeature(featureName: string): Promise<ITag[]>;
getAllFeaturesForTag(tagValue: string): Promise<string[]>;
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
tagFeature(featureName: string, tag: ITag): Promise<ITag>;
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/types/stores/tag-type-store.ts
Expand Up @@ -3,7 +3,7 @@ import { Store } from './store';
export interface ITagType {
name: string;
description?: string;
icon?: string;
icon?: string | null;
}

export interface ITagTypeStore extends Store<ITagType, string> {
Expand Down
38 changes: 29 additions & 9 deletions src/test/e2e/api/openapi/__snapshots__/openapi.e2e.test.ts.snap
Expand Up @@ -1851,24 +1851,44 @@ The provider you choose for your addon dictates what properties the \`parameters
"type": "object",
},
"exportQuerySchema": {
"additionalProperties": false,
"additionalProperties": true,
"oneOf": [
{
"properties": {
"features": {
"description": "Selects features to export by name.",
"items": {
"minLength": 1,
"type": "string",
},
"type": "array",
},
},
"required": [
"features",
],
},
{
"properties": {
"tag": {
"description": "Selects features to export by tag. Takes precedence over the features field.",
"type": "string",
},
},
"required": [
"tag",
],
},
],
"properties": {
"downloadFile": {
"type": "boolean",
},
"environment": {
"type": "string",
},
"features": {
"items": {
"minLength": 1,
"type": "string",
},
"type": "array",
},
},
"required": [
"features",
"environment",
],
"type": "object",
Expand Down
7 changes: 7 additions & 0 deletions src/test/fixtures/fake-feature-tag-store.ts
Expand Up @@ -18,6 +18,13 @@ export default class FakeFeatureTagStore implements IFeatureTagStore {
return Promise.resolve(tags);
}

async getAllFeaturesForTag(tagValue: string): Promise<string[]> {
const tags = this.featureTags
.filter((f) => f.tagValue === tagValue)
.map((f) => f.featureName);
return Promise.resolve(tags);
}

async delete(key: IFeatureTag): Promise<void> {
this.featureTags.splice(
this.featureTags.findIndex((t) => t === key),
Expand Down

0 comments on commit 70a8ab4

Please sign in to comment.