mirror of
https://github.com/Unleash/unleash.git
synced 2025-01-11 00:08:30 +01:00
feat: export by tags (#3635)
This commit is contained in:
parent
4f12361c94
commit
70a8ab4c47
@ -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',
|
||||
|
@ -169,7 +169,7 @@ export default class ExportImportService {
|
||||
const errors = ImportValidationMessages.compileErrors(
|
||||
dto.project,
|
||||
unsupportedStrategies,
|
||||
unsupportedContextFields,
|
||||
unsupportedContextFields || [],
|
||||
[],
|
||||
otherProjectFeatures,
|
||||
false,
|
||||
@ -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,
|
||||
@ -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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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: {
|
||||
@ -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) {
|
||||
@ -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,
|
||||
@ -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(),
|
||||
]);
|
||||
|
@ -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 = {
|
||||
|
@ -3,16 +3,9 @@ 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',
|
||||
},
|
||||
@ -20,6 +13,31 @@ export const exportQuerySchema = {
|
||||
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: {},
|
||||
},
|
||||
|
@ -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,
|
||||
|
@ -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[]>;
|
||||
|
@ -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> {
|
||||
|
@ -1851,7 +1851,35 @@ 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",
|
||||
@ -1859,16 +1887,8 @@ The provider you choose for your addon dictates what properties the \`parameters
|
||||
"environment": {
|
||||
"type": "string",
|
||||
},
|
||||
"features": {
|
||||
"items": {
|
||||
"minLength": 1,
|
||||
"type": "string",
|
||||
},
|
||||
"type": "array",
|
||||
},
|
||||
},
|
||||
"required": [
|
||||
"features",
|
||||
"environment",
|
||||
],
|
||||
"type": "object",
|
||||
|
7
src/test/fixtures/fake-feature-tag-store.ts
vendored
7
src/test/fixtures/fake-feature-tag-store.ts
vendored
@ -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),
|
||||
|
Loading…
Reference in New Issue
Block a user