Skip to content

Commit f38a358

Browse files
Jobs list pagination treated every full page as if another page existed. Exact multiples of the page size could enable Next on the final page, and inclusive cursor bounds could repeat the boundary job when paging back or in ascending order.
Normalize cursor-boundary duplicates before rendering a page and probe for one more page only when a page is completely full. The dashboard now disables Next when no later page exists while keeping page boundaries stable across forward and backward navigation.
1 parent 9dd9c35 commit f38a358

1 file changed

Lines changed: 107 additions & 25 deletions

File tree

dashboard/src/Dashboard/Component/JobsList.purs

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ type State =
141141
, pageCursor :: Maybe PageCursor
142142
}
143143

144+
type FetchResult =
145+
{ jobs :: Array Job
146+
, hasNextPage :: Boolean
147+
}
148+
144149
-- | Direction of a pagination cursor relative to the sort order.
145150
data PaginationDir = Forward | Backward
146151

@@ -168,7 +173,7 @@ data Action
168173
= Initialize
169174
| FetchJobs
170175
| FetchJobsSilent
171-
| HandleFetchResult (Either ApiError (Array Job))
176+
| HandleFetchResult (Either ApiError FetchResult)
172177
| SetTimeRange TimeRange
173178
| SetCustomSinceDate String
174179
| SetCustomSinceTime String
@@ -687,21 +692,16 @@ handleAction = case _ of
687692
else
688693
msg
689694
H.modify_ _ { loading = false, error = Just displayMsg, jobs = [] }
690-
Right jobs -> do
695+
Right { jobs, hasNextPage } -> do
691696
state <- H.get
692697
let summaries = map Job.toJobSummary jobs
693698
let newFingerprints = map jobFingerprint summaries
694699
let oldFingerprints = map jobFingerprint state.jobs
695700
-- Skip the state update when the set of jobs hasn't changed. This
696701
-- avoids VDOM diffing on every auto-refresh tick when nothing new
697702
-- has arrived.
698-
unless (not state.loading && newFingerprints == oldFingerprints) do
699-
let
700-
isBackward = case state.pageCursor of
701-
Just { dir: Backward } -> true
702-
_ -> false
703-
let hasNext = isBackward || Array.length jobs >= pageSize
704-
H.modify_ _ { loading = false, error = Nothing, jobs = summaries, hasNextPage = hasNext }
703+
unless (not state.loading && newFingerprints == oldFingerprints && state.hasNextPage == hasNextPage) do
704+
H.modify_ _ { loading = false, error = Nothing, jobs = summaries, hasNextPage = hasNextPage }
705705

706706
SetTimeRange range -> do
707707
when (range == Custom) do
@@ -773,11 +773,7 @@ handleAction = case _ of
773773
NextPage -> do
774774
state <- H.get
775775
when (state.hasNextPage && not state.loading) do
776-
let
777-
cursor = case state.sortOrder of
778-
DESC -> extremeCreatedAt min state.jobs
779-
ASC -> extremeCreatedAt max state.jobs
780-
case cursor of
776+
case nextPageCursor state.sortOrder state.jobs of
781777
Nothing -> pure unit
782778
Just ts -> do
783779
H.modify_ _ { currentPage = state.currentPage + 1, pageCursor = Just { timestamp: ts, dir: Forward } }
@@ -794,11 +790,7 @@ handleAction = case _ of
794790
handleAction FetchJobs
795791
notifyFiltersChanged
796792
else do
797-
let
798-
cursor = case state.sortOrder of
799-
DESC -> extremeCreatedAt max state.jobs
800-
ASC -> extremeCreatedAt min state.jobs
801-
case cursor of
793+
case prevPageCursor state.sortOrder state.jobs of
802794
Nothing -> pure unit
803795
Just ts -> do
804796
H.modify_ _ { currentPage = targetPage, pageCursor = Just { timestamp: ts, dir: Backward }, hasNextPage = true }
@@ -827,10 +819,27 @@ jobFingerprint job =
827819
}
828820

