Skip to content

Commit 8a4bb93

Browse files
refactor: centralize relay connection pagination logic (#1332)
- Add shared relay pagination hook and migrate list pages, selects, tabs, and forms to use it. - Remove duplicated debounce/refetch/loadNext boilerplate and standardize onLoadMore handling across relay pagination fragments. Keeping behavior consistent and making future pagination changes easier Signed-off-by: Omar <omar.brbutovic@secomind.com>
1 parent d9938eb commit 8a4bb93

34 files changed

Lines changed: 861 additions & 1261 deletions

frontend/src/components/BaseImageSelect.tsx

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
* SPDX-License-Identifier: Apache-2.0
1919
*/
2020

21-
import _ from "lodash";
2221
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
2322
import { ErrorBoundary } from "react-error-boundary";
2423
import type { FallbackProps } from "react-error-boundary";
@@ -42,8 +41,9 @@ import { BaseImageSelect_getBaseImageCollection_Query } from "@/api/__generated_
4241
import Button from "@/components/Button";
4342
import Spinner from "@/components/Spinner";
4443
import Stack from "@/components/Stack";
45-
import { RECORDS_TO_LOAD_FIRST, RECORDS_TO_LOAD_NEXT } from "@/constants";
44+
import { RECORDS_TO_LOAD_FIRST } from "@/constants";
4645
import { BaseImageCollectionRecord } from "@/forms/CreateUpdateCampaign";
46+
import useRelayConnectionPagination from "@/hooks/useRelayConnectionPagination";
4747

4848
const GET_BASE_IMAGE_COLLECTION_QUERY = graphql`
4949
query BaseImageSelect_getBaseImageCollection_Query(
@@ -111,40 +111,24 @@ const BaseImageSelect = ({
111111

112112
const [searchText, setSearchText] = useState<string | null>(null);
113113

114-
const debounceRefetch = useMemo(
115-
() =>
116-
_.debounce((text: string) => {
117-
if (text === "") {
118-
refetch(
119-
{
120-
first: RECORDS_TO_LOAD_FIRST,
121-
},
122-
{ fetchPolicy: "network-only" },
123-
);
124-
} else {
125-
refetch(
126-
{
127-
first: RECORDS_TO_LOAD_FIRST,
128-
filter: { version: { ilike: `%${text}%` } },
129-
},
130-
{ fetchPolicy: "network-only" },
131-
);
132-
}
133-
}, 500),
134-
[refetch],
135-
);
136-
137-
useEffect(() => {
138-
if (searchText !== null) {
139-
debounceRefetch(searchText);
140-
}
141-
}, [debounceRefetch, searchText]);
114+
const { onLoadMore } = useRelayConnectionPagination({
115+
hasNext,
116+
isLoadingNext,
117+
loadNext,
118+
refetch,
119+
searchText,
120+
buildFilter: (text) => {
121+
if (text === "") {
122+
return undefined;
123+
}
142124

143-
const loadNextOptions = useCallback(() => {
144-
if (hasNext && !isLoadingNext) {
145-
loadNext(RECORDS_TO_LOAD_NEXT);
146-
}
147-
}, [hasNext, isLoadingNext, loadNext]);
125+
return {
126+
version: {
127+
ilike: `%${text}%`,
128+
},
129+
};
130+
},
131+
});
148132

149133
const baseImages = useMemo(() => {
150134
return (
@@ -187,7 +171,7 @@ const BaseImageSelect = ({
187171
noBaseImageOptionsMessage(inputValue)
188172
}
189173
isLoading={isLoadingNext}
190-
onMenuScrollToBottom={hasNext ? loadNextOptions : undefined}
174+
onMenuScrollToBottom={onLoadMore}
191175
onInputChange={(text) => setSearchText(text)}
192176
/>
193177
);

frontend/src/components/DeploymentTargetsTabs.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* SPDX-License-Identifier: Apache-2.0
1919
*/
2020

21-
import { useCallback, useEffect, useMemo, useState } from "react";
21+
import { useEffect, useMemo, useState } from "react";
2222
import Nav from "react-bootstrap/Nav";
2323
import NavItem from "react-bootstrap/NavItem";
2424
import NavLink from "react-bootstrap/NavLink";
@@ -36,7 +36,8 @@ import type { ColumnId } from "@/components/DeploymentTargetsTable";
3636
import DeploymentTargetsTable, {
3737
columnIds,
3838
} from "@/components/DeploymentTargetsTable";
39-
import { RECORDS_TO_LOAD_FIRST, RECORDS_TO_LOAD_NEXT } from "@/constants";
39+
import { RECORDS_TO_LOAD_FIRST } from "@/constants";
40+
import useRelayConnectionPagination from "@/hooks/useRelayConnectionPagination";
4041
import Spinner from "./Spinner";
4142

4243
/* eslint-disable relay/unused-fields */
@@ -122,11 +123,11 @@ const DeploymentTargetsTabs = ({ campaignRef }: Props) => {
122123
);
123124
}, [activeTab, committedTab, refetch]);
124125

125-
const loadNextDeploymentTargets = useCallback(() => {
126-
if (hasNext && !isLoadingNext) {
127-
loadNext(RECORDS_TO_LOAD_NEXT);
128-
}
129-
}, [hasNext, isLoadingNext, loadNext]);
126+
const { onLoadMore } = useRelayConnectionPagination({
127+
hasNext,
128+
isLoadingNext,
129+
loadNext,
130+
});
130131

131132
const deploymentTargetsRef = data?.campaignTargets;
132133

@@ -171,7 +172,7 @@ const DeploymentTargetsTabs = ({ campaignRef }: Props) => {
171172
campaignTargetsRef={deploymentTargetsRef}
172173
hiddenColumns={hiddenColumns}
173174
loading={isLoadingNext}
174-
onLoadMore={hasNext ? loadNextDeploymentTargets : undefined}
175+
onLoadMore={onLoadMore}
175176
/>
176177
)}
177178
</div>

frontend/src/components/FileDownloadTargetsTabs.tsx

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
* SPDX-License-Identifier: Apache-2.0
1919
*/
2020

21-
import { useCallback, useEffect, useMemo, useState } from "react";
21+
import { useEffect, useMemo, useState } from "react";
2222
import Nav from "react-bootstrap/Nav";
2323
import NavItem from "react-bootstrap/NavItem";
2424
import NavLink from "react-bootstrap/NavLink";
@@ -36,7 +36,8 @@ import type { ColumnId } from "@/components/FileDownloadTargetsTable";
3636
import FileDownloadTargetsTable, {
3737
columnIds,
3838
} from "@/components/FileDownloadTargetsTable";
39-
import { RECORDS_TO_LOAD_FIRST, RECORDS_TO_LOAD_NEXT } from "@/constants";
39+
import { RECORDS_TO_LOAD_FIRST } from "@/constants";
40+
import useRelayConnectionPagination from "@/hooks/useRelayConnectionPagination";
4041
import Spinner from "./Spinner";
4142

4243
const FILE_DOWNLOAD_TARGETS_FRAGMENT = graphql`
@@ -125,11 +126,11 @@ const FileDownloadTargetsTabs = ({
125126
);
126127
}, [activeTab, committedTab, refetch]);
127128

