{props.isReorderMode && (
diff --git a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldsJsonModal.tsx b/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldsJsonModal.tsx
deleted file mode 100644
index ab739b920..000000000
--- a/src/app/admin/emulators/[emulatorId]/custom-fields/components/CustomFieldsJsonModal.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-'use client'
-
-import { Copy, Check, FileJson } from 'lucide-react'
-import { useTheme } from 'next-themes'
-import { useMemo, useState } from 'react'
-import { PrismLight as SyntaxHighlighter } from 'react-syntax-highlighter'
-import jsonLang from 'react-syntax-highlighter/dist/esm/languages/prism/json'
-import { solarizedDarkAtom, solarizedlight } from 'react-syntax-highlighter/dist/esm/styles/prism'
-import { Button, Modal } from '@/components/ui'
-import { copyToClipboard } from '@/utils/copyToClipboard'
-import { parseCustomFieldOptions } from '@/utils/custom-fields'
-import { CustomFieldType, type CustomFieldDefinition } from '@orm'
-
-// Register the JSON language once in this module
-SyntaxHighlighter.registerLanguage('json', jsonLang)
-
-interface Props {
- isOpen: boolean
- onClose: () => void
- emulator: { id: string; name: string }
- customFields: CustomFieldDefinition[]
-}
-
-function valueTypeFor(fieldType: CustomFieldType): 'string' | 'boolean' | 'number' {
- switch (fieldType) {
- case CustomFieldType.BOOLEAN:
- return 'boolean'
- case CustomFieldType.RANGE:
- return 'number'
- default:
- return 'string'
- }
-}
-
-export default function CustomFieldsJsonModal(props: Props) {
- const { resolvedTheme } = useTheme()
- const [copied, setCopied] = useState(false)
-
- const exportObject = useMemo(() => {
- const fields = (props.customFields ?? [])
- .slice()
- .sort((a, b) => a.displayOrder - b.displayOrder)
- .map((f) => ({
- name: f.name,
- label: f.label,
- type: f.type, // enum value (string) for readability
- valueType: valueTypeFor(f.type),
- required: !!f.isRequired,
- defaultValue: f.defaultValue ?? null,
- placeholder: f.placeholder ?? undefined,
- options: parseCustomFieldOptions(f),
- range:
- f.type === CustomFieldType.RANGE
- ? {
- min: f.rangeMin ?? 0,
- max: f.rangeMax ?? 100,
- unit: f.rangeUnit ?? undefined,
- decimals: f.rangeDecimals ?? 0,
- }
- : undefined,
- displayOrder: f.displayOrder ?? 0,
- }))
-
- return {
- emulator: {
- id: props.emulator.id,
- name: props.emulator.name,
- },
- fields,
- notes:
- 'valueType indicates the expected JSON type for values when building converters from customFieldValues.',
- }
- }, [props.customFields, props.emulator.id, props.emulator.name])
-
- const jsonString = useMemo(() => JSON.stringify(exportObject, null, 2), [exportObject])
-
- const handleCopy = async () => {
- try {
- await navigator.clipboard.writeText(jsonString)
- setCopied(true)
- setTimeout(() => setCopied(false), 1500)
- } catch {
- copyToClipboard(jsonString, 'custom fields JSON')
- }
- }
-
- return (
-
-
-
-
-
- Emulator: {props.emulator.name}
-
- Fields: {props.customFields?.length ?? 0}
-
-
-
-
-
-
-
-
- custom-fields.json
-
-
-
- {jsonString}
-
-
-
-
-
- )
-}
diff --git a/src/app/admin/emulators/page.test.tsx b/src/app/admin/emulators/page.test.tsx
new file mode 100644
index 000000000..6cce39113
--- /dev/null
+++ b/src/app/admin/emulators/page.test.tsx
@@ -0,0 +1,65 @@
+import { render, screen } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import { PERMISSIONS } from '@/utils/permission-system'
+import { Role } from '@orm'
+import AdminEmulatorsPage from './page'
+import type * as UI from '@/components/ui'
+
+const mocks = vi.hoisted(() => {
+ const permissions: string[] = []
+ return { permissions, statsQuery: vi.fn() }
+})
+
+vi.mock('@/lib/api', () => ({
+ api: {
+ useUtils: () => ({}),
+ users: {
+ me: {
+ useQuery: () => ({
+ data: { id: 'admin', role: Role.ADMIN, permissions: mocks.permissions },
+ }),
+ },
+ },
+ emulators: {
+ stats: { useQuery: mocks.statsQuery },
+ getForAdmin: { useQuery: () => ({ data: { emulators: [] }, isPending: false }) },
+ delete: { useMutation: () => ({ isPending: false }) },
+ },
+ },
+}))
+vi.mock('@/hooks', () => ({
+ useColumnVisibility: () => ({}),
+ useEmulatorLogos: () => ({ isHydrated: true, showEmulatorLogos: false }),
+}))
+vi.mock('@/hooks/admin', () => ({
+ useAdminTable: () => ({ search: '', page: 1, limit: 20, sortField: null, sortDirection: null }),
+}))
+vi.mock('@/components/ui', async (importOriginal) => ({
+ ...(await importOriginal()),
+ ColumnVisibilityControl: () => null,
+ DisplayToggleButton: () => null,
+ useConfirmDialog: () => vi.fn(),
+}))
+vi.mock('@/app/admin/emulators/components/EmulatorModal', () => ({ default: () => null }))
+
+describe('emulator statistics visibility', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.permissions = [PERMISSIONS.VIEW_STATISTICS]
+ mocks.statsQuery.mockReturnValue({
+ data: { total: 99, withListings: 41, withSystems: 17 },
+ isPending: false,
+ })
+ })
+
+ it('hides cached statistics when the user loses permission', () => {
+ const view = render()
+ expect(screen.getByText('Total Emulators')).toBeVisible()
+
+ mocks.permissions = []
+ view.rerender()
+
+ expect(screen.queryByText('Total Emulators')).not.toBeInTheDocument()
+ expect(mocks.statsQuery).toHaveBeenLastCalledWith(undefined, { enabled: false })
+ })
+})
diff --git a/src/app/admin/emulators/page.tsx b/src/app/admin/emulators/page.tsx
index a6b71c224..fe6a4da4f 100644
--- a/src/app/admin/emulators/page.tsx
+++ b/src/app/admin/emulators/page.tsx
@@ -60,11 +60,7 @@ function AdminEmulatorsPage() {
storageKey: storageKeys.columnVisibility.adminEmulators,
})
- // Get current user to check permissions
const userQuery = api.users.me.useQuery()
- {
- /* TODO: validate this permission*/
- }
const currentUserRole = userQuery.data?.role
const currentUserId = userQuery.data?.id
const userPermissions = userQuery.data?.permissions
@@ -76,8 +72,11 @@ function AdminEmulatorsPage() {
PERMISSIONS.MANAGE_CUSTOM_FIELDS,
)
const canDeleteEmulators = isAdminOrHigher && hasManageEmulatorsPermission
+ const canViewStatistics = hasPermission(userPermissions, PERMISSIONS.VIEW_STATISTICS)
- const emulatorsStatsQuery = api.emulators.stats.useQuery()
+ const emulatorsStatsQuery = api.emulators.stats.useQuery(undefined, {
+ enabled: canViewStatistics,
+ })
const emulatorsQuery = api.emulators.getForAdmin.useQuery({
search: table.search ?? undefined,
sortField: table.sortField ?? undefined,
@@ -160,7 +159,7 @@ function AdminEmulatorsPage() {
>
}
>
- {emulatorsStatsQuery.data && (
+ {canViewStatistics && emulatorsStatsQuery.data && (
= 2 && !selectedUser },
)
- useEffect(() => {
- if (searchQuery.data && query.length >= 2 && !selectedUser) setShowDropdown(true)
- }, [searchQuery.data, query, selectedUser])
-
useEffect(() => {
const handler = (event: MouseEvent) => {
if (!dropdownRef.current || dropdownRef.current.contains(event.target as Node)) return
@@ -70,7 +66,7 @@ export default function GrantEntitlementModal(props: Props) {
setQuery('')
setSelectedUser(null)
setShowDropdown(false)
- setSource('MANUAL')
+ setSource(EntitlementSource.MANUAL)
setReferenceId('')
setNotes('')
props.onClose()
@@ -86,12 +82,13 @@ export default function GrantEntitlementModal(props: Props) {
value={selectedUser ? selectedUser.name || selectedUser.email : query}
onChange={(e) => {
setSelectedUser(null)
- setQuery((e.target as HTMLInputElement).value)
+ setQuery(e.target.value)
+ setShowDropdown(true)
}}
placeholder="Search users by name or email…"
leftIcon={}
/>
- {showDropdown && (
+ {showDropdown && query.length >= 2 && !selectedUser && (
{searchQuery.isPending ? (
Searching…
diff --git a/src/app/admin/games/[id]/components/GameEditForm.tsx b/src/app/admin/games/[id]/components/GameEditForm.tsx
index 90a0dc2c1..e57b58db0 100644
--- a/src/app/admin/games/[id]/components/GameEditForm.tsx
+++ b/src/app/admin/games/[id]/components/GameEditForm.tsx
@@ -3,7 +3,7 @@
import { zodResolver } from '@hookform/resolvers/zod'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
-import { useForm } from 'react-hook-form'
+import { useForm, useWatch } from 'react-hook-form'
import { Button, Input, Autocomplete } from '@/components/ui'
import { AdminImageSelectorSwitcher } from '@/components/ui/image-selectors'
import { api } from '@/lib/api'
@@ -48,7 +48,7 @@ export function GameEditForm(props: Props) {
},
})
- const { register, handleSubmit, formState, setValue, watch } = useForm<
+ const { register, handleSubmit, formState, setValue, control } = useForm<
UpdateGameFormInput,
unknown,
UpdateGameInput
@@ -65,6 +65,12 @@ export function GameEditForm(props: Props) {
mode: 'onChange', // Enable validation on change to track form state
})
+ const [systemId, gameTitle, imageUrl, boxartUrl, bannerUrl] = useWatch({
+ control,
+ name: ['systemId', 'title', 'imageUrl', 'boxartUrl', 'bannerUrl'] as const,
+ })
+ const selectedSystem = systemsQuery.data?.find((system) => system.id === systemId)
+
const onSubmit = (data: UpdateGameInput) => {
setIsSubmitting(true)
updateGame.mutate({ id: props.game.id, ...data })
@@ -95,7 +101,7 @@ export function GameEditForm(props: Props) {
System
setValue('systemId', value ?? '')}
items={systemsQuery.data ?? []}
optionToValue={(system) => system.id}
@@ -113,13 +119,10 @@ export function GameEditForm(props: Props) {
system.id === watch('systemId'))?.name}
- tgdbPlatformId={
- systemsQuery.data?.find((system) => system.id === watch('systemId'))?.tgdbPlatformId ||
- undefined
- }
- selectedImageUrl={watch('imageUrl')}
+ gameTitle={gameTitle}
+ systemName={selectedSystem?.name}
+ tgdbPlatformId={selectedSystem?.tgdbPlatformId || undefined}
+ selectedImageUrl={imageUrl}
onImageSelect={(url) => setValue('imageUrl', url, { shouldValidate: true })}
onError={(error) => console.error('Image selection error:', error)}
placeholder="https://example.com/game-image.jpg"
@@ -134,13 +137,10 @@ export function GameEditForm(props: Props) {
system.id === watch('systemId'))?.name}
- tgdbPlatformId={
- systemsQuery.data?.find((system) => system.id === watch('systemId'))?.tgdbPlatformId ||
- undefined
- }
- selectedImageUrl={watch('boxartUrl')}
+ gameTitle={gameTitle}
+ systemName={selectedSystem?.name}
+ tgdbPlatformId={selectedSystem?.tgdbPlatformId || undefined}
+ selectedImageUrl={boxartUrl}
onImageSelect={(url) => setValue('boxartUrl', url, { shouldValidate: true })}
onError={(error) => console.error('Boxart selection error:', error)}
placeholder="https://example.com/boxart-image.jpg"
@@ -155,13 +155,10 @@ export function GameEditForm(props: Props) {
system.id === watch('systemId'))?.name}
- tgdbPlatformId={
- systemsQuery.data?.find((system) => system.id === watch('systemId'))?.tgdbPlatformId ||
- undefined
- }
- selectedImageUrl={watch('bannerUrl')}
+ gameTitle={gameTitle}
+ systemName={selectedSystem?.name}
+ tgdbPlatformId={selectedSystem?.tgdbPlatformId || undefined}
+ selectedImageUrl={bannerUrl}
onImageSelect={(url) => setValue('bannerUrl', url, { shouldValidate: true })}
onError={(error) => console.error('Banner selection error:', error)}
placeholder="https://example.com/banner-image.jpg"
diff --git a/src/app/admin/games/approvals/page.tsx b/src/app/admin/games/approvals/page.tsx
index 2fd13e109..98289809f 100644
--- a/src/app/admin/games/approvals/page.tsx
+++ b/src/app/admin/games/approvals/page.tsx
@@ -37,6 +37,7 @@ import { useAdminTable } from '@/hooks/admin'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
import toast from '@/lib/toast'
+import { BooleanPreferenceSchema } from '@/schemas/preferences'
import { type RouterOutput } from '@/types/trpc'
import { type Nullable } from '@/types/utils'
import getErrorMessage from '@/utils/getErrorMessage'
@@ -100,6 +101,7 @@ function GameApprovalsPage() {
const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage(
storageKeys.showSystemIcons,
true,
+ BooleanPreferenceSchema,
)
const pendingGamesQuery = api.games.getPendingGames.useQuery({
diff --git a/src/app/admin/listings/page.tsx b/src/app/admin/listings/page.tsx
index be82c45c9..7f8fabb43 100644
--- a/src/app/admin/listings/page.tsx
+++ b/src/app/admin/listings/page.tsx
@@ -37,6 +37,7 @@ import {
import { useAdminTable, useAdminFilters } from '@/hooks/admin'
import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
+import { BooleanPreferenceSchema } from '@/schemas/preferences'
import { type RouterInput, type RouterOutput } from '@/types/trpc'
import getGameImageUrl from '@/utils/images/getGameImageUrl'
import { hasPermission, PERMISSIONS } from '@/utils/permission-system'
@@ -87,6 +88,7 @@ function AdminListingsPage() {
const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage(
storageKeys.showSystemIcons,
true,
+ BooleanPreferenceSchema,
)
const emulatorLogos = useEmulatorLogos()
diff --git a/src/app/admin/pc-listing-approvals/page.tsx b/src/app/admin/pc-listing-approvals/page.tsx
index e8abc319d..176f93725 100644
--- a/src/app/admin/pc-listing-approvals/page.tsx
+++ b/src/app/admin/pc-listing-approvals/page.tsx
@@ -57,6 +57,7 @@ import analytics from '@/lib/analytics'
import { api } from '@/lib/api'
import { logger } from '@/lib/logger'
import toast from '@/lib/toast'
+import { BooleanPreferenceSchema } from '@/schemas/preferences'
import { type RouterOutput, type RouterInput } from '@/types/trpc'
import getErrorMessage from '@/utils/getErrorMessage'
import getImageUrl from '@/utils/getImageUrl'
@@ -110,6 +111,7 @@ function PcListingApprovalsPage() {
const [showSystemIcons, setShowSystemIcons, isSystemIconsHydrated] = useLocalStorage(
storageKeys.showSystemIcons,
true,
+ BooleanPreferenceSchema,
)
const emulatorLogos = useEmulatorLogos()
diff --git a/src/app/admin/performance/components/PerformanceScaleModal.tsx b/src/app/admin/performance/components/PerformanceScaleModal.tsx
index bb3db9a40..efafb3ec6 100644
--- a/src/app/admin/performance/components/PerformanceScaleModal.tsx
+++ b/src/app/admin/performance/components/PerformanceScaleModal.tsx
@@ -1,8 +1,9 @@
'use client'
-import { useState, type FormEvent, type ChangeEvent, useEffect } from 'react'
+import { useEffect, useRef, useState, type FormEvent, type ChangeEvent } from 'react'
import { Button, Input, Modal } from '@/components/ui'
import { api } from '@/lib/api'
+import toast from '@/lib/toast'
import { type RouterInput } from '@/types/trpc'
import getErrorMessage from '@/utils/getErrorMessage'
import { type PerformanceScale } from '../types'
@@ -18,38 +19,56 @@ interface Props {
function PerformanceScaleModal(props: Props) {
const createPerformanceScale = api.performanceScales.create.useMutation()
const updatePerformanceScale = api.performanceScales.update.useMutation()
- const [label, setLabel] = useState('')
- const [description, setDescription] = useState('')
- const [rank, setRank] = useState('')
- const [error, setError] = useState('')
- const [success, setSuccess] = useState('')
+ const isPending = createPerformanceScale.isPending || updatePerformanceScale.isPending
+ function closeModal() {
+ if (!isPending) props.onClose()
+ }
+ return (
+
+
+
+ )
+}
- // Update form fields when props change or modal opens
- useEffect(() => {
- if (props.isOpen) {
- setLabel(props.scale?.label || '')
- setDescription(props.scale?.description || '')
- setRank(props.scale?.rank || '')
- setError('')
- setSuccess('')
- }
- }, [props.isOpen, props.scale])
+type FormProps = Omit & {
+ createPerformanceScale: ReturnType
+ updatePerformanceScale: ReturnType
+}
- // Reset form when modal closes
+function PerformanceScaleForm(props: FormProps) {
+ const { createPerformanceScale, updatePerformanceScale } = props
+ const active = useRef(false)
useEffect(() => {
- if (!props.isOpen) {
- setLabel('')
- setDescription('')
- setRank('')
- setError('')
- setSuccess('')
+ active.current = true
+ return () => {
+ active.current = false
}
- }, [props.isOpen])
+ }, [])
+ const [label, setLabel] = useState(props.scale?.label ?? '')
+ const [description, setDescription] = useState(props.scale?.description ?? '')
+ const [rank, setRank] = useState(props.scale?.rank ?? '')
+ const [error, setError] = useState('')
const handleSubmit = async (ev: FormEvent) => {
ev.preventDefault()
+ if (createPerformanceScale.isPending || updatePerformanceScale.isPending) return
setError('')
- setSuccess('')
const rankNumber = Number(rank)
if (isNaN(rankNumber) || rankNumber < 1) {
@@ -65,113 +84,99 @@ function PerformanceScaleModal(props: Props) {
description: description.trim() || undefined,
rank: rankNumber,
} satisfies RouterInput['performanceScales']['update'])
- setSuccess('Performance scale updated!')
} else {
await createPerformanceScale.mutateAsync({
label,
description: description.trim() || undefined,
rank: rankNumber,
} satisfies RouterInput['performanceScales']['create'])
- setSuccess('Performance scale created!')
}
+ toast.success(props.editId ? 'Performance scale updated!' : 'Performance scale created!')
props.onSuccess()
+ if (active.current) props.onClose()
} catch (err) {
- setError(getErrorMessage(err, 'Failed to save performance scale.'))
+ if (active.current) setError(getErrorMessage(err, 'Failed to save performance scale.'))
}
}
return (
-
-
-
+
)
}
diff --git a/src/app/admin/performance/components/ReplacementSelectionModal.test.tsx b/src/app/admin/performance/components/ReplacementSelectionModal.test.tsx
new file mode 100644
index 000000000..39eadb136
--- /dev/null
+++ b/src/app/admin/performance/components/ReplacementSelectionModal.test.tsx
@@ -0,0 +1,134 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+import ReplacementSelectionModal from './ReplacementSelectionModal'
+import type { RouterInput } from '@/types/trpc'
+
+const mocks = vi.hoisted(() => ({ deleteScale: vi.fn() }))
+
+vi.mock('@/lib/api', () => ({
+ api: {
+ performanceScales: {
+ get: {
+ useQuery: () => ({
+ data: [
+ { id: 1, label: 'Perfect', rank: 1 },
+ { id: 2, label: 'Great', rank: 2 },
+ { id: 3, label: 'Playable', rank: 3 },
+ ],
+ }),
+ },
+ delete: {
+ useMutation: (options: { onSuccess: () => void }) => ({
+ isPending: false,
+ mutateAsync: async (input: RouterInput['performanceScales']['delete']) => {
+ await mocks.deleteScale(input)
+ options.onSuccess()
+ },
+ }),
+ },
+ },
+ },
+}))
+
+const initialProps = {
+ isOpen: true,
+ onClose: vi.fn(),
+ onSuccess: vi.fn(),
+ scaleToDelete: { id: 1, label: 'Perfect', listingsCount: 2 },
+}
+
+function selectReplacement(id: number) {
+ fireEvent.change(screen.getByLabelText('Replacement Performance Scale'), {
+ target: { value: String(id) },
+ })
+}
+
+describe('performance replacement session', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mocks.deleteScale.mockResolvedValue(undefined)
+ })
+
+ it('clears selection when canceled, including reopening the same target', () => {
+ const view = render()
+ selectReplacement(2)
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ expect(initialProps.onClose).toHaveBeenCalledOnce()
+ expect(screen.getByLabelText('Replacement Performance Scale')).toHaveValue('')
+
+ view.rerender()
+ view.rerender()
+
+ expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled()
+ expect(mocks.deleteScale).not.toHaveBeenCalled()
+ })
+
+ it.each(['Cancel', 'target change'] as const)(
+ 'clears a failed deletion error after %s',
+ async (action) => {
+ mocks.deleteScale.mockRejectedValue(new Error('Replacement failed'))
+ const view = render()
+ selectReplacement(2)
+ fireEvent.click(screen.getByRole('button', { name: 'Delete and Replace' }))
+ await screen.findByText('Replacement failed')
+
+ if (action === 'Cancel') {
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
+ view.rerender()
+ view.rerender()
+ } else {
+ view.rerender(
+ ,
+ )
+ }
+
+ expect(screen.queryByText('Replacement failed')).not.toBeInTheDocument()
+ expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled()
+ },
+ )
+
+ it('clears selection when the target changes while open', () => {
+ const view = render()
+ selectReplacement(2)
+
+ view.rerender(
+ ,
+ )
+
+ expect(screen.getByLabelText('Replacement Performance Scale')).toHaveValue('')
+ expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled()
+ })
+
+ it('clears selection when externally closed without clearing the target', () => {
+ const view = render()
+ selectReplacement(2)
+ view.rerender()
+ view.rerender()
+
+ expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled()
+ })
+
+ it('submits only a selection made for the current target and clears it after success', async () => {
+ const view = render()
+ selectReplacement(2)
+ view.rerender(
+ ,
+ )
+ selectReplacement(1)
+ fireEvent.click(screen.getByRole('button', { name: 'Delete and Replace' }))
+
+ await waitFor(() => expect(initialProps.onSuccess).toHaveBeenCalledOnce())
+ expect(mocks.deleteScale).toHaveBeenCalledExactlyOnceWith({ id: 3, replacementId: 1 })
+ expect(screen.getByLabelText('Replacement Performance Scale')).toHaveValue('')
+ expect(screen.getByRole('button', { name: 'Delete and Replace' })).toBeDisabled()
+ })
+})
diff --git a/src/app/admin/performance/components/ReplacementSelectionModal.tsx b/src/app/admin/performance/components/ReplacementSelectionModal.tsx
index d6aea305a..2435ef75f 100644
--- a/src/app/admin/performance/components/ReplacementSelectionModal.tsx
+++ b/src/app/admin/performance/components/ReplacementSelectionModal.tsx
@@ -15,12 +15,22 @@ interface Props {
}
function ReplacementSelectionModal(props: Props) {
+ const activeScaleId = props.isOpen ? (props.scaleToDelete?.id ?? null) : null
+ const [previousScaleId, setPreviousScaleId] = useState(activeScaleId)
const [selectedReplacementId, setSelectedReplacementId] = useState(null)
const [error, setError] = useState('')
+ if (previousScaleId !== activeScaleId) {
+ setPreviousScaleId(activeScaleId)
+ setSelectedReplacementId(null)
+ setError('')
+ }
+
const performanceScalesQuery = api.performanceScales.get.useQuery({})
const deletePerformanceScale = api.performanceScales.delete.useMutation({
onSuccess: () => {
+ setSelectedReplacementId(null)
+ setError('')
props.onSuccess()
},
onError: (err: unknown) => {
@@ -32,6 +42,12 @@ function ReplacementSelectionModal(props: Props) {
(scale) => scale.id !== props.scaleToDelete?.id,
)
+ const handleClose = () => {
+ setSelectedReplacementId(null)
+ setError('')
+ props.onClose()
+ }
+
const handleDelete = async () => {
if (!props.scaleToDelete || selectedReplacementId === null) return
@@ -52,7 +68,7 @@ function ReplacementSelectionModal(props: Props) {
return (
-
-
+
{
diff --git a/src/app/admin/socs/components/SocModal.tsx b/src/app/admin/socs/components/SocModal.tsx
index ed568b2b3..02bed07e9 100644
--- a/src/app/admin/socs/components/SocModal.tsx
+++ b/src/app/admin/socs/components/SocModal.tsx
@@ -1,20 +1,21 @@
'use client'
-import { useState, useEffect, type FormEvent } from 'react'
+import { useEffect, useRef, useState, type FormEvent } from 'react'
import { Button, Input, Modal, ModalCommonButton } from '@/components/ui'
import { api } from '@/lib/api'
+import toast from '@/lib/toast'
+import { CreateSoCSchema } from '@/schemas/soc'
import { type RouterInput, type RouterOutput } from '@/types/trpc'
import getErrorMessage from '@/utils/getErrorMessage'
// Common values for quick selection
const commonNames = ['Snapdragon', 'Exynos', 'Dimensity', 'Helio']
const commonManufacturers = ['Qualcomm', 'MediaTek', 'Samsung', 'Apple', 'AMD', 'NVIDIA']
-const commonArchitectures = ['ARM64', 'x86_64'] as const
+const commonArchitectures = CreateSoCSchema.shape.architecture.unwrap().options
const commonProcessNodes = ['3nm', '4nm', '5nm', '6nm', '7nm', '10nm', '12nm', '14nm']
const commonGpuModels = ['Adreno', 'Mali']
type SocData = RouterOutput['socs']['get']['socs'][number]
-type SocArchitecture = (typeof commonArchitectures)[number]
interface Props {
isOpen: boolean
@@ -27,46 +28,72 @@ interface Props {
function SocModal(props: Props) {
const createSoC = api.socs.create.useMutation()
const updateSoC = api.socs.update.useMutation()
+ const isPending = createSoC.isPending || updateSoC.isPending
+ function closeModal() {
+ if (!isPending) props.onClose()
+ }
+ return (
+
+
+
+ )
+}
- const [name, setName] = useState('')
- const [manufacturer, setManufacturer] = useState('')
- const [architecture, setArchitecture] = useState('')
- const [processNode, setProcessNode] = useState('')
- const [cpuCores, setCpuCores] = useState('')
- const [gpuModel, setGpuModel] = useState('')
- const [error, setError] = useState('')
- const [success, setSuccess] = useState('')
+type FormProps = Omit & {
+ createSoC: ReturnType
+ updateSoC: ReturnType
+}
- // Update form fields when socData changes
+function SocForm(props: FormProps) {
+ const { createSoC, updateSoC } = props
+ const active = useRef(false)
useEffect(() => {
- if (props.socData) {
- setName(props.socData.name)
- setManufacturer(props.socData.manufacturer)
- setArchitecture((props.socData.architecture as SocArchitecture) ?? '')
- setProcessNode(props.socData.processNode ?? '')
- setCpuCores(props.socData.cpuCores?.toString() ?? '')
- setGpuModel(props.socData.gpuModel ?? '')
- } else {
- setName('')
- setManufacturer('')
- setArchitecture('')
- setProcessNode('')
- setCpuCores('')
- setGpuModel('')
+ active.current = true
+ return () => {
+ active.current = false
}
- setError('')
- setSuccess('')
- }, [props.socData, props.isOpen])
+ }, [])
+
+ const [name, setName] = useState(props.socData?.name ?? '')
+ const [manufacturer, setManufacturer] = useState(props.socData?.manufacturer ?? '')
+ const [architecture, setArchitecture] = useState(props.socData?.architecture ?? '')
+ const [processNode, setProcessNode] = useState(props.socData?.processNode ?? '')
+ const [cpuCores, setCpuCores] = useState(props.socData?.cpuCores?.toString() ?? '')
+ const [gpuModel, setGpuModel] = useState(props.socData?.gpuModel ?? '')
+ const [error, setError] = useState('')
const handleSubmit = async (ev: FormEvent) => {
ev.preventDefault()
+ if (createSoC.isPending || updateSoC.isPending) return
setError('')
- setSuccess('')
+ const parsedArchitecture = CreateSoCSchema.shape.architecture.safeParse(
+ architecture || undefined,
+ )
+ if (!parsedArchitecture.success) {
+ setError(`Architecture must be one of: ${commonArchitectures.join(', ')}.`)
+ return
+ }
try {
const socData = {
name,
manufacturer,
- architecture: architecture || undefined,
+ architecture: parsedArchitecture.data,
processNode: processNode || undefined,
cpuCores: cpuCores ? parseInt(cpuCores, 10) : undefined,
gpuModel: gpuModel || undefined,
@@ -77,217 +104,194 @@ function SocModal(props: Props) {
id: props.editId,
...socData,
} satisfies RouterInput['socs']['update'])
- setSuccess('SoC updated!')
- props.onSuccess()
} else {
await createSoC.mutateAsync(socData satisfies RouterInput['socs']['create'])
- setSuccess('SoC created!')
- props.onSuccess()
}
- // Reset form
- setName('')
- setManufacturer('')
- setArchitecture('')
- setProcessNode('')
- setCpuCores('')
- setGpuModel('')
+ toast.success(props.editId ? 'SoC updated!' : 'SoC created!')
+ props.onSuccess()
+ if (active.current) props.onClose()
} catch (err) {
- setError(getErrorMessage(err, 'Failed to save SoC.'))
+ if (active.current) setError(getErrorMessage(err, 'Failed to save SoC.'))
}
}
return (
-
- |