mirror of
https://github.com/Unleash/unleash.git
synced 2025-05-08 01:15:49 +02: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> {
|
async featureExists(featureName: string): Promise<boolean> {
|
||||||
const result = await this.db.raw(
|
const result = await this.db.raw(
|
||||||
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
|
'SELECT EXISTS (SELECT 1 FROM features WHERE name = ?) AS present',
|
||||||
|
@ -169,7 +169,7 @@ export default class ExportImportService {
|
|||||||
const errors = ImportValidationMessages.compileErrors(
|
const errors = ImportValidationMessages.compileErrors(
|
||||||
dto.project,
|
dto.project,
|
||||||
unsupportedStrategies,
|
unsupportedStrategies,
|
||||||
unsupportedContextFields,
|
unsupportedContextFields || [],
|
||||||
[],
|
[],
|
||||||
otherProjectFeatures,
|
otherProjectFeatures,
|
||||||
false,
|
false,
|
||||||
@ -226,7 +226,7 @@ export default class ExportImportService {
|
|||||||
|
|
||||||
private async importToggleStatuses(dto: ImportTogglesSchema, user: User) {
|
private async importToggleStatuses(dto: ImportTogglesSchema, user: User) {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
dto.data.featureEnvironments?.map((featureEnvironment) =>
|
(dto.data.featureEnvironments || []).map((featureEnvironment) =>
|
||||||
this.featureToggleService.updateEnabled(
|
this.featureToggleService.updateEnabled(
|
||||||
dto.project,
|
dto.project,
|
||||||
featureEnvironment.name,
|
featureEnvironment.name,
|
||||||
@ -281,13 +281,15 @@ export default class ExportImportService {
|
|||||||
dto.data.features.map((feature) => feature.name),
|
dto.data.features.map((feature) => feature.name),
|
||||||
);
|
);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
dto.data.featureTags?.map((tag) =>
|
(dto.data.featureTags || []).map((tag) => {
|
||||||
this.featureTagService.addTag(
|
return tag.tagType
|
||||||
tag.featureName,
|
? this.featureTagService.addTag(
|
||||||
{ type: tag.tagType, value: tag.tagValue },
|
tag.featureName,
|
||||||
extractUsernameFromUser(user),
|
{ 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) {
|
private async importTagTypes(dto: ImportTogglesSchema, user: User) {
|
||||||
const newTagTypes = await this.getNewTagTypes(dto);
|
const newTagTypes = await this.getNewTagTypes(dto);
|
||||||
return Promise.all(
|
return Promise.all(
|
||||||
newTagTypes.map((tagType) =>
|
newTagTypes.map((tagType) => {
|
||||||
this.tagTypeService.createTagType(
|
return tagType
|
||||||
tagType,
|
? this.tagTypeService.createTagType(
|
||||||
extractUsernameFromUser(user),
|
tagType,
|
||||||
),
|
extractUsernameFromUser(user),
|
||||||
),
|
)
|
||||||
|
: Promise.resolve();
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -328,15 +332,17 @@ export default class ExportImportService {
|
|||||||
featureEnvironment.variants.length > 0,
|
featureEnvironment.variants.length > 0,
|
||||||
) || [];
|
) || [];
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
featureEnvsWithVariants.map((featureEnvironment) =>
|
featureEnvsWithVariants.map((featureEnvironment) => {
|
||||||
this.featureToggleService.saveVariantsOnEnv(
|
return featureEnvironment.featureName
|
||||||
dto.project,
|
? this.featureToggleService.saveVariantsOnEnv(
|
||||||
featureEnvironment.featureName,
|
dto.project,
|
||||||
dto.environment,
|
featureEnvironment.featureName,
|
||||||
featureEnvironment.variants as IVariant[],
|
dto.environment,
|
||||||
user,
|
featureEnvironment.variants as IVariant[],
|
||||||
),
|
user,
|
||||||
),
|
)
|
||||||
|
: Promise.resolve();
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,11 +401,10 @@ export default class ExportImportService {
|
|||||||
|
|
||||||
private async cleanData(dto: ImportTogglesSchema) {
|
private async cleanData(dto: ImportTogglesSchema) {
|
||||||
const removedFeaturesDto = await this.removeArchivedFeatures(dto);
|
const removedFeaturesDto = await this.removeArchivedFeatures(dto);
|
||||||
const remappedDto = this.remapSegments(removedFeaturesDto);
|
return ExportImportService.remapSegments(removedFeaturesDto);
|
||||||
return remappedDto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async remapSegments(dto: ImportTogglesSchema) {
|
private static async remapSegments(dto: ImportTogglesSchema) {
|
||||||
return {
|
return {
|
||||||
...dto,
|
...dto,
|
||||||
data: {
|
data: {
|
||||||
@ -533,14 +538,12 @@ export default class ExportImportService {
|
|||||||
const existingTagTypes = (await this.tagTypeService.getAll()).map(
|
const existingTagTypes = (await this.tagTypeService.getAll()).map(
|
||||||
(tagType) => tagType.name,
|
(tagType) => tagType.name,
|
||||||
);
|
);
|
||||||
const newTagTypes =
|
const newTagTypes = (dto.data.tagTypes || []).filter(
|
||||||
dto.data.tagTypes?.filter(
|
(tagType) => !existingTagTypes.includes(tagType.name),
|
||||||
(tagType) => !existingTagTypes.includes(tagType.name),
|
);
|
||||||
) || [];
|
return [
|
||||||
const uniqueTagTypes = [
|
|
||||||
...new Map(newTagTypes.map((item) => [item.name, item])).values(),
|
...new Map(newTagTypes.map((item) => [item.name, item])).values(),
|
||||||
];
|
];
|
||||||
return uniqueTagTypes;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getNewContextFields(dto: ImportTogglesSchema) {
|
private async getNewContextFields(dto: ImportTogglesSchema) {
|
||||||
@ -559,6 +562,10 @@ export default class ExportImportService {
|
|||||||
query: ExportQuerySchema,
|
query: ExportQuerySchema,
|
||||||
userName: string,
|
userName: string,
|
||||||
): Promise<ExportResultSchema> {
|
): Promise<ExportResultSchema> {
|
||||||
|
const featureNames =
|
||||||
|
typeof query.tag === 'string'
|
||||||
|
? await this.featureTagService.listFeatures(query.tag)
|
||||||
|
: (query.features as string[]) || [];
|
||||||
const [
|
const [
|
||||||
features,
|
features,
|
||||||
featureEnvironments,
|
featureEnvironments,
|
||||||
@ -569,18 +576,18 @@ export default class ExportImportService {
|
|||||||
segments,
|
segments,
|
||||||
tagTypes,
|
tagTypes,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
this.toggleStore.getAllByNames(query.features),
|
this.toggleStore.getAllByNames(featureNames),
|
||||||
await this.featureEnvironmentStore.getAllByFeatures(
|
await this.featureEnvironmentStore.getAllByFeatures(
|
||||||
query.features,
|
featureNames,
|
||||||
query.environment,
|
query.environment,
|
||||||
),
|
),
|
||||||
this.featureStrategiesStore.getAllByFeatures(
|
this.featureStrategiesStore.getAllByFeatures(
|
||||||
query.features,
|
featureNames,
|
||||||
query.environment,
|
query.environment,
|
||||||
),
|
),
|
||||||
this.segmentStore.getAllFeatureStrategySegments(),
|
this.segmentStore.getAllFeatureStrategySegments(),
|
||||||
this.contextFieldStore.getAll(),
|
this.contextFieldStore.getAll(),
|
||||||
this.featureTagStore.getAllByFeatures(query.features),
|
this.featureTagStore.getAllByFeatures(featureNames),
|
||||||
this.segmentStore.getAll(),
|
this.segmentStore.getAll(),
|
||||||
this.tagTypeStore.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 () => {
|
test('should export custom context fields from strategies and variants', async () => {
|
||||||
await createProjects();
|
await createProjects();
|
||||||
const strategyContext = {
|
const strategyContext = {
|
||||||
|
@ -3,16 +3,9 @@ import { FromSchema } from 'json-schema-to-ts';
|
|||||||
export const exportQuerySchema = {
|
export const exportQuerySchema = {
|
||||||
$id: '#/components/schemas/exportQuerySchema',
|
$id: '#/components/schemas/exportQuerySchema',
|
||||||
type: 'object',
|
type: 'object',
|
||||||
additionalProperties: false,
|
additionalProperties: true,
|
||||||
required: ['features', 'environment'],
|
required: ['environment'],
|
||||||
properties: {
|
properties: {
|
||||||
features: {
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'string',
|
|
||||||
minLength: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
environment: {
|
environment: {
|
||||||
type: 'string',
|
type: 'string',
|
||||||
},
|
},
|
||||||
@ -20,6 +13,31 @@ export const exportQuerySchema = {
|
|||||||
type: 'boolean',
|
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: {
|
components: {
|
||||||
schemas: {},
|
schemas: {},
|
||||||
},
|
},
|
||||||
|
@ -47,6 +47,10 @@ class FeatureTagService {
|
|||||||
return this.featureTagStore.getAllTagsForFeature(featureName);
|
return this.featureTagStore.getAllTagsForFeature(featureName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async listFeatures(tagValue: string): Promise<string[]> {
|
||||||
|
return this.featureTagStore.getAllFeaturesForTag(tagValue);
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: add project Id
|
// TODO: add project Id
|
||||||
async addTag(
|
async addTag(
|
||||||
featureName: string,
|
featureName: string,
|
||||||
|
@ -13,6 +13,7 @@ export interface IFeatureAndTag {
|
|||||||
}
|
}
|
||||||
export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
|
export interface IFeatureTagStore extends Store<IFeatureTag, IFeatureTag> {
|
||||||
getAllTagsForFeature(featureName: string): Promise<ITag[]>;
|
getAllTagsForFeature(featureName: string): Promise<ITag[]>;
|
||||||
|
getAllFeaturesForTag(tagValue: string): Promise<string[]>;
|
||||||
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
|
getAllByFeatures(features: string[]): Promise<IFeatureTag[]>;
|
||||||
tagFeature(featureName: string, tag: ITag): Promise<ITag>;
|
tagFeature(featureName: string, tag: ITag): Promise<ITag>;
|
||||||
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
|
tagFeatures(featureTags: IFeatureTag[]): Promise<IFeatureAndTag[]>;
|
||||||
|
@ -3,7 +3,7 @@ import { Store } from './store';
|
|||||||
export interface ITagType {
|
export interface ITagType {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
icon?: string;
|
icon?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITagTypeStore extends Store<ITagType, string> {
|
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",
|
"type": "object",
|
||||||
},
|
},
|
||||||
"exportQuerySchema": {
|
"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": {
|
"properties": {
|
||||||
"downloadFile": {
|
"downloadFile": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
@ -1859,16 +1887,8 @@ The provider you choose for your addon dictates what properties the \`parameters
|
|||||||
"environment": {
|
"environment": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
},
|
},
|
||||||
"features": {
|
|
||||||
"items": {
|
|
||||||
"minLength": 1,
|
|
||||||
"type": "string",
|
|
||||||
},
|
|
||||||
"type": "array",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"features",
|
|
||||||
"environment",
|
"environment",
|
||||||
],
|
],
|
||||||
"type": "object",
|
"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);
|
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> {
|
async delete(key: IFeatureTag): Promise<void> {
|
||||||
this.featureTags.splice(
|
this.featureTags.splice(
|
||||||
this.featureTags.findIndex((t) => t === key),
|
this.featureTags.findIndex((t) => t === key),
|
||||||
|
Loading…
Reference in New Issue
Block a user