From 1a8b04aa0d2834954bbff06dc7c3849fbbddfa30 Mon Sep 17 00:00:00 2001 From: Sergey Kozyrenko Date: Mon, 18 May 2026 21:01:24 +0700 Subject: [PATCH] fix(frontend): persist API token name through subscription refetches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The inline create/edit row in the API tokens table used uncontrolled and read the value back from the ref inside the submit handler. When the apiTokens subscription fired during the user's interaction, DataTable would reorder rows and the input would re-mount with defaultValue="" (or the original name), discarding what the user just typed. The mutation then sent name: null and the freshly created token appeared as "(unnamed)". Move both create and edit name inputs to controlled state, drop the two refs, and read the value from form state at submit time so it survives any number of re-mounts. While here, give each input a stable useId() for id, an explicit name="token-name", and autoComplete="off" to silence the "A form field element should have an id or name attribute" Chrome DevTools issue that this form was triggering. Verified via chrome-devtools MCP: creating a token named "qa-pr1-verify" now stores and displays the name correctly across the useApiTokenCreatedSubscription refetch. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../pages/settings/settings-api-tokens.tsx | 48 ++++++++++++------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/frontend/src/pages/settings/settings-api-tokens.tsx b/frontend/src/pages/settings/settings-api-tokens.tsx index 0b046b1b..49f4b310 100644 --- a/frontend/src/pages/settings/settings-api-tokens.tsx +++ b/frontend/src/pages/settings/settings-api-tokens.tsx @@ -16,7 +16,7 @@ import { Trash, X, } from 'lucide-react'; -import { useCallback, useMemo, useRef, useState } from 'react'; +import { useCallback, useId, useMemo, useState } from 'react'; import { toast } from 'sonner'; import type { ApiTokenFragmentFragment } from '@/graphql/types'; @@ -64,6 +64,7 @@ interface CreateFormData { } interface EditFormData { + name: string; status: TokenStatusEnum; } @@ -198,15 +199,17 @@ function SettingsAPITokens() { const [editingTokenId, setEditingTokenId] = useState(null); const [creatingToken, setCreatingToken] = useState(false); - const [editFormData, setEditFormData] = useState({ status: TokenStatusEnum.Active }); + const [editFormData, setEditFormData] = useState({ name: '', status: TokenStatusEnum.Active }); const [createFormData, setCreateFormData] = useState({ expiresAt: null, name: '' }); const [tokenSecret, setTokenSecret] = useState(null); const [showTokenDialog, setShowTokenDialog] = useState(false); const [deleteErrorMessage, setDeleteErrorMessage] = useState(null); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); const [deletingToken, setDeletingToken] = useState(null); - const editingInputRef = useRef(null); - const creatingInputRef = useRef(null); + // Stable per-instance ids — keep label/for and a11y warnings clean even when + // the row re-mounts due to subscription-driven refetches. + const createNameFieldId = useId(); + const editNameFieldId = useId(); const { filter, pageIndex: currentPage, setFilter, setPage: handlePageChange } = useTableState(); @@ -231,18 +234,19 @@ function SettingsAPITokens() { const handleEdit = useCallback((token: APIToken) => { setEditingTokenId(token.tokenId); setEditFormData({ + name: token.name ?? '', status: token.status, }); }, []); const handleCancelEdit = useCallback(() => { setEditingTokenId(null); - setEditFormData({ status: TokenStatusEnum.Active }); + setEditFormData({ name: '', status: TokenStatusEnum.Active }); }, []); const handleSave = useCallback( async (tokenId: string) => { - const name = editingInputRef.current?.value.trim() || null; + const name = editFormData.name.trim() || null; try { await updateAPIToken({ @@ -257,12 +261,12 @@ function SettingsAPITokens() { }); setEditingTokenId(null); - setEditFormData({ status: TokenStatusEnum.Active }); + setEditFormData({ name: '', status: TokenStatusEnum.Active }); } catch (error) { console.error('Failed to update token:', error); } }, - [editFormData.status, updateAPIToken], + [editFormData.name, editFormData.status, updateAPIToken], ); const handleCreateNew = useCallback(() => { @@ -280,7 +284,7 @@ function SettingsAPITokens() { return; } - const name = creatingInputRef.current?.value.trim() || null; + const name = createFormData.name.trim() || null; try { const ttl = calculateTTL(createFormData.expiresAt); @@ -304,7 +308,7 @@ function SettingsAPITokens() { } catch (error) { console.error('Failed to create token:', error); } - }, [createAPIToken, createFormData.expiresAt]); + }, [createAPIToken, createFormData.expiresAt, createFormData.name]); const handleDeleteDialogOpen = useCallback((token: APIToken) => { setDeletingToken(token); @@ -358,27 +362,35 @@ function SettingsAPITokens() { if (isCreating) { return ( + setCreateFormData((prev) => ({ ...prev, name: event.target.value })) + } placeholder="Token name (optional)" - ref={creatingInputRef} + value={createFormData.name} /> ); } if (isEditing) { - const tokenName = token.name || ''; - return ( + setEditFormData((prev) => ({ ...prev, name: event.target.value })) + } placeholder="Token name (optional)" - ref={editingInputRef} + value={editFormData.name} /> ); } @@ -706,8 +718,12 @@ function SettingsAPITokens() { ], [ createFormData.expiresAt, + createFormData.name, + createNameFieldId, deletingToken, + editFormData.name, editFormData.status, + editNameFieldId, editingTokenId, handleCancelCreate, handleCancelEdit,