From cf07f3b4d557d2068b0571d69956715faf289c70 Mon Sep 17 00:00:00 2001 From: anggorodewanto Date: Thu, 4 Jun 2026 08:54:59 +0700 Subject: [PATCH] fix(admin): address PM playtest UX feedback (6 items) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product-manager playtest surfaced 6 admin UX issues. All fixes are frontend-only (no backend/proto/RPC change); backend binary unchanged. 1. Create form now scrolls to the first invalid field on submit (scrollToFirstError on create + edit Form) — errors were rendered off-screen and invisible when the Create button sat below the fold. 2. Duplicate-slug create now shows "Slug is already in-use, please use another" instead of a generic failure. Root cause: the error surface read response.data.errorMessage, a field grpc-gateway never emits (the body is {code,message,details}) — so EVERY error toast silently fell back to a generic string. Added robust api-error helpers (apiErrorMessage / apiErrorCode / isSlugConflict, matching gRPC code 6 / HTTP 409, never the message text). 3. ADT distribution radio card no longer stretches at 1440px — scoped .pth-radio-card CSS keeps the indicator fixed and lets the label flex. 4. Player sign-up URL is hidden on the detail page while a playtest is DRAFT (header link + share-link input), with a note explaining it appears once published — opening a draft URL showed "not available". 5. "Link ADT Namespace" button added beside the ADT-linking-required badge on the create form, reusing the existing StartADTLink flow. 6. Distribution tab now derives ADT "Connected" from the live linkage (GetAdtLinkages) instead of the stale playtest.adtNamespace, adds a loading guard, and warns the admin to re-link when the linkage is gone ("Download link for the players cannot be generated unless they re-link the ADT again"). Verified: admin npm test 111/111, eslint 0 errors, tsc+vite build, backend make smoke PASS. Item 3 pixel result still wants a 1440px eyeball. Co-Authored-By: Claude Opus 4.8 (1M context) --- admin/src/PlaytestDetailPage.test.tsx | 22 ++++- admin/src/PlaytestDetailPage.tsx | 57 +++++++----- admin/src/federated-element.scroll.test.tsx | 97 +++++++++++++++++++++ admin/src/federated-element.test.tsx | 62 +++++++++++++ admin/src/federated-element.tsx | 68 ++++++++++++--- admin/src/index.css | 17 ++++ admin/src/shared/api-error.test.ts | 49 +++++++++++ admin/src/shared/api-error.ts | 43 +++++++-- admin/src/tabs/DistributionTab.test.tsx | 41 +++++++++ admin/src/tabs/DistributionTab.tsx | 27 +++++- 10 files changed, 433 insertions(+), 50 deletions(-) create mode 100644 admin/src/federated-element.scroll.test.tsx create mode 100644 admin/src/shared/api-error.test.ts diff --git a/admin/src/PlaytestDetailPage.test.tsx b/admin/src/PlaytestDetailPage.test.tsx index c72991c..74b8fdb 100644 --- a/admin/src/PlaytestDetailPage.test.tsx +++ b/admin/src/PlaytestDetailPage.test.tsx @@ -476,23 +476,39 @@ describe('PlaytestDetailPage shell', () => { it('Copy share link uses the backend-supplied player_base_url, not the admin host', async () => { mockGetPublicConfig.mockReturnValue({ data: { playerBaseUrl: 'https://play.example.com/' } }) const user = userEvent.setup() - renderDetail('autumn-draft') + // The header "Playtest Link" button only renders for OPEN/CLOSED (hidden in DRAFT). + renderDetail('autumn-open') await user.click(screen.getByRole('button', { name: /Playtest Link/ })) await waitFor(async () => { const text = await navigator.clipboard.readText() - expect(text).toBe('https://play.example.com/#/playtest/autumn-draft') + expect(text).toBe('https://play.example.com/#/playtest/autumn-open') }) }) it('Copy share link shows an error when player_base_url is unset (no clipboard write)', async () => { mockGetPublicConfig.mockReturnValue({ data: { playerBaseUrl: '' } }) const user = userEvent.setup() - renderDetail('autumn-draft') + renderDetail('autumn-open') await user.click(screen.getByRole('button', { name: /Playtest Link/ })) expect(await screen.findByText(/PLAYER_BASE_URL/)).toBeInTheDocument() expect(await navigator.clipboard.readText()).toBe('') }) + it('hides the player-facing link (header button + share input) while DRAFT', () => { + renderDetail('autumn-draft') + expect(screen.queryByText('Playtest Link')).not.toBeInTheDocument() + expect(screen.queryByTestId('playtest-share-link')).not.toBeInTheDocument() + expect(screen.queryByDisplayValue(/play\.example\.com/)).not.toBeInTheDocument() + expect(screen.getByText(/becomes available once the playtest is published/i)).toBeInTheDocument() + }) + + it('shows the player-facing link (header button + share input) once OPEN', () => { + renderDetail('autumn-open') + expect(screen.getByRole('button', { name: /Playtest Link/ })).toBeInTheDocument() + const share = screen.getByTestId('playtest-share-link') + expect(within(share).getByDisplayValue('https://play.example.com/#/playtest/autumn-open')).toBeInTheDocument() + }) + it('Publish click triggers the transition mutation via confirm modal', async () => { const mutate = vi.fn() mockTransition.mockReturnValue({ mutate }) diff --git a/admin/src/PlaytestDetailPage.tsx b/admin/src/PlaytestDetailPage.tsx index 6537a75..109470c 100644 --- a/admin/src/PlaytestDetailPage.tsx +++ b/admin/src/PlaytestDetailPage.tsx @@ -173,16 +173,18 @@ export function PlaytestDetailPage() { {formatDateRange(playtest.startsAt, playtest.endsAt)} - + {!isDraft && ( + + )} @@ -235,6 +237,7 @@ const DISTRIBUTION_MODEL_LABEL: Record = { function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; playerBaseUrl: string }) { const navigate = useNavigate() + const isDraft = playtest.status === PlaytestStatus.DRAFT const distributionLabel = playtest.distributionModel ? (DISTRIBUTION_MODEL_LABEL[playtest.distributionModel] ?? playtest.distributionModel) @@ -301,21 +304,27 @@ function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; pl ))} -
- - Shareable Sign-Up Link + {isDraft ? ( + + Sign-up link becomes available once the playtest is published. - - Copy - - } - /> -
+ ) : ( +
+ + Shareable Sign-Up Link + + + Copy + + } + /> +
+ )}
) } diff --git a/admin/src/federated-element.scroll.test.tsx b/admin/src/federated-element.scroll.test.tsx new file mode 100644 index 0000000..9e0b2d9 --- /dev/null +++ b/admin/src/federated-element.scroll.test.tsx @@ -0,0 +1,97 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// PM fix #1: the Create button sits far below the required fields, so antd's +// default validation jump-to-top leaves the error off-screen. We pass +// scrollToFirstError to both create + edit Forms. Scroll behavior can't be +// observed in jsdom, so we partially mock antd to capture the props the +//
is constructed with and assert the prop is wired. This mock is +// isolated to this file so the main suite still renders the real antd Form. +const formProps: Array> = [] + +vi.mock('antd', async () => { + const actual = await vi.importActual('antd') + const RealForm = actual.Form as unknown as React.ComponentType> & Record + const FormSpy = (props: Record) => { + formProps.push(props) + return + } + // Preserve the statics antd hangs off Form (useForm, useWatch, Item, ...). + Object.assign(FormSpy, RealForm) + return { ...actual, Form: FormSpy } +}) + +vi.mock('@accelbyte/sdk-extend-app-ui', () => ({ + useAppUIContext: () => ({ sdk: {}, isCurrentUserHasPermission: () => true }), + CrudType: { READ: 'READ', CREATE: 'CREATE', UPDATE: 'UPDATE', DELETE: 'DELETE' } +})) + +const noopQuery = { data: undefined, isLoading: false, error: null, refetch: vi.fn() } +const noopMutation = { mutate: vi.fn(), isPending: false, isError: false, error: null } + +vi.mock('./playtesthubapi/generated-public/queries/PlaytesthubService.query', () => ({ + usePlaytesthubServiceApi_GetConfig: () => ({ data: { playerBaseUrl: 'https://play.example.com' } }) +})) + +vi.mock('./playtesthubapi/generated-admin/queries/PlaytesthubServiceAdmin.query', () => ({ + Key_PlaytesthubServiceAdmin: { Playtests: 'playtests', Playtest_ByPlaytestId: 'playtest-by-id', AdtLinkages: 'adt-linkages' }, + usePlaytesthubServiceAdminApi_GetPlaytests: () => ({ data: { playtests: [] }, isLoading: false, error: null, refetch: vi.fn() }), + usePlaytesthubServiceAdminApi_GetPlaytest_ByPlaytestId: () => ({ + data: { + playtest: { + id: 'pt_1', + slug: 'summer-alpha', + title: 'Summer Alpha', + platforms: ['PLATFORM_STEAM'], + distributionModel: 'DISTRIBUTION_MODEL_STEAM_KEYS', + ndaRequired: false + } + }, + isLoading: false, + error: null + }), + usePlaytesthubServiceAdminApi_CreatePlaytestMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_DeletePlaytest_ByPlaytestIdMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_PatchPlaytest_ByPlaytestIdMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_CreatePlaytest_ByPlaytestIdTransitionStatuMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_GetWorkersHealth: () => ({ data: { workers: [] }, isLoading: false, error: null }), + usePlaytesthubServiceAdminApi_GetAdtLinkages: () => ({ data: { linkages: [] }, isLoading: false, error: null }), + usePlaytesthubServiceAdminApi_GetGamesAdt_ByAdtLinkageId: () => noopQuery, + usePlaytesthubServiceAdminApi_CreateAdtLinkagesStartMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_CreateAdtLinkagesCompleteMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_CreateAdtLinkagesRecoverMutation: () => noopMutation, + usePlaytesthubServiceAdminApi_DeleteAdtLinkage_ByAdtLinkageIdMutation: () => noopMutation +})) + +import { FederatedElement } from './federated-element' + +function renderAt(path: string) { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + return render( + + + + + + ) +} + +beforeEach(() => { + formProps.length = 0 +}) + +describe('scrollToFirstError (PM fix #1)', () => { + it('passes scrollToFirstError on the create Form', () => { + renderAt('/new') + const withScroll = formProps.find(p => p.scrollToFirstError) + expect(withScroll?.scrollToFirstError).toEqual({ behavior: 'smooth', block: 'center' }) + }) + + it('passes scrollToFirstError on the edit Form', () => { + renderAt('/pt_1/edit') + const withScroll = formProps.find(p => p.scrollToFirstError) + expect(withScroll?.scrollToFirstError).toEqual({ behavior: 'smooth', block: 'center' }) + }) +}) diff --git a/admin/src/federated-element.test.tsx b/admin/src/federated-element.test.tsx index 658210f..d87451d 100644 --- a/admin/src/federated-element.test.tsx +++ b/admin/src/federated-element.test.tsx @@ -613,6 +613,68 @@ describe('PlaytestCreatePage', () => { expect(screen.getByLabelText(/playtest title/i)).toHaveValue('My Title') }) }) + + // PM fix #2: a duplicate slug returns gRPC AlreadyExists (code 6) → HTTP 409. + // The inline Alert must show the friendly slug-conflict copy, not the generic + // "Create failed" fallback. The message text is implementation-defined so we + // drive the mutation into the {code:6}/409 shape and assert on the UI copy. + describe('create error surfacing (PM fix #2)', () => { + it('shows the slug-conflict copy when the create mutation fails with code 6 / 409', () => { + mockCreateMutation.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isError: true, + error: { response: { status: 409, data: { code: 6, message: 'slug "x" already exists in namespace "y"', details: [] } } } + }) + renderAt('/new') + expect(screen.getByText('Slug is already in-use, please use another')).toBeInTheDocument() + }) + + it('shows the server message for a non-slug error (e.g. code 13 / 500)', () => { + mockCreateMutation.mockReturnValue({ + mutate: vi.fn(), + isPending: false, + isError: true, + error: { response: { status: 500, data: { code: 13, message: 'internal boom', details: [] } } } + }) + renderAt('/new') + expect(screen.getByText('internal boom')).toBeInTheDocument() + expect(screen.queryByText('Slug is already in-use, please use another')).not.toBeInTheDocument() + }) + }) + + // PM fix #5: when no ADT linkage exists, the ADT radio card carries the + // "linking required" warning. Add a Link ADT button beside it that drives the + // existing StartADTLink flow (the LinkADTModal → start mutation). + describe('ADT-not-linked Link button (PM fix #5)', () => { + it('renders a Link ADT Namespace button when linkageCount is 0', () => { + mockGetAdtLinkages.mockReturnValue({ data: { linkages: [] }, isLoading: false, error: null }) + renderAt('/new') + expect(screen.getByRole('button', { name: /link adt namespace/i })).toBeInTheDocument() + }) + + it('does not render the Link ADT button once a linkage exists', () => { + mockGetAdtLinkages.mockReturnValue({ + data: { linkages: [{ id: 'lnk-1', adtNamespace: 'adt-ns-1', studioNamespace: 'studio-A' }] }, + isLoading: false, + error: null + }) + renderAt('/new') + expect(screen.queryByRole('button', { name: /link adt namespace/i })).not.toBeInTheDocument() + }) + + it('opens the Link ADT modal and fires StartADTLink on Proceed', async () => { + const startMutate = vi.fn() + mockStartAdtLinkMutation.mockReturnValue({ mutate: startMutate, isPending: false, isError: false, error: null }) + mockGetAdtLinkages.mockReturnValue({ data: { linkages: [] }, isLoading: false, error: null }) + renderAt('/new') + const user = userEvent.setup() + await user.click(screen.getByRole('button', { name: /link adt namespace/i })) + expect(await screen.findByText(/you will be redirected to ADT to authorise the linkage/i)).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: /^proceed$/i })) + expect(startMutate).toHaveBeenCalledWith({ data: {} }) + }) + }) }) describe('ADTLinkagesPanel', () => { diff --git a/admin/src/federated-element.tsx b/admin/src/federated-element.tsx index 22eb352..fba9349 100644 --- a/admin/src/federated-element.tsx +++ b/admin/src/federated-element.tsx @@ -66,7 +66,7 @@ const PLATFORMS = [ import { DistributionModel, PlaytestStatus } from './shared/playtesthub-enums' import { usePlaytesthubServiceApi_GetConfig } from './playtesthubapi/generated-public/queries/PlaytesthubService.query' -import { toastError } from './shared/api-error' +import { apiErrorMessage, isSlugConflict, toastError } from './shared/api-error' import { ADTBuildPickerModal } from './shared/adt-build-picker' const STATUS_TAG: Record = { @@ -83,6 +83,8 @@ function StatusTag({ status, startsAt, endsAt }: { status: string | null | undef return {tag} } +const SLUG_CONFLICT_MESSAGE = 'Slug is already in-use, please use another' + const DISTRIBUTION_LABEL: Record = { [DistributionModel.STEAM_KEYS]: 'Steam Keys', [DistributionModel.AGS_CAMPAIGN]: 'AGS Campaign Codes', @@ -869,17 +871,35 @@ function RadioCardLabel({ function DistributionRadioCards({ value, onChange, - linkageCount + linkageCount, + onLinkAdt }: { value?: string onChange?: (v: string) => void linkageCount: number + onLinkAdt?: () => void }) { const adtBadge = linkageCount === 0 ? ( - - ⚠ ADT namespace linking required - + + + ⚠ ADT namespace linking required + + {onLinkAdt && ( + + )} + ) : null // aria-label pins each radio's accessible name to the bare title so tests // (and screen readers) can disambiguate without dragging the description @@ -889,19 +909,19 @@ function DistributionRadioCards({ value={value} onChange={e => onChange?.(e.target.value)} style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%' }}> - + - + - + onChange?.(e.target.value)} style={{ display: 'flex', flexDirection: 'column', gap: 8, width: '100%' }}> - + - + { message.success('Playtest created') queryClient.invalidateQueries({ queryKey: [Key_PlaytesthubServiceAdmin.Playtests] }) navigate('/') }, - onError: toastError('create') + onError: err => { + if (isSlugConflict(err)) { + message.error(SLUG_CONFLICT_MESSAGE) + return + } + message.error(apiErrorMessage(err, 'Create failed')) + } }) + const createError = createMutation.error + const createErrorMessage = + createError && isSlugConflict(createError) ? SLUG_CONFLICT_MESSAGE : apiErrorMessage(createError ?? {}, 'Create failed') + const handleSubmit = (values: FormValues) => { const isADT = values.distributionModel === DistributionModel.ADT createMutation.mutate({ @@ -1002,6 +1034,7 @@ function PlaytestCreatePage() { form={form} layout="vertical" onFinish={handleSubmit} + scrollToFirstError={{ behavior: 'smooth', block: 'center' }} initialValues={{ platforms: [], ndaRequired: false, @@ -1061,7 +1094,7 @@ function PlaytestCreatePage() { title="Distribution Model" description="Choose how the game build or keys will be delivered to approved participants."> - + setLinkAdtOpen(true)} /> {distributionModel === DistributionModel.AGS_CAMPAIGN && ( )} @@ -1148,6 +1181,7 @@ function PlaytestCreatePage() { + setLinkAdtOpen(false)} /> ) } @@ -1234,7 +1268,13 @@ function PlaytestEditPage() { Slug {playtest.slug} · distribution model {playtest.distributionModel} (immutable after creation). - form={form} layout="vertical" onFinish={handleSubmit} style={{ marginTop: 16 }} initialValues={initialValues as FormValues}> + + form={form} + layout="vertical" + onFinish={handleSubmit} + scrollToFirstError={{ behavior: 'smooth', block: 'center' }} + style={{ marginTop: 16 }} + initialValues={initialValues as FormValues}> diff --git a/admin/src/index.css b/admin/src/index.css index 387ee23..ee7d9c2 100644 --- a/admin/src/index.css +++ b/admin/src/index.css @@ -1,2 +1,19 @@ @import "tailwindcss/theme" prefix(appui); @import "tailwindcss/utilities.css" prefix(appui); + +/* Distribution / approval radio cards: the wrapping label is laid out as a + flex card (see radioCardStyle). The default antd flex sizing lets the radio + indicator and the label span grow, so on narrower viewports (e.g. 1440px) + the long ADT description wraps and the indicator visibly stretches. Pin the + indicator to its intrinsic size and let only the label flex, scoped to the + .pth-radio-card class so global radios are untouched. antd v6 markup: + label.ant-radio-wrapper > span.ant-radio (indicator) + span.ant-radio-label. */ +.pth-radio-card.ant-radio-wrapper > .ant-radio { + flex: 0 0 auto; + align-self: flex-start; +} + +.pth-radio-card.ant-radio-wrapper > .ant-radio-label { + flex: 1 1 auto; + min-width: 0; +} diff --git a/admin/src/shared/api-error.test.ts b/admin/src/shared/api-error.test.ts new file mode 100644 index 0000000..8dfeb12 --- /dev/null +++ b/admin/src/shared/api-error.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { apiErrorCode, apiErrorMessage, apiErrorStatus, isSlugConflict, type ApiError } from './api-error' + +describe('api-error helpers', () => { + describe('apiErrorMessage', () => { + it('prefers the grpc-gateway data.message field', () => { + const err: ApiError = { response: { status: 500, data: { code: 13, message: 'boom from gateway' } } } + expect(apiErrorMessage(err, 'fallback')).toBe('boom from gateway') + }) + + it('falls back to legacy errorMessage when message is absent', () => { + const err: ApiError = { response: { data: { errorMessage: 'legacy text' } } } + expect(apiErrorMessage(err, 'fallback')).toBe('legacy text') + }) + + it('returns the fallback when neither field is present', () => { + expect(apiErrorMessage({}, 'Create failed')).toBe('Create failed') + }) + }) + + describe('apiErrorCode / apiErrorStatus', () => { + it('reads the numeric gRPC code from response.data.code', () => { + expect(apiErrorCode({ response: { data: { code: 6 } } })).toBe(6) + }) + + it('reads the HTTP status from response.status', () => { + expect(apiErrorStatus({ response: { status: 409 } })).toBe(409) + }) + }) + + describe('isSlugConflict', () => { + it('is true when the gRPC code is 6 (AlreadyExists)', () => { + const err: ApiError = { response: { status: 409, data: { code: 6, message: 'slug "x" already exists in namespace "y"' } } } + expect(isSlugConflict(err)).toBe(true) + }) + + it('is true when the HTTP status is 409 even without a numeric code', () => { + expect(isSlugConflict({ response: { status: 409, data: {} } })).toBe(true) + }) + + it('is false for an unrelated error (code 13 / status 500)', () => { + expect(isSlugConflict({ response: { status: 500, data: { code: 13, message: 'internal' } } })).toBe(false) + }) + + it('is false for an empty error', () => { + expect(isSlugConflict({})).toBe(false) + }) + }) +}) diff --git a/admin/src/shared/api-error.ts b/admin/src/shared/api-error.ts index 0be7f07..3780272 100644 --- a/admin/src/shared/api-error.ts +++ b/admin/src/shared/api-error.ts @@ -1,10 +1,43 @@ import { message } from 'antd' -// ApiError matches the shape AccelByte codegen surfaces for failed -// gateway requests. The errorMessage from the gRPC status (when -// present) is preferred over the fallback verb. -export type ApiError = { response?: { data?: { errorMessage?: string } } } +// ApiError matches what the codegen axios client surfaces on a failed +// gateway request: the raw grpc-gateway body lives on `response.data` as +// `{ code, message, details }` (numeric gRPC code + human message), and the +// HTTP status on `response.status`. Older code assumed a `data.errorMessage` +// field that the gateway never emits — both names are read here so the helpers +// stay correct regardless of which surface produced the error. +export type ApiError = { + response?: { + status?: number + data?: { + code?: number + message?: string + errorMessage?: string + } + } +} + +export function apiErrorCode(err: ApiError): number | undefined { + return err?.response?.data?.code +} + +export function apiErrorStatus(err: ApiError): number | undefined { + return err?.response?.status +} + +export function apiErrorMessage(err: ApiError, fallback: string): string { + return err?.response?.data?.message ?? err?.response?.data?.errorMessage ?? fallback +} + +// isSlugConflict detects the CreatePlaytest duplicate-slug case. The backend +// returns gRPC AlreadyExists (numeric code 6), which grpc-gateway maps to HTTP +// 409. The error message text is implementation-defined, so we match the code +// or status — never the literal string. +export function isSlugConflict(err: ApiError): boolean { + if (apiErrorCode(err) === 6) return true + return apiErrorStatus(err) === 409 +} export function toastError(verb: string) { - return (err: ApiError) => message.error(err?.response?.data?.errorMessage ?? `Failed to ${verb}`) + return (err: ApiError) => message.error(apiErrorMessage(err, `Failed to ${verb}`)) } diff --git a/admin/src/tabs/DistributionTab.test.tsx b/admin/src/tabs/DistributionTab.test.tsx index 32c7035..87b312b 100644 --- a/admin/src/tabs/DistributionTab.test.tsx +++ b/admin/src/tabs/DistributionTab.test.tsx @@ -145,3 +145,44 @@ describe('ADTPanel (build health surfacing)', () => { await waitFor(() => expect(mutate).toHaveBeenCalledWith({ playtestId: 'pt-adt', data: {} })) }) }) + +describe('ADTPanel (connection state derived from live linkages)', () => { + it('shows Connected when a live linkage matches the playtest namespace', () => { + mockGetAdtLinkages.mockReturnValue({ + data: { linkages: [{ id: 'l1', adtNamespace: 'ns-x', studioNamespace: 'studio-A', linkedAt: '2026-05-19T00:00:00Z' }] }, + isLoading: false, + error: null, + refetch: vi.fn() + }) + renderTab({ ...ADT_PT, adtNamespace: 'ns-x' }) + expect(screen.getByText('● Connected')).toBeInTheDocument() + expect(screen.getByTestId('adt-build-card')).toBeInTheDocument() + }) + + it('shows Not Connected + re-link warning when the namespace is set but no live linkage exists', () => { + mockGetAdtLinkages.mockReturnValue({ + data: { linkages: [{ id: 'l-other', adtNamespace: 'some-other-ns', studioNamespace: 'studio-A' }] }, + isLoading: false, + error: null, + refetch: vi.fn() + }) + renderTab({ ...ADT_PT, adtNamespace: 'ns-x' }) + expect(screen.getByText('Not Connected')).toBeInTheDocument() + expect( + screen.getByText(/Download link for the players cannot be generated unless they re-link the ADT again\./i) + ).toBeInTheDocument() + // Game Build card / Change Build affordance must be hidden when unlinked. + expect(screen.queryByTestId('adt-build-card')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: /change build/i })).not.toBeInTheDocument() + }) + + it('does not flash Not Connected while the linkages query is still loading', () => { + mockGetAdtLinkages.mockReturnValue({ data: undefined, isLoading: true, error: null, refetch: vi.fn() }) + renderTab({ ...ADT_PT, adtNamespace: 'ns-x' }) + expect(screen.queryByText('Not Connected')).not.toBeInTheDocument() + expect(screen.queryByText('● Connected')).not.toBeInTheDocument() + expect( + screen.queryByText(/Download link for the players cannot be generated unless they re-link the ADT again\./i) + ).not.toBeInTheDocument() + }) +}) diff --git a/admin/src/tabs/DistributionTab.tsx b/admin/src/tabs/DistributionTab.tsx index 949b717..857e6ee 100644 --- a/admin/src/tabs/DistributionTab.tsx +++ b/admin/src/tabs/DistributionTab.tsx @@ -74,7 +74,13 @@ function ADTPanel({ playtest }: { playtest: V1Playtest }) { const linkagesQuery = usePlaytesthubServiceAdminApi_GetAdtLinkages(sdk, {}) const linkages = (linkagesQuery.data?.linkages ?? []) as V1AdtLinkage[] const linkage = linkages.find(l => l.adtNamespace === playtest.adtNamespace) ?? null - const linked = Boolean(playtest.adtNamespace) + // GetAdtLinkages returns LIVE linkages only (unlinked rows are excluded), so a + // matching linkage IS the connection state. Deriving from playtest.adtNamespace + // (frozen at create time) would keep showing "Connected" after an unlink. + const linked = Boolean(linkage) + // Until the query settles we don't know the real state — avoid a false + // "Not Connected" flash by rendering a loading indicator instead. + const linkagesPending = linkagesQuery.isLoading || (linkagesQuery.data === undefined && !linkagesQuery.error) const [pickerOpen, setPickerOpen] = useState(false) @@ -109,7 +115,7 @@ function ADTPanel({ playtest }: { playtest: V1Playtest }) { data-testid="adt-connection-card" title="ADT Connection" extra={ - linked ? ( + linkagesPending ? null : linked ? ( ● Connected @@ -117,7 +123,9 @@ function ADTPanel({ playtest }: { playtest: V1Playtest }) { Not Connected ) }> - {linked ? ( + {linkagesPending ? ( + + ) : linked ? (
{playtest.adtNamespace ?? '—'}} /> @@ -126,6 +134,17 @@ function ADTPanel({ playtest }: { playtest: V1Playtest }) { value={linkage?.linkedAt ? dayjs(linkage.linkedAt).format('MMM D, YYYY, h:mm A') : '—'} />
+ ) : playtest.adtNamespace ? ( + + ADT Namespace No Longer Linked + + ) : ( ADT Namespace Not Linked @@ -139,7 +158,7 @@ function ADTPanel({ playtest }: { playtest: V1Playtest }) { )} - {linked && ( + {!linkagesPending && linked && (