128-
const loadNextTargets = useCallback(() => {
129-
if (hasNext && !isLoadingNext) {
130-
loadNext(RECORDS_TO_LOAD_NEXT);
131-
}
132-
}, [hasNext, isLoadingNext, loadNext]);
129+
const { onLoadMore } = useRelayConnectionPagination({
130+
hasNext,
131+
isLoadingNext,
132+
loadNext,
133+
});
133134

134135
const targetsRef = data?.campaignTargets;
135136

@@ -189,7 +190,7 @@ const FileDownloadTargetsTabs = ({
189190
campaignTargetsRef={targetsRef}
190191
hiddenColumns={hiddenColumns}
191192
loading={isLoadingNext}
192-
onLoadMore={hasNext ? loadNextTargets : undefined}
193+
onLoadMore={onLoadMore}
193194
/>
194195
)}
195196
</div>

frontend/src/components/FileSelect.tsx

Lines changed: 20 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
* SPDX-License-Identifier: Apache-2.0
1919
*/
2020

21-
import _ from "lodash";
2221
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
2322
import type { FallbackProps } from "react-error-boundary";
2423
import { ErrorBoundary } from "react-error-boundary";
@@ -42,7 +41,8 @@ import type { FileSelect_getRepository_Query } from "@/api/__generated__/FileSel
4241
import Button from "@/components/Button";
4342
import Spinner from "@/components/Spinner";
4443
import Stack from "@/components/Stack";
45-
import { RECORDS_TO_LOAD_FIRST, RECORDS_TO_LOAD_NEXT } from "@/constants";
44+
import { RECORDS_TO_LOAD_FIRST } from "@/constants";
45+
import useRelayConnectionPagination from "@/hooks/useRelayConnectionPagination";
4646

