From a7ec72a351ac58fd7d2eb32c0904f408ba7d9f83 Mon Sep 17 00:00:00 2001 From: agatha197 Date: Fri, 29 Aug 2025 14:05:47 +0900 Subject: [PATCH] feat(FR-1416): add deployment select component and pagination hook --- .../src/components/Chat/DeploymentSelect.tsx | 232 ++++++++++++++++++ .../src/hooks/useRelayCursorPaginatedQuery.ts | 91 +++++++ resources/i18n/de.json | 3 + resources/i18n/el.json | 3 + resources/i18n/en.json | 1 + resources/i18n/es.json | 3 + resources/i18n/fi.json | 3 + resources/i18n/fr.json | 3 + resources/i18n/id.json | 3 + resources/i18n/it.json | 3 + resources/i18n/ja.json | 3 + resources/i18n/ko.json | 3 + resources/i18n/mn.json | 3 + resources/i18n/ms.json | 3 + resources/i18n/pl.json | 3 + resources/i18n/pt-BR.json | 3 + resources/i18n/pt.json | 3 + resources/i18n/ru.json | 3 + resources/i18n/th.json | 3 + resources/i18n/tr.json | 3 + resources/i18n/vi.json | 3 + resources/i18n/zh-CN.json | 3 + 22 files changed, 381 insertions(+) create mode 100644 react/src/components/Chat/DeploymentSelect.tsx create mode 100644 react/src/hooks/useRelayCursorPaginatedQuery.ts diff --git a/react/src/components/Chat/DeploymentSelect.tsx b/react/src/components/Chat/DeploymentSelect.tsx new file mode 100644 index 0000000000..9c05ef101f --- /dev/null +++ b/react/src/components/Chat/DeploymentSelect.tsx @@ -0,0 +1,232 @@ +import { + DeploymentSelectQuery, + DeploymentSelectQuery$data, + DeploymentFilter, +} from '../../__generated__/DeploymentSelectQuery.graphql'; +import { DeploymentSelectValueQuery } from '../../__generated__/DeploymentSelectValueQuery.graphql'; +import BAILink from '../BAILink'; +import BAISelect from '../BAISelect'; +import TotalFooter from '../TotalFooter'; +import { useControllableValue } from 'ahooks'; +import { GetRef, SelectProps, Skeleton, Tooltip } from 'antd'; +import { BAIFlex, toLocalId } from 'backend.ai-ui'; +import _ from 'lodash'; +import { InfoIcon } from 'lucide-react'; +import React, { useDeferredValue, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { graphql, useLazyLoadQuery } from 'react-relay'; +import { useRelayCursorPaginatedQuery } from 'src/hooks/useRelayCursorPaginatedQuery'; + +export type Deployment = NonNullableNodeOnEdges< + DeploymentSelectQuery$data['deployments'] +>; + +export interface DeploymentSelectProps + extends Omit { + fetchKey?: string; + filter?: DeploymentFilter; +} + +const DeploymentSelect: React.FC = ({ + fetchKey, + filter, + loading, + ...selectPropsWithoutLoading +}) => { + const { t } = useTranslation(); + const [controllableValue, setControllableValue] = useControllableValue< + string | undefined + >(selectPropsWithoutLoading); + const [controllableOpen, setControllableOpen] = useControllableValue( + selectPropsWithoutLoading, + { + valuePropName: 'open', + trigger: 'onDropdownVisibleChange', + }, + ); + const deferredOpen = useDeferredValue(controllableOpen); + const [searchStr, setSearchStr] = useState(); + const deferredSearchStr = useDeferredValue(searchStr); + + const selectRef = useRef | null>(null); + + // Query for selected deployment details + const { deployment: selectedDeployment } = + useLazyLoadQuery( + graphql` + query DeploymentSelectValueQuery($id: ID!) { + deployment(id: $id) { + id + metadata { + name + status + createdAt + } + } + } + `, + { + id: controllableValue ?? '', + }, + { + // to skip the query when controllableValue is empty + fetchPolicy: controllableValue ? 'store-or-network' : 'store-only', + }, + ); + + // Paginated deployments query (cursor-based) + const { + paginationData, + result: { deployments }, + loadNext, + isLoadingNext, + } = useRelayCursorPaginatedQuery( + graphql` + query DeploymentSelectQuery( + $first: Int + $after: String + $filter: DeploymentFilter + $orderBy: [DeploymentOrderBy!] + ) { + deployments( + first: $first + after: $after + filter: $filter + orderBy: $orderBy + ) { + count + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + metadata { + name + status + createdAt + } + } + } + } + } + `, + { first: 10 }, + { + filter, + ...(deferredSearchStr + ? { + filter: { + ...filter, + name: { iContains: `%${deferredSearchStr}%` }, + }, + } + : {}), + }, + { + fetchKey, + fetchPolicy: deferredOpen ? 'network-only' : 'store-only', + }, + { + // getTotal: (result) => result.deployments?.count, + getItem: (result) => result.deployments?.edges?.map((e) => e.node), + getPageInfo: (result) => { + const pageInfo = result.deployments?.pageInfo; + return { + hasNextPage: pageInfo?.hasNextPage ?? false, + endCursor: pageInfo?.endCursor ?? undefined, + }; + }, + getId: (item: Deployment) => item?.id, + }, + ); + + const selectOptions = _.map(paginationData, (item: Deployment) => { + return { + label: item?.metadata?.name, + value: item?.id, + deployment: item, + }; + }); + + const [optimisticValueWithLabel, setOptimisticValueWithLabel] = useState( + selectedDeployment + ? { + label: selectedDeployment?.metadata?.name || undefined, + value: selectedDeployment?.id || undefined, + } + : controllableValue + ? { + label: controllableValue, + value: controllableValue, + } + : controllableValue, + ); + + const isValueMatched = searchStr === deferredSearchStr; + useEffect(() => { + if (isValueMatched) { + selectRef.current?.scrollTo(0); + } + }, [isValueMatched]); + + return ( + { + setSearchStr(v); + }} + labelRender={({ label }: { label: React.ReactNode }) => { + return label ? ( + + {label} + + + + + + + ) : ( + label + ); + }} + autoClearSearchValue + filterOption={false} + loading={searchStr !== deferredSearchStr || loading} + options={selectOptions} + {...selectPropsWithoutLoading} + // override value and onChange + labelInValue // use labelInValue to display the selected option label + value={optimisticValueWithLabel} + onChange={(v, option) => { + setOptimisticValueWithLabel(v); + setControllableValue(v.value, option); + selectPropsWithoutLoading.onChange?.(v.value || '', option); + }} + endReached={() => { + loadNext(); + }} + open={controllableOpen} + onDropdownVisibleChange={setControllableOpen} + notFoundContent={ + _.isUndefined(paginationData) ? ( + + ) : undefined + } + footer={ + _.isNumber(deployments?.count) && deployments.count > 0 ? ( + + ) : undefined + } + /> + ); +}; + +export default DeploymentSelect; diff --git a/react/src/hooks/useRelayCursorPaginatedQuery.ts b/react/src/hooks/useRelayCursorPaginatedQuery.ts new file mode 100644 index 0000000000..159cc891ac --- /dev/null +++ b/react/src/hooks/useRelayCursorPaginatedQuery.ts @@ -0,0 +1,91 @@ +import _ from 'lodash'; +import { useState, useRef, useMemo, useTransition, useEffect } from 'react'; +import { GraphQLTaggedNode, useLazyLoadQuery } from 'react-relay'; +import type { OperationType } from 'relay-runtime'; + +/** + * Cursor-based pagination hook for Relay-compliant GraphQL connections. + * Supports queries with `first`, `after`, `last`, `before`, etc. + */ +export type CursorOptions = { + getItem: (result: Result) => ItemType[] | undefined; + // getTotal: (result: Result) => number | undefined; + getPageInfo: (result: Result) => { + hasNextPage: boolean; + endCursor?: string; + hasPreviousPage?: boolean; + startCursor?: string; + }; + getId: (item: ItemType) => string | undefined | null; +}; + +export function useRelayCursorPaginatedQuery( + query: GraphQLTaggedNode, + initialPaginationVariables: { first?: number; last?: number }, + otherVariables: Omit< + Partial, + 'first' | 'last' | 'after' | 'before' + >, + options: Parameters>[2], + { + getItem, + getId, + // getTotal, + getPageInfo, + }: CursorOptions, +) { + const [cursor, setCursor] = useState(undefined); + const [isLoadingNext, startLoadingNextTransition] = useTransition(); + const previousResult = useRef([]); + + const previousOtherVariablesRef = useRef(otherVariables); + + const isNewOtherVariables = !_.isEqual( + previousOtherVariablesRef.current, + otherVariables, + ); + + const variables = { + ...initialPaginationVariables, + ...otherVariables, + after: cursor, + }; + + const result = useLazyLoadQuery(query, variables, options); + + const data = useMemo(() => { + const items = getItem(result); + return items + ? _.uniqBy([...previousResult.current, ...items], getId) + : undefined; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [result]); + + const pageInfo = getPageInfo(result); + const hasNext = pageInfo.hasNextPage; + + const loadNext = () => { + if (isLoadingNext || !hasNext) return; + previousResult.current = data || []; + startLoadingNextTransition(() => { + setCursor(pageInfo.endCursor); + }); + }; + + useEffect(() => { + // Reset cursor when otherVariables change + if (isNewOtherVariables) { + previousResult.current = []; + setCursor(undefined); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isNewOtherVariables]); + + return { + paginationData: data, + result, + loadNext, + hasNext, + isLoadingNext, + }; +} diff --git a/resources/i18n/de.json b/resources/i18n/de.json index d0e72806ae..c97ea4e22e 100644 --- a/resources/i18n/de.json +++ b/resources/i18n/de.json @@ -485,6 +485,9 @@ "Used": "gebraucht" } }, + "deployment": { + "SelectDeployment": "Wählen Sie Bereitstellung" + }, "desktopNotification": { "NotSupported": "Dieser Browser unterstützt keine Benachrichtigungen.", "PermissionDenied": "Sie haben den Benachrichtigungszugriff abgelehnt. \nUm Warnungen zu verwenden, lassen Sie diese bitte in Ihren Browsereinstellungen zu." diff --git a/resources/i18n/el.json b/resources/i18n/el.json index f0b7d09bec..92904095e2 100644 --- a/resources/i18n/el.json +++ b/resources/i18n/el.json @@ -483,6 +483,9 @@ "Used": "χρησιμοποιημένο" } }, + "deployment": { + "SelectDeployment": "Επιλέξτε την ανάπτυξη" + }, "desktopNotification": { "NotSupported": "Αυτό το πρόγραμμα περιήγησης δεν υποστηρίζει ειδοποιήσεις.", "PermissionDenied": "Έχετε αρνηθεί την πρόσβαση ειδοποίησης. \nΓια να χρησιμοποιήσετε ειδοποιήσεις, αφήστε το στις ρυθμίσεις του προγράμματος περιήγησής σας." diff --git a/resources/i18n/en.json b/resources/i18n/en.json index 6fa89a7704..495a1258b7 100644 --- a/resources/i18n/en.json +++ b/resources/i18n/en.json @@ -581,6 +581,7 @@ "RoutesInfo": "Routes Info", "RoutingID": "Routing ID", "ScalingSettings": "Scaling Settings", + "SelectDeployment": "Select Deployment", "SessionID": "Session ID", "SetAsActiveRevision": "Set as active revision", "SetAutoScalingRule": "Set Auto Scaling Rule", diff --git a/resources/i18n/es.json b/resources/i18n/es.json index 18f31c0398..052fe51ff7 100644 --- a/resources/i18n/es.json +++ b/resources/i18n/es.json @@ -485,6 +485,9 @@ "Used": "usado" } }, + "deployment": { + "SelectDeployment": "Seleccionar implementación" + }, "desktopNotification": { "NotSupported": "Este navegador no admite notificaciones.", "PermissionDenied": "Has negado el acceso a la notificación. \nPara usar alertas, permítelo en la configuración de su navegador." diff --git a/resources/i18n/fi.json b/resources/i18n/fi.json index f32063a6e9..0ce86ec3ff 100644 --- a/resources/i18n/fi.json +++ b/resources/i18n/fi.json @@ -485,6 +485,9 @@ "Used": "käytetty" } }, + "deployment": { + "SelectDeployment": "Valitse käyttöönotto" + }, "desktopNotification": { "NotSupported": "Tämä selain ei tue ilmoituksia.", "PermissionDenied": "Olet kiistänyt ilmoituskäytön. \nJos haluat käyttää hälytyksiä, salli se selaimen asetuksissa." diff --git a/resources/i18n/fr.json b/resources/i18n/fr.json index 79f2131f8d..00b9e2c306 100644 --- a/resources/i18n/fr.json +++ b/resources/i18n/fr.json @@ -485,6 +485,9 @@ "Used": "utilisé" } }, + "deployment": { + "SelectDeployment": "Sélectionnez le déploiement" + }, "desktopNotification": { "NotSupported": "Ce navigateur ne prend pas en charge les notifications.", "PermissionDenied": "Vous avez nié l'accès à la notification. \nPour utiliser des alertes, veuillez le permettre dans les paramètres de votre navigateur." diff --git a/resources/i18n/id.json b/resources/i18n/id.json index 7862ce702b..3d1ba10f14 100644 --- a/resources/i18n/id.json +++ b/resources/i18n/id.json @@ -484,6 +484,9 @@ "Used": "digunakan" } }, + "deployment": { + "SelectDeployment": "Pilih penempatan" + }, "desktopNotification": { "NotSupported": "Browser ini tidak mendukung pemberitahuan.", "PermissionDenied": "Anda telah menolak akses pemberitahuan. \nUntuk menggunakan lansiran, harap diizinkan di pengaturan browser Anda." diff --git a/resources/i18n/it.json b/resources/i18n/it.json index bb2d82a260..53417e8f9b 100644 --- a/resources/i18n/it.json +++ b/resources/i18n/it.json @@ -484,6 +484,9 @@ "Used": "usato" } }, + "deployment": { + "SelectDeployment": "Seleziona la distribuzione" + }, "desktopNotification": { "NotSupported": "Questo browser non supporta le notifiche.", "PermissionDenied": "Hai negato l'accesso alla notifica. \nPer utilizzare gli avvisi, consentirlo nelle impostazioni del browser." diff --git a/resources/i18n/ja.json b/resources/i18n/ja.json index 3da99967e7..46282cf657 100644 --- a/resources/i18n/ja.json +++ b/resources/i18n/ja.json @@ -484,6 +484,9 @@ "Used": "使用中" } }, + "deployment": { + "SelectDeployment": "展開を選択します" + }, "desktopNotification": { "NotSupported": "このブラウザは通知をサポートしていません。", "PermissionDenied": "通知アクセスを拒否しました。\nアラートを使用するには、ブラウザの設定で許可してください。" diff --git a/resources/i18n/ko.json b/resources/i18n/ko.json index 77bdd31e39..64c5c21033 100644 --- a/resources/i18n/ko.json +++ b/resources/i18n/ko.json @@ -487,6 +487,9 @@ "Used": "사용중" } }, + "deployment": { + "SelectDeployment": "디플로이먼트 선택" + }, "desktopNotification": { "NotSupported": "현재 브라우저에서는 알림 기능을 지원하지 않습니다. ", "PermissionDenied": "알림 권한이 거부되었습니다. 브라우저 설정에서 알림을 허용해주세요." diff --git a/resources/i18n/mn.json b/resources/i18n/mn.json index 7476519d3f..408c128ab5 100644 --- a/resources/i18n/mn.json +++ b/resources/i18n/mn.json @@ -483,6 +483,9 @@ "Used": "ашигласан" } }, + "deployment": { + "SelectDeployment": "Байршуулалтыг сонгоно уу" + }, "desktopNotification": { "NotSupported": "Энэ хөтөч нь мэдэгдлийг дэмждэггүй.", "PermissionDenied": "Та мэдэгдлийн хандалтыг үгүйсгэж байна. \nАнхааруулга ашиглахын тулд үүнийг өөрийн хөтөчийн тохиргоонд оруулна уу." diff --git a/resources/i18n/ms.json b/resources/i18n/ms.json index bb76015ca6..7f95d8161e 100644 --- a/resources/i18n/ms.json +++ b/resources/i18n/ms.json @@ -484,6 +484,9 @@ "Used": "digunakan" } }, + "deployment": { + "SelectDeployment": "Pilih penyebaran" + }, "desktopNotification": { "NotSupported": "Penyemak imbas ini tidak menyokong pemberitahuan.", "PermissionDenied": "Anda telah menafikan akses pemberitahuan. \nUntuk menggunakan makluman, sila berikannya dalam tetapan penyemak imbas anda." diff --git a/resources/i18n/pl.json b/resources/i18n/pl.json index de4a5e9eae..be5ca9be20 100644 --- a/resources/i18n/pl.json +++ b/resources/i18n/pl.json @@ -485,6 +485,9 @@ "Used": "używany" } }, + "deployment": { + "SelectDeployment": "Wybierz wdrożenie" + }, "desktopNotification": { "NotSupported": "Ta przeglądarka nie obsługuje powiadomień.", "PermissionDenied": "Odmówiłeś dostępu do powiadomień. \nAby użyć alertów, pozwól temu w ustawieniach przeglądarki." diff --git a/resources/i18n/pt-BR.json b/resources/i18n/pt-BR.json index 345f0ea375..c09aff1528 100644 --- a/resources/i18n/pt-BR.json +++ b/resources/i18n/pt-BR.json @@ -485,6 +485,9 @@ "Used": "utilizado" } }, + "deployment": { + "SelectDeployment": "Selecione a implantação" + }, "desktopNotification": { "NotSupported": "Este navegador não suporta notificações.", "PermissionDenied": "Você negou acesso à notificação. \nPara usar alertas, deixe -o nas configurações do navegador." diff --git a/resources/i18n/pt.json b/resources/i18n/pt.json index 19e2504e2d..d73aefad37 100644 --- a/resources/i18n/pt.json +++ b/resources/i18n/pt.json @@ -485,6 +485,9 @@ "Used": "utilizado" } }, + "deployment": { + "SelectDeployment": "Selecione a implantação" + }, "desktopNotification": { "NotSupported": "Este navegador não suporta notificações.", "PermissionDenied": "Você negou acesso à notificação. \nPara usar alertas, deixe -o nas configurações do navegador." diff --git a/resources/i18n/ru.json b/resources/i18n/ru.json index 48ea1a860f..1608d319a7 100644 --- a/resources/i18n/ru.json +++ b/resources/i18n/ru.json @@ -485,6 +485,9 @@ "Used": "используется" } }, + "deployment": { + "SelectDeployment": "Выберите развертывание" + }, "desktopNotification": { "NotSupported": "Этот браузер не поддерживает уведомления.", "PermissionDenied": "Вы отрицали доступ уведомлений. \nЧтобы использовать оповещения, позвольте им в настройках браузера." diff --git a/resources/i18n/th.json b/resources/i18n/th.json index 9d4bb7adda..e6f6d2ab93 100644 --- a/resources/i18n/th.json +++ b/resources/i18n/th.json @@ -479,6 +479,9 @@ "Used": "ใช้แล้ว" } }, + "deployment": { + "SelectDeployment": "เลือกการปรับใช้" + }, "desktopNotification": { "NotSupported": "เบราว์เซอร์นี้ไม่รองรับการแจ้งเตือน", "PermissionDenied": "คุณปฏิเสธการเข้าถึงการแจ้งเตือน \nหากต้องการใช้การแจ้งเตือนโปรดรอการตั้งค่าเบราว์เซอร์ของคุณ" diff --git a/resources/i18n/tr.json b/resources/i18n/tr.json index c5f5cd8ad7..0b32ec4d3c 100644 --- a/resources/i18n/tr.json +++ b/resources/i18n/tr.json @@ -485,6 +485,9 @@ "Used": "kullanılmış" } }, + "deployment": { + "SelectDeployment": "Dağıtım'ı seçin" + }, "desktopNotification": { "NotSupported": "Bu tarayıcı bildirimleri desteklemez.", "PermissionDenied": "Bildirim erişimini reddettiniz. \nUyarıları kullanmak için lütfen tarayıcı ayarlarınıza izin verin." diff --git a/resources/i18n/vi.json b/resources/i18n/vi.json index d7aaa57a02..1c0c65b464 100644 --- a/resources/i18n/vi.json +++ b/resources/i18n/vi.json @@ -485,6 +485,9 @@ "Used": "đã sử dụng" } }, + "deployment": { + "SelectDeployment": "Chọn triển khai" + }, "desktopNotification": { "NotSupported": "Trình duyệt này không hỗ trợ thông báo.", "PermissionDenied": "Bạn đã từ chối truy cập thông báo. \nĐể sử dụng cảnh báo, vui lòng cho phép nó trong cài đặt trình duyệt của bạn." diff --git a/resources/i18n/zh-CN.json b/resources/i18n/zh-CN.json index d21b84add9..427b51cd65 100644 --- a/resources/i18n/zh-CN.json +++ b/resources/i18n/zh-CN.json @@ -485,6 +485,9 @@ "Used": "中古" } }, + "deployment": { + "SelectDeployment": "选择部署" + }, "desktopNotification": { "NotSupported": "该浏览器不支持通知。", "PermissionDenied": "您否认通知访问。\n要使用警报,请在浏览器设置中允许它。"