829821
-- | Build query parameters from the current state and fetch jobs from the API.
830-
doFetchJobs :: forall m. MonadAff m => H.HalogenM State Action () Output m (Either ApiError (Array Job))
822+
doFetchJobs :: forall m. MonadAff m => H.HalogenM State Action () Output m (Either ApiError FetchResult)
831823
doFetchJobs = do
832824
state <- H.get
833825
now <- liftEffect Now.nowDateTime
826+
jobsResult <- fetchJobsPage state now state.pageCursor
827+
case jobsResult of
828+
Left err ->
829+
pure (Left err)
830+
Right jobs -> do
831+
hasNextPage <- determineHasNextPage state now jobs
832+
pure (Right { jobs, hasNextPage })
833+
834+
-- | Fetch one jobs page using the current filters and an optional pagination cursor.
835+
fetchJobsPage
836+
:: forall m
837+
. MonadAff m
838+
=> State
839+
-> DateTime
840+
-> Maybe PageCursor
841+
-> H.HalogenM State Action () Output m (Either ApiError (Array Job))
842+
fetchJobsPage state now cursor = do
834843
let
835844
customSince = Job.parseDateTimeLocal state.sinceStr
836845
customUntil = Job.parseDateTimeLocal state.untilStr
@@ -842,7 +851,7 @@ doFetchJobs = do
842851
Custom -> customUntil
843852
UntilNow -> Just now
844853
_ -> Nothing
845-
{ since, until, fetchOrder, needsReverse } = case state.pageCursor of
854+
{ since, until, fetchOrder, needsReverse } = case cursor of
846855
Nothing ->
847856
{ since: baseSince, until: baseUntil, fetchOrder: state.sortOrder, needsReverse: false }
848857
Just { timestamp, dir: Forward } -> case state.sortOrder of
@@ -853,7 +862,34 @@ doFetchJobs = do
853862
ASC -> { since: baseSince, until: Just timestamp, fetchOrder: DESC, needsReverse: true }
854863
includeCompleted = Just (state.filters.statusFilter /= ActiveOnly)
855864
result <- H.liftAff $ API.fetchJobs state.apiConfig { since, until, order: Just fetchOrder, includeCompleted }
856-
pure $ if needsReverse then map Array.reverse result else result
865+
pure $ map (normalizeFetchedJobs state.sortOrder cursor <<< applyFetchedOrder needsReverse) result
866+
867+
-- | Decide whether another page exists past the current page. Full pages require
868+
-- | a probe request because the jobs API is capped at `pageSize`.
869+
determineHasNextPage
870+
:: forall m
871+
. MonadAff m
872+
=> State
873+
-> DateTime
874+
-> Array Job
875+
-> H.HalogenM State Action () Output m Boolean
876+
determineHasNextPage state now jobs = do
877+
let
878+
isBackward = case state.pageCursor of
879+
Just { dir: Backward } -> true
880+
_ -> false
881+
if isBackward then
882+
pure true
883+
else if Array.length jobs < pageSize then
884+
pure false
885+
else case nextPageCursorForJobs state.sortOrder jobs of
886+
Nothing ->
887+
pure false
888+
Just ts -> do
889+
probe <- fetchJobsPage state now (Just { timestamp: ts, dir: Forward })
890+
pure case probe of
891+
Left _ -> true
892+
Right nextJobs -> not (Array.null nextJobs)
857893

858894
-- | Update the combined sinceStr from a date or time part change, fetch if
859895
-- | both endpoints parse, and sync the URL.
@@ -897,14 +933,60 @@ syncAutoRefresh enabled = do
897933
refreshInterval :: Milliseconds
898934
refreshInterval = Milliseconds 5000.0
899935

900-
-- | Find the extreme (min or max) `createdAt` timestamp among job summaries.
901-
extremeCreatedAt :: (DateTime -> DateTime -> DateTime) -> Array JobSummary -> Maybe DateTime
902-
extremeCreatedAt pick = Array.foldl (\acc s -> Just (maybe s.createdAt (pick s.createdAt) acc)) Nothing
936+
-- | Find the extreme (min or max) `createdAt` timestamp using the provided accessor.
937+
extremeCreatedAtBy :: forall a. (a -> DateTime) -> (DateTime -> DateTime -> DateTime) -> Array a -> Maybe DateTime
938+
extremeCreatedAtBy getCreatedAt pick = Array.foldl (\acc row -> Just (maybe (getCreatedAt row) (pick (getCreatedAt row)) acc)) Nothing
903939
where
904940
maybe fallback f = case _ of
905941
Nothing -> fallback
906942
Just x -> f x
907943

944+
nextPageCursor :: forall a. SortOrder -> Array { createdAt :: DateTime | a } -> Maybe DateTime
945+
nextPageCursor sortOrder jobs = case sortOrder of
946+
DESC -> extremeCreatedAtBy _.createdAt min jobs
947+
ASC -> extremeCreatedAtBy _.createdAt max jobs
948+
949+
prevPageCursor :: forall a. SortOrder -> Array { createdAt :: DateTime | a } -> Maybe DateTime
950+
prevPageCursor sortOrder jobs = case sortOrder of
951+
DESC -> extremeCreatedAtBy _.createdAt max jobs
952+
ASC -> extremeCreatedAtBy _.createdAt min jobs
953+
954+
nextPageCursorForJobs :: SortOrder -> Array Job -> Maybe DateTime
955+
nextPageCursorForJobs sortOrder jobs = case sortOrder of
956+
DESC -> extremeCreatedAtBy (V1.jobInfo >>> _.createdAt) min jobs
957+
ASC -> extremeCreatedAtBy (V1.jobInfo >>> _.createdAt) max jobs
958+
959+
applyFetchedOrder :: Boolean -> Array Job -> Array Job
960+
applyFetchedOrder needsReverse =
961+
if needsReverse then Array.reverse else identity
962+
963+
normalizeFetchedJobs :: SortOrder -> Maybe PageCursor -> Array Job -> Array Job
964+
normalizeFetchedJobs sortOrder cursor jobs = case cursor of
965+
Just { timestamp, dir: Forward }
966+
| sortOrder == ASC ->
967+
dropBoundaryFromStart timestamp jobs
968+
Just { timestamp, dir: Backward }
969+
| sortOrder == DESC ->
970+
dropBoundaryFromEnd timestamp jobs
971+
_ ->
972+
jobs
973+
974+
dropBoundaryFromStart :: DateTime -> Array Job -> Array Job
975+
dropBoundaryFromStart timestamp jobs = case Array.uncons jobs of
976+
Just { head, tail }
977+
| (V1.jobInfo head).createdAt == timestamp ->
978+
tail
979+
_ ->
980+
jobs
981+
982+
dropBoundaryFromEnd :: DateTime -> Array Job -> Array Job
983+
dropBoundaryFromEnd timestamp jobs = case Array.unsnoc jobs of
984+
Just { init, last }
985+
| (V1.jobInfo last).createdAt == timestamp ->
986+
init
987+
_ ->
988+
jobs
989+
908990
-- | Apply client-side filters to an array of job summaries.
909991
applyFilters :: Filters -> Array JobSummary -> Array JobSummary
910992
applyFilters filters = Array.filter matchesAll

0 commit comments

Comments
 (0)