Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 232 additions & 0 deletions react/src/components/Chat/DeploymentSelect.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectProps, 'options' | 'labelInValue'> {
fetchKey?: string;
filter?: DeploymentFilter;
}

const DeploymentSelect: React.FC<DeploymentSelectProps> = ({
fetchKey,
filter,
loading,
...selectPropsWithoutLoading
}) => {
const { t } = useTranslation();
const [controllableValue, setControllableValue] = useControllableValue<
string | undefined
>(selectPropsWithoutLoading);
const [controllableOpen, setControllableOpen] = useControllableValue<boolean>(
selectPropsWithoutLoading,
{
valuePropName: 'open',
trigger: 'onDropdownVisibleChange',
},
);
const deferredOpen = useDeferredValue(controllableOpen);
const [searchStr, setSearchStr] = useState<string>();
const deferredSearchStr = useDeferredValue(searchStr);

const selectRef = useRef<GetRef<typeof BAISelect> | null>(null);

// Query for selected deployment details
const { deployment: selectedDeployment } =
useLazyLoadQuery<DeploymentSelectValueQuery>(
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<DeploymentSelectQuery, Deployment>(
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 (
<BAISelect
ref={selectRef}
placeholder={t('deployment.SelectDeployment')}
style={{ minWidth: 100 }}
showSearch
searchValue={searchStr}
onSearch={(v) => {
setSearchStr(v);
}}
labelRender={({ label }: { label: React.ReactNode }) => {
return label ? (
<BAIFlex gap="xxs">
{label}
<Tooltip title={t('general.NavigateToDetailPage')}>
<BAILink
to={`/deployment/${toLocalId(selectedDeployment?.id || '')}`}
>
<InfoIcon />
</BAILink>
</Tooltip>
</BAIFlex>
) : (
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) ? (
<Skeleton.Input active size="small" block />
) : undefined
}
footer={
_.isNumber(deployments?.count) && deployments.count > 0 ? (
<TotalFooter loading={isLoadingNext} total={deployments?.count} />
) : undefined
}
/>
);
};

export default DeploymentSelect;
91 changes: 91 additions & 0 deletions react/src/hooks/useRelayCursorPaginatedQuery.ts
Original file line number Diff line number Diff line change
@@ -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<Result, ItemType> = {
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<T extends OperationType, ItemType>(
query: GraphQLTaggedNode,
initialPaginationVariables: { first?: number; last?: number },
otherVariables: Omit<
Partial<T['variables']>,
'first' | 'last' | 'after' | 'before'
>,
options: Parameters<typeof useLazyLoadQuery<T>>[2],
{
getItem,
getId,
// getTotal,
getPageInfo,
}: CursorOptions<T['response'], ItemType>,
) {
const [cursor, setCursor] = useState<string | undefined>(undefined);
const [isLoadingNext, startLoadingNextTransition] = useTransition();
const previousResult = useRef<ItemType[]>([]);

const previousOtherVariablesRef = useRef(otherVariables);

const isNewOtherVariables = !_.isEqual(
previousOtherVariablesRef.current,
otherVariables,
);

const variables = {
...initialPaginationVariables,
...otherVariables,
after: cursor,
};

const result = useLazyLoadQuery<T>(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,
};
}
3 changes: 3 additions & 0 deletions resources/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 3 additions & 0 deletions resources/i18n/el.json
Original file line number Diff line number Diff line change
Expand Up @@ -483,6 +483,9 @@
"Used": "χρησιμοποιημένο"
}
},
"deployment": {
"SelectDeployment": "Επιλέξτε την ανάπτυξη"
},
"desktopNotification": {
"NotSupported": "Αυτό το πρόγραμμα περιήγησης δεν υποστηρίζει ειδοποιήσεις.",
"PermissionDenied": "Έχετε αρνηθεί την πρόσβαση ειδοποίησης. \nΓια να χρησιμοποιήσετε ειδοποιήσεις, αφήστε το στις ρυθμίσεις του προγράμματος περιήγησής σας."
Expand Down
1 change: 1 addition & 0 deletions resources/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -581,6 +581,7 @@
"RoutesInfo": "Routes Info",
"RoutingID": "Routing ID",
"ScalingSettings": "Scaling Settings",
"SelectDeployment": "Select Deployment",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the strings in the deployment object are not translated into other languages.

"SessionID": "Session ID",
"SetAsActiveRevision": "Set as active revision",
"SetAutoScalingRule": "Set Auto Scaling Rule",
Expand Down
3 changes: 3 additions & 0 deletions resources/i18n/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 3 additions & 0 deletions resources/i18n/fi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 3 additions & 0 deletions resources/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
3 changes: 3 additions & 0 deletions resources/i18n/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Loading
Loading