Skip to content
Merged
1 change: 1 addition & 0 deletions src/app/admin/api-access/components/AdminCreateKeyForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ export function AdminCreateKeyForm(props: Props) {

<div className="flex items-center gap-3 text-sm">
<Toggle
aria-label="Create as system key (unlimited)"
checked={isSystemKey}
onChange={handleSystemKeyToggle}
disabled={!props.canCreateSystemKey}
Expand Down
7 changes: 1 addition & 6 deletions src/app/admin/badges/components/BadgeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,15 +189,10 @@ export default function BadgeModal({ isOpen, onClose, badge, onSuccess }: Props)

<div className="flex items-center gap-3">
<Toggle
label="Active"
checked={isActive}
onChange={(checked: boolean) => form.setValue('isActive', checked)}
/>
<label
htmlFor="isActive"
className="text-sm font-medium text-gray-700 dark:text-gray-300"
>
Active
</label>
</div>
</div>

Expand Down
10 changes: 6 additions & 4 deletions src/app/admin/releases/components/CreateReleaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -327,10 +327,12 @@ export default function CreateReleaseCard() {
</div>

<div className="flex items-center justify-between gap-3">
<label className="inline-flex items-center gap-3 text-sm text-gray-700 dark:text-gray-300">
<Toggle checked={autoPublish} onChange={setAutoPublish} size="sm" />
<span>Auto‑publish latest.json</span>
</label>
<Toggle
checked={autoPublish}
onChange={setAutoPublish}
size="sm"
label="Auto-publish latest.json"
/>
<Button
variant="outline"
onClick={() => {
Expand Down
90 changes: 90 additions & 0 deletions src/app/devices/DevicesPage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { render, screen } from '@testing-library/react'
import { type ComponentProps } from 'react'
import { beforeEach, expect, it, vi } from 'vitest'
import { type api } from '@/lib/api'
import { type RouterOutput } from '@/types/trpc'
import DevicesPage from './DevicesPage'
import type DeviceViewModal from './components/DeviceViewModal'
import type * as UI from '@/components/ui'

type Device = RouterOutput['devices']['get']['devices'][number]
const selected: Device = {
id: 'device-1',
modelName: 'Selected device',
brandId: 'brand-1',
socId: null,
brand: { id: 'brand-1', name: 'Brand', createdAt: new Date('2026-11-01') },
soc: null,
_count: { listings: 1 },
}
let devices: Device[] = []
let params = new URLSearchParams()
let byId: { data: RouterOutput['devices']['byId'] | undefined; isError: boolean }
const byIdQuery = vi.hoisted(() =>
vi.fn<(...args: Parameters<typeof api.devices.byId.useQuery>) => typeof byId>(),
)

vi.mock('next/navigation', () => ({
useRouter: () => ({ replace: vi.fn() }),
useSearchParams: () => params,
}))
vi.mock('@/hooks', () => ({ useColumnVisibility: () => ({ isColumnVisible: () => false }) }))
vi.mock('@/lib/api', () => ({
api: {
devices: {
get: { useQuery: () => ({ data: { devices }, isPending: false }) },
byId: { useQuery: byIdQuery },
},
},
}))
vi.mock('@/components/ui', async (importOriginal) => ({
...(await importOriginal<typeof UI>()),
ColumnVisibilityControl: () => null,
}))
vi.mock('./components/DeviceViewModal', () => ({
default: (props: ComponentProps<typeof DeviceViewModal>) =>
props.isOpen && props.deviceData ? <div role="dialog">{props.deviceData.modelName}</div> : null,
}))

beforeEach(() => {
devices = [selected]
params = new URLSearchParams('deviceId=device-1')
byId = { data: undefined, isError: false }
byIdQuery.mockReset().mockImplementation(() => byId)
})

it('keeps the latest resolved device open while it leaves the current results', () => {
const view = render(<DevicesPage />)
expect(screen.getByRole('dialog')).toHaveTextContent('Selected device')
expect(byIdQuery).toHaveBeenLastCalledWith({ id: 'device-1' }, { enabled: false })

devices = []
view.rerender(<DevicesPage />)
expect(byIdQuery).toHaveBeenLastCalledWith({ id: 'device-1' }, { enabled: true })
expect(screen.getByRole('dialog')).toHaveTextContent('Selected device')

byId.data = { ...selected, modelName: 'Fetched device' }
view.rerender(<DevicesPage />)
expect(screen.getByRole('dialog')).toHaveTextContent('Fetched device')

byId.data = undefined
view.rerender(<DevicesPage />)
expect(screen.getByRole('dialog')).toHaveTextContent('Fetched device')
})

it('does not show an old device after changing or removing its URL id, or a failed lookup', () => {
const view = render(<DevicesPage />)
params = new URLSearchParams('deviceId=another-device')
view.rerender(<DevicesPage />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()

params = new URLSearchParams('deviceId=device-1')
devices = []
byId.isError = true
view.rerender(<DevicesPage />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()

params = new URLSearchParams()
view.rerender(<DevicesPage />)
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
})
11 changes: 10 additions & 1 deletion src/app/devices/DevicesPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,19 @@ function DevicesPage() {
},
)

const selectedDevice =
const availableDevice =
deviceInCurrentPage ??
(singleDeviceQuery.data?.id === deviceIdFromUrl ? (singleDeviceQuery.data ?? null) : null)

const [lastSelectedDevice, setLastSelectedDevice] = useState<typeof availableDevice>(null)
if (availableDevice && availableDevice !== lastSelectedDevice)
setLastSelectedDevice(availableDevice)
const selectedDevice =
availableDevice ??
(!singleDeviceQuery.isError && lastSelectedDevice?.id === deviceIdFromUrl
? lastSelectedDevice
: null)

const openModal = (device: DeviceData) => {
// Update URL with device ID
const params = new URLSearchParams(searchParams.toString())
Expand Down
12 changes: 12 additions & 0 deletions src/app/listings/shared/hooks/useUrlFilterState.page-size.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PAGINATION } from '@/data/constants'
import toast from '@/lib/toast'
import { GetListingsSchema } from '@/schemas/listing'
import { GetPcListingsSchema } from '@/schemas/pcListing'
import { useUrlFilterState } from './useUrlFilterState'
Expand Down Expand Up @@ -72,6 +74,16 @@ describe.each([
})

describe('report page size preference', () => {
it.each(['0', '-5', '2.5', 'null', '"25"'])(
'silently defaults an invalid stored page size %s',
(stored) => {
storage.set('page-size', stored)
const { result } = renderHook(() => useUrlFilterState(config))
expect(result.current.limit).toBe(PAGINATION.PUBLIC_DEFAULT_LIMIT)
expect(toast.warning).not.toHaveBeenCalled()
},
)

it('reads a saved page size without mirroring it into local state', () => {
storage.set('page-size', '25')
const { result } = renderHook(() => useUrlFilterState(config))
Expand Down
6 changes: 4 additions & 2 deletions src/app/listings/shared/hooks/useUrlFilterState.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import { useRouter, useSearchParams } from 'next/navigation'
import { useCallback, useEffect } from 'react'
import { type PageSizeOption } from '@/data/constants'
import { PAGINATION, type PageSizeOption } from '@/data/constants'
import { useLocalStorage } from '@/hooks/useLocalStorage'
import { useUrlSearch } from '@/hooks/useUrlSearch'
import analytics from '@/lib/analytics'
import { PageSizePreferenceSchema } from '@/schemas/preferences'
import { type SortDirection } from '@/types/api'
import { parseLimit } from '../utils/pagination'

const storedPageSizeSchema = PageSizePreferenceSchema.catch(PAGINATION.PUBLIC_DEFAULT_LIMIT)

interface Config {
basePath: string
storageKey: string
Expand All @@ -28,7 +30,7 @@ export function useUrlFilterState<TSortField extends string>(config: Config) {
const [storedLimit, setStoredLimit] = useLocalStorage(
config.storageKey,
parseLimit(null, config.maxLimit),
PageSizePreferenceSchema,
storedPageSizeSchema,
)
const limit = parseLimit(urlLimit || String(storedLimit), config.maxLimit)
const page =
Expand Down
26 changes: 21 additions & 5 deletions src/app/profile/components/ProfileInformation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ describe('profile information draft', () => {
let completion: unknown
act(() => {
completion = complete(
{ success: true },
{ success: true, profile: { name: 'My saved name', bio: 'Original bio' } },
{ name: 'My saved name', bio: 'Original bio' },
undefined,
{ client: new QueryClient(), meta: undefined, mutationKey: undefined },
Expand Down Expand Up @@ -150,23 +150,26 @@ describe('profile information draft', () => {
)
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
fireEvent.change(screen.getByPlaceholderText('Enter your name'), {
target: { value: 'My saved name' },
target: { value: ' My saved name ' },
})
fireEvent.change(screen.getByPlaceholderText('Tell us about yourself'), {
target: { value: '<b>Submitted bio</b>' },
})
fireEvent.click(screen.getByRole('button', { name: 'Save Changes' }))
const complete = onSuccess
if (!complete) throw new Error('Expected a profile success callback')
await act(async () => {
await complete(
{ success: true },
{ name: 'My saved name', bio: 'Original bio' },
{ success: true, profile: { name: 'My saved name', bio: 'Persisted bio' } },
{ name: ' My saved name ', bio: '<b>Submitted bio</b>' },
undefined,
{ client: queryClient, meta: undefined, mutationKey: undefined },
)
})
await waitFor(() => expect(profileQueryError).toHaveBeenCalledOnce())
expect(fetchProfile).toHaveBeenCalledOnce()
expect(queryClient.getQueryData(['profile'])).toEqual(profile)
expect(screen.getByDisplayValue('My saved name')).toBeInTheDocument()
expect(screen.getByPlaceholderText('Enter your name')).toHaveValue(' My saved name ')
expect(
screen.queryByText('Error loading profile. Please try again later.'),
).not.toBeInTheDocument()
Expand All @@ -175,6 +178,19 @@ describe('profile information draft', () => {
)
expect(toast.success).not.toHaveBeenCalled()
expect(toast.error).not.toHaveBeenCalled()

fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(screen.getByText('My saved name', { selector: 'p' }).textContent).toBe('My saved name')
expect(screen.getByText('Persisted bio', { selector: 'p' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Edit' }))
expect(screen.getByDisplayValue('My saved name')).toBeInTheDocument()
expect(screen.getByDisplayValue('Persisted bio')).toBeInTheDocument()

act(() => {
queryClient.setQueryData(['profile'], { ...profile, name: 'Latest server name' })
})
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
expect(await screen.findByText('Latest server name', { selector: 'p' })).toBeInTheDocument()
} finally {
queryClient.clear()
errorLog.mockRestore()
Expand Down
30 changes: 20 additions & 10 deletions src/app/profile/components/ProfileInformation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,19 +24,31 @@ function ProfileInformation(props: Props) {
name: '',
bio: '',
})
const [savedProfile, setSavedProfile] = useState<{
data: UserProfileData
previousQueryData: Props['userQuery']['data']
} | null>(null)
const profileData =
savedProfile && savedProfile.previousQueryData === props.userQuery.data
? savedProfile.data
: props.userQuery.data

const utils = api.useUtils()

function handleEditToggle() {
setFormData({
name: props.userQuery.data?.name ?? '',
bio: props.userQuery.data?.bio ?? '',
name: profileData?.name ?? '',
bio: profileData?.bio ?? '',
})
setIsEditing(!isEditing)
}

const updateProfile = api.users.update.useMutation({
onSuccess: async () => {
onSuccess: async (result) => {
setSavedProfile({
data: result.profile,
previousQueryData: props.userQuery.data,
})
try {
await utils.users.getProfile.invalidate(undefined, undefined, { throwOnError: true })
toast.success('Profile updated successfully!')
Expand All @@ -57,10 +69,10 @@ function ProfileInformation(props: Props) {
}

const handleFormReset = () => {
if (props.userQuery.data) {
if (profileData) {
setFormData({
name: props.userQuery.data.name ?? '',
bio: props.userQuery.data.bio ?? '',
name: profileData.name ?? '',
bio: profileData.bio ?? '',
})
}
setIsEditing(false)
Expand Down Expand Up @@ -116,9 +128,7 @@ function ProfileInformation(props: Props) {
className="w-full"
/>
) : (
<p className="text-gray-900 dark:text-white">
{props.userQuery.data?.name || 'Not set'}
</p>
<p className="text-gray-900 dark:text-white">{profileData?.name || 'Not set'}</p>
)}
</div>

Expand All @@ -137,7 +147,7 @@ function ProfileInformation(props: Props) {
/>
) : (
<p className="text-gray-900 dark:text-white">
{props.userQuery.data?.bio || 'No bio added yet'}
{profileData?.bio || 'No bio added yet'}
</p>
)}
</div>
Expand Down
3 changes: 2 additions & 1 deletion src/app/users/[id]/UserProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,7 +241,7 @@ function UserDetailsPage() {
activityTabs.push({
id: 'votes',
label: 'Votes',
count: userQuery.data?.votes.pagination.total ?? 0,
count: userQuery.data?._count?.votes ?? 0,
Comment thread
Producdevity marked this conversation as resolved.
Resolved
})
}
if (showBookmarksTab) {
Expand Down Expand Up @@ -737,6 +737,7 @@ function UserDetailsPage() {
{activeTab === 'bookmarks' && bookmarkCounts && (
<UserBookmarksSection
userId={userId}
ownProfile={isOwnProfile}
handheldCount={bookmarkCounts.listingBookmarks}
pcCount={bookmarkCounts.pcListingBookmarks}
/>
Expand Down
1 change: 1 addition & 0 deletions src/app/users/[id]/components/UserBookmarksSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import BookmarkBrowser from '@/app/profile/components/bookmarks/BookmarkBrowser'

interface Props {
userId: string
ownProfile?: boolean
handheldCount: number
pcCount: number
}
Expand Down
3 changes: 3 additions & 0 deletions src/components/CookieConsent.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { act, cleanup, fireEvent, render, renderHook, screen } from '@testing-li
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import storageKeys from '@/data/storageKeys'
import { useLocalStorage } from '@/hooks/useLocalStorage'
import toast from '@/lib/toast'
import { BooleanPreferenceSchema } from '@/schemas/preferences'
import CookieConsent from './CookieConsent'

Expand All @@ -17,6 +18,7 @@ Object.defineProperty(window, 'localStorage', {
})

beforeEach(() => {
vi.clearAllMocks()
storage.clear()
vi.useFakeTimers()
})
Expand Down Expand Up @@ -49,6 +51,7 @@ describe('cookie banner storage updates', () => {
act(() => vi.advanceTimersByTime(2000))
fireEvent.click(screen.getByRole('button', { name: 'Necessary Only' }))
expect(screen.getByRole('alert')).toHaveTextContent('Please try again')
expect(toast.warning).not.toHaveBeenCalled()
expect(screen.getByRole('button', { name: 'Necessary Only' })).toBeInTheDocument()
writer.mockRestore()
fireEvent.click(screen.getByRole('button', { name: 'Necessary Only' }))
Expand Down
10 changes: 5 additions & 5 deletions src/components/CookieConsent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@ function CookieConsent() {
setDraftPreferences(newPreferences)
setSaveError(null)
try {
setSavedPreferences(newPreferences)
setConsentDate(new Date().toISOString())
setAnalyticsEnabled(newPreferences.analytics)
setPerformanceEnabled(newPreferences.performance)
setHasConsented(true)
setSavedPreferences(newPreferences, { notifyOnError: false })
setConsentDate(new Date().toISOString(), { notifyOnError: false })
setAnalyticsEnabled(newPreferences.analytics, { notifyOnError: false })
setPerformanceEnabled(newPreferences.performance, { notifyOnError: false })
setHasConsented(true, { notifyOnError: false })
setIsVisible(false)
} catch {
setSaveError('Your cookie preferences could not be saved. Please try again.')
Expand Down
Loading
Loading