mirror of
https://github.com/Unleash/unleash.git
synced 2024-12-22 19:07:54 +01: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,
|
||||
): Promise<void> {
|
||||
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 normalizedTag = tag
|
||||
?.map((tag) => tag.split(':'))
|
||||
@ -84,6 +92,7 @@ export default class FeatureSearchController extends Controller {
|
||||
tag.length === 2 &&
|
||||
['enabled', 'disabled'].includes(tag[1]),
|
||||
);
|
||||
const normalizedLimit = limit > 0 && limit <= 50 ? limit : 50;
|
||||
const features = await this.featureSearchService.search({
|
||||
query,
|
||||
projectId,
|
||||
@ -91,6 +100,8 @@ export default class FeatureSearchController extends Controller {
|
||||
userId,
|
||||
tag: normalizedTag,
|
||||
status: normalizedStatus,
|
||||
cursor,
|
||||
limit: normalizedLimit,
|
||||
});
|
||||
res.json({ features });
|
||||
} else {
|
||||
|
@ -43,6 +43,22 @@ const searchFeatures = async (
|
||||
.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 typeParams = types.map((type) => `type[]=${type}`).join('&');
|
||||
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 () => {
|
||||
await app.createFeature({ name: 'my_feature_a', type: 'release' });
|
||||
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 Raw = Knex.Raw;
|
||||
import { IFeatureSearchParams } from './types/feature-toggle-strategies-store-type';
|
||||
import { addMilliseconds, format, formatISO, parseISO } from 'date-fns';
|
||||
|
||||
const COLUMNS = [
|
||||
'id',
|
||||
@ -523,6 +524,8 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
type,
|
||||
tag,
|
||||
status,
|
||||
cursor,
|
||||
limit,
|
||||
}: IFeatureSearchParams): Promise<IFeatureOverview[]> {
|
||||
let query = this.db('features');
|
||||
if (projectId) {
|
||||
@ -540,9 +543,11 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
`%${queryString}%`,
|
||||
]);
|
||||
|
||||
query = query
|
||||
query = query.where((builder) => {
|
||||
builder
|
||||
.whereILike('features.name', `%${queryString}%`)
|
||||
.orWhereIn('features.name', tagQuery);
|
||||
});
|
||||
}
|
||||
if (tag && tag.length > 0) {
|
||||
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
|
||||
.modify(FeatureToggleStore.filterByArchived, false)
|
||||
.leftJoin(
|
||||
@ -656,6 +676,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
|
||||
query = query.select(selectColumns);
|
||||
const rows = await query;
|
||||
|
||||
if (rows.length > 0) {
|
||||
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
||||
return sortEnvironments(overview);
|
||||
|
@ -28,6 +28,8 @@ export interface IFeatureSearchParams {
|
||||
type?: string[];
|
||||
tag?: string[][];
|
||||
status?: string[][];
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
}
|
||||
|
||||
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.',
|
||||
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;
|
||||
|
||||
export type FeatureSearchQueryParameters = Partial<
|
||||
|
Loading…
Reference in New Issue
Block a user