diff --git a/frontend/src/component/project/Project/ProjectOverview.tsx b/frontend/src/component/project/Project/ProjectOverview.tsx index c0f50eb5f83b..5a98434a88b4 100644 --- a/frontend/src/component/project/Project/ProjectOverview.tsx +++ b/frontend/src/component/project/Project/ProjectOverview.tsx @@ -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([]); - 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 ( { onChange={refetch} total={total} /> - {prevCursors.length > 0 ? ( - Prev - ) : null} - {nextCursor && Next} + Prev} + /> + Next} + /> @@ -118,7 +122,7 @@ const ProjectOverview = () => { setLastViewed(projectId); }, [projectId, setLastViewed]); - if (featureSearchFrontend) return ; + if (featureSearchFrontend) return ; return ( diff --git a/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts b/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts index 1982fbe016e1..116a8464b455 100644 --- a/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts +++ b/frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts @@ -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( 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 { diff --git a/src/lib/features/feature-search/feature-search-controller.ts b/src/lib/features/feature-search/feature-search-controller.ts index 5e0363e425b9..b02c2f7049e2 100644 --- a/src/lib/features/feature-search/feature-search-controller.ts +++ b/src/lib/features/feature-search/feature-search-controller.ts @@ -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( diff --git a/src/lib/features/feature-search/feature-search-service.ts b/src/lib/features/feature-search/feature-search-service.ts index 9746d0bd93da..9fcb7411510a 100644 --- a/src/lib/features/feature-search/feature-search-service.ts +++ b/src/lib/features/feature-search/feature-search-service.ts @@ -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, }; } diff --git a/src/lib/features/feature-search/feature.search.e2e.test.ts b/src/lib/features/feature-search/feature.search.e2e.test.ts index 7fd32ba70bc8..9e9c30d9efa8 100644 --- a/src/lib/features/feature-search/feature.search.e2e.test.ts +++ b/src/lib/features/feature-search/feature.search.e2e.test.ts @@ -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 () => { diff --git a/src/lib/features/feature-search/next-link.test.ts b/src/lib/features/feature-search/next-link.test.ts deleted file mode 100644 index 2fa347d78f4c..000000000000 --- a/src/lib/features/feature-search/next-link.test.ts +++ /dev/null @@ -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; - - 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; - - 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; - - 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; - - const cursor = 'abc123'; - const result = nextLink(req, cursor); - - expect(result).toBe('/api/events/?cursor=abc123'); - }); -}); diff --git a/src/lib/features/feature-search/next-link.ts b/src/lib/features/feature-search/next-link.ts deleted file mode 100644 index 6e24b4722692..000000000000 --- a/src/lib/features/feature-search/next-link.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Request } from 'express'; - -export function nextLink( - req: Pick, - cursor?: string, -): string { - if (!cursor) { - return ''; - } - - const url = `${req.baseUrl}${req.path}?`; - - const params = new URLSearchParams(req.query as Record); - - params.set('cursor', cursor); - - return `${url}${params.toString()}`; -} diff --git a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts index bda7ea6cefa7..3ebc3c0b6da0 100644 --- a/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts +++ b/src/lib/features/feature-toggle/feature-toggle-strategies-store.ts @@ -253,7 +253,10 @@ class FeatureStrategiesStore implements IFeatureStrategiesStore { environment: string, ): Promise { 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 { await this.db(T.featureStrategies) - .where({ project_name: projectId, environment }) + .where({ + project_name: projectId, + environment, + }) .del(); } diff --git a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts index 86c4d0b2247d..b944caaa4900 100644 --- a/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts +++ b/src/lib/features/feature-toggle/types/feature-toggle-strategies-store-type.ts @@ -28,8 +28,8 @@ export interface IFeatureSearchParams { type?: string[]; tag?: string[][]; status?: string[][]; + offset: number; limit: number; - cursor?: string; sortBy: string; sortOrder: 'asc' | 'desc'; } diff --git a/src/lib/openapi/spec/feature-search-query-parameters.ts b/src/lib/openapi/spec/feature-search-query-parameters.ts index 9c5e838c8b79..84a8cda139dc 100644 --- a/src/lib/openapi/spec/feature-search-query-parameters.ts +++ b/src/lib/openapi/spec/feature-search-query-parameters.ts @@ -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', }, {