+ )}
)
}
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
+//
+ setLinkAdtOpen(false)} />
>
)
}
@@ -1234,7 +1268,13 @@ function PlaytestEditPage() {
Slug {playtest.slug} · distribution model {playtest.distributionModel} (immutable after creation).
-
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 ? (