mirror of
https://github.com/Unleash/unleash.git
synced 2025-04-06 01:15:28 +02:00
feat: cursor based pagination in search (#5174)
This commit is contained in:
parent
c9f9fc7521
commit
6d17c3b320
@ -72,7 +72,15 @@ export default class FeatureSearchController extends Controller {
|
|||||||
res: Response,
|
res: Response,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
|
if (this.config.flagResolver.isEnabled('featureSearchAPI')) {
|
||||||
const { query, projectId, type, tag, status } = req.query;
|
const {
|
||||||
|
query,
|
||||||
|
projectId,
|
||||||
|
type,
|
||||||
|
tag,
|
||||||
|
status,
|
||||||
|
cursor,
|
||||||
|
limit = 50,
|
||||||
|
} = req.query;
|
||||||
const userId = req.user.id;
|
const userId = req.user.id;
|
||||||
const normalizedTag = tag
|
const normalizedTag = tag
|
||||||
?.map((tag) => tag.split(':'))
|
?.map((tag) => tag.split(':'))
|
||||||
@ -84,6 +92,7 @@ export default class FeatureSearchController extends Controller {
|
|||||||
tag.length === 2 &&
|
tag.length === 2 &&
|
||||||
['enabled', 'disabled'].includes(tag[1]),
|
['enabled', 'disabled'].includes(tag[1]),
|
||||||
);
|
);
|
||||||
|
const normalizedLimit = limit > 0 && limit <= 50 ? limit : 50;
|
||||||
const features = await this.featureSearchService.search({
|
const features = await this.featureSearchService.search({
|
||||||
query,
|
query,
|
||||||
projectId,
|
projectId,
|
||||||
@ -91,6 +100,8 @@ export default class FeatureSearchController extends Controller {
|
|||||||
userId,
|
userId,
|
||||||
tag: normalizedTag,
|
tag: normalizedTag,
|
||||||
status: normalizedStatus,
|
status: normalizedStatus,
|
||||||
|
cursor,
|
||||||
|
limit: normalizedLimit,
|
||||||
});
|
});
|
||||||
res.json({ features });
|
res.json({ features });
|
||||||
} else {
|
} else {
|
||||||
|
@ -43,6 +43,22 @@ const searchFeatures = async (
|
|||||||
.expect(expectedCode);
|
.expect(expectedCode);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const searchFeaturesWithCursor = async (
|
||||||
|
{
|
||||||
|
query = '',
|
||||||
|
projectId = 'default',
|
||||||
|
cursor = '',
|
||||||
|
limit = 10,
|
||||||
|
}: FeatureSearchQueryParameters,
|
||||||
|
expectedCode = 200,
|
||||||
|
) => {
|
||||||
|
return app.request
|
||||||
|
.get(
|
||||||
|
`/api/admin/search/features?query=${query}&projectId=${projectId}&cursor=${cursor}&limit=${limit}`,
|
||||||
|
)
|
||||||
|
.expect(expectedCode);
|
||||||
|
};
|
||||||
|
|
||||||
const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
|
const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
|
||||||
const typeParams = types.map((type) => `type[]=${type}`).join('&');
|
const typeParams = types.map((type) => `type[]=${type}`).join('&');
|
||||||
return app.request
|
return app.request
|
||||||
@ -85,6 +101,46 @@ test('should search matching features by name', async () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should paginate with cursor', async () => {
|
||||||
|
await app.createFeature('my_feature_a');
|
||||||
|
await app.createFeature('my_feature_b');
|
||||||
|
await app.createFeature('my_feature_c');
|
||||||
|
await app.createFeature('my_feature_d');
|
||||||
|
|
||||||
|
const { body: firstPage } = await searchFeaturesWithCursor({
|
||||||
|
query: 'feature',
|
||||||
|
cursor: '',
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
const nextCursor =
|
||||||
|
firstPage.features[firstPage.features.length - 1].createdAt;
|
||||||
|
|
||||||
|
expect(firstPage).toMatchObject({
|
||||||
|
features: [{ name: 'my_feature_a' }, { name: 'my_feature_b' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { body: secondPage } = await searchFeaturesWithCursor({
|
||||||
|
query: 'feature',
|
||||||
|
cursor: nextCursor,
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondPage).toMatchObject({
|
||||||
|
features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }],
|
||||||
|
});
|
||||||
|
const lastCursor =
|
||||||
|
secondPage.features[secondPage.features.length - 1].createdAt;
|
||||||
|
|
||||||
|
const { body: lastPage } = await searchFeaturesWithCursor({
|
||||||
|
query: 'feature',
|
||||||
|
cursor: lastCursor,
|
||||||
|
limit: 2,
|
||||||
|
});
|
||||||
|
expect(lastPage).toMatchObject({
|
||||||
|
features: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test('should filter features by type', async () => {
|
test('should filter features by type', async () => {
|
||||||
await app.createFeature({ name: 'my_feature_a', type: 'release' });
|
await app.createFeature({ name: 'my_feature_a', type: 'release' });
|
||||||
await app.createFeature({ name: 'my_feature_b', type: 'experimental' });
|
await app.createFeature({ name: 'my_feature_b', type: 'experimental' });
|
||||||
|
@ -26,6 +26,7 @@ import { IFeatureProjectUserParams } from './feature-toggle-controller';
|
|||||||
import { Db } from '../../db/db';
|
import { Db } from '../../db/db';
|
||||||
import Raw = Knex.Raw;
|
import Raw = Knex.Raw;
|
||||||
import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type';
|
import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type';
|
||||||
|
import { addMilliseconds, format, formatISO, parseISO } from 'date-fns';
|
||||||
|
|
||||||
const COLUMNS = [
|
const COLUMNS = [
|
||||||
'id',
|
'id',
|
||||||
@ -523,6 +524,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
type,
|
type,
|
||||||
tag,
|
tag,
|
||||||
status,
|
status,
|
||||||
|
cursor,
|
||||||
|
limit,
|
||||||
}: IFeatureSearchParams): Promise<IFeatureOverview[]> {
|
}: IFeatureSearchParams): Promise<IFeatureOverview[]> {
|
||||||
let query = this.db('features');
|
let query = this.db('features');
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
@ -540,9 +543,11 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
`%${queryString}%`,
|
`%${queryString}%`,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
query = query
|
query = query.where((builder) => {
|
||||||
|
builder
|
||||||
.whereILike('features.name', `%${queryString}%`)
|
.whereILike('features.name', `%${queryString}%`)
|
||||||
.orWhereIn('features.name', tagQuery);
|
.orWhereIn('features.name', tagQuery);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (tag && tag.length > 0) {
|
if (tag && tag.length > 0) {
|
||||||
const tagQuery = this.db
|
const tagQuery = this.db
|
||||||
@ -571,6 +576,21 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// workaround for imprecise timestamp that was including the cursor itself
|
||||||
|
const addMillisecond = (cursor: string) =>
|
||||||
|
format(
|
||||||
|
addMilliseconds(parseISO(cursor), 1),
|
||||||
|
"yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
|
||||||
|
);
|
||||||
|
if (cursor) {
|
||||||
|
query = query.where(
|
||||||
|
'features.created_at',
|
||||||
|
'>',
|
||||||
|
addMillisecond(cursor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
query = query.orderBy('features.created_at', 'asc').limit(limit);
|
||||||
|
|
||||||
query = query
|
query = query
|
||||||
.modify(FeatureToggleStore.filterByArchived, false)
|
.modify(FeatureToggleStore.filterByArchived, false)
|
||||||
.leftJoin(
|
.leftJoin(
|
||||||
@ -656,6 +676,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
|||||||
|
|
||||||
query = query.select(selectColumns);
|
query = query.select(selectColumns);
|
||||||
const rows = await query;
|
const rows = await query;
|
||||||
|
|
||||||
if (rows.length > 0) {
|
if (rows.length > 0) {
|
||||||
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
||||||
return sortEnvironments(overview);
|
return sortEnvironments(overview);
|
||||||
|
@ -28,6 +28,8 @@ export interface IFeatureSearchParams {
|
|||||||
type?: string[];
|
type?: string[];
|
||||||
tag?: string[][];
|
tag?: string[][];
|
||||||
status?: string[][];
|
status?: string[][];
|
||||||
|
limit: number;
|
||||||
|
cursor?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFeatureStrategiesStore
|
export interface IFeatureStrategiesStore
|
||||||
|
@ -57,6 +57,26 @@ export const featureSearchQueryParameters = [
|
|||||||
'The list of feature environment status to filter by. Feature environment has to specify a name and a status joined with a colon.',
|
'The list of feature environment status to filter by. Feature environment has to specify a name and a status joined with a colon.',
|
||||||
in: 'query',
|
in: 'query',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: 'cursor',
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
example: '1',
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'The last feature id the client has seen. Used for cursor-based pagination.',
|
||||||
|
in: 'query',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'limit',
|
||||||
|
schema: {
|
||||||
|
type: 'number',
|
||||||
|
example: 10,
|
||||||
|
},
|
||||||
|
description:
|
||||||
|
'The number of results to return in a page. By default it is set to 50',
|
||||||
|
in: 'query',
|
||||||
|
},
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type FeatureSearchQueryParameters = Partial<
|
export type FeatureSearchQueryParameters = Partial<
|
||||||
|
Loading…
Reference in New Issue
Block a user