Skip to content

Commit 56878fd

Browse files
committed
feat(FR-2671): add DeploymentListPage with myDeployments query and URL state
1 parent a66af8f commit 56878fd

1 file changed

Lines changed: 187 additions & 5 deletions

File tree

react/src/pages/DeploymentListPage.tsx

Lines changed: 187 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,196 @@
22
@license
33
Copyright (c) 2015-2026 Lablup Inc. All rights reserved.
44
*/
5-
import React from 'react';
5+
import {
6+
DeploymentFilter,
7+
DeploymentListPageQuery,
8+
DeploymentOrderBy,
9+
} from '../__generated__/DeploymentListPageQuery.graphql';
10+
import DeploymentList, { DeploymentSort } from '../components/DeploymentList';
11+
import { useWebUINavigate } from '../hooks';
12+
import { Button } from 'antd';
13+
import { BAICard, BAIFlex, toLocalId } from 'backend.ai-ui';
14+
import { PlusIcon } from 'lucide-react';
15+
import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs';
16+
import React, { useDeferredValue, useMemo } from 'react';
17+
import { useTranslation } from 'react-i18next';
18+
import { graphql, useLazyLoadQuery } from 'react-relay';
619

7-
// TODO(needs-backend): FR-2671 — Deployment list page (user view).
8-
// This is a placeholder stub introduced by FR-2664 so that the new
9-
// /deployments route can be wired before the real page lands in Phase 3.
20+
/**
21+
* Parse the JSON-serialized `GraphQLFilter` stored in the URL back into the
22+
* shape accepted by the `myDeployments(filter: DeploymentFilter)` query
23+
* variable. Invalid / empty values become `undefined` so the server applies
24+
* no filter.
25+
*/
26+
const parseFilterForQuery = (
27+
filter: string | null,
28+
): DeploymentFilter | undefined => {
29+
if (!filter) return undefined;
30+
try {
31+
const parsed = JSON.parse(filter);
32+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
33+
return parsed as DeploymentFilter;
34+
}
35+
return undefined;
36+
} catch {
37+
return undefined;
38+
}
39+
};
40+
41+
/**
42+
* Serialize a structured sort value into the JSON URL state and back.
43+
*
44+
* The `sort` URL parameter is stored as a JSON string so that both the
45+
* `field` enum value and `order` direction survive a round-trip through
46+
* `nuqs`. An absent / malformed value is treated as "no sort" and falls
47+
* back to the server default (most recently created first).
48+
*/
49+
const parseSortString = (raw: string | null): DeploymentSort | undefined => {
50+
if (!raw) return undefined;
51+
try {
52+
const parsed = JSON.parse(raw);
53+
if (
54+
parsed &&
55+
typeof parsed === 'object' &&
56+
typeof parsed.field === 'string' &&
57+
(parsed.order === 'ASC' || parsed.order === 'DESC')
58+
) {
59+
return parsed as DeploymentSort;
60+
}
61+
return undefined;
62+
} catch {
63+
return undefined;
64+
}
65+
};
66+
67+
const stringifySort = (sort: DeploymentSort | undefined): string | null => {
68+
if (!sort) return null;
69+
return JSON.stringify(sort);
70+
};
71+
72+
/**
73+
* User-facing deployment list page. Owns the `myDeployments` Relay query
74+
* and feeds the returned connection into `<DeploymentList mode="user" />`.
75+
*
76+
* URL-synced state (filter / sort / page / pageSize) is managed via `nuqs`
77+
* so the view survives refreshes and can be shared via link.
78+
*/
1079
const DeploymentListPage: React.FC = () => {
1180
'use memo';
12-
return <div>TODO: DeploymentListPage — FR-2671</div>;
81+
const { t } = useTranslation();
82+
const webuiNavigate = useWebUINavigate();
83+
84+
const [queryParams, setQueryParams] = useQueryStates(
85+
{
86+
filter: parseAsString,
87+
sort: parseAsString,
88+
page: parseAsInteger.withDefault(1),
89+
pageSize: parseAsInteger.withDefault(10),
90+
},
91+
{ history: 'push' },
92+
);
93+
94+
const sort = parseSortString(queryParams.sort);
95+
96+
const queryVariables = useMemo(() => {
97+
const orderBy: DeploymentOrderBy[] | undefined = sort
98+
? [
99+
{
100+
field: sort.field as DeploymentOrderBy['field'],
101+
direction: sort.order,
102+
},
103+
]
104+
: undefined;
105+
return {
106+
filter: parseFilterForQuery(queryParams.filter),
107+
orderBy,
108+
first: queryParams.pageSize,
109+
offset:
110+
queryParams.page > 1
111+
? (queryParams.page - 1) * queryParams.pageSize
112+
: 0,
113+
};
114+
}, [queryParams.filter, queryParams.page, queryParams.pageSize, sort]);
115+
116+
const deferredQueryVariables = useDeferredValue(queryVariables);
117+
118+
const { myDeployments } = useLazyLoadQuery<DeploymentListPageQuery>(
119+
graphql`
120+
query DeploymentListPageQuery(
121+
$filter: DeploymentFilter
122+
$orderBy: [DeploymentOrderBy!]
123+
$first: Int
124+
$offset: Int
125+
) {
126+
myDeployments(
127+
filter: $filter
128+
orderBy: $orderBy
129+
first: $first
130+
offset: $offset
131+
) {
132+
...DeploymentList_modelDeploymentConnection
133+
}
134+
}
135+
`,
136+
deferredQueryVariables,
137+
{ fetchPolicy: 'store-and-network' },
138+
);
139+
140+
const isPending = deferredQueryVariables !== queryVariables;
141+
142+
return (
143+
<BAIFlex direction="column" align="stretch" gap="md">
144+
<BAICard
145+
variant="borderless"
146+
title={t('deployment.Deployments')}
147+
extra={
148+
<Button
149+
type="primary"
150+
icon={<PlusIcon size={16} />}
151+
onClick={() => webuiNavigate('/deployments/new')}
152+
>
153+
{t('deployment.CreateDeployment')}
154+
</Button>
155+
}
156+
styles={{
157+
header: { borderBottom: 'none' },
158+
body: { paddingTop: 0 },
159+
}}
160+
>
161+
{myDeployments ? (
162+
<DeploymentList
163+
deploymentsFrgmt={myDeployments}
164+
filter={queryParams.filter ?? undefined}
165+
setFilter={(value) => {
166+
setQueryParams({
167+
filter: value ? value : null,
168+
page: 1,
169+
});
170+
}}
171+
sort={sort}
172+
setSort={(value) => {
173+
setQueryParams({ sort: stringifySort(value) });
174+
}}
175+
page={queryParams.page}
176+
setPage={(value) => {
177+
setQueryParams({ page: value });
178+
}}
179+
pageSize={queryParams.pageSize}
180+
setPageSize={(value) => {
181+
setQueryParams({ pageSize: value });
182+
}}
183+
mode="user"
184+
loading={isPending}
185+
onRowClick={(deploymentId) => {
186+
// DeploymentList surfaces the global Relay ID; the route
187+
// expects a local UUID (see `/deployments/:deploymentId`).
188+
webuiNavigate(`/deployments/${toLocalId(deploymentId)}`);
189+
}}
190+
/>
191+
) : null}
192+
</BAICard>
193+
</BAIFlex>
194+
);
13195
};
14196

15197
export default DeploymentListPage;

0 commit comments

Comments
 (0)