Skip to content

Commit b99076b

Browse files
committed
feat(FR-1416): add deployment select component and pagination hook
1 parent 405e387 commit b99076b

File tree

22 files changed

+381
-0
lines changed

22 files changed

+381
-0
lines changed
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import {
2+
DeploymentSelectQuery,
3+
DeploymentSelectQuery$data,
4+
DeploymentFilter,
5+
} from '../../__generated__/DeploymentSelectQuery.graphql';
6+
import { DeploymentSelectValueQuery } from '../../__generated__/DeploymentSelectValueQuery.graphql';
7+
import BAILink from '../BAILink';
8+
import BAISelect from '../BAISelect';
9+
import TotalFooter from '../TotalFooter';
10+
import { useControllableValue } from 'ahooks';
11+
import { GetRef, SelectProps, Skeleton, Tooltip } from 'antd';
12+
import { BAIFlex, toLocalId } from 'backend.ai-ui';
13+
import _ from 'lodash';
14+
import { InfoIcon } from 'lucide-react';
15+
import React, { useDeferredValue, useEffect, useRef, useState } from 'react';
16+
import { useTranslation } from 'react-i18next';
17+
import { graphql, useLazyLoadQuery } from 'react-relay';
18+
import { useRelayCursorPaginatedQuery } from 'src/hooks/useRelayCursorPaginatedQuery';
19+
20+
export type Deployment = NonNullableNodeOnEdges<
21+
DeploymentSelectQuery$data['deployments']
22+
>;
23+
24+
export interface DeploymentSelectProps
25+
extends Omit<SelectProps, 'options' | 'labelInValue'> {
26+
fetchKey?: string;
27+
filter?: DeploymentFilter;
28+
}
29+
30+
const DeploymentSelect: React.FC<DeploymentSelectProps> = ({
31+
fetchKey,
32+
filter,
33+
loading,
34+
...selectPropsWithoutLoading
35+
}) => {
36+
const { t } = useTranslation();
37+
const [controllableValue, setControllableValue] = useControllableValue<
38+
string | undefined
39+
>(selectPropsWithoutLoading);
40+
const [controllableOpen, setControllableOpen] = useControllableValue<boolean>(
41+
selectPropsWithoutLoading,
42+
{
43+
valuePropName: 'open',
44+
trigger: 'onDropdownVisibleChange',
45+
},
46+
);
47+
const deferredOpen = useDeferredValue(controllableOpen);
48+
const [searchStr, setSearchStr] = useState<string>();
49+
const deferredSearchStr = useDeferredValue(searchStr);
50+
51+
const selectRef = useRef<GetRef<typeof BAISelect> | null>(null);
52+
53+
// Query for selected deployment details
54+
const { deployment: selectedDeployment } =
55+
useLazyLoadQuery<DeploymentSelectValueQuery>(
56+
graphql`
57+
query DeploymentSelectValueQuery($id: ID!) {
58+
deployment(id: $id) {
59+
id
60+
metadata {
61+
name
62+
status
63+
createdAt
64+
}
65+
}
66+
}
67+
`,
68+
{
69+
id: controllableValue ?? '',
70+
},
71+
{
72+
// to skip the query when controllableValue is empty
73+
fetchPolicy: controllableValue ? 'store-or-network' : 'store-only',
74+
},
75+
);
76+
77+
// Paginated deployments query (cursor-based)
78+
const {
79+
paginationData,
80+
result: { deployments },
81+
loadNext,
82+
isLoadingNext,
83+
} = useRelayCursorPaginatedQuery<DeploymentSelectQuery, Deployment>(
84+
graphql`
85+
query DeploymentSelectQuery(
86+
$first: Int
87+
$after: String
88+
$filter: DeploymentFilter
89+
$orderBy: [DeploymentOrderBy!]
90+
) {
91+
deployments(
92+
first: $first
93+
after: $after
94+
filter: $filter
95+
orderBy: $orderBy
96+
) {
97+
count
98+
pageInfo {
99+
hasNextPage
100+
endCursor
101+
}
102+
edges {
103+
node {
104+
id
105+
metadata {
106+
name
107+
status
108+
createdAt
109+
}
110+
}
111+
}
112+
}
113+
}
114+
`,
115+
{ first: 10 },
116+
{
117+
filter,
118+
...(deferredSearchStr
119+
? {
120+
filter: {
121+
...filter,
122+
name: { iContains: `%${deferredSearchStr}%` },
123+
},
124+
}
125+
: {}),
126+
},
127+
{
128+
fetchKey,
129+
fetchPolicy: deferredOpen ? 'network-only' : 'store-only',
130+
},
131+
{
132+
// getTotal: (result) => result.deployments?.count,
133+
getItem: (result) => result.deployments?.edges?.map((e) => e.node),
134+
getPageInfo: (result) => {
135+
const pageInfo = result.deployments?.pageInfo;
136+
return {
137+
hasNextPage: pageInfo?.hasNextPage ?? false,
138+
endCursor: pageInfo?.endCursor ?? undefined,
139+
};
140+
},
141+
getId: (item: Deployment) => item?.id,
142+
},
143+
);
144+
145+
const selectOptions = _.map(paginationData, (item: Deployment) => {
146+
return {
147+
label: item?.metadata?.name,
148+
value: item?.id,
149+
deployment: item,
150+
};
151+
});
152+
153+
const [optimisticValueWithLabel, setOptimisticValueWithLabel] = useState(
154+
selectedDeployment
155+
? {
156+
label: selectedDeployment?.metadata?.name || undefined,
157+
value: selectedDeployment?.id || undefined,
158+
}
159+
: controllableValue
160+
? {
161+
label: controllableValue,
162+
value: controllableValue,
163+
}
164+
: controllableValue,
165+
);
166+
167+
const isValueMatched = searchStr === deferredSearchStr;
168+
useEffect(() => {
169+
if (isValueMatched) {
170+
selectRef.current?.scrollTo(0);
171+
}
172+
}, [isValueMatched]);
173+
174+
return (
175+
<BAISelect
176+
ref={selectRef}
177+
placeholder={t('deployment.SelectDeployment')}
178+
style={{ minWidth: 100 }}
179+
showSearch
180+
searchValue={searchStr}
181+
onSearch={(v) => {
182+
setSearchStr(v);
183+
}}
184+
labelRender={({ label }: { label: React.ReactNode }) => {
185+
return label ? (
186+
<BAIFlex gap="xxs">
187+
{label}
188+
<Tooltip title={t('general.NavigateToDetailPage')}>
189+
<BAILink
190+
to={`/deployment/${toLocalId(selectedDeployment?.id || '')}`}
191+
>
192+
<InfoIcon />
193+
</BAILink>
194+
</Tooltip>
195+
</BAIFlex>
196+
) : (
197+
label
198+
);
199+
}}
200+
autoClearSearchValue
201+
filterOption={false}
202+
loading={searchStr !== deferredSearchStr || loading}
203+
options={selectOptions}
204+
{...selectPropsWithoutLoading}
205+
// override value and onChange
206+
labelInValue // use labelInValue to display the selected option label
207+
value={optimisticValueWithLabel}
208+
onChange={(v, option) => {
209+
setOptimisticValueWithLabel(v);
210+
setControllableValue(v.value, option);
211+
selectPropsWithoutLoading.onChange?.(v.value || '', option);
212+
}}
213+
endReached={() => {
214+
loadNext();
215+
}}
216+
open={controllableOpen}
217+
onDropdownVisibleChange={setControllableOpen}
218+
notFoundContent={
219+
_.isUndefined(paginationData) ? (
220+
<Skeleton.Input active size="small" block />
221+
) : undefined
222+
}
223+
footer={
224+
_.isNumber(deployments?.count) && deployments.count > 0 ? (
225+
<TotalFooter loading={isLoadingNext} total={deployments?.count} />
226+
) : undefined
227+
}
228+
/>
229+
);
230+
};
231+
232+
export default DeploymentSelect;
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import _ from 'lodash';
2+
import { useState, useRef, useMemo, useTransition, useEffect } from 'react';
3+
import { GraphQLTaggedNode, useLazyLoadQuery } from 'react-relay';
4+
import type { OperationType } from 'relay-runtime';
5+
6+
/**
7+
* Cursor-based pagination hook for Relay-compliant GraphQL connections.
8+
* Supports queries with `first`, `after`, `last`, `before`, etc.
9+
*/
10+
export type CursorOptions<Result, ItemType> = {
11+
getItem: (result: Result) => ItemType[] | undefined;
12+
// getTotal: (result: Result) => number | undefined;
13+
getPageInfo: (result: Result) => {
14+
hasNextPage: boolean;
15+
endCursor?: string;
16+
hasPreviousPage?: boolean;
17+
startCursor?: string;
18+
};
19+
getId: (item: ItemType) => string | undefined | null;
20+
};
21+
22+
export function useRelayCursorPaginatedQuery<T extends OperationType, ItemType>(
23+
query: GraphQLTaggedNode,
24+
initialPaginationVariables: { first?: number; last?: number },
25+
otherVariables: Omit<
26+
Partial<T['variables']>,
27+
'first' | 'last' | 'after' | 'before'
28+
>,
29+
options: Parameters<typeof useLazyLoadQuery<T>>[2],
30+
{
31+
getItem,
32+
getId,
33+
// getTotal,
34+
getPageInfo,
35+
}: CursorOptions<T['response'], ItemType>,
36+
) {
37+
const [cursor, setCursor] = useState<string | undefined>(undefined);
38+
const [isLoadingNext, startLoadingNextTransition] = useTransition();
39+
const previousResult = useRef<ItemType[]>([]);
40+
41+
const previousOtherVariablesRef = useRef(otherVariables);
42+
43+
const isNewOtherVariables = !_.isEqual(
44+
previousOtherVariablesRef.current,
45+
otherVariables,
46+
);
47+
48+
const variables = {
49+
...initialPaginationVariables,
50+
...otherVariables,
51+
after: cursor,
52+
};
53+
54+
const result = useLazyLoadQuery<T>(query, variables, options);
55+
56+
const data = useMemo(() => {
57+
const items = getItem(result);
58+
return items
59+
? _.uniqBy([...previousResult.current, ...items], getId)
60+
: undefined;
61+
// eslint-disable-next-line react-hooks/exhaustive-deps
62+
}, [result]);
63+
64+
const pageInfo = getPageInfo(result);
65+
const hasNext = pageInfo.hasNextPage;
66+
67+
const loadNext = () => {
68+
if (isLoadingNext || !hasNext) return;
69+
previousResult.current = data || [];
70+
startLoadingNextTransition(() => {
71+
setCursor(pageInfo.endCursor);
72+
});
73+
};
74+
75+
useEffect(() => {
76+
// Reset cursor when otherVariables change
77+
if (isNewOtherVariables) {
78+
previousResult.current = [];
79+
setCursor(undefined);
80+
}
81+
// eslint-disable-next-line react-hooks/exhaustive-deps
82+
}, [isNewOtherVariables]);
83+
84+
return {
85+
paginationData: data,
86+
result,
87+
loadNext,
88+
hasNext,
89+
isLoadingNext,
90+
};
91+
}

