mirror of
https://github.com/Unleash/unleash.git
synced 2025-07-26 13:48:33 +02:00
feat: introduce offset based search instead of cursor (#5274)
This commit is contained in:
parent
06d62278dc
commit
4bacd3e055
@ -35,37 +35,37 @@ const StyledContentContainer = styled(Box)(() => ({
|
||||
minWidth: 0,
|
||||
}));
|
||||
|
||||
const InfiniteProjectOverview = () => {
|
||||
const PAGE_LIMIT = 25;
|
||||
|
||||
const PaginatedProjectOverview = () => {
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const { project, loading: projectLoading } = useProject(projectId, {
|
||||
refreshInterval,
|
||||
});
|
||||
const [prevCursors, setPrevCursors] = useState<string[]>([]);
|
||||
const [currentCursor, setCurrentCursor] = useState('');
|
||||
const [currentOffset, setCurrentOffset] = useState(0);
|
||||
const {
|
||||
features: searchFeatures,
|
||||
nextCursor,
|
||||
total,
|
||||
refetch,
|
||||
loading,
|
||||
} = useFeatureSearch(currentCursor, projectId, { refreshInterval });
|
||||
} = useFeatureSearch(currentOffset, PAGE_LIMIT, projectId, {
|
||||
refreshInterval,
|
||||
});
|
||||
|
||||
const { members, features, health, description, environments, stats } =
|
||||
project;
|
||||
const fetchNextPage = () => {
|
||||
if (!loading && nextCursor !== currentCursor && nextCursor !== '') {
|
||||
setPrevCursors([...prevCursors, currentCursor]);
|
||||
setCurrentCursor(nextCursor);
|
||||
if (!loading) {
|
||||
setCurrentOffset(Math.min(total, currentOffset + PAGE_LIMIT));
|
||||
}
|
||||
};
|
||||
const fetchPrevPage = () => {
|
||||
const prevCursor = prevCursors.pop();
|
||||
if (prevCursor) {
|
||||
setCurrentCursor(prevCursor);
|
||||
}
|
||||
setPrevCursors([...prevCursors]);
|
||||
setCurrentOffset(Math.max(0, currentOffset - PAGE_LIMIT));
|
||||
};
|
||||
|
||||
const hasPreviousPage = currentOffset > 0;
|
||||
const hasNextPage = currentOffset + PAGE_LIMIT < total;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<ProjectInfo
|
||||
@ -91,10 +91,14 @@ const InfiniteProjectOverview = () => {
|
||||
onChange={refetch}
|
||||
total={total}
|
||||
/>
|
||||
{prevCursors.length > 0 ? (
|
||||
<Box onClick={fetchPrevPage}>Prev</Box>
|
||||
) : null}
|
||||
{nextCursor && <Box onClick={fetchNextPage}>Next</Box>}
|
||||
<ConditionallyRender
|
||||
condition={hasPreviousPage}
|
||||
show={<Box onClick={fetchPrevPage}>Prev</Box>}
|
||||
/>
|
||||
<ConditionallyRender
|
||||
condition={hasNextPage}
|
||||
show={<Box onClick={fetchNextPage}>Next</Box>}
|
||||
/>
|
||||
</StyledProjectToggles>
|
||||
</StyledContentContainer>
|
||||
</StyledContainer>
|
||||
@ -118,7 +122,7 @@ const ProjectOverview = () => {
|
||||
setLastViewed(projectId);
|
||||
}, [projectId, setLastViewed]);
|
||||
|
||||
if (featureSearchFrontend) return <InfiniteProjectOverview />;
|
||||
if (featureSearchFrontend) return <PaginatedProjectOverview />;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
|
@ -6,14 +6,12 @@ import handleErrorResponses from '../httpErrorResponseHandler';
|
||||
|
||||
type IFeatureSearchResponse = {
|
||||
features: IFeatureToggleListItem[];
|
||||
nextCursor: string;
|
||||
total: number;
|
||||
};
|
||||
|
||||
interface IUseFeatureSearchOutput {
|
||||
features: IFeatureToggleListItem[];
|
||||
total: number;
|
||||
nextCursor: string;
|
||||
loading: boolean;
|
||||
error: string;
|
||||
refetch: () => void;
|
||||
@ -22,19 +20,18 @@ interface IUseFeatureSearchOutput {
|
||||
const fallbackData: {
|
||||
features: IFeatureToggleListItem[];
|
||||
total: number;
|
||||
nextCursor: string;
|
||||
} = {
|
||||
features: [],
|
||||
total: 0,
|
||||
nextCursor: '',
|
||||
};
|
||||
|
||||
export const useFeatureSearch = (
|
||||
cursor: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
projectId = '',
|
||||
options: SWRConfiguration = {},
|
||||
): IUseFeatureSearchOutput => {
|
||||
const { KEY, fetcher } = getFeatureSearchFetcher(projectId, cursor);
|
||||
const { KEY, fetcher } = getFeatureSearchFetcher(projectId, offset, limit);
|
||||
const { data, error, mutate } = useSWR<IFeatureSearchResponse>(
|
||||
KEY,
|
||||
fetcher,
|
||||
@ -54,15 +51,12 @@ export const useFeatureSearch = (
|
||||
};
|
||||
};
|
||||
|
||||
// temporary experiment
|
||||
const getQueryParam = (queryParam: string, path: string | null) => {
|
||||
const url = new URL(path || '', 'https://getunleash.io');
|
||||
const params = new URLSearchParams(url.search);
|
||||
return params.get(queryParam) || '';
|
||||
};
|
||||
|
||||
const getFeatureSearchFetcher = (projectId: string, cursor: string) => {
|
||||
const KEY = `api/admin/search/features?projectId=${projectId}&cursor=${cursor}&limit=25`;
|
||||
const getFeatureSearchFetcher = (
|
||||
projectId: string,
|
||||
offset: number,
|
||||
limit: number,
|
||||
) => {
|
||||
const KEY = `api/admin/search/features?projectId=${projectId}&offset=${offset}&limit=${limit}`;
|
||||
|
||||
const fetcher = () => {
|
||||
const path = formatApiPath(KEY);
|
||||
@ -70,15 +64,7 @@ const getFeatureSearchFetcher = (projectId: string, cursor: string) => {
|
||||
method: 'GET',
|
||||
})
|
||||
.then(handleErrorResponses('Feature search'))
|
||||
.then(async (res) => {
|
||||
const json = await res.json();
|
||||
// TODO: try using Link as key
|
||||
const nextCursor = getQueryParam(
|
||||
'cursor',
|
||||
res.headers.get('link'),
|
||||
);
|
||||
return { ...json, nextCursor };
|
||||
});
|
||||
.then((res) => res.json());
|
||||
};
|
||||
|
||||
return {
|
||||
|
@ -1,9 +1,8 @@
|
||||
import { Response, Request } from 'express';
|
||||
import { Response } from 'express';
|
||||
import Controller from '../../routes/controller';
|
||||
import { FeatureSearchService, OpenApiService } from '../../services';
|
||||
import {
|
||||
IFlagResolver,
|
||||
ITag,
|
||||
IUnleashConfig,
|
||||
IUnleashServices,
|
||||
NONE,
|
||||
@ -16,7 +15,6 @@ import {
|
||||
FeatureSearchQueryParameters,
|
||||
featureSearchQueryParameters,
|
||||
} from '../../openapi/spec/feature-search-query-parameters';
|
||||
import { nextLink } from './next-link';
|
||||
|
||||
const PATH = '/features';
|
||||
|
||||
@ -79,7 +77,7 @@ export default class FeatureSearchController extends Controller {
|
||||
type,
|
||||
tag,
|
||||
status,
|
||||
cursor,
|
||||
offset,
|
||||
limit = '50',
|
||||
sortOrder,
|
||||
sortBy,
|
||||
@ -97,24 +95,23 @@ export default class FeatureSearchController extends Controller {
|
||||
);
|
||||
const normalizedLimit =
|
||||
Number(limit) > 0 && Number(limit) <= 50 ? Number(limit) : 50;
|
||||
const normalizedOffset = Number(offset) > 0 ? Number(limit) : 0;
|
||||
const normalizedSortBy: string = sortBy ? sortBy : 'createdAt';
|
||||
const normalizedSortOrder =
|
||||
sortOrder === 'asc' || sortOrder === 'desc' ? sortOrder : 'asc';
|
||||
const { features, nextCursor, total } =
|
||||
await this.featureSearchService.search({
|
||||
query,
|
||||
projectId,
|
||||
type,
|
||||
userId,
|
||||
tag: normalizedTag,
|
||||
status: normalizedStatus,
|
||||
cursor,
|
||||
limit: normalizedLimit,
|
||||
sortBy: normalizedSortBy,
|
||||
sortOrder: normalizedSortOrder,
|
||||
});
|
||||
const { features, total } = await this.featureSearchService.search({
|
||||
query,
|
||||
projectId,
|
||||
type,
|
||||
userId,
|
||||
tag: normalizedTag,
|
||||
status: normalizedStatus,
|
||||
offset: normalizedOffset,
|
||||
limit: normalizedLimit,
|
||||
sortBy: normalizedSortBy,
|
||||
sortOrder: normalizedSortOrder,
|
||||
});
|
||||
|
||||
res.header('Link', nextLink(req, nextCursor));
|
||||
res.json({ features, total });
|
||||
} else {
|
||||
throw new InvalidOperationError(
|
||||
|
@ -25,21 +25,11 @@ export class FeatureSearchService {
|
||||
const { features, total } =
|
||||
await this.featureStrategiesStore.searchFeatures({
|
||||
...params,
|
||||
limit: params.limit + 1,
|
||||
limit: params.limit,
|
||||
});
|
||||
|
||||
const nextCursor =
|
||||
features.length > params.limit
|
||||
? features[features.length - 1].createdAt.toJSON()
|
||||
: undefined;
|
||||
|
||||
// do not return the items with the next cursor
|
||||
return {
|
||||
features:
|
||||
features.length > params.limit
|
||||
? features.slice(0, -1)
|
||||
: features,
|
||||
nextCursor,
|
||||
features,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
@ -58,26 +58,22 @@ const sortFeatures = async (
|
||||
.expect(expectedCode);
|
||||
};
|
||||
|
||||
const searchFeaturesWithCursor = async (
|
||||
const searchFeaturesWithOffset = async (
|
||||
{
|
||||
query = '',
|
||||
projectId = 'default',
|
||||
cursor = '',
|
||||
offset = '0',
|
||||
limit = '10',
|
||||
}: FeatureSearchQueryParameters,
|
||||
expectedCode = 200,
|
||||
) => {
|
||||
return app.request
|
||||
.get(
|
||||
`/api/admin/search/features?query=${query}&projectId=${projectId}&cursor=${cursor}&limit=${limit}`,
|
||||
`/api/admin/search/features?query=${query}&projectId=${projectId}&offset=${offset}&limit=${limit}`,
|
||||
)
|
||||
.expect(expectedCode);
|
||||
};
|
||||
|
||||
const getPage = async (url: string, expectedCode = 200) => {
|
||||
return app.request.get(url).expect(expectedCode);
|
||||
};
|
||||
|
||||
const filterFeaturesByType = async (types: string[], expectedCode = 200) => {
|
||||
const typeParams = types.map((type) => `type[]=${type}`).join('&');
|
||||
return app.request
|
||||
@ -121,16 +117,16 @@ test('should search matching features by name', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should paginate with cursor', async () => {
|
||||
test('should paginate with offset', 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, headers: firstHeaders } =
|
||||
await searchFeaturesWithCursor({
|
||||
await searchFeaturesWithOffset({
|
||||
query: 'feature',
|
||||
cursor: '',
|
||||
offset: '0',
|
||||
limit: '2',
|
||||
});
|
||||
|
||||
@ -139,16 +135,17 @@ test('should paginate with cursor', async () => {
|
||||
total: 4,
|
||||
});
|
||||
|
||||
const { body: secondPage, headers: secondHeaders } = await getPage(
|
||||
firstHeaders.link,
|
||||
);
|
||||
const { body: secondPage, headers: secondHeaders } =
|
||||
await searchFeaturesWithOffset({
|
||||
query: 'feature',
|
||||
offset: '2',
|
||||
limit: '2',
|
||||
});
|
||||
|
||||
expect(secondPage).toMatchObject({
|
||||
features: [{ name: 'my_feature_c' }, { name: 'my_feature_d' }],
|
||||
total: 4,
|
||||
});
|
||||
|
||||
expect(secondHeaders.link).toBe('');
|
||||
});
|
||||
|
||||
test('should filter features by type', async () => {
|
||||
|
@ -1,55 +0,0 @@
|
||||
import { nextLink } from './next-link';
|
||||
import { Request } from 'express';
|
||||
|
||||
describe('nextLink', () => {
|
||||
it('should generate the correct next link with a cursor', () => {
|
||||
const req = {
|
||||
baseUrl: '/api/events',
|
||||
path: '/',
|
||||
query: { page: '2', limit: '10' },
|
||||
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||
|
||||
const cursor = 'abc123';
|
||||
const result = nextLink(req, cursor);
|
||||
|
||||
expect(result).toBe('/api/events/?page=2&limit=10&cursor=abc123');
|
||||
});
|
||||
|
||||
it('should generate the correct next link without a cursor', () => {
|
||||
const req = {
|
||||
baseUrl: '/api/events',
|
||||
path: '/',
|
||||
query: { page: '2', limit: '10' },
|
||||
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||
|
||||
const result = nextLink(req);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('should exclude existing cursor from query parameters', () => {
|
||||
const req = {
|
||||
baseUrl: '/api/events',
|
||||
path: '/',
|
||||
query: { page: '2', limit: '10', cursor: 'oldCursor' },
|
||||
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||
|
||||
const cursor = 'newCursor';
|
||||
const result = nextLink(req, cursor);
|
||||
|
||||
expect(result).toBe('/api/events/?page=2&limit=10&cursor=newCursor');
|
||||
});
|
||||
|
||||
it('should handle empty query parameters correctly', () => {
|
||||
const req = {
|
||||
baseUrl: '/api/events',
|
||||
path: '/',
|
||||
query: {},
|
||||
} as Pick<Request, 'baseUrl' | 'path' | 'query'>;
|
||||
|
||||
const cursor = 'abc123';
|
||||
const result = nextLink(req, cursor);
|
||||
|
||||
expect(result).toBe('/api/events/?cursor=abc123');
|
||||
});
|
||||
});
|
@ -1,18 +0,0 @@
|
||||
import { Request } from 'express';
|
||||
|
||||
export function nextLink(
|
||||
req: Pick<Request, 'baseUrl' | 'path' | 'query'>,
|
||||
cursor?: string,
|
||||
): string {
|
||||
if (!cursor) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const url = `${req.baseUrl}${req.path}?`;
|
||||
|
||||
const params = new URLSearchParams(req.query as Record<string, string>);
|
||||
|
||||
params.set('cursor', cursor);
|
||||
|
||||
return `${url}${params.toString()}`;
|
||||
}
|
@ -253,7 +253,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
environment: string,
|
||||
): Promise<void> {
|
||||
await this.db('feature_strategies')
|
||||
.where({ feature_name: featureName, environment })
|
||||
.where({
|
||||
feature_name: featureName,
|
||||
environment,
|
||||
})
|
||||
.del();
|
||||
}
|
||||
|
||||
@ -296,8 +299,14 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
environment,
|
||||
})
|
||||
.orderBy([
|
||||
{ column: 'sort_order', order: 'asc' },
|
||||
{ column: 'created_at', order: 'asc' },
|
||||
{
|
||||
column: 'sort_order',
|
||||
order: 'asc',
|
||||
},
|
||||
{
|
||||
column: 'created_at',
|
||||
order: 'asc',
|
||||
},
|
||||
]);
|
||||
stopTimer();
|
||||
return rows.map(mapRow);
|
||||
@ -530,7 +539,7 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
type,
|
||||
tag,
|
||||
status,
|
||||
cursor,
|
||||
offset,
|
||||
limit,
|
||||
sortOrder,
|
||||
sortBy,
|
||||
@ -680,10 +689,6 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
];
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
query = query.where('features.created_at', '>=', cursor);
|
||||
}
|
||||
|
||||
const sortByMapping = {
|
||||
name: 'feature_name',
|
||||
type: 'type',
|
||||
@ -709,15 +714,24 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
.countDistinct({ total: 'features.name' })
|
||||
.first();
|
||||
|
||||
query = query.select(selectColumns).limit(limit * environmentCount);
|
||||
query = query
|
||||
.select(selectColumns)
|
||||
.limit(limit * environmentCount)
|
||||
.offset(offset * environmentCount);
|
||||
const rows = await query;
|
||||
|
||||
if (rows.length > 0) {
|
||||
const overview = this.getFeatureOverviewData(getUniqueRows(rows));
|
||||
const features = sortEnvironments(overview);
|
||||
return { features, total: Number(total?.total) || 0 };
|
||||
return {
|
||||
features,
|
||||
total: Number(total?.total) || 0,
|
||||
};
|
||||
}
|
||||
return { features: [], total: 0 };
|
||||
return {
|
||||
features: [],
|
||||
total: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async getFeatureOverview({
|
||||
@ -915,7 +929,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore {
|
||||
environment: String,
|
||||
): Promise<void> {
|
||||
await this.db(T.featureStrategies)
|
||||
.where({ project_name: projectId, environment })
|
||||
.where({
|
||||
project_name: projectId,
|
||||
environment,
|
||||
})
|
||||
.del();
|
||||
}
|
||||
|
||||
|
@ -28,8 +28,8 @@ export interface IFeatureSearchParams {
|
||||
type?: string[];
|
||||
tag?: string[][];
|
||||
status?: string[][];
|
||||
offset: number;
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
sortBy: string;
|
||||
sortOrder: 'asc' | 'desc';
|
||||
}
|
||||
|
@ -58,13 +58,13 @@ export const featureSearchQueryParameters = [
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
name: 'cursor',
|
||||
name: 'offset',
|
||||
schema: {
|
||||
type: 'string',
|
||||
example: '2023-10-31T09:21:04.056Z',
|
||||
example: '50',
|
||||
},
|
||||
description:
|
||||
'The next feature created at date the client has not seen. Used for cursor-based pagination. Empty if starting from the beginning.',
|
||||
'The number of features to skip when returning a page. By default it is set to 0.',
|
||||
in: 'query',
|
||||
},
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user