From a8ce1a0006f2ec99d16441c44680aad93ab44689 Mon Sep 17 00:00:00 2001 From: wahyuwidharto Date: Thu, 4 Jun 2026 16:10:24 +0700 Subject: [PATCH 1/3] feat(admin): visual and UX improvements across admin UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Playtest Hub list page - Full-width white header (negative-margin technique against FederatedElement padding) - Tab split: "Playtest List" and "Global Configurations" - ADT Linkages moved to Global Configurations tab, restyled as Card - Column reorder: Title → Status → Distribution → Slug → Approval → Actions; "Updated" column removed - Client-side search by title or slug - "+ Create Playtest" button moved to Card header Create Playtest page - Full-width white header (title only) - 3-step wizard with Ant Design navigation stepper inside the form Card: Step 1 – Playtest Setup (basic info, schedule, platforms) Step 2 – Build Distribution (distribution model + ADT picker) Step 3 – Access & Privacy (approval mode, NDA) - Fixed action bar: Cancel (left), Back + Next/Create (right) - Distribution option order: Steam Keys → ADT (Direct Download) → AGS Campaign - Radio card layout switched to CSS grid to fix indicator stretching on narrow viewports - scrollToFirstError on Create and Edit forms Playtest Detail page - Full-width white header: breadcrumb, title, date range, Playtest Link button (hidden for Draft), status tag, Publish/Stop actions; tabs in header - Tab content rendered in grey area below header - Playtest Info tab: new Overview card with participant count (with optional cap), pending approvals (manual approval only), playtest period with dynamic tag (Upcoming / Running / Ended / Stopped) and timeline subtext - Sign-up link moved into Playtest Information table row; shows "Available once published" for Draft status Survey tab - Wrapped in Card; save/create button moved to card header extra slot - Question containers: #fafafa background, softened border Responses tab - Question aggregate containers: #fafafa background, softened border - "Question N" label added above each question prompt Participants tab - Wrapped in Card with enrollment count in header Audit Log tab - Wrapped in Card with Refresh button in header Co-Authored-By: Claude Sonnet 4.6 --- admin/src/PlaytestDetailPage.tsx | 210 ++++++++++---- admin/src/federated-element.tsx | 450 +++++++++++++++++------------ admin/src/tabs/AuditTab.tsx | 154 +++++----- admin/src/tabs/ParticipantsTab.tsx | 31 +- admin/src/tabs/ResponsesTab.tsx | 181 ++++++------ admin/src/tabs/SurveyTab.tsx | 209 +++++++------- 6 files changed, 722 insertions(+), 513 deletions(-) diff --git a/admin/src/PlaytestDetailPage.tsx b/admin/src/PlaytestDetailPage.tsx index 109470c..15ddaec 100644 --- a/admin/src/PlaytestDetailPage.tsx +++ b/admin/src/PlaytestDetailPage.tsx @@ -6,10 +6,10 @@ import { Breadcrumb, Button, Card, - Input, Modal, Space, Spin, + Statistic, Tabs, Tag, Typography, @@ -22,10 +22,11 @@ import type { V1Playtest } from './playtesthubapi/generated-definitions/V1Playte import { Key_PlaytesthubServiceAdmin, usePlaytesthubServiceAdminApi_CreatePlaytest_ByPlaytestIdTransitionStatuMutation, + usePlaytesthubServiceAdminApi_GetParticipants_ByPlaytestId, usePlaytesthubServiceAdminApi_GetPlaytests } from './playtesthubapi/generated-admin/queries/PlaytesthubServiceAdmin.query' import { usePlaytesthubServiceApi_GetConfig } from './playtesthubapi/generated-public/queries/PlaytesthubService.query' -import { DistributionModel, PlaytestStatus } from './shared/playtesthub-enums' +import { ApplicantStatus, DistributionModel, PlaytestStatus } from './shared/playtesthub-enums' import { toastError } from './shared/api-error' import { AuditTab } from './tabs/AuditTab' import { DiscordBotToolsTab } from './tabs/DiscordBotToolsTab' @@ -158,15 +159,20 @@ export function PlaytestDetailPage() { } return ( - + <> +
navigate('/')}>Playtest Hub }, { title: playtest.title ?? playtest.slug ?? '—' } ]} /> -
+
{playtest.title ?? '—'} @@ -207,34 +213,132 @@ export function PlaytestDetailPage() { - }, - { - key: 'distribution', - label: 'Distribution', - children: - }, - { key: 'participants', label: 'Participants', children: }, - { key: 'bot-tools', label: 'Discord Bot Tools', children: }, - { key: 'survey', label: 'Survey', children: }, - { key: 'responses', label: 'Responses', children: }, - { key: 'audit', label: 'Audit', children: } + { key: 'info', label: 'Playtest Info', children: null }, + { key: 'distribution', label: 'Distribution', children: null }, + { key: 'participants', label: 'Participants', children: null }, + { key: 'bot-tools', label: 'Discord Bot Tools', children: null }, + { key: 'survey', label: 'Survey', children: null }, + { key: 'responses', label: 'Responses', children: null }, + { key: 'audit', label: 'Audit', children: null } ]} /> - +
+ +
+ {activeTab === 'info' && } + {activeTab === 'distribution' && } + {activeTab === 'participants' && } + {activeTab === 'bot-tools' && } + {activeTab === 'survey' && } + {activeTab === 'responses' && } + {activeTab === 'audit' && } +
+ ) } const DISTRIBUTION_MODEL_LABEL: Record = { - [DistributionModel.ADT]: 'Direct Download (ADT)', + [DistributionModel.ADT]: 'ADT (Direct Download)', [DistributionModel.STEAM_KEYS]: 'Steam Keys', [DistributionModel.AGS_CAMPAIGN]: 'AGS Campaign' } +function getPeriodTag(playtest: V1Playtest): { label: string; color: string } { + const now = dayjs() + const starts = playtest.startsAt ? dayjs(playtest.startsAt) : null + const ends = playtest.endsAt ? dayjs(playtest.endsAt) : null + if (playtest.status === PlaytestStatus.CLOSED) return { label: 'Stopped', color: 'red' } + if (playtest.status === PlaytestStatus.DRAFT) return { label: 'Upcoming', color: 'default' } + if (starts && now.isBefore(starts)) return { label: 'Upcoming', color: 'blue' } + if (ends && now.isAfter(ends)) return { label: 'Ended', color: 'orange' } + return { label: 'Running', color: 'green' } +} + +function getDaysText(playtest: V1Playtest): string | null { + const now = dayjs() + const starts = playtest.startsAt ? dayjs(playtest.startsAt) : null + const ends = playtest.endsAt ? dayjs(playtest.endsAt) : null + + if (playtest.status === PlaytestStatus.CLOSED) { + if (!ends) return null + const n = now.diff(ends, 'day') + return n === 0 ? 'Ended today' : `Ended ${n} day${n === 1 ? '' : 's'} ago` + } + if (playtest.status === PlaytestStatus.DRAFT) { + if (!starts) return null + const n = starts.diff(now, 'day') + if (n <= 0) return 'Starting soon' + return `Starts in ${n} day${n === 1 ? '' : 's'}` + } + // OPEN + if (ends) { + const n = ends.diff(now, 'day') + if (n < 0) { + const ago = now.diff(ends, 'day') + return `Ended ${ago} day${ago === 1 ? '' : 's'} ago` + } + if (n === 0) return 'Ends today' + return `${n} day${n === 1 ? '' : 's'} left` + } + if (starts && now.isBefore(starts)) { + const n = starts.diff(now, 'day') + return n <= 0 ? 'Starting soon' : `Starts in ${n} day${n === 1 ? '' : 's'}` + } + return null +} + + +function OverviewCard({ playtest }: { playtest: V1Playtest }) { + const { sdk } = useAppUIContext() + const playtestId = playtest.id ?? '' + const { data } = usePlaytesthubServiceAdminApi_GetParticipants_ByPlaytestId(sdk, { playtestId }, { retry: false }) + const participants = data?.participants ?? [] + const total = participants.length + const cap = playtest.autoApproveLimit ?? null + const isManual = !playtest.autoApprove + const pending = isManual ? participants.filter(p => p.status === ApplicantStatus.PENDING).length : 0 + const periodTag = getPeriodTag(playtest) + const daysText = getDaysText(playtest) + + const subsectionStyle: React.CSSProperties = { + flex: 1, + background: '#fafafa', + border: '1px solid #f0f0f0', + borderRadius: 8, + padding: 16, + } + + return ( + +
+
+ + {isManual && ( + + )} +
+
Playtest Period
+ + {formatDateRange(playtest.startsAt, playtest.endsAt)} + {periodTag.label} + + {daysText && ( +
{daysText}
+ )} +
+
+
+
+ ) +} + function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; playerBaseUrl: string }) { const navigate = useNavigate() const isDraft = playtest.status === PlaytestStatus.DRAFT @@ -243,19 +347,6 @@ function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; pl ? (DISTRIBUTION_MODEL_LABEL[playtest.distributionModel] ?? playtest.distributionModel) : '—' - const rows: Array<[string, React.ReactNode]> = [ - ['Title', playtest.title ?? '—'], - ['Slug', {playtest.slug ?? '—'}], - ['Description', playtest.description ?? '—'], - ['Start Date', playtest.startsAt ? dayjs(playtest.startsAt).format('MMMM D, YYYY') : '—'], - ['End Date', playtest.endsAt ? dayjs(playtest.endsAt).format('MMMM D, YYYY') : '—'], - ['Platforms', (playtest.platforms ?? []).join(', ') || '—'], - ['NDA Required', playtest.ndaRequired ? 'Yes' : 'No'], - ['Distribution Model', distributionLabel], - ['Approval Method', playtest.autoApprove ? 'Auto-Approve' : 'Manual'], - ['Max Participants', playtest.autoApproveLimit ?? '—'] - ] - const shareLink = playerBaseUrl ? `${playerBaseUrl.replace(/\/$/, '')}/#/playtest/${playtest.slug ?? ''}` : '' const copyShareLink = () => { @@ -269,8 +360,33 @@ function PlaytestInfoTab({ playtest, playerBaseUrl }: { playtest: V1Playtest; pl ) } + const rows: Array<[string, React.ReactNode]> = [ + ['Title', playtest.title ?? '—'], + ['Slug', {playtest.slug ?? '—'}], + ['Description', playtest.description ?? '—'], + ['Start Date', playtest.startsAt ? dayjs(playtest.startsAt).format('MMMM D, YYYY') : '—'], + ['End Date', playtest.endsAt ? dayjs(playtest.endsAt).format('MMMM D, YYYY') : '—'], + ['Platforms', (playtest.platforms ?? []).join(', ') || '—'], + ['NDA Required', playtest.ndaRequired ? 'Yes' : 'No'], + ['Distribution Model', distributionLabel], + ['Approval Method', playtest.autoApprove ? 'Auto-Approve' : 'Manual'], + ['Max Participants', playtest.autoApproveLimit ?? '—'], + ['Sign-Up Link', isDraft + ? Available once published + : shareLink ? ( + + + {shareLink} + +
))} - - {isDraft ? ( - - Sign-up link becomes available once the playtest is published. - - ) : ( -
- - Shareable Sign-Up Link - - - Copy - - } - /> -
- )} ) } diff --git a/admin/src/federated-element.tsx b/admin/src/federated-element.tsx index fba9349..684dbcc 100644 --- a/admin/src/federated-element.tsx +++ b/admin/src/federated-element.tsx @@ -10,12 +10,14 @@ import { Form, Input, InputNumber, + Tabs, Modal, Popconfirm, Radio, Select, Space, Spin, + Steps, Switch, Table, Tag, @@ -88,7 +90,7 @@ 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', - [DistributionModel.ADT]: 'Direct Download (ADT)' + [DistributionModel.ADT]: 'ADT (Direct Download)' } function WorkerHealthBanner() { @@ -116,7 +118,7 @@ function WorkerHealthBanner() { export function FederatedElement() { return ( -
+
} /> @@ -338,6 +340,9 @@ function PlaytestsListPage() { const publicConfigQuery = usePlaytesthubServiceApi_GetConfig(sdk, {}) const playerBaseUrl = publicConfigQuery.data?.playerBaseUrl ?? '' + const [activeTab, setActiveTab] = useState<'list' | 'config'>('list') + const [search, setSearch] = useState('') + const deleteMutation = usePlaytesthubServiceAdminApi_DeletePlaytest_ByPlaytestIdMutation(sdk, { onSuccess: () => { message.success('Playtest deleted') @@ -354,6 +359,11 @@ function PlaytestsListPage() { }) const playtests = (data?.playtests ?? []) as V1Playtest[] + const filtered = playtests.filter(p => + !search || + (p.title ?? '').toLowerCase().includes(search.toLowerCase()) || + (p.slug ?? '').toLowerCase().includes(search.toLowerCase()) + ) const copyLink = (slug: string) => { if (!playerBaseUrl) { @@ -419,10 +429,10 @@ function PlaytestsListPage() { render: (value: string | null | undefined) => {value ?? '—'} }, { - title: 'Slug', - dataIndex: 'slug', - key: 'slug', - render: (value: string | null | undefined) => (value ? {value} : '—') + title: 'Status', + dataIndex: 'status', + key: 'status', + render: (_: unknown, row: V1Playtest) => }, { title: 'Distribution', @@ -430,6 +440,12 @@ function PlaytestsListPage() { key: 'distributionModel', render: (value: string | null | undefined) => DISTRIBUTION_LABEL[value ?? ''] ?? value ?? '—' }, + { + title: 'Slug', + dataIndex: 'slug', + key: 'slug', + render: (value: string | null | undefined) => (value ? {value} : '—') + }, { title: 'Approval', dataIndex: 'autoApprove', @@ -438,19 +454,7 @@ function PlaytestsListPage() { value ? Auto-Approve : Manual }, { - title: 'Status', - dataIndex: 'status', - key: 'status', - render: (_: unknown, row: V1Playtest) => - }, - { - title: 'Updated', - dataIndex: 'updatedAt', - key: 'updatedAt', - render: (value: string | null | undefined) => (value ? dayjs(value).format('M/D/YYYY, h:mm A') : '—') - }, - { - title: 'Action', + title: 'Actions', key: 'actions', render: (_: unknown, row: V1Playtest) => { const isDraft = row.status === PlaytestStatus.DRAFT @@ -486,40 +490,67 @@ function PlaytestsListPage() { return ( <> -
- + {/* Full-width white header — negative margins counteract FederatedElement's padding: 16 */} +
+ Playtest Hub - + setActiveTab(key as 'list' | 'config')} + items={[ + { key: 'list', label: 'Playtest List', children: null }, + { key: 'config', label: 'Global Configurations', children: null } + ]} + tabBarStyle={{ marginBottom: 0 }} + />
- {error && ( - refetch()}> - Retry - - } - /> - )} - - {isLoading ? ( - - ) : ( - - rowKey={row => row.id ?? row.slug ?? ''} - dataSource={playtests} - columns={columns} - pagination={{ pageSize: 20 }} - /> +
+ {activeTab === 'list' && ( + <> + {error && ( + refetch()}> + Retry + + } + /> + )} + navigate('new')}> + + Create Playtest + + } + > + setSearch(e.target.value)} + allowClear + style={{ marginBottom: 16, maxWidth: 320 }} + /> + {isLoading ? ( + + ) : ( + + rowKey={row => row.id ?? row.slug ?? ''} + dataSource={filtered} + columns={columns} + pagination={{ pageSize: 20 }} + /> + )} + + )} - - + {activeTab === 'config' && } +
) } @@ -618,14 +649,12 @@ function ADTLinkagesPanel() { } ] return ( -
-
- - ADT Linkages - - -
- + setModalOpen(true)}>Link new ADT Namespace} + > + Studio-wide linkage — one row covers every game namespace under your studio. {isLoading && } @@ -640,7 +669,7 @@ function ADTLinkagesPanel() { /> )} setModalOpen(false)} /> -
+
) } @@ -831,8 +860,9 @@ function PlatformsPills({ } const radioCardStyle = (active: boolean): React.CSSProperties => ({ - display: 'flex', - alignItems: 'flex-start', + display: 'grid', + gridTemplateColumns: 'auto 1fr', + alignItems: 'start', width: '100%', margin: 0, padding: 16, @@ -915,19 +945,19 @@ function DistributionRadioCards({ description="Upload a CSV of Steam keys. Approved players receive a key via Discord DM and redeem it manually on Steam." /> - - - + + + ) } @@ -1025,134 +1055,171 @@ function PlaytestCreatePage() { const sectionDivider =
+ const [currentStep, setCurrentStep] = useState(0) + + const handleNext = async () => { + try { + await form.validateFields() + setCurrentStep(s => s + 1) + } catch { + // validation errors shown inline — stay on current step + } + } + return ( <> - - Create New Playtest - + {/* Full-width white header — title only */} +
+ + Create New Playtest + +
+ form={form} layout="vertical" onFinish={handleSubmit} scrollToFirstError={{ behavior: 'smooth', block: 'center' }} + style={{ marginTop: 16, paddingBottom: 80 }} initialValues={{ platforms: [], ndaRequired: false, distributionModel: DistributionModel.STEAM_KEYS, autoApprove: false }}> - - - - - - - - - - - - - - - - - {sectionDivider} - - - - - - - - - - - {sectionDivider} - - - - setLinkAdtOpen(true)} /> - - {distributionModel === DistributionModel.AGS_CAMPAIGN && ( - - - - )} - {distributionModel === DistributionModel.ADT && ( -
- -
- )} -
- - {sectionDivider} + + +
+ + {/* Step 1 — Playtest Setup */} + {currentStep === 0 && ( + <> + + + + + + + + + + + + + + + + {sectionDivider} + + + + + + + + + + + )} - - - - - {autoApprove && ( - - + {/* Step 2 — Build Distribution */} + {currentStep === 1 && ( + + + setLinkAdtOpen(true)} /> - )} - + {distributionModel === DistributionModel.AGS_CAMPAIGN && ( + + + + )} + {distributionModel === DistributionModel.ADT && ( +
+ +
+ )} +
+ )} - {sectionDivider} + {/* Step 3 — Access & Privacy */} + {currentStep === 2 && ( + <> + + + + + {autoApprove && ( + + + + )} + + + {sectionDivider} + + + + Require NDA acceptance + + {ndaRequired && ( + + + + )} + + + )} - - - Require NDA acceptance - - {ndaRequired && ( - - - - )} - +
{createMutation.isError && ( @@ -1165,20 +1232,37 @@ function PlaytestCreatePage() {
- - + + {currentStep > 0 && ( + + )} + {currentStep < 2 ? ( + + ) : ( + + )} +
setLinkAdtOpen(false)} /> diff --git a/admin/src/tabs/AuditTab.tsx b/admin/src/tabs/AuditTab.tsx index f4da069..9f7bf13 100644 --- a/admin/src/tabs/AuditTab.tsx +++ b/admin/src/tabs/AuditTab.tsx @@ -1,5 +1,5 @@ import { useAppUIContext } from '@accelbyte/sdk-extend-app-ui' -import { Alert, Button, Input, Select, Space, Spin, Table, Tag, Typography } from 'antd' +import { Alert, Button, Card, Input, Select, Space, Spin, Table, Tag, Typography } from 'antd' import dayjs from 'dayjs' import { useState } from 'react' import type { V1AuditLogEntry } from '../playtesthubapi/generated-definitions/V1AuditLogEntry' @@ -207,84 +207,88 @@ export function AuditTab({ playtest }: { playtest: V1Playtest }) { return ( -
- Read-only. System-emitted rows show actor as “system”. - -
+ auditQuery.refetch()}>Refresh} + > + + Read-only. System-emitted rows show actor as "system". + - - setActorUserInput(e.target.value)} - onBlur={commitActorUserInput} - onPressEnter={commitActorUserInput} - style={{ width: 320 }} + + ({ value: a, label: a }))} - /> - - - {auditQuery.isLoading && } - {auditQuery.error && ( - auditQuery.refetch()}> - Retry - - } - /> - )} - {!auditQuery.isLoading && !auditQuery.error && ( - <> - - rowKey={row => row.id ?? ''} - dataSource={entries} - columns={columns} - pagination={false} - expandable={{ - expandedRowRender: row => , - rowExpandable: () => true - }} + {actorMode === ACTOR_USER && ( + setActorUserInput(e.target.value)} + onBlur={commitActorUserInput} + onPressEnter={commitActorUserInput} + style={{ width: 320 }} + /> + )} + setDmFailedOnly(e.target.checked)}> DM failed only - -
- - rowKey={row => row.applicantId ?? ''} - loading={participantsQuery.isLoading} - dataSource={rows} - columns={columns} - pagination={{ pageSize: 25 }} - /> +
+ + rowKey={row => row.applicantId ?? ''} + loading={participantsQuery.isLoading} + dataSource={rows} + columns={columns} + pagination={{ pageSize: 25 }} + /> + (undefined) + // Default the version filter to the current survey version once it loads. + useEffect(() => { + if (survey?.id && surveyIdFilter === undefined) { + setSurveyIdFilter(survey.id) + } + }, [survey?.id]) + const responsesQuery = usePlaytesthubServiceAdminApi_GetSurveyResponses_ByPlaytestId(sdk, { playtestId, queryParams: { surveyIdFilter, pageSize: 200 } @@ -121,92 +128,104 @@ export function ResponsesTab({ playtest }: { playtest: V1Playtest }) { ] return ( - -
- {responses.length} response(s) total · {versions.length} version(s) - setSurveyIdFilter(val ?? undefined)} + options={versions.map(v => ({ value: v, label: v === survey?.id ? `${v} (current)` : v }))} + /> + + } + > + {aggregate.length === 0 && ( + No survey questions to aggregate. + )} + + {aggregate.map((a, idx) => ( +
+ Question {idx + 1} +
{a.prompt}
+
+ {a.type === QUESTION_TYPE_TEXT && ( + {a.textCount} text answer(s) — see response list below for content + )} + {a.type === QUESTION_TYPE_RATING && ( + + {[1, 2, 3, 4, 5].map(n => ( +
+ ★ {n} + + + + {a.ratingCounts[n] ?? 0} +
+ ))} +
+ )} + {a.type === QUESTION_TYPE_MULTI_CHOICE && ( + + {Object.entries(a.optionLabels).map(([id, label]) => ( +
+ {label} + + + + {a.optionCounts[id] ?? 0} +
+ ))} +
+ )} +
+ ))} +
+ + + + {Array.from(grouped.entries()).map(([surveyVersionId, rows]) => ( +
+ + Survey version: {surveyVersionId === survey?.id ? `${surveyVersionId} (current)` : surveyVersionId} + + + rowKey={row => row.id ?? ''} + dataSource={rows} + columns={responseColumns} + pagination={{ pageSize: 50 }} + size="small" + style={{ marginTop: 8 }} + />
))} - - - Responses - {Array.from(grouped.entries()).map(([surveyVersionId, rows]) => ( -
- - Survey {surveyVersionId === survey?.id ? `${surveyVersionId} (current)` : surveyVersionId} - - - rowKey={row => row.id ?? ''} - dataSource={rows} - columns={responseColumns} - pagination={{ pageSize: 50 }} - size="small" - style={{ marginTop: 8 }} - /> -
- ))} +
)} diff --git a/admin/src/tabs/SurveyTab.tsx b/admin/src/tabs/SurveyTab.tsx index 536041e..78474ef 100644 --- a/admin/src/tabs/SurveyTab.tsx +++ b/admin/src/tabs/SurveyTab.tsx @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { Alert, Button, + Card, Checkbox, Form, Input, @@ -238,112 +239,116 @@ function SurveyTabForm({ playtestId, initialSurvey, hasSurvey, draftPreloadFaile const saving = createMutation.isPending || editMutation.isPending return ( - - - {hasSurvey ? 'Editing existing survey' : 'Configure the post-playtest survey for approved players.'} - {version != null && ` · current version v${version} (saving creates v${version + 1})`} - + + {hasSurvey ? 'Save new version' : 'Create survey'} + + } + > + + + {hasSurvey ? 'Editing existing survey.' : 'Configure the post-playtest survey for approved players.'} + {version != null && ` Current version v${version} — saving creates v${version + 1}.`} + - {draftPreloadFailed && ( - - )} + {draftPreloadFailed && ( + + )} - - {questions.map((q, i) => ( -
-
- Question {i + 1} - - - - removeQuestion(q.key)}> - + - - + removeQuestion(q.key)}> + + + +
+
+ + updateOption(q.key, oIdx, e.target.value)} + placeholder={`Option ${oIdx + 1}`} + /> + + + ))} + + + + + )} +
-
- - updateOption(q.key, oIdx, e.target.value)} - placeholder={`Option ${oIdx + 1}`} - /> - - - ))} - - - - - )} -
-
- ))} - + ))} + +
- -
- -
- + ) } From 97c5366899b4e410b9a66168a63550e1841448e8 Mon Sep 17 00:00:00 2001 From: wahyuwidharto Date: Fri, 5 Jun 2026 08:48:53 +0700 Subject: [PATCH 2/3] fix(admin): remove setState-in-effect in ResponsesTab survey version filter Replace useEffect + setSurveyIdFilter with a derived effectiveSurveyIdFilter. null = user explicitly cleared (all versions), undefined = not set (defaults to current survey id). Fixes react-hooks/set-state-in-effect lint error. Co-Authored-By: Claude Sonnet 4.6 --- admin/src/tabs/ResponsesTab.tsx | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/admin/src/tabs/ResponsesTab.tsx b/admin/src/tabs/ResponsesTab.tsx index eba155b..ba945e9 100644 --- a/admin/src/tabs/ResponsesTab.tsx +++ b/admin/src/tabs/ResponsesTab.tsx @@ -1,7 +1,7 @@ import { useAppUIContext } from '@accelbyte/sdk-extend-app-ui' import { Alert, Card, Select, Space, Table, Typography } from 'antd' import dayjs from 'dayjs' -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import type { V1Playtest } from '../playtesthubapi/generated-definitions/V1Playtest' import type { V1Survey } from '../playtesthubapi/generated-definitions/V1Survey' import type { V1SurveyAnswer } from '../playtesthubapi/generated-definitions/V1SurveyAnswer' @@ -63,18 +63,13 @@ export function ResponsesTab({ playtest }: { playtest: V1Playtest }) { ) const survey = surveyQuery.data?.survey as V1Survey | undefined - const [surveyIdFilter, setSurveyIdFilter] = useState(undefined) - - // Default the version filter to the current survey version once it loads. - useEffect(() => { - if (survey?.id && surveyIdFilter === undefined) { - setSurveyIdFilter(survey.id) - } - }, [survey?.id]) + // null = user explicitly cleared (all versions); undefined = not yet set (defaults to current) + const [surveyIdFilter, setSurveyIdFilter] = useState(undefined) + const effectiveSurveyIdFilter = surveyIdFilter === undefined ? survey?.id : (surveyIdFilter ?? undefined) const responsesQuery = usePlaytesthubServiceAdminApi_GetSurveyResponses_ByPlaytestId(sdk, { playtestId, - queryParams: { surveyIdFilter, pageSize: 200 } + queryParams: { surveyIdFilter: effectiveSurveyIdFilter, pageSize: 200 } }) const responses = useMemo(() => (responsesQuery.data?.responses ?? []) as V1SurveyResponse[], [responsesQuery.data]) @@ -144,8 +139,8 @@ export function ResponsesTab({ playtest }: { playtest: V1Playtest }) { allowClear placeholder="All versions" style={{ minWidth: 200 }} - value={surveyIdFilter} - onChange={val => setSurveyIdFilter(val ?? undefined)} + value={effectiveSurveyIdFilter} + onChange={val => setSurveyIdFilter(val ?? null)} options={versions.map(v => ({ value: v, label: v === survey?.id ? `${v} (current)` : v }))} /> From 58d6160ce1f364dd879bb0963bf8531928594ef5 Mon Sep 17 00:00:00 2001 From: wahyuwidharto Date: Fri, 5 Jun 2026 10:28:48 +0700 Subject: [PATCH 3/3] fix(admin): update create-wizard + detail-page tests for redesigned UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align the admin test suite with the visual/UX redesign in PR #7: - drive the 3-step create wizard (Setup → Build Distribution → Access) - open the Global Configurations tab for ADTLinkagesPanel - match reworded copy (ADT label, survey version, responses summary, sign-up link placeholder) and the share-link input→text change Co-Authored-By: Claude Opus 4.8 (1M context) --- admin/src/PlaytestDetailPage.test.tsx | 25 ++++--- admin/src/federated-element.test.tsx | 96 +++++++++++++++++++++------ 2 files changed, 89 insertions(+), 32 deletions(-) diff --git a/admin/src/PlaytestDetailPage.test.tsx b/admin/src/PlaytestDetailPage.test.tsx index 74b8fdb..8189421 100644 --- a/admin/src/PlaytestDetailPage.test.tsx +++ b/admin/src/PlaytestDetailPage.test.tsx @@ -494,19 +494,21 @@ describe('PlaytestDetailPage shell', () => { expect(await navigator.clipboard.readText()).toBe('') }) - it('hides the player-facing link (header button + share input) while DRAFT', () => { + it('hides the player-facing link while DRAFT (header button + Info-tab Sign-Up Link)', () => { renderDetail('autumn-draft') + // Header copy-link button is hidden for 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() + // The Info tab's Sign-Up Link row shows a placeholder, never the live link. + expect(screen.queryByText(/play\.example\.com/)).not.toBeInTheDocument() + expect(screen.getByText(/available once published/i)).toBeInTheDocument() }) - it('shows the player-facing link (header button + share input) once OPEN', () => { + it('shows the player-facing link once OPEN (header button + Info-tab Sign-Up Link)', () => { renderDetail('autumn-open') + // Header exposes a copy-to-clipboard button... 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() + // ...and the Info tab renders the resolved share link as text. + expect(screen.getByText('https://play.example.com/#/playtest/autumn-open')).toBeInTheDocument() }) it('Publish click triggers the transition mutation via confirm modal', async () => { @@ -578,7 +580,7 @@ describe('PlaytestDetailPage shell', () => { await user.click(screen.getByRole('tab', { name: 'Survey' })) await waitFor(() => expect(screen.getAllByTestId('survey-question')).toHaveLength(1)) expect(screen.getByDisplayValue('Tell us')).toBeInTheDocument() - expect(screen.getByText(/current version v2 \(saving creates v3\)/i)).toBeInTheDocument() + expect(screen.getByText(/current version v2 — saving creates v3/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /save new version/i })).toBeInTheDocument() }) @@ -665,8 +667,11 @@ describe('PlaytestDetailPage shell', () => { const user = userEvent.setup() await user.click(screen.getByRole('tab', { name: 'Responses' })) await waitFor(() => expect(screen.getAllByTestId('survey-aggregate')).toHaveLength(2)) - expect(screen.getByText(/2 response\(s\) total/)).toBeInTheDocument() - expect(screen.getByText(/sur_2 \(current\)/)).toBeInTheDocument() + // Summary header counts total responses + distinct survey versions. + expect(screen.getByText(/2 response\(s\).+2 version\(s\)/)).toBeInTheDocument() + // The current version surfaces in the version filter and as a grouped + // responses heading; the older version surfaces as its own group. + expect(screen.getAllByText(/sur_2 \(current\)/).length).toBeGreaterThan(0) expect(screen.getByText('sur_1', { exact: false })).toBeInTheDocument() expect(screen.getByTestId('option-bar-q1-o1')).toBeInTheDocument() expect(screen.getByTestId('option-bar-q1-o2')).toBeInTheDocument() diff --git a/admin/src/federated-element.test.tsx b/admin/src/federated-element.test.tsx index d87451d..77bb25d 100644 --- a/admin/src/federated-element.test.tsx +++ b/admin/src/federated-element.test.tsx @@ -224,7 +224,7 @@ describe('PlaytestsListPage', () => { expect(screen.getByText('AGS Campaign Codes')).toBeInTheDocument() }) - it('renders ADT distribution label as "Direct Download (ADT)"', () => { + it('renders ADT distribution label as "ADT (Direct Download)"', () => { mockGetPlaytests.mockReturnValue({ data: { playtests: [{ id: 'pt_1', slug: 'adt-alpha', title: 'ADT', status: 'PLAYTEST_STATUS_DRAFT', distributionModel: 'DISTRIBUTION_MODEL_ADT' }] @@ -234,7 +234,7 @@ describe('PlaytestsListPage', () => { refetch: vi.fn() }) renderAt('/') - expect(screen.getByText('Direct Download (ADT)')).toBeInTheDocument() + expect(screen.getByText('ADT (Direct Download)')).toBeInTheDocument() }) it('publishes a DRAFT row via the row menu', async () => { @@ -334,8 +334,29 @@ describe('PlaytestsListPage', () => { }) describe('PlaytestCreatePage', () => { - it('offers both distribution models with STEAM_KEYS as default', () => { + // The create form is a 3-step wizard (Setup → Build Distribution → Access & + // Privacy). "Next" runs form.validateFields(), so advancing past step 1 + // requires the required fields: title + slug + at least one platform. + // dateRange is optional. These helpers drive the wizard to the step a given + // assertion lives on. + async function fillSetupStep(user: ReturnType, { slug = 'demo-slug', title = 'Demo' } = {}) { + await user.type(screen.getByLabelText(/playtest title/i), title) + await user.type(screen.getByLabelText(/slug/i), slug) + await user.click(within(screen.getByTestId('platforms-select')).getByRole('button', { name: 'Steam' })) + } + async function gotoDistributionStep(user: ReturnType, opts?: { slug?: string; title?: string }) { + await fillSetupStep(user, opts) + await user.click(screen.getByRole('button', { name: /^next$/i })) + } + async function gotoAccessStep(user: ReturnType, opts?: { slug?: string; title?: string }) { + await gotoDistributionStep(user, opts) + await user.click(screen.getByRole('button', { name: /^next$/i })) + } + + it('offers both distribution models with STEAM_KEYS as default', async () => { renderAt('/new') + const user = userEvent.setup() + await gotoDistributionStep(user) const agsRadio = screen.getByRole('radio', { name: /AGS Campaign/i }) expect(agsRadio).toBeEnabled() const steamRadio = screen.getByRole('radio', { name: /Steam keys/i }) @@ -343,19 +364,27 @@ describe('PlaytestCreatePage', () => { expect(steamRadio).toBeChecked() }) - it('shows all the PRD-required fields on the create form', () => { + it('shows all the PRD-required fields across the create wizard', async () => { renderAt('/new') + const user = userEvent.setup() + // Step 1 — Playtest Setup. expect(screen.getByLabelText(/slug/i)).toBeInTheDocument() expect(screen.getByLabelText(/playtest title/i)).toBeInTheDocument() expect(screen.getByLabelText(/description/i)).toBeInTheDocument() expect(screen.getByLabelText(/banner image url/i)).toBeInTheDocument() expect(screen.getByLabelText(/platforms/i)).toBeInTheDocument() + // Step 2 — Build Distribution. + await gotoDistributionStep(user) expect(screen.getByText(/distribution model/i)).toBeInTheDocument() - expect(screen.getByRole('button', { name: /^create$/i })).toBeInTheDocument() + // Step 3 — Access & Privacy + the final submit affordance. + await user.click(screen.getByRole('button', { name: /^next$/i })) + expect(screen.getByRole('button', { name: /create playtest/i })).toBeInTheDocument() }) - it('approval radio defaults to Manual Approval and hides the auto-approve limit input', () => { + it('approval radio defaults to Manual Approval and hides the auto-approve limit input', async () => { renderAt('/new') + const user = userEvent.setup() + await gotoAccessStep(user) const manualRadio = screen.getByRole('radio', { name: /manual approval/i }) expect(manualRadio).toBeChecked() expect(screen.queryByLabelText(/auto-approve limit/i)).not.toBeInTheDocument() @@ -364,6 +393,7 @@ describe('PlaytestCreatePage', () => { it('reveals the auto-approve limit input when the Auto-Approve radio is picked', async () => { renderAt('/new') const user = userEvent.setup() + await gotoAccessStep(user) await user.click(screen.getByRole('radio', { name: /auto-approve/i })) expect(await screen.findByLabelText(/auto-approve limit/i)).toBeInTheDocument() }) @@ -373,21 +403,22 @@ describe('PlaytestCreatePage', () => { mockCreateMutation.mockReturnValue({ mutate, isPending: false, isError: false, error: null }) renderAt('/new') const user = userEvent.setup() + // gotoAccessStep fills the required slug + title + platform on step 1. + await gotoAccessStep(user) await user.click(screen.getByRole('radio', { name: /auto-approve/i })) const limit = await screen.findByLabelText(/auto-approve limit/i) await user.type(limit, '100001') - // Required fields so the form actually reaches the validator. - await user.type(screen.getByLabelText(/slug/i), 'demo-slug') - await user.type(screen.getByLabelText(/playtest title/i), 'Demo') - await user.click(screen.getByRole('button', { name: /^create$/i })) + await user.click(screen.getByRole('button', { name: /create playtest/i })) expect( await screen.findByText('auto_approve_limit must be between 1 and 100000 when auto_approve is true') ).toBeInTheDocument() expect(mutate).not.toHaveBeenCalled() }) - it('offers the ADT distribution radio (M5.B)', () => { + it('offers the ADT distribution radio (M5.B)', async () => { renderAt('/new') + const user = userEvent.setup() + await gotoDistributionStep(user) expect(screen.getByRole('radio', { name: /^ADT$/i })).toBeEnabled() }) @@ -399,6 +430,7 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() + await gotoDistributionStep(user) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) expect(await screen.findByLabelText(/adt linkage/i)).toBeInTheDocument() // The build picker modal (B13) is the canonical UX; the typed adt game @@ -425,6 +457,7 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() + await gotoDistributionStep(user) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) const openButton = await screen.findByRole('button', { name: /select game build/i }) @@ -460,6 +493,7 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() + await gotoDistributionStep(user) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) await user.click(screen.getByLabelText(/adt linkage/i)) await user.click(await screen.findByText(/adt-ns-1/i)) @@ -491,6 +525,7 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() + await gotoDistributionStep(user) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) await user.click(screen.getByLabelText(/adt linkage/i)) await user.click(await screen.findByText(/adt-ns-1/i)) @@ -524,6 +559,7 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() + await gotoDistributionStep(user) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) await user.click(screen.getByLabelText(/adt linkage/i)) await user.click(await screen.findByText(/adt-ns-1/i)) @@ -566,11 +602,10 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() - // Fill enough of the form to reach submit; the build picker drives the - // adtGameId + adtBuildId fields which we then assert end up on the parent - // form's submit payload. - await user.type(screen.getByLabelText(/slug/i), 'demo-slug') - await user.type(screen.getByLabelText(/playtest title/i), 'Demo') + // Fill enough of the form to reach the Build Distribution step; the build + // picker drives the adtGameId + adtBuildId fields which we then assert end + // up on the parent form's submit payload. + await gotoDistributionStep(user, { slug: 'demo-slug', title: 'Demo' }) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) await user.click(screen.getByLabelText(/adt linkage/i)) await user.click(await screen.findByText(/adt-ns-1/i)) @@ -598,8 +633,7 @@ describe('PlaytestCreatePage', () => { }) renderAt('/new') const user = userEvent.setup() - await user.type(screen.getByLabelText(/slug/i), 'demo-slug') - await user.type(screen.getByLabelText(/playtest title/i), 'My Title') + await gotoDistributionStep(user, { slug: 'demo-slug', title: 'My Title' }) await user.click(screen.getByRole('radio', { name: /^ADT$/i })) await user.click(screen.getByLabelText(/adt linkage/i)) await user.click(await screen.findByText(/adt-ns-1/i)) @@ -609,6 +643,9 @@ describe('PlaytestCreatePage', () => { await user.click(within(dialog).getByRole('button', { name: /cancel/i })) await waitFor(() => expect(screen.queryByRole('dialog', { name: /select game build/i })).not.toBeInTheDocument()) + // Step 1 fields are unmounted on the Build Distribution step; step back to + // confirm the build-picker cancel did not wipe the typed form state. + await user.click(screen.getByRole('button', { name: /^back$/i })) expect(screen.getByLabelText(/slug/i)).toHaveValue('demo-slug') expect(screen.getByLabelText(/playtest title/i)).toHaveValue('My Title') }) @@ -647,19 +684,23 @@ describe('PlaytestCreatePage', () => { // "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', () => { + it('renders a Link ADT Namespace button when linkageCount is 0', async () => { mockGetAdtLinkages.mockReturnValue({ data: { linkages: [] }, isLoading: false, error: null }) renderAt('/new') + const user = userEvent.setup() + await gotoDistributionStep(user) expect(screen.getByRole('button', { name: /link adt namespace/i })).toBeInTheDocument() }) - it('does not render the Link ADT button once a linkage exists', () => { + it('does not render the Link ADT button once a linkage exists', async () => { mockGetAdtLinkages.mockReturnValue({ data: { linkages: [{ id: 'lnk-1', adtNamespace: 'adt-ns-1', studioNamespace: 'studio-A' }] }, isLoading: false, error: null }) renderAt('/new') + const user = userEvent.setup() + await gotoDistributionStep(user) expect(screen.queryByRole('button', { name: /link adt namespace/i })).not.toBeInTheDocument() }) @@ -669,6 +710,7 @@ describe('PlaytestCreatePage', () => { mockGetAdtLinkages.mockReturnValue({ data: { linkages: [] }, isLoading: false, error: null }) renderAt('/new') const user = userEvent.setup() + await gotoDistributionStep(user) 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 })) @@ -678,19 +720,28 @@ describe('PlaytestCreatePage', () => { }) describe('ADTLinkagesPanel', () => { - it('renders empty-state copy when no linkages exist', () => { + // The panel now lives behind the "Global Configurations" tab on the list page. + async function openConfigTab(user: ReturnType) { + await user.click(screen.getByRole('tab', { name: /global configurations/i })) + } + + it('renders empty-state copy when no linkages exist', async () => { renderAt('/') + const user = userEvent.setup() + await openConfigTab(user) expect(screen.getByText(/no ADT linkages yet/i)).toBeInTheDocument() expect(screen.getByRole('button', { name: /link new ADT namespace/i })).toBeInTheDocument() }) - it('renders linkage rows + an Unlink button per row', () => { + it('renders linkage rows + an Unlink button per row', async () => { mockGetAdtLinkages.mockReturnValue({ data: { linkages: [{ id: 'lnk-1', adtNamespace: 'adt-ns-1', studioNamespace: 'studio-A', linkedAt: '2026-05-19T00:00:00Z' }] }, isLoading: false, error: null }) renderAt('/') + const user = userEvent.setup() + await openConfigTab(user) expect(screen.getByText('adt-ns-1')).toBeInTheDocument() expect(screen.getByRole('button', { name: /unlink/i })).toBeInTheDocument() }) @@ -698,6 +749,7 @@ describe('ADTLinkagesPanel', () => { it('opens the Link ADT modal on click', async () => { renderAt('/') const user = userEvent.setup() + await openConfigTab(user) await user.click(screen.getByRole('button', { name: /link new ADT namespace/i })) expect(await screen.findByText(/you will be redirected to ADT to authorise the linkage/i)).toBeInTheDocument() })