resources/i18n/de.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,9 @@
497497
"Used": "gebraucht"
498498
}
499499
},
500+
"deployment": {
501+
"SelectDeployment": "Wählen Sie Bereitstellung"
502+
},
500503
"desktopNotification": {
501504
"NotSupported": "Dieser Browser unterstützt keine Benachrichtigungen.",
502505
"PermissionDenied": "Sie haben den Benachrichtigungszugriff abgelehnt. \nUm Warnungen zu verwenden, lassen Sie diese bitte in Ihren Browsereinstellungen zu."

resources/i18n/el.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,9 @@
495495
"Used": "χρησιμοποιημένο"
496496
}
497497
},
498+
"deployment": {
499+
"SelectDeployment": "Επιλέξτε την ανάπτυξη"
500+
},
498501
"desktopNotification": {
499502
"NotSupported": "Αυτό το πρόγραμμα περιήγησης δεν υποστηρίζει ειδοποιήσεις.",
500503
"PermissionDenied": "Έχετε αρνηθεί την πρόσβαση ειδοποίησης. \nΓια να χρησιμοποιήσετε ειδοποιήσεις, αφήστε το στις ρυθμίσεις του προγράμματος περιήγησής σας."

resources/i18n/en.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -594,6 +594,7 @@
594594
"RoutesInfo": "Routes Info",
595595
"RoutingID": "Routing ID",
596596
"ScalingSettings": "Scaling Settings",
597+
"SelectDeployment": "Select Deployment",
597598
"SessionID": "Session ID",
598599
"SetAsActiveRevision": "Set as active revision",
599600
"SetAutoScalingRule": "Set Auto Scaling Rule",

resources/i18n/es.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,9 @@
497497
"Used": "usado"
498498
}
499499
},
500+
"deployment": {
501+
"SelectDeployment": "Seleccionar implementación"
502+
},
500503
"desktopNotification": {
501504
"NotSupported": "Este navegador no admite notificaciones.",
502505
"PermissionDenied": "Has negado el acceso a la notificación. \nPara usar alertas, permítelo en la configuración de su navegador."

resources/i18n/fi.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,9 @@
497497
"Used": "käytetty"
498498
}
499499
},
500+
"deployment": {
501+
"SelectDeployment": "Valitse käyttöönotto"
502+
},
500503
"desktopNotification": {
501504
"NotSupported": "Tämä selain ei tue ilmoituksia.",
502505
"PermissionDenied": "Olet kiistänyt ilmoituskäytön. \nJos haluat käyttää hälytyksiä, salli se selaimen asetuksissa."

resources/i18n/fr.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,9 @@
497497
"Used": "utilisé"
498498
}
499499
},
500+
"deployment": {
501+
"SelectDeployment": "Sélectionnez le déploiement"
502+
},
500503
"desktopNotification": {
501504
"NotSupported": "Ce navigateur ne prend pas en charge les notifications.",
502505
"PermissionDenied": "Vous avez nié l'accès à la notification. \nPour utiliser des alertes, veuillez le permettre dans les paramètres de votre navigateur."

resources/i18n/id.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,9 @@
496496
"Used": "digunakan"
497497
}
498498
},
499+
"deployment": {
500+
"SelectDeployment": "Pilih penempatan"
501+
},
499502
"desktopNotification": {
500503
"NotSupported": "Browser ini tidak mendukung pemberitahuan.",
501504
"PermissionDenied": "Anda telah menolak akses pemberitahuan. \nUntuk menggunakan lansiran, harap diizinkan di pengaturan browser Anda."

0 commit comments

Comments
 (0)