Skip to content

Commit

Permalink
feat: introduce offset based search instead of cursor (#5274)
Browse files Browse the repository at this point in the history
  • Loading branch information
sjaanus authored Nov 8, 2023
1 parent 06d6227 commit 4bacd3e
Show file tree
Hide file tree
Showing 10 changed files with 94 additions and 176 deletions.
40 changes: 22 additions & 18 deletions frontend/src/component/project/Project/ProjectOverview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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>
Expand All @@ -118,7 +122,7 @@ const ProjectOverview = () => {
setLastViewed(projectId);
}, [projectId, setLastViewed]);

if (featureSearchFrontend) return <InfiniteProjectOverview />;
if (featureSearchFrontend) return <PaginatedProjectOverview />;

return (
<StyledContainer>
Expand Down
34 changes: 10 additions & 24 deletions frontend/src/hooks/api/getters/useFeatureSearch/useFeatureSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -54,31 +51,20 @@ 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);
return fetch(path, {
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 {
Expand Down
33 changes: 15 additions & 18 deletions src/lib/features/feature-search/feature-search-controller.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -16,7 +15,6 @@ import {
FeatureSearchQueryParameters,
featureSearchQueryParameters,
} from '../../openapi/spec/feature-search-query-parameters';
import { nextLink } from './next-link';

const PATH = '/features';

Expand Down Expand Up @@ -79,7 +77,7 @@ export default class FeatureSearchController extends Controller {
type,
tag,
status,
cursor,
offset,
limit = '50',
sortOrder,
sortBy,
Expand All @@ -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(
Expand Down
14 changes: 2 additions & 12 deletions src/lib/features/feature-search/feature-search-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
}
Expand Down
27 changes: 12 additions & 15 deletions src/lib/features/feature-search/feature.search.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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',
});

Expand All @@ -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 () => {
Expand Down
55 changes: 0 additions & 55 deletions src/lib/features/feature-search/next-link.test.ts

This file was deleted.

18 changes: 0 additions & 18 deletions src/lib/features/feature-search/next-link.ts

This file was deleted.

Loading

0 comments on commit 4bacd3e

Please sign in to comment.