From 8a4f59d1b047962116bda046de219e977af0c4c4 Mon Sep 17 00:00:00 2001 From: SungChul Hong Date: Thu, 23 Apr 2026 11:54:41 +0900 Subject: [PATCH] feat(FR-2685): migrate VFolder selector queries to Strawberry V2 GraphQL API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves #6929(FR-2685) ## Summary - Migrate `BAIVFolderSelect`, `useGetAvailableFolderName`, `VFolderMountFormItem` (auto-mount section), and the two caller-side filters in `AdminModelCardSettingModal` / `ImportArtifactRevisionToFolderModal` from the Graphene V1 `vfolder_nodes` query to the Strawberry V2 `myVfolders` / `projectVfolders` / `vfolderV2` API. - The paginated list routes to `projectVfolders` when `currentProjectId` is set, otherwise `myVfolders` — matching the V1 `scope_id` behavior. - Search and status-exclusion semantics are preserved via `VFolderFilter` (`name.iContains` for search, `status.notIn DELETE_*` for `excludeDeleted`). The `filter` prop is now a structured `VFolderFilter` (previously a V1 filter string). - Preselected-value resolution uses up to 10 aliased `vfolderV2(vfolderId:)` lookups gated by `@include` booleans, since `VFolderFilter` does not expose an id filter. Values beyond that fall back to paginated name resolution or the raw id label. - `BAIVFolderSelect.stories.tsx` mock shapes are updated to the V2 `{ id, status, metadata.name }` form for both `myVfolders` and `projectVfolders`. ## Caller adjustments - `AdminModelCardSettingModal` and `ImportArtifactRevisionToFolderModal` previously passed V1 filter strings (`ownership_type == "group"`, `group == ""`). These clauses are redundant under V2 because `projectVfolders` already scopes results to project-owned folders; `VFolderFilter` does not expose `ownershipType` or `projectId`. The model-store modal now scopes via `currentProjectId={modelStoreProject.id}`. ## Part of Epic FR-2572 — Migrate WebUI to Strawberry V2 GraphQL API. ## Verification - `bash scripts/verify.sh` → `=== ALL PASS ===` --- .../fragments/BAIVFolderSelect.stories.tsx | 160 +++--- .../components/fragments/BAIVFolderSelect.tsx | 470 +++++++++++++----- .../src/hooks/useGetAvailableFolderName.ts | 24 +- .../components/AdminModelCardSettingModal.tsx | 8 +- .../ImportArtifactRevisionToFolderModal.tsx | 17 +- react/src/components/VFolderMountFormItem.tsx | 41 +- 6 files changed, 482 insertions(+), 238 deletions(-) diff --git a/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.stories.tsx b/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.stories.tsx index c3d8bedf35..7f0d3c63c5 100644 --- a/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.stories.tsx +++ b/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.stories.tsx @@ -29,49 +29,64 @@ const VFolderRelayResolver = ({ ); }; +// V2 VFolder mock shape: `id`, `status`, `metadata { name }`. The component +// derives local UUIDs (used for `valuePropName='row_id'`) via `toLocalId(id)` +// at render time, so stories no longer need to provide a separate `row_id`. const sampleVFolders = [ { node: { - id: 'VkZvbGRlck5vZGU6MTIzNDU2Nzg5MA==', - name: 'my-project-data', - row_id: 'abcd1234-5678-90ef-ghij-klmnopqrstuv', + id: 'VkZvbGRlcjphYmNkMTIzNC01Njc4LTkwZWYtZ2hpai1rbG1ub3BxcnN0dXY=', + status: 'READY', + metadata: { + name: 'my-project-data', + }, }, }, { node: { - id: 'VkZvbGRlck5vZGU6MDk4NzY1NDMyMQ==', - name: 'shared-datasets', - row_id: 'wxyz9876-5432-10ab-cdef-ghijklmnopqr', + id: 'VkZvbGRlcjp3eHl6OTg3Ni01NDMyLTEwYWItY2RlZi1naGlqa2xtbm9wcXI=', + status: 'READY', + metadata: { + name: 'shared-datasets', + }, }, }, { node: { - id: 'VkZvbGRlck5vZGU6MTExMTExMTExMQ==', - name: 'model-checkpoints', - row_id: 'aaaa1111-2222-3333-4444-555566667777', + id: 'VkZvbGRlcjphYWFhMTExMS0yMjIyLTMzMzMtNDQ0NC01NTU1NjY2Njc3Nzc=', + status: 'READY', + metadata: { + name: 'model-checkpoints', + }, }, }, { node: { - id: 'VkZvbGRlck5vZGU6MjIyMjIyMjIyMg==', - name: 'training-logs', - row_id: 'bbbb2222-3333-4444-5555-666677778888', + id: 'VkZvbGRlcjpiYmJiMjIyMi0zMzMzLTQ0NDQtNTU1NS02NjY2Nzc3Nzg4ODg=', + status: 'READY', + metadata: { + name: 'training-logs', + }, }, }, { node: { - id: 'VkZvbGRlck5vZGU6MzMzMzMzMzMzMw==', - name: 'test-results', - row_id: 'cccc3333-4444-5555-6666-777788889999', + id: 'VkZvbGRlcjpjY2NjMzMzMy00NDQ0LTU1NTUtNjY2Ni03Nzc3ODg4ODk5OTk=', + status: 'READY', + metadata: { + name: 'test-results', + }, }, }, ]; const sampleManyVFolders = Array.from({ length: 15 }, (_, i) => ({ node: { - id: `VkZvbGRlck5vZGU6${i + 1000000000}`, - name: `vfolder-${i + 1}`, - row_id: `${i.toString().padStart(8, '0')}-aaaa-bbbb-cccc-ddddeeeefffff`, + id: btoa(`VFolder:00000000-aaaa-bbbb-cccc-${String(i).padStart(12, '0')}`), + status: 'READY', + metadata: { + name: `vfolder-${i + 1}`, + }, }, })); @@ -106,24 +121,24 @@ const meta: Meta = { | Name | Type | Default | Description | |------|------|---------|-------------| -| \`currentProjectId\` | \`string\` | - | Project ID to scope vfolder selection | +| \`currentProjectId\` | \`string\` | - | Project ID to scope vfolder selection. When set, the paginated query uses \`projectVfolders\`; otherwise \`myVfolders\`. | | \`onClickVFolder\` | \`(value: string) => void\` | - | Callback when vfolder name is clicked | -| \`filter\` | \`string\` | - | Additional filter string for vfolder query | +| \`filter\` | \`VFolderFilter \\| null\` | - | Additional structured filter merged into the paginated query (AND-combined with internal filters) | | \`valuePropName\` | \`'id' \\| 'row_id'\` | \`'id'\` | Which field to use as option value | | \`excludeDeleted\` | \`boolean\` | \`false\` | Exclude deleted or deleting vfolders | | \`ref\` | \`React.Ref\` | - | Ref exposing \`refetch()\` method | ## Features -- Fetches vfolders from two GraphQL queries: - - \`BAIVFolderSelectValueQuery\`: Fetch selected vfolder labels - - \`BAIVFolderSelectPaginatedQuery\`: Fetch paginated available vfolders +- Strawberry V2 GraphQL queries: + - \`BAIVFolderSelectValueQuery\`: Resolves preselected vfolders by id via aliased \`vfolderV2(vfolderId:)\` lookups (up to 10 slots) + - \`BAIVFolderSelectPaginatedQuery\`: Paginated \`myVfolders\` / \`projectVfolders\` depending on \`currentProjectId\` - Pagination support with \`useLazyPaginatedQuery\` hook -- Search functionality with debounced loading state +- Search functionality with debounced loading state, using \`VFolderFilter.name.iContains\` - Infinite scroll via \`endReached\` callback - Total count footer with loading indicator - Custom label/option rendering with ID display (truncated) - Clickable vfolder names when \`onClickVFolder\` is provided -- Automatic filtering of deleted vfolders when \`excludeDeleted\` is true +- Automatic filtering of deleted vfolders via \`VFolderOperationStatusFilter\` when \`excludeDeleted\` is true - Project scoping through \`currentProjectId\` prop - Optimistic UI updates for smooth user experience - Exposed \`refetch()\` method via ref @@ -133,23 +148,15 @@ const meta: Meta = { ### Value Query (for selected items) \`\`\`graphql query BAIVFolderSelectValueQuery( - $selectedFilter: String - $skipSelectedVFolder: Boolean! - $scopeId: ScopeField + $id0: UUID! + $include0: Boolean! + # ... up to $id9 / $include9 ) { - vfolder_nodes( - scope_id: $scopeId - filter: $selectedFilter - permission: "read_attribute" - ) @skip(if: $skipSelectedVFolder) { - edges { - node { - name - id - row_id - } - } + v0: vfolderV2(vfolderId: $id0) @include(if: $include0) { + id + metadata { name } } + # ... v1..v9 } \`\`\` @@ -158,26 +165,25 @@ query BAIVFolderSelectValueQuery( query BAIVFolderSelectPaginatedQuery( $offset: Int! $limit: Int! - $scopeId: ScopeField - $filter: String - $permission: VFolderPermissionValueField + $projectId: UUID! + $filter: VFolderFilter + $orderBy: [VFolderOrderBy!] + $useProject: Boolean! ) { - vfolder_nodes( - scope_id: $scopeId + myVfolders(offset: $offset, limit: $limit, filter: $filter, orderBy: $orderBy) + @skip(if: $useProject) { + count + edges { node { id metadata { name } } } + } + projectVfolders( + projectId: $projectId offset: $offset - first: $limit + limit: $limit filter: $filter - permission: $permission - order: "-created_at" - ) { + orderBy: $orderBy + ) @include(if: $useProject) { count - edges { - node { - id - name - row_id - } - } + edges { node { id metadata { name } } } } } \`\`\` @@ -190,16 +196,16 @@ query BAIVFolderSelectPaginatedQuery( onChange={(value) => console.log(value)} /> -// With project scoping +// With project scoping (uses projectVfolders) console.log(value)} /> -// With clickable names +// With structured filter window.open(\`/vfolder/\${id}\`)} + filter={{ name: { iContains: 'test' } }} onChange={(value) => console.log(value)} /> @@ -230,11 +236,11 @@ For all other props, refer to [BAISelect](/?path=/docs/components-input-baiselec description: 'Callback when vfolder name is clicked', }, filter: { - control: { type: 'text' }, + control: false, description: - 'Additional filter string for vfolder query (e.g., "name ilike \'%test%\'")', + 'Additional VFolderFilter merged into the paginated query (e.g., `{ name: { iContains: "test" } }`)', table: { - type: { summary: 'string' }, + type: { summary: 'VFolderFilter | null' }, }, }, valuePropName: { @@ -308,7 +314,11 @@ export const Default: Story = { ({ - vfolder_nodes: { + myVfolders: { + count: 5, + edges: sampleVFolders, + }, + projectVfolders: { count: 5, edges: sampleVFolders, }, @@ -338,7 +348,11 @@ export const Empty: Story = { ({ - vfolder_nodes: { + myVfolders: { + count: 0, + edges: [], + }, + projectVfolders: { count: 0, edges: [], }, @@ -370,7 +384,11 @@ export const ManyVFolders: Story = { ({ - vfolder_nodes: { + myVfolders: { + count: 15, + edges: sampleManyVFolders, + }, + projectVfolders: { count: 15, edges: sampleManyVFolders, }, @@ -402,7 +420,11 @@ export const WithClickableNames: Story = { ({ - vfolder_nodes: { + myVfolders: { + count: 5, + edges: sampleVFolders, + }, + projectVfolders: { count: 5, edges: sampleVFolders, }, @@ -439,7 +461,11 @@ export const WithRowId: Story = { ({ - vfolder_nodes: { + myVfolders: { + count: 5, + edges: sampleVFolders, + }, + projectVfolders: { count: 5, edges: sampleVFolders, }, diff --git a/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.tsx b/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.tsx index 917ec64f02..74081ee055 100644 --- a/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.tsx +++ b/packages/backend.ai-ui/src/components/fragments/BAIVFolderSelect.tsx @@ -1,11 +1,13 @@ -import { BAIVFolderSelectPaginatedQuery } from '../../__generated__/BAIVFolderSelectPaginatedQuery.graphql'; +import { + BAIVFolderSelectPaginatedQuery, + VFolderFilter, +} from '../../__generated__/BAIVFolderSelectPaginatedQuery.graphql'; import { BAIVFolderSelectValueQuery } from '../../__generated__/BAIVFolderSelectValueQuery.graphql'; import { toLocalId } from '../../helper'; import useDebouncedDeferredValue from '../../helper/useDebouncedDeferredValue'; import { useFetchKey } from '../../hooks'; import { useLazyPaginatedQuery } from '../../hooks/usePaginatedQuery'; import BAILink from '../BAILink'; -import { mergeFilterValues } from '../BAIPropertyFilter'; import BAISelect, { BAISelectProps } from '../BAISelect'; import BAIText from '../BAIText'; import TotalFooter from '../TotalFooter'; @@ -15,6 +17,7 @@ import * as _ from 'lodash-es'; import { useDeferredValue, useEffect, + useEffectEvent, useImperativeHandle, useOptimistic, useRef, @@ -26,7 +29,7 @@ import { graphql, useLazyLoadQuery } from 'react-relay'; export type VFolderNode = NonNullable< NonNullable< - BAIVFolderSelectPaginatedQuery['response']['vfolder_nodes'] + BAIVFolderSelectPaginatedQuery['response']['myVfolders'] >['edges'][number] >['node']; @@ -40,16 +43,41 @@ export interface BAIVFolderSelectProps extends Omit< > { currentProjectId?: string; onClickVFolder?: (value: string) => void; - filter?: string; + /** + * Additional structured filter (VFolderFilter) merged via AND with the + * internal filters (status exclusion and search term). Pass `null` to clear. + */ + filter?: VFolderFilter | null; valuePropName?: 'id' | 'row_id'; excludeDeleted?: boolean; onResolvedNamesChange?: (nameMap: Record) => void; ref?: React.Ref; } -// Exclude deleted or deleting vfolders -const excludeDeletedStatusFilter = - 'status != "DELETE_PENDING" & status != "DELETE_ONGOING" & status != "DELETE_ERROR" & status != "DELETE_COMPLETE"'; +// Status values excluded when `excludeDeleted` is true. Mirrors the V1 +// filter-string semantics (`status != DELETE_*`) using the V2 +// `VFolderOperationStatusFilter` enum shape. The cast targets the +// Relay-generated `VFolderOperationStatus` union without re-importing the +// alias (kept local to minimize coupling to the generated types). +const EXCLUDE_DELETED_STATUSES: ReadonlyArray< + 'DELETE_PENDING' | 'DELETE_ONGOING' | 'DELETE_ERROR' | 'DELETE_COMPLETE' +> = ['DELETE_PENDING', 'DELETE_ONGOING', 'DELETE_ERROR', 'DELETE_COMPLETE']; + +// Keep in sync with the `$id0..$id9` / `$include0..$include9` variables in +// `BAIVFolderSelectValueQuery`. See the note inside the query for why we use +// a fixed set of aliased `vfolderV2` lookups instead of a single filter-by-id +// query (V2 `VFolderFilter` does not expose an id field, tracked under +// FR-2685 follow-ups). +const MAX_PRESELECTED_VALUE_RESOLVE = 10; + +const combineVFolderFilters = ( + filters: Array, +): VFolderFilter | null => { + const cleaned = _.compact(filters); + if (cleaned.length === 0) return null; + if (cleaned.length === 1) return cleaned[0] as VFolderFilter; + return { AND: cleaned } as VFolderFilter; +}; const BAIVFolderSelect: React.FC = ({ loading, @@ -82,9 +110,10 @@ const BAIVFolderSelect: React.FC = ({ const [optimisticSearchStr, setOptimisticSearchStr] = useOptimistic(searchStr); const [isPendingRefetch, startRefetchTransition] = useTransition(); - const mergedFilter = mergeFilterValues([ - excludeDeleted ? excludeDeletedStatusFilter : null, - filter, + + const mergedFilter = combineVFolderFilters([ + excludeDeleted ? { status: { notIn: EXCLUDE_DELETED_STATUSES } } : null, + filter ?? null, ]); const [fetchKey, updateFetchKey] = useFetchKey(); const deferredFetchKey = useDeferredValue(fetchKey); @@ -92,111 +121,247 @@ const BAIVFolderSelect: React.FC = ({ // Defer query refetch to prevent flickering during user selection const deferredControllableValue = useDeferredValue(controllableValue); - const { vfolder_nodes: selectedVFolderNodes } = + // Build an array of (id, include) pairs to drive the aliased + // `vfolderV2(vfolderId:)` queries. V2 `VFolderFilter` cannot filter by id, + // so we fetch each preselected vfolder via a dedicated aliased query up to + // `MAX_PRESELECTED_VALUE_RESOLVE`. Extra values fall through to the + // paginated list for name resolution. + const selectedValueList = _.castArray(deferredControllableValue).filter( + (v): v is string => !_.isNil(v) && v !== '', + ); + const valueQuerySlots = _.range(MAX_PRESELECTED_VALUE_RESOLVE).map( + (index) => { + const value = selectedValueList[index]; + if (!value) { + return { id: null as string | null, include: false }; + } + const localId = valuePropName === 'id' ? toLocalId(value) : value; + return { id: localId, include: Boolean(localId) }; + }, + ); + + const { v0, v1, v2, v3, v4, v5, v6, v7, v8, v9 } = useLazyLoadQuery( graphql` query BAIVFolderSelectValueQuery( - $selectedFilter: String - $first: Int! - $skipSelectedVFolder: Boolean! - $scopeId: ScopeField + $id0: UUID! + $id1: UUID! + $id2: UUID! + $id3: UUID! + $id4: UUID! + $id5: UUID! + $id6: UUID! + $id7: UUID! + $id8: UUID! + $id9: UUID! + $include0: Boolean! + $include1: Boolean! + $include2: Boolean! + $include3: Boolean! + $include4: Boolean! + $include5: Boolean! + $include6: Boolean! + $include7: Boolean! + $include8: Boolean! + $include9: Boolean! ) { - vfolder_nodes( - scope_id: $scopeId - filter: $selectedFilter - first: $first - permission: "read_attribute" - ) @skip(if: $skipSelectedVFolder) { - edges { - node { - name - id - row_id - } + v0: vfolderV2(vfolderId: $id0) @include(if: $include0) { + id + metadata { + name + } + } + v1: vfolderV2(vfolderId: $id1) @include(if: $include1) { + id + metadata { + name + } + } + v2: vfolderV2(vfolderId: $id2) @include(if: $include2) { + id + metadata { + name + } + } + v3: vfolderV2(vfolderId: $id3) @include(if: $include3) { + id + metadata { + name + } + } + v4: vfolderV2(vfolderId: $id4) @include(if: $include4) { + id + metadata { + name + } + } + v5: vfolderV2(vfolderId: $id5) @include(if: $include5) { + id + metadata { + name + } + } + v6: vfolderV2(vfolderId: $id6) @include(if: $include6) { + id + metadata { + name + } + } + v7: vfolderV2(vfolderId: $id7) @include(if: $include7) { + id + metadata { + name + } + } + v8: vfolderV2(vfolderId: $id8) @include(if: $include8) { + id + metadata { + name + } + } + v9: vfolderV2(vfolderId: $id9) @include(if: $include9) { + id + metadata { + name } } } `, { - selectedFilter: mergeFilterValues( - [ - !_.isEmpty(deferredControllableValue) - ? mergeFilterValues( - _.castArray(deferredControllableValue).map((value) => { - // When valuePropName is 'id', convert Global ID to local UUID - // When valuePropName is 'row_id', use the value directly - const filterValue = - valuePropName === 'id' ? toLocalId(value) : value; - return `${valuePropName} == "${filterValue}"`; - }), - '|', - ) - : null, - mergedFilter, - ], - '&', - ), - first: _.castArray(deferredControllableValue).length, - skipSelectedVFolder: _.isEmpty(deferredControllableValue), - scopeId: currentProjectId ? `project:${currentProjectId}` : undefined, + // Non-nullable UUID! args must always be a valid string; the + // `@include(if:)` guard prevents the server from actually running the + // lookup when we don't have a real id. Use a zeroed UUID as a safe + // placeholder to avoid GraphQL validation errors. + id0: valueQuerySlots[0]?.id ?? '00000000-0000-0000-0000-000000000000', + id1: valueQuerySlots[1]?.id ?? '00000000-0000-0000-0000-000000000000', + id2: valueQuerySlots[2]?.id ?? '00000000-0000-0000-0000-000000000000', + id3: valueQuerySlots[3]?.id ?? '00000000-0000-0000-0000-000000000000', + id4: valueQuerySlots[4]?.id ?? '00000000-0000-0000-0000-000000000000', + id5: valueQuerySlots[5]?.id ?? '00000000-0000-0000-0000-000000000000', + id6: valueQuerySlots[6]?.id ?? '00000000-0000-0000-0000-000000000000', + id7: valueQuerySlots[7]?.id ?? '00000000-0000-0000-0000-000000000000', + id8: valueQuerySlots[8]?.id ?? '00000000-0000-0000-0000-000000000000', + id9: valueQuerySlots[9]?.id ?? '00000000-0000-0000-0000-000000000000', + include0: valueQuerySlots[0]?.include ?? false, + include1: valueQuerySlots[1]?.include ?? false, + include2: valueQuerySlots[2]?.include ?? false, + include3: valueQuerySlots[3]?.include ?? false, + include4: valueQuerySlots[4]?.include ?? false, + include5: valueQuerySlots[5]?.include ?? false, + include6: valueQuerySlots[6]?.include ?? false, + include7: valueQuerySlots[7]?.include ?? false, + include8: valueQuerySlots[8]?.include ?? false, + include9: valueQuerySlots[9]?.include ?? false, }, { - fetchPolicy: !_.isEmpty(deferredControllableValue) - ? 'store-or-network' - : 'store-only', + fetchPolicy: + selectedValueList.length > 0 ? 'store-or-network' : 'store-only', fetchKey: deferredFetchKey, }, ); - const { paginationData, result, loadNext, isLoadingNext } = - useLazyPaginatedQuery( - graphql` - query BAIVFolderSelectPaginatedQuery( - $offset: Int! - $limit: Int! - $scopeId: ScopeField - $filter: String - $permission: VFolderPermissionValueField - ) { - vfolder_nodes( - scope_id: $scopeId - offset: $offset - first: $limit - filter: $filter - permission: $permission - order: "-created_at" - ) { - count - edges { - node { - id + // Aggregate aliased results into a flat list of {id, name} pairs aligned + // with the original selection order. Slots beyond MAX_PRESELECTED_VALUE_RESOLVE + // or entries whose id/metadata failed to resolve are skipped; the consumer + // falls back to the paginated results or the raw id for labels. + const resolvedSelectedList = _.compact([ + v0, + v1, + v2, + v3, + v4, + v5, + v6, + v7, + v8, + v9, + ]); + + const { + paginationData, + result: paginatedResult, + loadNext, + isLoadingNext, + } = useLazyPaginatedQuery( + // The paginated list targets `projectVfolders` when the caller scopes + // the picker to a project, otherwise the current user's scope. Super + // admin (`adminVfoldersV2`) surfacing is intentionally out of scope for + // this migration. + graphql` + query BAIVFolderSelectPaginatedQuery( + $offset: Int! + $limit: Int! + $projectId: UUID! + $filter: VFolderFilter + $orderBy: [VFolderOrderBy!] + $useProject: Boolean! + ) { + myVfolders( + offset: $offset + limit: $limit + filter: $filter + orderBy: $orderBy + ) @skip(if: $useProject) { + count + edges { + node { + id + metadata { name - row_id } } } } - `, - { limit: 10 }, - { - filter: mergeFilterValues([ - mergedFilter, - deferredSearchStr ? `name ilike "%${deferredSearchStr}%"` : null, - ]), - scopeId: currentProjectId ? `project:${currentProjectId}` : undefined, - permission: 'read_attribute' as const, - }, - { - fetchPolicy: deferredOpen ? 'network-only' : 'store-only', - fetchKey: deferredFetchKey, - }, - { - getTotal: (result) => result.vfolder_nodes?.count ?? undefined, - getItem: (result) => - result.vfolder_nodes?.edges?.map((edge) => edge?.node), - getId: (item) => item?.id, - }, - ); + projectVfolders( + projectId: $projectId + offset: $offset + limit: $limit + filter: $filter + orderBy: $orderBy + ) @include(if: $useProject) { + count + edges { + node { + id + metadata { + name + } + } + } + } + } + `, + { limit: 10 }, + { + filter: combineVFolderFilters([ + mergedFilter, + deferredSearchStr ? { name: { iContains: deferredSearchStr } } : null, + ]), + orderBy: [{ field: 'CREATED_AT', direction: 'DESC' }], + // `$projectId` is typed `UUID!` because Strawberry's + // `projectVfolders(projectId:)` is non-nullable. When `useProject` is + // false the `@include(if:)` guard prevents the field from executing, + // so a zeroed UUID is a safe placeholder. + projectId: currentProjectId ?? '00000000-0000-0000-0000-000000000000', + useProject: Boolean(currentProjectId), + }, + { + fetchPolicy: deferredOpen ? 'network-only' : 'store-only', + fetchKey: deferredFetchKey, + }, + { + getTotal: (result) => + (result.projectVfolders ?? result.myVfolders)?.count ?? undefined, + getItem: (result) => + (result.projectVfolders ?? result.myVfolders)?.edges?.map( + (edge) => edge?.node, + ), + getId: (item) => item?.id, + }, + ); + + const paginatedConnection = + paginatedResult.projectVfolders ?? paginatedResult.myVfolders; // Expose refetch function through ref useImperativeHandle( @@ -211,50 +376,81 @@ const BAIVFolderSelect: React.FC = ({ [updateFetchKey, startRefetchTransition], ); - // Notify parent of resolved id→name mapping when selected nodes are loaded - useEffect(() => { - if (onResolvedNamesChange && selectedVFolderNodes?.edges) { - const nameMap: Record = {}; - selectedVFolderNodes.edges.forEach((edge) => { - const key = edge?.node?.[valuePropName]; - const name = edge?.node?.name; - if (key && name) { - nameMap[key] = name; - } - }); + // Notify parent of resolved id→name mapping when selected nodes are loaded. + // V2 `VFolder.id` is a global ID; we additionally map the local UUID + // derivation so callers using `row_id` as `valuePropName` can resolve names + // with the same key shape. Uses `useEffectEvent` so the effect only + // re-runs when the list of resolved ids changes — not on every parent + // render that re-creates `onResolvedNamesChange`. + const notifyResolvedNames = useEffectEvent(() => { + if (!onResolvedNamesChange || resolvedSelectedList.length === 0) return; + const nameMap: Record = {}; + resolvedSelectedList.forEach((node) => { + if (!node?.id || !node.metadata?.name) return; + const name = node.metadata.name; + if (valuePropName === 'row_id') { + nameMap[toLocalId(node.id) ?? node.id] = name; + } else { + nameMap[node.id] = name; + } + }); + if (!_.isEmpty(nameMap)) { onResolvedNamesChange(nameMap); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [selectedVFolderNodes]); + }); + const resolvedSelectedKey = resolvedSelectedList.map((n) => n?.id).join('|'); + useEffect(() => { + notifyResolvedNames(); + }, [resolvedSelectedKey]); - const availableOptions = _.map(paginationData, (item) => ({ - label: item?.name, - value: item?.[valuePropName], + const availableOptions = _.map(_.compact(paginationData), (item) => ({ + label: item.metadata?.name, + value: + valuePropName === 'row_id' + ? item.id + ? (toLocalId(item.id) ?? undefined) + : undefined + : item.id, })); - const controllableValueWithLabel = selectedVFolderNodes?.edges - ? // Sort by deferredControllableValue order to maintain selection order - _.castArray(deferredControllableValue) - .map((value) => { - const edge = selectedVFolderNodes.edges.find( - (edge) => edge?.node?.[valuePropName] === value, - ); - return edge - ? { - label: edge.node?.name, - value: edge.node?.[valuePropName], - } - : null; - }) - .filter( - (item): item is { label: string; value: string } => item !== null, - ) - : !_.isEmpty(deferredControllableValue) - ? _.castArray(deferredControllableValue).map((value) => ({ - label: value, - value: value, - })) - : undefined; + const controllableValueWithLabel = (() => { + const values = _.castArray(deferredControllableValue).filter( + (v): v is string => !_.isNil(v) && v !== '', + ); + if (values.length === 0) return undefined; + // Index resolved selected-node names by the outward `valuePropName` key + // for O(1) lookup during render. + const resolvedByKey = new Map(); + resolvedSelectedList.forEach((node) => { + if (!node?.id || !node.metadata?.name) return; + if (valuePropName === 'row_id') { + const localId = toLocalId(node.id); + if (localId) resolvedByKey.set(localId, node.metadata.name); + } else { + resolvedByKey.set(node.id, node.metadata.name); + } + }); + return values + .map((value) => { + const resolvedLabel = resolvedByKey.get(value); + if (resolvedLabel) { + return { label: resolvedLabel, value }; + } + const availableOption = availableOptions.find( + (opt) => opt.value === value, + ); + if (availableOption?.label) { + return { label: availableOption.label, value }; + } + // Fallback — show the raw value until the name resolves via the + // ValueQuery or paginated list. + return { label: value, value }; + }) + .filter( + (item): item is { label: string; value: string } => + item !== null && _.isString(item.value), + ); + })(); const [optimisticValueWithLabel, setOptimisticValueWithLabel] = useState( controllableValueWithLabel, @@ -378,11 +574,11 @@ const BAIVFolderSelect: React.FC = ({ ) : undefined } footer={ - _.isNumber(result.vfolder_nodes?.count) && - result.vfolder_nodes.count > 0 ? ( + _.isNumber(paginatedConnection?.count) && + paginatedConnection.count > 0 ? ( ) : undefined } diff --git a/packages/backend.ai-ui/src/hooks/useGetAvailableFolderName.ts b/packages/backend.ai-ui/src/hooks/useGetAvailableFolderName.ts index 77d03785dd..e7c719b94f 100644 --- a/packages/backend.ai-ui/src/hooks/useGetAvailableFolderName.ts +++ b/packages/backend.ai-ui/src/hooks/useGetAvailableFolderName.ts @@ -8,27 +8,31 @@ export const useGetAvailableFolderName = () => { return async (seedName: string) => { // Limit folder name length to 64 characters const targetName = seedName.substring(0, 64); + // Use the V2 `myVfolders` query so the availability check stays in the + // caller's own vfolder scope (matching the V1 default scope). The filter + // mirrors the previous V1 semantics: exact name match, excluding + // already-purged (`DELETE_COMPLETE`) folders so reused names become + // reclaimable after a full purge. const count = await fetchQuery( relayEnv, graphql` - query useGetAvailableFolderNameQuery($filter: String!) { - vfolder_nodes(filter: $filter, permission: "read_attribute") { - edges { - node { - name - status - } - } + query useGetAvailableFolderNameQuery($filter: VFolderFilter) { + myVfolders(filter: $filter) { count } } `, { - filter: `(name == "${targetName}") & (status != "delete-complete")`, + filter: { + AND: [ + { name: { equals: targetName } }, + { status: { notEquals: 'DELETE_COMPLETE' } }, + ], + }, }, ) .toPromise() - .then((data) => data?.vfolder_nodes?.count) + .then((data) => data?.myVfolders?.count) .catch(() => 0); const hash = generateRandomString(5); diff --git a/react/src/components/AdminModelCardSettingModal.tsx b/react/src/components/AdminModelCardSettingModal.tsx index 764418e8ac..9d55b4ea61 100644 --- a/react/src/components/AdminModelCardSettingModal.tsx +++ b/react/src/components/AdminModelCardSettingModal.tsx @@ -33,7 +33,6 @@ import { BAIVFolderSelect, BAIVFolderSelectRef, convertToUUID, - mergeFilterValues, toGlobalId, toLocalId, useBAILogger, @@ -348,7 +347,12 @@ const AdminModelCardSettingModal: React.FC = ({ ref={vfolderSelectRef} excludeDeleted currentProjectId={currentProject.id ?? undefined} - filter={mergeFilterValues(['ownership_type == "group"'])} + // `currentProjectId` routes the paginated query to + // `projectVfolders`, which already only returns + // project-owned folders — the legacy V1 filter + // `ownership_type == "group"` is redundant and + // `VFolderFilter` does not expose ownershipType. See + // FR-2685 for the full V2 migration. style={{ flex: 1 }} /> diff --git a/react/src/components/ImportArtifactRevisionToFolderModal.tsx b/react/src/components/ImportArtifactRevisionToFolderModal.tsx index ec46d707bf..f41a88d588 100644 --- a/react/src/components/ImportArtifactRevisionToFolderModal.tsx +++ b/react/src/components/ImportArtifactRevisionToFolderModal.tsx @@ -23,7 +23,6 @@ import { convertToUUID, useBAILogger, toLocalId, - mergeFilterValues, } from 'backend.ai-ui'; import * as _ from 'lodash-es'; import { PlusIcon } from 'lucide-react'; @@ -256,13 +255,15 @@ const ImportArtifactRevisionToFolderModal = ({ "`) are therefore + // redundant and `VFolderFilter` exposes neither. + currentProjectId={modelStoreProject?.id ?? undefined} /> {currentProject.id === modelStoreProject?.id ? ( diff --git a/react/src/components/VFolderMountFormItem.tsx b/react/src/components/VFolderMountFormItem.tsx index 46f1c397b5..07f77c8ef9 100644 --- a/react/src/components/VFolderMountFormItem.tsx +++ b/react/src/components/VFolderMountFormItem.tsx @@ -2,7 +2,10 @@ @license Copyright (c) 2015-2026 Lablup Inc. All rights reserved. */ -import { VFolderMountFormItemAutoMountQuery } from '../__generated__/VFolderMountFormItemAutoMountQuery.graphql'; +import { + VFolderMountFormItemAutoMountQuery, + VFolderFilter, +} from '../__generated__/VFolderMountFormItemAutoMountQuery.graphql'; import FolderCreateModalV2 from './FolderCreateModalV2'; import { useFolderExplorerOpener } from './FolderExplorerOpener'; import { @@ -48,7 +51,7 @@ import { graphql, useLazyLoadQuery } from 'react-relay'; */ interface VFolderMountFormItemProps { - filter?: string; + filter?: VFolderFilter | null; currentProjectId?: string; label?: React.ReactNode; } @@ -328,7 +331,8 @@ const VFolderMountFormItem: React.FC = ({ /** * Lazy-loaded section that queries and displays auto-mount folders (name starts with '.'). - * Uses GraphQL vfolder_nodes with the same filter condition as VFolderTable and VFolderNodeListPage. + * Uses the Strawberry V2 `projectVfolders` query with a `VFolderFilter` that + * mirrors the previous filter string (`name ilike ".%" & status == "ready"`). */ const AutoMountFolderSection: React.FC<{ currentProjectId: string }> = ({ currentProjectId, @@ -336,36 +340,45 @@ const AutoMountFolderSection: React.FC<{ currentProjectId: string }> = ({ 'use memo'; const { t } = useTranslation(); - const { vfolder_nodes } = + const { projectVfolders } = useLazyLoadQuery( graphql` query VFolderMountFormItemAutoMountQuery( - $scopeId: ScopeField - $filter: String + $projectId: UUID! + $filter: VFolderFilter + $limit: Int ) { - vfolder_nodes( - scope_id: $scopeId + projectVfolders( + projectId: $projectId filter: $filter - first: 100 - permission: "read_attribute" + limit: $limit ) { edges { node { - name + id status + metadata { + name + } } } } } `, { - scopeId: `project:${currentProjectId}`, - filter: 'name ilike ".%" & status == "ready"', + projectId: currentProjectId, + filter: { + AND: [ + { name: { iStartsWith: '.' } }, + { status: { equals: 'READY' } }, + ], + }, + limit: 100, }, ); const autoMountNames = _.compact( - _.map(vfolder_nodes?.edges, (edge) => edge?.node?.name), + _.map(projectVfolders?.edges, (edge) => edge?.node?.metadata?.name), ); if (autoMountNames.length === 0) return null;