|
2 | 2 | @license |
3 | 3 | Copyright (c) 2015-2026 Lablup Inc. All rights reserved. |
4 | 4 | */ |
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'; |
6 | 19 |
|
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 | + */ |
10 | 79 | const DeploymentListPage: React.FC = () => { |
11 | 80 | '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 | + ); |
13 | 195 | }; |
14 | 196 |
|
15 | 197 | export default DeploymentListPage; |
0 commit comments