1
0
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:
Jaanus Sellin 2023-11-08 11:12:42 +02:00 committed by GitHub
parent 06d62278dc
commit 4bacd3e055
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 94 additions and 176 deletions

View File

@ -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>

View File

@ -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 {

View File

@ -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(

View File

@ -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,
};
}

View File

@ -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 () => {

View File

@ -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');
});
});

View File

@ -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()}`;
}

View File

@ -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();
}

View File

@ -28,8 +28,8 @@ export interface IFeatureSearchParams {
type?: string[];
tag?: string[][];
status?: string[][];
offset: number;
limit: number;
cursor?: string;
sortBy: string;
sortOrder: 'asc' | 'desc';
}

View File

@ -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',
},
{