Skip to content

Commit 3438fb6

Browse files
committed
feat(FR-2827): make deployment tags clickable to filter the list
Resolves #7273(FR-2827) Deployment tags rendered in the list table and the detail Overview card are now interactive. Clicking a tag navigates to the deployment list filtered by that tag using the existing iContains filter shape (`/deployments?filter={"tags":{"iContains":"<tag>"}}`), so users can use a tag as a one-click pivot to find related deployments. The URL filter is built once via a small shared helper exported from `DeploymentList.tsx` (`buildDeploymentTagFilterUrl`) and reused by the detail Overview component. List-side click also stops propagation so a future row-click handler would not fire when a user clicks a tag inside a row.
1 parent 731574c commit 3438fb6

6 files changed

Lines changed: 136 additions & 115 deletions

File tree

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

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -475,9 +475,12 @@ const BAIGraphQLPropertyFilter: React.FC<BAIGraphQLPropertyFilterProps> = ({
475475
onChange: propOnChange,
476476
});
477477

478-
const [conditions, setConditions] = useState<FilterCondition[]>(() =>
479-
convertGraphQLFilterToConditions(value, filterProperties),
480-
);
478+
// Reassign sequential ids: the converter generates random ids per call,
479+
// which would change every render and remount every Tag.
480+
const conditions = convertGraphQLFilterToConditions(
481+
value,
482+
filterProperties,
483+
).map((c, i) => ({ ...c, id: `cond-${i}` }));
481484