4747
const GET_REPOSITORY_QUERY = graphql`
4848
query FileSelect_getRepository_Query(
@@ -112,40 +112,24 @@ const FileSelect = ({ filesFragmentRef, controllerProps }: FileSelectProps) => {
112112

113113
const [searchText, setSearchText] = useState<string | null>(null);
114114

115-
const debounceRefetch = useMemo(
116-
() =>
117-
_.debounce((text: string) => {
118-
if (text === "") {
119-
refetch(
120-
{
121-
first: RECORDS_TO_LOAD_FIRST,
122-
},
123-
{ fetchPolicy: "network-only" },
124-
);
125-
} else {
126-
refetch(
127-
{
128-
first: RECORDS_TO_LOAD_FIRST,
129-
filter: { name: { ilike: `%${text}%` } },
130-
},
131-
{ fetchPolicy: "network-only" },
132-
);
133-
}
134-
}, 500),
135-
[refetch],
136-
);
137-
138-
useEffect(() => {
139-
if (searchText !== null) {
140-
debounceRefetch(searchText);
141-
}
142-
}, [debounceRefetch, searchText]);
115+
const { onLoadMore } = useRelayConnectionPagination({
116+
hasNext,
117+
isLoadingNext,
118+
loadNext,
119+
refetch,
120+
searchText,
121+
buildFilter: (text) => {
122+
if (text === "") {
123+
return undefined;
124+
}
143125

144-
const loadNextOptions = useCallback(() => {
145-
if (hasNext && !isLoadingNext) {
146-
loadNext(RECORDS_TO_LOAD_NEXT);
147-
}
148-
}, [hasNext, isLoadingNext, loadNext]);
126+
return {
127+
name: {
128+
ilike: `%${text}%`,
129+
},
130+
};
131+
},
132+
});
149133

150134
const files = useMemo(() => {
151135
return (
@@ -185,7 +169,7 @@ const FileSelect = ({ filesFragmentRef, controllerProps }: FileSelectProps) => {
185169
getOptionValue={getFileValue}
186170
noOptionsMessage={({ inputValue }) => noFileOptionsMessage(inputValue)}
187171
isLoading={isLoadingNext}
188-
onMenuScrollToBottom={hasNext ? loadNextOptions : undefined}
172+
onMenuScrollToBottom={onLoadMore}
189173
onInputChange={(text) => setSearchText(text)}
190174
/>
191175
);

frontend/src/components/ReleaseSelect.tsx

Lines changed: 37 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
1-
/*
2-
* This file is part of Edgehog.
3-
*
4-
* Copyright 2025 SECO Mind Srl
5-
*
6-
* Licensed under the Apache License, Version 2.0 (the "License");
7-
* you may not use this file except in compliance with the License.
8-
* You may obtain a copy of the License at
9-
*
10-
* http://www.apache.org/licenses/LICENSE-2.0
11-
*
12-
* Unless required by applicable law or agreed to in writing, software
13-
* distributed under the License is distributed on an "AS IS" BASIS,
14-
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15-
* See the License for the specific language governing permissions and
16-
* limitations under the License.
17-
*
18-
* SPDX-License-Identifier: Apache-2.0
19-
*/
1+
// This file is part of Edgehog.
2+
//
3+
// Copyright 2025-2026 SECO Mind Srl
4+
//
5+
// Licensed under the Apache License, Version 2.0 (the "License");
6+
// you may not use this file except in compliance with the License.
7+
// You may obtain a copy of the License at
8+
//
9+
// http://www.apache.org/licenses/LICENSE-2.0
10+
//
11+
// Unless required by applicable law or agreed to in writing, software
12+
// distributed under the License is distributed on an "AS IS" BASIS,
13+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
// See the License for the specific language governing permissions and
15+
// limitations under the License.
16+
//
17+
// SPDX-License-Identifier: Apache-2.0
2018

21-
import _ from "lodash";
2219
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
2320
import type { FallbackProps } from "react-error-boundary";
2421
import { ErrorBoundary } from "react-error-boundary";
@@ -43,8 +40,9 @@ import {
4340
import Button from "@/components/Button";
4441
import Spinner from "@/components/Spinner";
4542
import Stack from "@/components/Stack";
46-
import { RECORDS_TO_LOAD_FIRST, RECORDS_TO_LOAD_NEXT } from "@/constants";
43+
import { RECORDS_TO_LOAD_FIRST } from "@/constants";
4744
import { ApplicationRecord } from "@/forms/CreateDeploymentCampaign";
45+
import useRelayConnectionPagination from "@/hooks/useRelayConnectionPagination";
4846

4947
const GET_APPLICATION_QUERY = graphql`
5048
query ReleaseSelect_getApplication_Query(
@@ -109,40 +107,24 @@ const ReleaseSelect = ({
109107

110108
const [searchText, setSearchText] = useState<string | null>(null);
111109

112-
const debounceRefetch = useMemo(
113-
() =>
114-
_.debounce((text: string) => {
115-
if (text === "") {
116-
refetch(
117-
{
118-
first: RECORDS_TO_LOAD_FIRST,
119-
},
120-
{ fetchPolicy: "network-only" },
121-
);
122-
} else {
123-
refetch(
124-
{
125-
first: RECORDS_TO_LOAD_FIRST,
126-
filter: { version: { ilike: `%${text}%` } },
127-
},
128-
{ fetchPolicy: "network-only" },
129-
);
130-
}
131-
}, 500),
132-
[refetch],
133-
);
134-
135-
useEffect(() => {
136-
if (searchText !== null) {
137-
debounceRefetch(searchText);
138-
}
139-
}, [debounceRefetch, searchText]);
110+
const { onLoadMore } = useRelayConnectionPagination({
111+
hasNext,
112+
isLoadingNext,
113+
loadNext,
114+
refetch,
115+
searchText,
116+
buildFilter: (text) => {
117+
if (text === "") {
118+
return undefined;
119+
}
140120

141-
const loadNextOptions = useCallback(() => {
142-
if (hasNext && !isLoadingNext) {
143-
loadNext(RECORDS_TO_LOAD_NEXT);
144-
}
145-
}, [hasNext, isLoadingNext, loadNext]);
121+
return {
122+
version: {
123+
ilike: `%${text}%`,
124+
},
125+
};
126+
},
127+
});
146128

147129
const releaseOptions = useMemo(() => {
148130
const releases =
@@ -192,7 +174,7 @@ const ReleaseSelect = ({
192174
getOptionValue={getReleaseValue}
193175
noOptionsMessage={({ inputValue }) => noReleaseOptionsMessage(inputValue)}
194176
isLoading={isLoadingNext}
195-
onMenuScrollToBottom={hasNext ? loadNextOptions : undefined}
177+
onMenuScrollToBottom={onLoadMore}
196178
onInputChange={(text) => setSearchText(text)}
197179
/>
198180
);

0 commit comments

Comments
 (0)