mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-10 01:16:39 +02:00
parent
ee44fae6ea
commit
065e588e64
@ -71,7 +71,11 @@ export default class FeatureSearchController extends Controller {
|
||||
res: Response,
|
||||
): Promise<void> {
|
||||
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
|
||||
const features = await this.featureSearchService.search(req.query);
|
||||
const { query = '', projectId = '' } = req.query;
|
||||
const features = await this.featureSearchService.search({
|
||||
query,
|
||||
projectId,
|
||||
});
|
||||
res.json({ features });
|
||||
} else {
|
||||
throw new InvalidOperationError(
|
||||
|
@ -20,9 +20,9 @@ export class FeatureSearchService {
|
||||
}
|
||||
|
||||
async search(params: FeatureSearchQueryParameters) {
|
||||
const features = await this.featureStrategiesStore.getFeatureOverview({
|
||||
const features = await this.featureStrategiesStore.searchFeatures({
|
||||
projectId: params.projectId,
|
||||
namePrefix: params.query,
|
||||
queryString: params.query,
|
||||
});
|
||||
|
||||
return features;
|
||||
|
@ -43,18 +43,48 @@ const searchFeatures = async (
|
||||
.expect(expectedCode);
|
||||
};
|
||||
|
||||
test('should return matching features', async () => {
|
||||
const searchFeaturesWithoutQueryParams = async (expectedCode = 200) => {
|
||||
return app.request.get(`/api/admin/search/features`).expect(expectedCode);
|
||||
};
|
||||
|
||||
test('should return matching features by name', async () => {
|
||||
await app.createFeature('my_feature_a');
|
||||
await app.createFeature('my_feature_b');
|
||||
await app.createFeature('my_feat_c');
|
||||
|
||||
const { body } = await searchFeatures({ query: 'my_feature' });
|
||||
const { body } = await searchFeatures({ query: 'feature' });
|
||||
|
||||
expect(body).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('should return matching features by tag', async () => {
|
||||
await app.createFeature('my_feature_a');
|
||||
await app.createFeature('my_feature_b');
|
||||
await app.addTag('my_feature_a', { type: 'simple', value: 'my_tag' });
|
||||
|
||||
const { body: fullMatch } = await searchFeatures({
|
||||
query: 'simple:my_tag',
|
||||
});
|
||||
const { body: tagTypeMatch } = await searchFeatures({ query: 'simple' });
|
||||
const { body: tagValueMatch } = await searchFeatures({ query: 'my_tag' });
|
||||
const { body: partialTagMatch } = await searchFeatures({ query: 'e:m' });
|
||||
|
||||
expect(fullMatch).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }],
|
||||
});
|
||||
expect(tagTypeMatch).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }],
|
||||
});
|
||||
expect(tagValueMatch).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }],
|
||||
});
|
||||
expect(partialTagMatch).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }],
|
||||
});
|
||||
});
|
||||
|
||||
test('should return empty features', async () => {
|
||||
const { body } = await searchFeatures({ query: '' });
|
||||
expect(body).toMatchObject({ features: [] });
|
||||
@ -71,3 +101,14 @@ test('should not return features from another project', async () => {
|
||||
|
||||
expect(body).toMatchObject({ features: [] });
|
||||
});
|
||||
|
||||
test('should return features without query', async () => {
|
||||
await app.createFeature('my_feature_a');
|
||||
await app.createFeature('my_feature_b');
|
||||
|
||||
const { body } = await searchFeaturesWithoutQueryParams();
|
||||
|
||||
expect(body).toMatchObject({
|
||||
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
|
||||
});
|
||||
});
|
||||
|
@ -322,6 +322,14 @@ export default class FakeFeatureStrategiesStore
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
searchFeatures(params: {
|
||||
projectId: string;
|
||||
userId?: number | undefined;
|
||||
queryString: string;
|
||||
}): Promise<IFeatureOverview[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
getAllByFeatures(
|
||||
features: string[],
|
||||
environment?: string,
|
||||
|
@ -514,6 +514,126 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
};
|
||||
}
|
||||
|
||||
// WIP copy of getFeatureOverview to get the search PoC working
|
||||
async searchFeatures({
|
||||
projectId,
|
||||
userId,
|
||||
queryString,
|
||||
}: { projectId: string; userId?: number; queryString: string }): Promise<
|
||||
IFeatureOverview[]
|
||||
> {
|
||||
let query = this.db('features');
|
||||
if (projectId) {
|
||||
query = query.where({ project: projectId });
|
||||
}
|
||||
|
||||
if (queryString?.trim()) {
|
||||
// todo: we can run cheaper query when no colon is detected
|
||||
const tagQuery = this.db
|
||||
.from('feature_tag')
|
||||
.select('feature_name')
|
||||
.whereRaw("(?? || ':' || ??) LIKE ?", [
|
||||
'tag_type',
|
||||
'tag_value',
|
||||
`%${queryString}%`,
|
||||
]);
|
||||
|
||||
query = query
|
||||
.whereILike('features.name', `%${queryString}%`)
|
||||
.orWhereIn('features.name', tagQuery);
|
||||
}
|
||||
query = query
|
||||
.modify(FeatureToggleStore.filterByArchived, false)
|
||||
.leftJoin(
|
||||
'feature_environments',
|
||||
'feature_environments.feature_name',
|
||||
'features.name',
|
||||
)
|
||||
.leftJoin(
|
||||
'environments',
|
||||
'feature_environments.environment',
|
||||
'environments.name',
|
||||
)
|
||||
.leftJoin('feature_tag as ft', 'ft.feature_name', 'features.name');
|
||||
|
||||
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||
query.leftJoin('last_seen_at_metrics', function () {
|
||||
this.on(
|
||||
'last_seen_at_metrics.environment',
|
||||
'=',
|
||||
'environments.name',
|
||||
).andOn(
|
||||
'last_seen_at_metrics.feature_name',
|
||||
'=',
|
||||
'features.name',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
let selectColumns = [
|
||||
'features.name as feature_name',
|
||||
'features.description as description',
|
||||
'features.type as type',
|
||||
'features.created_at as created_at',
|
||||
'features.last_seen_at as last_seen_at',
|
||||
'features.stale as stale',
|
||||
'features.impression_data as impression_data',
|
||||
'feature_environments.enabled as enabled',
|
||||
'feature_environments.environment as environment',
|
||||
'feature_environments.variants as variants',
|
||||
'environments.type as environment_type',
|
||||
'environments.sort_order as environment_sort_order',
|
||||
'ft.tag_value as tag_value',
|
||||
'ft.tag_type as tag_type',
|
||||
] as (string | Raw<any> | Knex.QueryBuilder)[];
|
||||
|
||||
if (this.flagResolver.isEnabled('useLastSeenRefactor')) {
|
||||
selectColumns.push(
|
||||
'last_seen_at_metrics.last_seen_at as env_last_seen_at',
|
||||
);
|
||||
} else {
|
||||
selectColumns.push(
|
||||
'feature_environments.last_seen_at as env_last_seen_at',
|
||||
);
|
||||
}
|
||||
|
||||
if (userId) {
|
||||
query = query.leftJoin(`favorite_features`, function () {
|
||||
this.on('favorite_features.feature', 'features.name').andOnVal(
|
||||
'favorite_features.user_id',
|
||||
'=',
|
||||
userId,
|
||||
);
|
||||
});
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
this.db.raw(
|
||||
'favorite_features.feature is not null as favorite',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
if (this.flagResolver.isEnabled('featureSwitchRefactor')) {
|
||||
selectColumns = [
|
||||
...selectColumns,
|
||||
this.db.raw(
|
||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment) as has_strategies',
|
||||
),
|
||||
this.db.raw(
|
||||
'EXISTS (SELECT 1 FROM feature_strategies WHERE feature_strategies.feature_name = features.name AND feature_strategies.environment = feature_environments.environment AND (feature_strategies.disabled IS NULL OR feature_strategies.disabled = false)) as has_enabled_strategies',
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
query = query.select(selectColumns);
|
||||
const rows = await query;
|
||||
if (rows.length > 0) {
|
||||
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
||||
return sortEnvironments(overview);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
async getFeatureOverview({
|
||||
projectId,
|
||||
archived,
|
||||
@ -522,6 +642,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
namePrefix,
|
||||
}: IFeatureProjectUserParams): Promise<IFeatureOverview[]> {
|
||||
let query = this.db('features').where({ project: projectId });
|
||||
|
||||
if (tag) {
|
||||
const tagQuery = this.db
|
||||
.from('feature_tag')
|
||||
|
@ -46,6 +46,11 @@ export interface IFeatureStrategiesStore
|
||||
getFeatureOverview(
|
||||
params: IFeatureProjectUserParams,
|
||||
): Promise<IFeatureOverview[]>;
|
||||
searchFeatures(params: {
|
||||
projectId: string;
|
||||
userId?: number;
|
||||
queryString: string;
|
||||
}): Promise<IFeatureOverview[]>;
|
||||
getStrategyById(id: string): Promise<IFeatureStrategy>;
|
||||
updateStrategy(
|
||||
id: string,
|
||||
|
@ -78,6 +78,12 @@ export interface IUnleashHttpAPI {
|
||||
): supertest.Test;
|
||||
|
||||
addDependency(child: string, parent: string): supertest.Test;
|
||||
|
||||
addTag(
|
||||
feature: string,
|
||||
tag: { type: string; value: string },
|
||||
expectedResponseCode?: number,
|
||||
): supertest.Test;
|
||||
}
|
||||
|
||||
function httpApis(
|
||||
@ -201,6 +207,18 @@ function httpApis(
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(expectedResponseCode);
|
||||
},
|
||||
|
||||
addTag(
|
||||
feature: string,
|
||||
tag: { type: string; value: string },
|
||||
expectedResponseCode: number = 201,
|
||||
): supertest.Test {
|
||||
return request
|
||||
.post(`/api/admin/features/${feature}/tags`)
|
||||
.send({ type: tag.type, value: tag.value })
|
||||
.set('Content-Type', 'application/json')
|
||||
.expect(expectedResponseCode);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user