diff --git a/api/src/main/java/io/kafbat/ui/emitter/AbstractEmitter.java b/api/src/main/java/io/kafbat/ui/emitter/AbstractEmitter.java index 7638586a5..04d21b72c 100644 --- a/api/src/main/java/io/kafbat/ui/emitter/AbstractEmitter.java +++ b/api/src/main/java/io/kafbat/ui/emitter/AbstractEmitter.java @@ -40,9 +40,9 @@ protected void sendConsuming(FluxSink sink, PolledRecords messagesProcessing.sentConsumingInfo(sink, records); } - // cursor is null if target partitions were fully polled (no, need to do paging) - protected void sendFinishStatsAndCompleteSink(FluxSink sink, @Nullable Cursor.Tracking cursor) { - messagesProcessing.sendFinishEvents(sink, cursor); + protected void sendFinishStatsAndCompleteSink(FluxSink sink, Cursor.Tracking cursor, + boolean hasNext) { + messagesProcessing.sendFinishEvents(sink, cursor, hasNext); sink.complete(); } } diff --git a/api/src/main/java/io/kafbat/ui/emitter/ConsumingStats.java b/api/src/main/java/io/kafbat/ui/emitter/ConsumingStats.java index 6287a9903..b09570b15 100644 --- a/api/src/main/java/io/kafbat/ui/emitter/ConsumingStats.java +++ b/api/src/main/java/io/kafbat/ui/emitter/ConsumingStats.java @@ -2,8 +2,7 @@ import io.kafbat.ui.model.TopicMessageConsumingDTO; import io.kafbat.ui.model.TopicMessageEventDTO; -import io.kafbat.ui.model.TopicMessageNextPageCursorDTO; -import javax.annotation.Nullable; +import io.kafbat.ui.model.TopicMessagePageCursorDTO; import reactor.core.publisher.FluxSink; class ConsumingStats { @@ -28,13 +27,19 @@ void incFilterApplyError() { filterApplyErrors++; } - void sendFinishEvent(FluxSink sink, @Nullable Cursor.Tracking cursor) { + void sendFinishEvent(FluxSink sink, Cursor.Tracking cursor, boolean hasNext) { + String previousCursorId = cursor.getPreviousCursorId(); sink.next( new TopicMessageEventDTO() .type(TopicMessageEventDTO.TypeEnum.DONE) - .cursor( - cursor != null - ? new TopicMessageNextPageCursorDTO().id(cursor.registerCursor()) + .prevCursor( + previousCursorId != null + ? new TopicMessagePageCursorDTO().id(previousCursorId) + : null + ) + .nextCursor( + hasNext + ? new TopicMessagePageCursorDTO().id(cursor.registerCursor()) : null ) .consuming(createConsumingStats()) diff --git a/api/src/main/java/io/kafbat/ui/emitter/Cursor.java b/api/src/main/java/io/kafbat/ui/emitter/Cursor.java index 1569cf85f..87f765400 100644 --- a/api/src/main/java/io/kafbat/ui/emitter/Cursor.java +++ b/api/src/main/java/io/kafbat/ui/emitter/Cursor.java @@ -8,6 +8,8 @@ import io.kafbat.ui.serdes.ConsumerRecordDeserializer; import java.util.HashMap; import java.util.Map; +import java.util.Optional; +import java.util.function.BiFunction; import java.util.function.Function; import java.util.function.Predicate; import org.apache.kafka.common.TopicPartition; @@ -22,7 +24,9 @@ public static class Tracking { private final ConsumerPosition originalPosition; private final Predicate filter; private final int limit; - private final Function registerAction; + private final String cursorId; + private final BiFunction registerAction; + private final Function> previousCursorIdGetter; //topic -> partition -> offset private final Table trackingOffsets = HashBasedTable.create(); @@ -31,12 +35,16 @@ public Tracking(ConsumerRecordDeserializer deserializer, ConsumerPosition originalPosition, Predicate filter, int limit, - Function registerAction) { + String cursorId, + BiFunction registerAction, + Function> previousCursorIdGetter) { this.deserializer = deserializer; this.originalPosition = originalPosition; this.filter = filter; this.limit = limit; + this.cursorId = cursorId; this.registerAction = registerAction; + this.previousCursorIdGetter = previousCursorIdGetter; } void trackOffset(String topic, int partition, long offset) { @@ -82,9 +90,14 @@ String registerCursor() { ), filter, limit - ) + ), + this.cursorId ); } + + String getPreviousCursorId() { + return this.previousCursorIdGetter.apply(this.cursorId).orElse(null); + } } } diff --git a/api/src/main/java/io/kafbat/ui/emitter/MessagesProcessing.java b/api/src/main/java/io/kafbat/ui/emitter/MessagesProcessing.java index 16dead8f5..815192306 100644 --- a/api/src/main/java/io/kafbat/ui/emitter/MessagesProcessing.java +++ b/api/src/main/java/io/kafbat/ui/emitter/MessagesProcessing.java @@ -72,9 +72,9 @@ void sentConsumingInfo(FluxSink sink, PolledRecords polled } } - void sendFinishEvents(FluxSink sink, @Nullable Cursor.Tracking cursor) { + void sendFinishEvents(FluxSink sink, Cursor.Tracking cursor, boolean hasNext) { if (!sink.isCancelled()) { - consumingStats.sendFinishEvent(sink, cursor); + consumingStats.sendFinishEvent(sink, cursor, hasNext); } } diff --git a/api/src/main/java/io/kafbat/ui/emitter/RangePollingEmitter.java b/api/src/main/java/io/kafbat/ui/emitter/RangePollingEmitter.java index 794c70e57..3275d73ce 100644 --- a/api/src/main/java/io/kafbat/ui/emitter/RangePollingEmitter.java +++ b/api/src/main/java/io/kafbat/ui/emitter/RangePollingEmitter.java @@ -64,7 +64,7 @@ public void accept(FluxSink sink) { if (sink.isCancelled()) { log.debug("Polling finished due to sink cancellation"); } - sendFinishStatsAndCompleteSink(sink, pollRange.isEmpty() ? null : cursor); + sendFinishStatsAndCompleteSink(sink, cursor, !pollRange.isEmpty()); log.debug("Polling finished"); } catch (InterruptException kafkaInterruptException) { log.debug("Polling finished due to thread interruption"); diff --git a/api/src/main/java/io/kafbat/ui/service/MessagesService.java b/api/src/main/java/io/kafbat/ui/service/MessagesService.java index 2f6192e11..7ecf987d7 100644 --- a/api/src/main/java/io/kafbat/ui/service/MessagesService.java +++ b/api/src/main/java/io/kafbat/ui/service/MessagesService.java @@ -46,6 +46,7 @@ import org.apache.kafka.clients.producer.RecordMetadata; import org.apache.kafka.common.TopicPartition; import org.apache.kafka.common.serialization.ByteArraySerializer; +import org.jetbrains.annotations.NotNull; import org.springframework.stereotype.Service; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -216,46 +217,37 @@ public Flux loadMessages(KafkaCluster cluster, @Nullable Integer limit, @Nullable String keySerde, @Nullable String valueSerde) { - return loadMessages( - cluster, - topic, + Cursor cursor = new Cursor( deserializationService.deserializerFor(cluster, topic, keySerde, valueSerde), consumerPosition, getMsgFilter(containsStringFilter, filterId), fixPageSize(limit) ); + String cursorId = cursorsStorage.register(cursor, null); + return loadMessages(cluster, topic, cursorId, cursor); } public Flux loadMessages(KafkaCluster cluster, String topic, String cursorId) { Cursor cursor = cursorsStorage.getCursor(cursorId) .orElseThrow(() -> new ValidationException("Next page cursor not found. Maybe it was evicted from cache.")); - return loadMessages( - cluster, - topic, - cursor.deserializer(), - cursor.consumerPosition(), - cursor.filter(), - cursor.limit() - ); + return loadMessages(cluster, topic, cursorId, cursor); } - private Flux loadMessages(KafkaCluster cluster, - String topic, - ConsumerRecordDeserializer deserializer, - ConsumerPosition consumerPosition, - Predicate filter, - int limit) { + private @NotNull Flux loadMessages(KafkaCluster cluster, String topic, + String cursorId, Cursor cursor) { return withExistingTopic(cluster, topic) .flux() .publishOn(Schedulers.boundedElastic()) - .flatMap(td -> loadMessagesImpl(cluster, deserializer, consumerPosition, filter, limit)); + .flatMap(td -> loadMessagesImpl(cluster, + cursor.deserializer(), cursor.consumerPosition(), cursor.filter(), cursor.limit(), cursorId)); } private Flux loadMessagesImpl(KafkaCluster cluster, ConsumerRecordDeserializer deserializer, ConsumerPosition consumerPosition, Predicate filter, - int limit) { + int limit, + String cursorId) { var emitter = switch (consumerPosition.pollingMode()) { case TO_OFFSET, TO_TIMESTAMP, LATEST -> new BackwardEmitter( () -> consumerGroupService.createConsumer(cluster), @@ -264,7 +256,7 @@ private Flux loadMessagesImpl(KafkaCluster cluster, deserializer, filter, cluster.getPollingSettings(), - cursorsStorage.createNewCursor(deserializer, consumerPosition, filter, limit) + cursorsStorage.createNewCursor(deserializer, consumerPosition, filter, limit, cursorId) ); case FROM_OFFSET, FROM_TIMESTAMP, EARLIEST -> new ForwardEmitter( () -> consumerGroupService.createConsumer(cluster), @@ -273,7 +265,7 @@ private Flux loadMessagesImpl(KafkaCluster cluster, deserializer, filter, cluster.getPollingSettings(), - cursorsStorage.createNewCursor(deserializer, consumerPosition, filter, limit) + cursorsStorage.createNewCursor(deserializer, consumerPosition, filter, limit, cursorId) ); case TAILING -> new TailingEmitter( () -> consumerGroupService.createConsumer(cluster), diff --git a/api/src/main/java/io/kafbat/ui/service/PollingCursorsStorage.java b/api/src/main/java/io/kafbat/ui/service/PollingCursorsStorage.java index 2b760f010..88fd26275 100644 --- a/api/src/main/java/io/kafbat/ui/service/PollingCursorsStorage.java +++ b/api/src/main/java/io/kafbat/ui/service/PollingCursorsStorage.java @@ -7,6 +7,7 @@ import io.kafbat.ui.model.ConsumerPosition; import io.kafbat.ui.model.TopicMessageDTO; import io.kafbat.ui.serdes.ConsumerRecordDeserializer; +import jakarta.annotation.Nullable; import java.util.Map; import java.util.Optional; import java.util.function.Predicate; @@ -20,23 +21,36 @@ public class PollingCursorsStorage { .maximumSize(MAX_SIZE) .build(); + private final Cache previousCursorsMap = CacheBuilder.newBuilder() + .maximumSize(MAX_SIZE) + .build(); + public Cursor.Tracking createNewCursor(ConsumerRecordDeserializer deserializer, ConsumerPosition originalPosition, Predicate filter, - int limit) { - return new Cursor.Tracking(deserializer, originalPosition, filter, limit, this::register); + int limit, + String cursorId) { + return new Cursor.Tracking(deserializer, originalPosition, filter, limit, cursorId, + this::register, this::getPreviousCursorId); } public Optional getCursor(String id) { return Optional.ofNullable(cursorsCache.getIfPresent(id)); } - public String register(Cursor cursor) { + public String register(Cursor cursor, @Nullable String previousCursorId) { var id = RandomStringUtils.random(8, true, true); cursorsCache.put(id, cursor); + if (previousCursorId != null) { + previousCursorsMap.put(id, previousCursorId); + } return id; } + public Optional getPreviousCursorId(String cursorId) { + return Optional.ofNullable(previousCursorsMap.getIfPresent(cursorId)); + } + @VisibleForTesting public Map asMap() { return cursorsCache.asMap(); diff --git a/api/src/test/java/io/kafbat/ui/emitter/CursorTest.java b/api/src/test/java/io/kafbat/ui/emitter/CursorTest.java index 3dd5dadd1..99f2a65d1 100644 --- a/api/src/test/java/io/kafbat/ui/emitter/CursorTest.java +++ b/api/src/test/java/io/kafbat/ui/emitter/CursorTest.java @@ -164,7 +164,7 @@ private ForwardEmitter createForwardEmitter(ConsumerPosition position) { } private Cursor.Tracking createCursor(ConsumerPosition position) { - return cursorsStorage.createNewCursor(createRecordsDeserializer(), position, m -> true, PAGE_SIZE); + return cursorsStorage.createNewCursor(createRecordsDeserializer(), position, m -> true, PAGE_SIZE, "CursorId"); } private EnhancedConsumer createConsumer() { diff --git a/api/src/test/java/io/kafbat/ui/service/MessagesServiceTest.java b/api/src/test/java/io/kafbat/ui/service/MessagesServiceTest.java index 8939b50c3..06dc8e056 100644 --- a/api/src/test/java/io/kafbat/ui/service/MessagesServiceTest.java +++ b/api/src/test/java/io/kafbat/ui/service/MessagesServiceTest.java @@ -131,8 +131,8 @@ void cursorIsRegisteredAfterPollingIsDoneAndCanBeUsedForNextPagePolling(PollingM null, null, pageSize, StringSerde.name(), StringSerde.name()) .doOnNext(evt -> { if (evt.getType() == TopicMessageEventDTO.TypeEnum.DONE) { - assertThat(evt.getCursor()).isNotNull(); - cursorIdCatcher.set(evt.getCursor().getId()); + assertThat(evt.getNextCursor()).isNotNull(); + cursorIdCatcher.set(evt.getNextCursor().getId()); } }) .filter(evt -> evt.getType() == TopicMessageEventDTO.TypeEnum.MESSAGE) @@ -147,7 +147,7 @@ void cursorIsRegisteredAfterPollingIsDoneAndCanBeUsedForNextPagePolling(PollingM Flux remainingMsgs = messagesService.loadMessages(cluster, testTopic, cursorIdCatcher.get()) .doOnNext(evt -> { if (evt.getType() == TopicMessageEventDTO.TypeEnum.DONE) { - assertThat(evt.getCursor()).isNull(); + assertThat(evt.getNextCursor()).isNull(); } }) .filter(evt -> evt.getType() == TopicMessageEventDTO.TypeEnum.MESSAGE) diff --git a/contract/src/main/resources/swagger/kafbat-ui-api.yaml b/contract/src/main/resources/swagger/kafbat-ui-api.yaml index 7ca62831f..1e64dc3b7 100644 --- a/contract/src/main/resources/swagger/kafbat-ui-api.yaml +++ b/contract/src/main/resources/swagger/kafbat-ui-api.yaml @@ -2954,8 +2954,10 @@ components: $ref: "#/components/schemas/TopicMessagePhase" consuming: $ref: "#/components/schemas/TopicMessageConsuming" - cursor: - $ref: "#/components/schemas/TopicMessageNextPageCursor" + prevCursor: + $ref: "#/components/schemas/TopicMessagePageCursor" + nextCursor: + $ref: "#/components/schemas/TopicMessagePageCursor" TopicMessagePhase: type: object @@ -2985,7 +2987,7 @@ components: filterApplyErrors: type: integer - TopicMessageNextPageCursor: + TopicMessagePageCursor: type: object properties: id: diff --git a/frontend/src/components/Topics/Topic/Messages/MessagesTable.tsx b/frontend/src/components/Topics/Topic/Messages/MessagesTable.tsx index 813cabcfa..f30f6c4b0 100644 --- a/frontend/src/components/Topics/Topic/Messages/MessagesTable.tsx +++ b/frontend/src/components/Topics/Topic/Messages/MessagesTable.tsx @@ -5,7 +5,11 @@ import { TopicMessage } from 'generated-sources'; import React, { useState } from 'react'; import { Button } from 'components/common/Button/Button'; import * as S from 'components/common/NewTable/Table.styled'; -import { usePaginateTopics, useIsLiveMode } from 'lib/hooks/useMessagesFilters'; +import { + useGoToNextPage, + useGoToPrevPage, + useIsLiveMode, +} from 'lib/hooks/useMessagesFilters'; import { useMessageFiltersStore } from 'lib/hooks/useMessageFiltersStore'; import PreviewModal from './PreviewModal'; @@ -20,12 +24,14 @@ const MessagesTable: React.FC = ({ messages, isFetching, }) => { - const paginate = usePaginateTopics(); + const goToNextPage = useGoToNextPage(); + const goToPrevPage = useGoToPrevPage(); const [previewFor, setPreviewFor] = useState(null); const [keyFilters, setKeyFilters] = useState([]); const [contentFilters, setContentFilters] = useState([]); const nextCursor = useMessageFiltersStore((state) => state.nextCursor); + const prevCursor = useMessageFiltersStore((state) => state.prevCursor); const isLive = useIsLiveMode(); return ( @@ -97,11 +103,19 @@ const MessagesTable: React.FC = ({ + diff --git a/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx b/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx index 808dde9e4..4e1c002f3 100644 --- a/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx +++ b/frontend/src/components/Topics/Topic/Messages/__test__/MessagesTable.spec.tsx @@ -30,7 +30,8 @@ jest.mock('react-router-dom', () => ({ jest.mock('lib/hooks/useMessagesFilters', () => ({ useIsLiveMode: jest.fn(), - usePaginateTopics: jest.fn(), + useGoToNextPage: jest.fn(), + useGoToPrevPage: jest.fn(), })); describe('MessagesTable', () => { @@ -73,12 +74,23 @@ describe('MessagesTable', () => { expect(screen.queryByText(/next/i)).toBeDisabled(); }); + it('should check if previous button is disabled isLive Param', () => { + renderComponent({ isFetching: true }); + expect(screen.queryByText(/previous/i)).toBeDisabled(); + }); + it('should check if next button is disabled if there is no nextCursor', () => { (useIsLiveMode as jest.Mock).mockImplementation(() => false); renderComponent({ isFetching: false }); expect(screen.queryByText(/next/i)).toBeDisabled(); }); + it('should check if previous button is disabled if there is no prevCursor', () => { + (useIsLiveMode as jest.Mock).mockImplementation(() => false); + renderComponent({ isFetching: false }); + expect(screen.queryByText(/previous/i)).toBeDisabled(); + }); + it('should check the display of the loader element during loader', () => { renderComponent({ isFetching: true }); expect(screen.getByRole('progressbar')).toBeInTheDocument(); diff --git a/frontend/src/lib/hooks/api/topicMessages.tsx b/frontend/src/lib/hooks/api/topicMessages.tsx index ec407c901..2e51382c5 100644 --- a/frontend/src/lib/hooks/api/topicMessages.tsx +++ b/frontend/src/lib/hooks/api/topicMessages.tsx @@ -13,10 +13,7 @@ import { showServerError } from 'lib/errorHandling'; import { useMutation, useQuery } from '@tanstack/react-query'; import { messagesApiClient } from 'lib/api'; import { useSearchParams } from 'react-router-dom'; -import { - getCursorValue, - MessagesFilterKeys, -} from 'lib/hooks/useMessagesFilters'; +import { getPageValue, MessagesFilterKeys } from 'lib/hooks/useMessagesFilters'; import { convertStrToPollingMode } from 'lib/hooks/filterUtils'; import { useMessageFiltersStore } from 'lib/hooks/useMessageFiltersStore'; import { TopicName } from 'lib/interfaces/topic'; @@ -31,14 +28,15 @@ export const useTopicMessages = ({ clusterName, topicName, }: UseTopicMessagesProps) => { - const [searchParams] = useSearchParams(); + const [searchParams, setSearchParams] = useSearchParams(); const [messages, setMessages] = React.useState([]); const [phase, setPhase] = React.useState(); const [consumptionStats, setConsumptionStats] = React.useState(); const [isFetching, setIsFetching] = React.useState(false); const abortController = useRef(new AbortController()); - const prevCursor = useRef(0); + const prevReqUrl = useRef(''); + const currentPage = useRef(1); // get initial properties @@ -102,18 +100,26 @@ export const useTopicMessages = ({ requestParams.append(MessagesFilterKeys.partitions, partitions); } const { nextCursor, setNextCursor } = useMessageFiltersStore.getState(); + const { prevCursor, setPrevCursor } = useMessageFiltersStore.getState(); + + const searchParamsWithoutPage = new URLSearchParams(searchParams); + searchParamsWithoutPage.delete(MessagesFilterKeys.page); + if (prevReqUrl.current !== searchParamsWithoutPage.toString()) { + searchParams.delete(MessagesFilterKeys.page); + setSearchParams(searchParams); + setPrevCursor(undefined); + setNextCursor(undefined); + } + prevReqUrl.current = searchParamsWithoutPage.toString(); - const tempCompareUrl = new URLSearchParams(requestParams); - tempCompareUrl.delete(MessagesFilterKeys.cursor); - - const currentCursor = getCursorValue(searchParams); - - // filters stay the same and we have cursor set cursor - if (nextCursor && prevCursor.current < currentCursor) { + const searchParamPage = getPageValue(searchParams); + if (currentPage.current < searchParamPage && nextCursor) { requestParams.set(MessagesFilterKeys.cursor, nextCursor); + } else if (currentPage.current > searchParamPage && prevCursor) { + requestParams.set(MessagesFilterKeys.cursor, prevCursor); } + currentPage.current = searchParamPage; - prevCursor.current = currentCursor; await fetchEventSource(`${url}?${requestParams.toString()}`, { method: 'GET', signal: abortController.current.signal, @@ -129,11 +135,7 @@ export const useTopicMessages = ({ }, onmessage(event) { const parsedData: TopicMessageEvent = JSON.parse(event.data); - const { message, consuming, cursor } = parsedData; - - if (useMessageFiltersStore.getState().nextCursor !== cursor?.id) { - setNextCursor(cursor?.id || undefined); - } + const { message, consuming } = parsedData; switch (parsedData.type) { case TopicMessageEventTypeEnum.MESSAGE: @@ -152,6 +154,14 @@ export const useTopicMessages = ({ case TopicMessageEventTypeEnum.CONSUMING: if (consuming) setConsumptionStats(consuming); break; + case TopicMessageEventTypeEnum.DONE: + if (nextCursor !== parsedData.nextCursor?.id) { + setNextCursor(parsedData.nextCursor?.id || undefined); + } + if (prevCursor !== parsedData.prevCursor?.id) { + setPrevCursor(parsedData.prevCursor?.id || undefined); + } + break; default: } }, @@ -161,6 +171,7 @@ export const useTopicMessages = ({ }, onerror(err) { setNextCursor(undefined); + setPrevCursor(undefined); setIsFetching(false); abortController.current = new AbortController(); showServerError(err); diff --git a/frontend/src/lib/hooks/useMessageFiltersStore.ts b/frontend/src/lib/hooks/useMessageFiltersStore.ts index dd4bd4dfb..5b29bf3db 100644 --- a/frontend/src/lib/hooks/useMessageFiltersStore.ts +++ b/frontend/src/lib/hooks/useMessageFiltersStore.ts @@ -15,7 +15,9 @@ interface MessageFiltersState { notPersistedFilter: AdvancedFilter | undefined; save: (filter: AdvancedFilter) => void; nextCursor: string | undefined; + prevCursor: string | undefined; setNextCursor: (str: string | undefined) => void; + setPrevCursor: (str: string | undefined) => void; replace: (filterId: string, filter: AdvancedFilter) => void; commit: (filter: AdvancedFilter | undefined) => void; remove: (id: string) => void; @@ -39,6 +41,7 @@ export const useMessageFiltersStore = create()( (set) => ({ filters: {}, nextCursor: undefined, + prevCursor: undefined, notPersistedFilter: undefined, save: (filter) => set((state) => ({ @@ -73,6 +76,7 @@ export const useMessageFiltersStore = create()( }), removeAll: () => set(() => ({ filters: {} })), setNextCursor: (cursor) => set(() => ({ nextCursor: cursor })), + setPrevCursor: (cursor) => set(() => ({ prevCursor: cursor })), }), { name: `${LOCAL_STORAGE_KEY_PREFIX}-message-filters`, diff --git a/frontend/src/lib/hooks/useMessagesFilters.ts b/frontend/src/lib/hooks/useMessagesFilters.ts index c72a91b49..e9d9fa075 100644 --- a/frontend/src/lib/hooks/useMessagesFilters.ts +++ b/frontend/src/lib/hooks/useMessagesFilters.ts @@ -28,6 +28,7 @@ export const MessagesFilterKeys = { activeFilterId: 'activeFilterId', activeFilterNPId: 'activeFilterNPId', // not persisted filter name to indicate the refresh cursor: 'cursor', + page: 'page', r: 'r', // used tp force refresh of the data } as const; @@ -52,28 +53,34 @@ export function useRefreshData(initSearchParams?: URLSearchParams) { }; } -export function getCursorValue(urlSearchParam: URLSearchParams) { - const cursor = parseInt( - urlSearchParam.get(MessagesFilterKeys.cursor) || '0', - 10 - ); +export function getPageValue(urlSearchParam: URLSearchParams) { + const page = parseInt(urlSearchParam.get(MessagesFilterKeys.page) || '1', 10); - if (Number.isNaN(cursor)) { + if (Number.isNaN(page)) { return 0; } - return cursor; + return page; } -export function usePaginateTopics(initSearchParams?: URLSearchParams) { +export function useGoToPrevPage(initSearchParams?: URLSearchParams) { const [, setSearchParams] = useSearchParams(initSearchParams); return () => { setSearchParams((params) => { - const cursor = getCursorValue(params) + 1; + const prevPage = getPageValue(params) - 1; + params.set(MessagesFilterKeys.page, prevPage.toString()); - if (cursor) { - params.set(MessagesFilterKeys.cursor, cursor.toString()); - } + return params; + }); + }; +} + +export function useGoToNextPage(initSearchParams?: URLSearchParams) { + const [, setSearchParams] = useSearchParams(initSearchParams); + return () => { + setSearchParams((params) => { + const nextPage = getPageValue(params) + 1; + params.set(MessagesFilterKeys.page, nextPage.toString()); return params; });