482485
const [search, setSearch] = useState<string>('');
483486
const [selectedDate, setSelectedDate] = useState<dayjs.Dayjs | null>(null);
@@ -505,15 +508,11 @@ const BAIGraphQLPropertyFilter: React.FC<BAIGraphQLPropertyFilterProps> = ({
505508
const [isValid, setIsValid] = useState(true);
506509
const [isFocused, setIsFocused] = useState(false);
507510

508-
const propertyOptions = useMemo(
509-
() =>
510-
filterProperties.map((property) => ({
511-
label: property.propertyLabel,
512-
value: property.key,
513-
filter: property,
514-
})),
515-
[filterProperties],
516-
);
511+
const propertyOptions = filterProperties.map((property) => ({
512+
label: property.propertyLabel,
513+
value: property.key,
514+
filter: property,
515+
}));
517516

518517
const availableOperators = useMemo(() => {
519518
const mode = getEffectiveValueMode(selectedProperty);
@@ -536,7 +535,6 @@ const BAIGraphQLPropertyFilter: React.FC<BAIGraphQLPropertyFilterProps> = ({
536535
}, [availableOperators, t]);
537536

538537
const updateConditions = (newConditions: FilterCondition[]) => {
539-
setConditions(newConditions);
540538
const filter = convertConditionsToGraphQLFilter(
541539
newConditions,
542540
filterProperties,

react/src/components/DeploymentConfigurationSection.tsx

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import DeploymentRevisionDetail from './DeploymentRevisionDetail';
99
import DeploymentRevisionDetailDrawer from './DeploymentRevisionDetailDrawer';
1010
import DeploymentRevisionHistoryTab from './DeploymentRevisionHistoryTab';
1111
import DeploymentSettingModal from './DeploymentSettingModal';
12+
import DeploymentTagChips from './DeploymentTagChips';
1213
import ErrorBoundaryWithNullFallback from './ErrorBoundaryWithNullFallback';
1314
import {
1415
CheckOutlined,
@@ -24,7 +25,6 @@ import {
2425
Descriptions,
2526
Empty,
2627
Skeleton,
27-
Tag,
2828
Typography,
2929
theme,
3030
} from 'antd';
@@ -64,12 +64,6 @@ const DeploymentOverviewContent: React.FC<{
6464
const projectName =
6565
deployment?.metadata.projectV2?.basicInfo?.name ??
6666
deployment?.metadata.projectId;
67-
const tags = (deployment?.metadata.tags ?? []).flatMap((tag) =>
68-
tag
69-
.split(',')
70-
.map((t) => t.trim())
71-
.filter(Boolean),
72-
);
7367

7468
const deploymentItems = filterOutEmpty([
7569
{
@@ -114,16 +108,12 @@ const DeploymentOverviewContent: React.FC<{
114108
{
115109
key: 'tags',
116110
label: t('deployment.Tags'),
117-
children:
118-
tags.length > 0 ? (
119-
<BAIFlex direction="row" wrap="wrap" gap="xxs">
120-
{tags.map((tag) => (
121-
<Tag key={tag}>{tag}</Tag>
122-
))}
123-
</BAIFlex>
124-
) : (
125-
renderFallback()
126-
),
111+
children: (
112+
<DeploymentTagChips
113+
metadataFrgmt={deployment?.metadata ?? null}
114+
fallback={renderFallback()}
115+
/>
116+
),
127117
},
128118
{
129119
key: 'desired-replicas',
@@ -299,14 +289,14 @@ const DeploymentConfigurationCards: React.FC<{
299289
...DeploymentSettingModal_deployment
300290
metadata {
301291
name
302-
tags
303292
projectId
304293
domainName
305294
projectV2 @since(version: "26.4.3") {
306295
basicInfo {
307296
name
308297
}
309298
}
299+
...DeploymentTagChips_metadata
310300
}
311301
networkAccess {
312302
openToPublic

react/src/components/DeploymentList.tsx

Lines changed: 14 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import { useSuspendedBackendaiClient } from '../hooks';
1212
import BAIRadioGroup from './BAIRadioGroup';
1313
import DeploymentOwnerInfo from './DeploymentOwnerInfo';
1414
import DeploymentStatusTag, { DeploymentStatus } from './DeploymentStatusTag';
15+
import DeploymentTagChips from './DeploymentTagChips';
1516
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
16-
import { Alert, App, Tag, Typography, theme } from 'antd';
17+
import { Alert, App, Typography, theme } from 'antd';
1718
import {
1819
BAIConfirmModalWithInput,
1920
BAIFlex,
@@ -83,26 +84,6 @@ export const tableOrderToSort = (
8384
return { field, order: descending ? 'DESC' : 'ASC' };
8485
};
8586

86-
const parseFilterString = (
87-
filter: string | undefined,
88-
): GraphQLFilter | undefined => {
89-
if (!filter) return undefined;
90-
try {
91-
const parsed = JSON.parse(filter);
92-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
93-
return parsed as GraphQLFilter;
94-
}
95-
return undefined;
96-
} catch {
97-
return undefined;
98-
}
99-
};
100-
101-
const stringifyFilter = (filter: GraphQLFilter | undefined): string => {
102-
if (!filter || Object.keys(filter).length === 0) return '';
103-
return JSON.stringify(filter);
104-
};
105-
10687
export type DeploymentStatusCategory = 'running' | 'finished';
10788

10889
export interface DeploymentListProps extends Omit<
@@ -113,8 +94,8 @@ export interface DeploymentListProps extends Omit<
11394
| DeploymentList_modelDeploymentConnection$key
11495
| null
11596
| undefined;
116-
filter?: string;
117-
setFilter: (value: string) => void;
97+
filter?: GraphQLFilter;
98+
setFilter: (value: GraphQLFilter | null | undefined) => void;
11899
onChangeOrder?: (order: string | null) => void;
119100
statusCategory?: DeploymentStatusCategory;
120101
onStatusCategoryChange?: (value: DeploymentStatusCategory) => void;
@@ -182,7 +163,7 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
182163
createdAt
183164
domainName
184165
projectId
185-
tags
166+
...DeploymentTagChips_metadata
186167
}
187168
networkAccess {
188169
endpointUrl
@@ -223,8 +204,6 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
223204
const supportsExtendedFilter =
224205
baiClient?.supports('model-deployment-extended-filter') ?? false;
225206

226-
const filterValue = parseFilterString(filter);
227-
228207
const baseFilterProperties = [
229208
{
230209
key: 'name',
@@ -372,23 +351,13 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
372351
{
373352
key: 'tags',
374353
title: t('deployment.Tags'),
375-
render: (_text, row) => {
376-
const tags = (row.metadata?.tags ?? []).flatMap((tag) =>
377-
tag
378-
.split(',')
379-
.map((t) => t.trim())
380-
.filter(Boolean),
381-
);
382-
if (tags.length === 0)
383-
return <Typography.Text type="secondary">-</Typography.Text>;
384-
return (
385-
<BAIFlex wrap="wrap" gap="xxs">
386-
{tags.map((tag) => (
387-
<Tag key={tag}>{tag}</Tag>
388-
))}
389-
</BAIFlex>
390-
);
391-
},
354+
render: (_text, row) => (
355+
<DeploymentTagChips
356+
metadataFrgmt={row.metadata}
357+
stopRowClick
358+
fallback={<Typography.Text type="secondary">-</Typography.Text>}
359+
/>
360+
),
392361
},
393362
{
394363
key: 'createdAt',
@@ -441,9 +410,9 @@ const DeploymentList: React.FC<DeploymentListProps> = ({
441410
/>
442411
<BAIGraphQLPropertyFilter
443412
filterProperties={filterProperties}
444-
value={filterValue}
413+
value={filter}
445414
onChange={(next) => {
446-
setFilter(stringifyFilter(next));
415+
setFilter(next);
447416
}}
448417
/>
449418
</BAIFlex>
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
@license
3+
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
4+
*/
5+
import type { DeploymentTagChips_metadata$key } from '../__generated__/DeploymentTagChips_metadata.graphql';
6+
import { useWebUINavigate } from '../hooks';
7+
import { Tag } from 'antd';
8+
import { BAIFlex } from 'backend.ai-ui';
9+
import React from 'react';
10+
import { graphql, useFragment } from 'react-relay';
11+
12+
interface DeploymentTagChipsProps {
13+
metadataFrgmt: DeploymentTagChips_metadata$key | null | undefined;
14+
/**
15+
* When true, click and Enter/Space keypress events stop bubbling so the
16+
* surrounding row click handler does not also fire (used inside the
17+
* deployment list table).
18+
*/
19+
stopRowClick?: boolean;
20+
/** Rendered when there are no tags to display. */
21+
fallback?: React.ReactNode;
22+
}
23+
24+
/**
25+
* Render a deployment metadata's tags as clickable chips. Activating a chip
26+
* (mouse click or keyboard Enter/Space) navigates to the deployment list
27+
* pre-filtered by that tag using `iContains`. Tag entries are split on
28+
* commas so legacy comma-joined values render as individual chips.
29+
*/
30+
const DeploymentTagChips: React.FC<DeploymentTagChipsProps> = ({
31+
metadataFrgmt,
32+
stopRowClick = false,
33+
fallback = null,
34+
}) => {
35+
'use memo';
36+
const webuiNavigate = useWebUINavigate();
37+
38+
const metadata = useFragment(
39+
graphql`
40+
fragment DeploymentTagChips_metadata on ModelDeploymentMetadata {
41+
tags
42+
}
43+
`,
44+
metadataFrgmt ?? null,
45+
);
46+
47+
const tags = (metadata?.tags ?? []).flatMap((tag) =>
48+
tag
49+
.split(',')
50+
.map((t) => t.trim())
51+
.filter(Boolean),
52+
);
53+
54+
if (tags.length === 0) return <>{fallback}</>;
55+
56+
const navigateToFiltered = (tag: string) => {
57+
const filter = JSON.stringify({ tags: { iContains: tag } });
58+
59+
webuiNavigate({
60+
pathname: '/deployments',
61+
search: new URLSearchParams({ filter }).toString(),
62+
});
63+
};
64+
65+
return (
66+
<BAIFlex wrap="wrap" gap="xxs">
67+
{tags.map((tag) => (
68+
<Tag
69+
key={tag}
70+
role="button"
71+
tabIndex={0}
72+
style={{ cursor: 'pointer' }}
73+
onClick={(e) => {
74+
if (stopRowClick) e.stopPropagation();
75+
navigateToFiltered(tag);
76+
}}
77+
onKeyDown={(e) => {
78+
if (e.key === 'Enter' || e.key === ' ') {
79+
e.preventDefault();
80+
if (stopRowClick) e.stopPropagation();
81+
navigateToFiltered(tag);
82+
}
83+
}}
84+
>
85+
{tag}
86+
</Tag>
87+
))}
88+
</BAIFlex>
89+
);
90+
};
91+
92+
export default DeploymentTagChips;

react/src/pages/AdminDeploymentListPage.tsx

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -30,31 +30,17 @@ import {
3030
BAICard,
3131
BAIFetchKeyButton,
3232
filterOutEmpty,
33+
GraphQLFilter,
3334
INITIAL_FETCH_KEY,
3435
toLocalId,
3536
useFetchKey,
3637
} from 'backend.ai-ui';
37-
import { parseAsString, parseAsStringLiteral, useQueryStates } from 'nuqs';
38+
import { parseAsJson, parseAsStringLiteral, useQueryStates } from 'nuqs';
3839
import React, { Suspense, useDeferredValue, useState } from 'react';
3940
import { useTranslation } from 'react-i18next';
4041
import { graphql, useLazyLoadQuery } from 'react-relay';
4142
import { useSearchParams } from 'react-router-dom';
4243

43-
const parseFilterVariable = (
44-
filter: string | null | undefined,
45-
): DeploymentFilter | undefined => {
46-
if (!filter) return undefined;
47-
try {
48-
const parsed = JSON.parse(filter);
49-
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
50-
return parsed as DeploymentFilter;
51-
}
52-
return undefined;
53-
} catch {
54-
return undefined;
55-
}
56-
};
57-
5844
const AdminDeploymentListPageContent: React.FC = () => {
5945
'use memo';
6046
const webUINavigate = useWebUINavigate();
@@ -72,7 +58,7 @@ const AdminDeploymentListPageContent: React.FC = () => {
7258

7359
const [queryParams, setQueryParams] = useQueryStates(
7460
{
75-
filter: parseAsString.withDefault(''),
61+
filter: parseAsJson<GraphQLFilter>((value) => value as GraphQLFilter),
7662
order: parseAsStringLiteral(availableDeploymentOrderValues),
7763
statusCategory: parseAsStringLiteral<DeploymentStatusCategory>([
7864
'running',
@@ -96,7 +82,7 @@ const AdminDeploymentListPageContent: React.FC = () => {
9682
: { status: { notIn: finishedStatuses } };
9783
const queryVariables = {
9884
filter: {
99-
...parseFilterVariable(queryParams.filter),
85+
...((queryParams.filter ?? {}) as DeploymentFilter),
10086
...statusCategoryFilter,
10187
},
10288
orderBy: sort
@@ -150,9 +136,9 @@ const AdminDeploymentListPageContent: React.FC = () => {
150136
<DeploymentList
151137
mode="admin"
152138
deploymentsFrgmt={adminDeployments}
153-
filter={queryParams.filter}
139+
filter={queryParams.filter ?? undefined}
154140
setFilter={(value) => {
155-
setQueryParams({ filter: value || null });
141+
setQueryParams({ filter: value ?? null });
156142
setTablePaginationOption({ current: 1 });
157143
}}
158144
order={queryParams.order ?? undefined}

0 commit comments

Comments
 (0)