Skip to content

Commit 03d8a84

Browse files
committed
feat(FR-2886): add BAIAvailablePresetSelect and BAIProjectVfolderSelect, migrate deployment callers
1 parent 34457b6 commit 03d8a84

30 files changed

Lines changed: 939 additions & 265 deletions

packages/backend.ai-ui/src/components/BAISelect.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -95,7 +95,7 @@ const useStyles = createStyles(({ css, token }) => ({
9595
export interface BAISelectProps<
9696
ValueType = any,
9797
OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType,
98-
> extends Omit<SelectProps<ValueType, OptionType>, 'onSearch'> {
98+
> extends Omit<SelectProps<ValueType, OptionType>, 'onSearch' | 'role'> {
9999
ref?: React.RefObject<GetRef<typeof Select<ValueType, OptionType>> | null>;
100100
ghost?: boolean;
101101
autoSelectOption?:
@@ -108,6 +108,11 @@ export interface BAISelectProps<
108108
footer?: React.ReactNode;
109109
endReached?: () => void; // New prop for endReached
110110
searchAction?: (value: string) => Promise<void>;
111+
// antd v6 made `role` required on SelectProps. We default it to
112+
// `'combobox'` here because every BAISelect in this project enables
113+
// search by default; callers can still override (e.g. `role="listbox"`
114+
// for non-searchable variants).
115+
role?: SelectProps<ValueType, OptionType>['role'];
111116
}
112117

113118
function BAISelect<
@@ -123,6 +128,7 @@ function BAISelect<
123128
footer,
124129
endReached, // Destructure the new prop
125130
searchAction,
131+
role = 'combobox',
126132
...selectProps
127133
}: BAISelectProps<ValueType, OptionType>): React.ReactElement {
128134
const { value, options, onChange } = selectProps;
@@ -196,6 +202,7 @@ function BAISelect<
196202
<Tooltip title={tooltip}>
197203
<Select<ValueType, OptionType>
198204
{...selectProps}
205+
role={role}
199206
loading={isPending || selectProps.loading}
200207
showSearch={composedShowSearch}
201208
ref={ref}
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
import { BAIAvailablePresetSelectPaginatedQuery } from '../../__generated__/BAIAvailablePresetSelectPaginatedQuery.graphql';
2+
import { BAIAvailablePresetSelectValueQuery } from '../../__generated__/BAIAvailablePresetSelectValueQuery.graphql';
3+
import { convertToUUID, toLocalId } from '../../helper';
4+
import useDebouncedDeferredValue from '../../helper/useDebouncedDeferredValue';
5+
import { useFetchKey } from '../../hooks';
6+
import { useLazyPaginatedQuery } from '../../hooks/usePaginatedQuery';
7+
import BAIFlex from '../BAIFlex';
8+
import BAISelect, { BAISelectProps } from '../BAISelect';
9+
import TotalFooter from '../TotalFooter';
10+
import { useControllableValue } from 'ahooks';
11+
import { GetRef, Skeleton, Typography, theme } from 'antd';
12+
import type { DefaultOptionType } from 'antd/es/select';
13+
import * as _ from 'lodash-es';
14+
import {
15+
useDeferredValue,
16+
useImperativeHandle,
17+
useOptimistic,
18+
useRef,
19+
useState,
20+
useTransition,
21+
} from 'react';
22+
import { useTranslation } from 'react-i18next';
23+
import { graphql, useLazyLoadQuery } from 'react-relay';
24+
25+
export type DeploymentRevisionPresetNode = NonNullable<
26+
NonNullable<
27+
NonNullable<
28+
BAIAvailablePresetSelectPaginatedQuery['response']['deploymentRevisionPresets']
29+
>['edges'][number]
30+
>['node']
31+
>;
32+
33+
export interface BAIAvailablePresetSelectRef {
34+
refetch: () => void;
35+
}
36+
37+
export interface BAIAvailablePresetSelectProps extends Omit<
38+
BAISelectProps,
39+
'options' | 'labelInValue' | 'ref'
40+
> {
41+
runtimeVariantId?: string;
42+
ref?: React.Ref<BAIAvailablePresetSelectRef>;
43+
}
44+
45+
const BAIAvailablePresetSelect: React.FC<BAIAvailablePresetSelectProps> = ({
46+
loading,
47+
runtimeVariantId,
48+
ref,
49+
...selectProps
50+
}) => {
51+
'use memo';
52+
const { t } = useTranslation();
53+
const { token } = theme.useToken();
54+
const selectRef = useRef<GetRef<typeof BAISelect>>(null);
55+
const [controllableValue, setControllableValue] = useControllableValue<
56+
string | string[] | undefined
57+
>(selectProps);
58+
const [controllableOpen, setControllableOpen] = useControllableValue<boolean>(
59+
selectProps,
60+
{
61+
valuePropName: 'open',
62+
trigger: 'onOpenChange',
63+
defaultValuePropName: 'defaultOpen',
64+
},
65+
);
66+
const deferredOpen = useDeferredValue(controllableOpen);
67+
const [searchStr, setSearchStr] = useState<string>();
68+
const deferredSearchStr = useDebouncedDeferredValue(searchStr);
69+
const [optimisticSearchStr, setOptimisticSearchStr] =
70+
useOptimistic(searchStr);
71+
const [isPendingRefetch, startRefetchTransition] = useTransition();
72+
const [fetchKey, updateFetchKey] = useFetchKey();
73+
const deferredFetchKey = useDeferredValue(fetchKey);
74+
75+
// Defer query refetch to prevent flickering during user selection
76+
const deferredControllableValue = useDeferredValue(controllableValue);
77+
78+
// Resolved (typed) UUIDs of currently selected presets. Each entry should be
79+
// a raw UUID so it can be fed into `DeploymentRevisionPresetFilter.id.in`.
80+
const selectedIds = _.compact(
81+
_.castArray(deferredControllableValue).map((v) =>
82+
v ? convertToUUID(_.toString(v)) : null,
83+
),
84+
);
85+
86+
// Fetch labels for the currently selected preset ids in one round-trip via
87+
// the `id: { in: [...] }` filter. `@skip(if: $skip)` collapses the request
88+
// when nothing is selected. We do not paginate this query — `first` is
89+
// sized exactly to the selection so all labels arrive together.
90+
const { deploymentRevisionPresets: selectedPresets } =
91+
useLazyLoadQuery<BAIAvailablePresetSelectValueQuery>(
92+
graphql`
93+
query BAIAvailablePresetSelectValueQuery(
94+
$ids: [UUID!]
95+
$first: Int!
96+
$skip: Boolean!
97+
) {
98+
deploymentRevisionPresets(
99+
filter: { id: { in: $ids } }
100+
first: $first
101+
) @skip(if: $skip) {
102+
edges {
103+
node {
104+
id
105+
name
106+
description
107+
runtimeVariantId
108+
runtimeVariant {
109+
name
110+
}
111+
}
112+
}
113+
}
114+
}
115+
`,
116+
{
117+
ids: selectedIds,
118+
first: Math.max(selectedIds.length, 1),
119+
skip: selectedIds.length === 0,
120+
},
121+
{
122+
fetchPolicy: selectedIds.length > 0 ? 'store-or-network' : 'store-only',
123+
fetchKey: deferredFetchKey,
124+
},
125+
);
126+
127+
// `DeploymentRevisionPresetFilter` has no AND/OR combinators — its fields
128+
// already AND together — so we merge by setting each field on a single
129+
// object. `null` when no filter is active so the query field receives the
130+
// schema default.
131+
const mergedFilter: NonNullable<
132+
BAIAvailablePresetSelectPaginatedQuery['variables']['filter']
133+
> | null =
134+
runtimeVariantId || deferredSearchStr
135+
? {
136+
...(runtimeVariantId
137+
? { runtimeVariantId: { equals: convertToUUID(runtimeVariantId) } }
138+
: {}),
139+
...(deferredSearchStr
140+
? { name: { iContains: deferredSearchStr } }
141+
: {}),
142+
}
143+
: null;
144+
145+
const { paginationData, result, loadNext, isLoadingNext } =
146+
useLazyPaginatedQuery<
147+
BAIAvailablePresetSelectPaginatedQuery,
148+
DeploymentRevisionPresetNode
149+
>(
150+
graphql`
151+
query BAIAvailablePresetSelectPaginatedQuery(
152+
$offset: Int!
153+
$limit: Int!
154+
$filter: DeploymentRevisionPresetFilter
155+
) {
156+
deploymentRevisionPresets(
157+
offset: $offset
158+
limit: $limit
159+
filter: $filter
160+
orderBy: [{ field: RANK, direction: "ASC" }]
161+
) {
162+
count
163+
edges {
164+
node {
165+
id
166+
name
167+
description
168+
rank
169+
runtimeVariantId
170+
runtimeVariant {
171+
name
172+
}
173+
}
174+
}
175+
}
176+
}
177+
`,
178+
{ limit: 10 },
179+
{
180+
filter: mergedFilter,
181+
},
182+
{
183+
fetchPolicy: deferredOpen ? 'network-only' : 'store-only',
184+
fetchKey: deferredFetchKey,
185+
},
186+
{
187+
getTotal: (r) => r.deploymentRevisionPresets?.count ?? undefined,
188+
getItem: (r) =>
189+
r.deploymentRevisionPresets?.edges?.map((edge) => edge?.node),
190+
getId: (item) => item?.id,
191+
},
192+
);
193+
194+
// Expose refetch function through ref
195+
useImperativeHandle(
196+
ref,
197+
() => ({
198+
refetch: () => {
199+
startRefetchTransition(() => {
200+
updateFetchKey();
201+
});
202+
},
203+
}),
204+
[updateFetchKey, startRefetchTransition],
205+
);
206+
207+
// Build base option list (raw UUID values + name/description metadata).
208+
type PresetOption = DefaultOptionType & {
209+
description?: string | null;
210+
runtimeVariantId?: string | null;
211+
runtimeVariantName?: string | null;
212+
};
213+
const flatOptions: PresetOption[] = _.compact(
214+
_.map(paginationData, (item) => {
215+
if (!item?.id) return null;
216+
return {
217+
label: item.name,
218+
value: toLocalId(item.id) ?? item.id,
219+
description: item.description,
220+
runtimeVariantId: item.runtimeVariantId,
221+
runtimeVariantName: item.runtimeVariant?.name,
222+
};
223+
}),
224+
);
225+
226+
// Group options by runtime variant. When only one variant is present in the
227+
// current page we render a flat list — otherwise an optgroup per variant.
228+
const grouped = _.groupBy(flatOptions, 'runtimeVariantId');
229+
const variantIds = Object.keys(grouped);
230+
const availableOptions: DefaultOptionType[] =
231+
variantIds.length <= 1
232+
? flatOptions
233+
: variantIds.map((variantId) => {
234+
const group = grouped[variantId];
235+
// Fall back to the variant id when the joined `runtimeVariant` is
236+
// missing (e.g., the variant row was deleted).
237+
const variantLabel = group[0]?.runtimeVariantName ?? variantId;
238+
return {
239+
label: variantLabel,
240+
options: group,
241+
};
242+
});
243+
244+
// Reconstruct labeled value objects for `labelInValue`. Falls back to the
245+
// raw id string when the label hasn't resolved yet.
246+
const selectedLabelMap: Record<string, string> = {};
247+
for (const edge of selectedPresets?.edges ?? []) {
248+
const node = edge?.node;
249+
if (!node?.id) continue;
250+
const uuid = toLocalId(node.id) ?? node.id;
251+
if (node.name) selectedLabelMap[uuid] = node.name;
252+
}
253+
const controllableValueWithLabel = !_.isEmpty(deferredControllableValue)
254+
? _.castArray(deferredControllableValue).map((value) => {
255+
const v = _.toString(value);
256+
return { label: selectedLabelMap[v] ?? v, value: v };
257+
})
258+
: undefined;
259+
260+
const [optimisticValueWithLabel, setOptimisticValueWithLabel] = useState(
261+
controllableValueWithLabel,
262+
);
263+
264+
return (
265+
<BAISelect
266+
ref={selectRef}
267+
placeholder={t('comp:BAIAvailablePresetSelect.SelectPreset')}
268+
loading={
269+
loading ||
270+
controllableValue !== deferredControllableValue ||
271+
searchStr !== deferredSearchStr ||
272+
isPendingRefetch
273+
}
274+
{...selectProps}
275+
searchAction={async (value) => {
276+
setOptimisticSearchStr(value);
277+
setSearchStr(value);
278+
await selectProps.searchAction?.(value);
279+
}}
280+
showSearch={
281+
selectProps.showSearch === false
282+
? false
283+
: {
284+
searchValue: optimisticSearchStr,
285+
autoClearSearchValue: true,
286+
...(_.isObject(selectProps.showSearch)
287+
? _.omit(selectProps.showSearch, ['searchValue'])
288+
: {}),
289+
filterOption: false,
290+
}
291+
}
292+
optionRender={(option) => (
293+
<BAIFlex direction="column" align="start">
294+
{option.label}
295+
{option.data.description && (
296+
<Typography.Text
297+
type="secondary"
298+
style={{ fontSize: token.fontSizeSM }}
299+
ellipsis
300+
>
301+
{option.data.description}
302+
</Typography.Text>
303+
)}
304+
</BAIFlex>
305+
)}
306+
value={
307+
controllableValue !== deferredControllableValue
308+
? optimisticValueWithLabel
309+
: controllableValueWithLabel
310+
}
311+
labelInValue
312+
onChange={(value, option) => {
313+
const castedValue = _.isEmpty(value) ? [] : _.castArray(value);
314+
const valueWithOriginalLabel = castedValue.map((v) => {
315+
const label = _.isString(v.label)
316+
? v.label
317+
: (flatOptions.find((opt) => opt.value === v.value)?.label ??
318+
v.value);
319+
return { label, value: v.value };
320+
});
321+
setOptimisticValueWithLabel(valueWithOriginalLabel);
322+
const isMultiple =
323+
selectProps.mode === 'multiple' || selectProps.mode === 'tags';
324+
const idArray = castedValue.map((v) => _.toString(v.value));
325+
setControllableValue(
326+
isMultiple ? idArray : (idArray[0] ?? undefined),
327+
option,
328+
);
329+
}}
330+
options={availableOptions}
331+
endReached={() => {
332+
loadNext();
333+
}}
334+
open={controllableOpen}
335+
onOpenChange={setControllableOpen}
336+
notFoundContent={
337+
_.isUndefined(paginationData) ? (
338+
<Skeleton.Input active size="small" block />
339+
) : undefined
340+
}
341+
footer={
342+
_.isNumber(result.deploymentRevisionPresets?.count) &&
343+
result.deploymentRevisionPresets.count > 0 ? (
344+
<TotalFooter
345+
loading={isLoadingNext}
346+
total={result.deploymentRevisionPresets.count}
347+
/>
348+
) : undefined
349+
}
350+
/>
351+
);
352+
};
353+
354+
export default BAIAvailablePresetSelect;

0 commit comments

Comments
 (0)