Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/client/src/api/posts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export type Post = {
author: { id: string; username: string }
tags: string[]
likeCount: number
/** This viewer's own like status. Absent for an anonymous viewer. */
liked?: boolean
coverImage?: string
/** Delivery URL derived server-side from coverImage. Absent when there is no cover. */
coverUrl?: string
Expand Down
12 changes: 12 additions & 0 deletions apps/client/src/components/patterns/AutoForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ describe('AutoForm', () => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['keep'] }))
})

it('submits a blank optional field as absent, not empty, so it never fails a value-only check like min-length', () => {
const onSubmit = vi.fn()
const withOptionalPassword = schema.extend({
password: z.string().min(8, 'Password must be at least 8 characters').optional(),
})
render(<AutoForm schema={withOptionalPassword} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
fireEvent.click(screen.getByText('Save'))
expect(screen.queryByText('Password must be at least 8 characters')).not.toBeInTheDocument()
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ password: undefined }))
})

it('seeds the form from initialValues', () => {
render(
<AutoForm
Expand Down
27 changes: 20 additions & 7 deletions apps/client/src/components/patterns/AutoForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,13 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
}) {
// Derived once, up front: `schema.shape` is indexed by an open `string` key,
// so re-reading it per render would fight `noUncheckedIndexedAccess`.
const fields: { key: string; kind: FieldKind }[] = Object.entries(schema.shape).map(
([key, fieldSchema]) => ({ key, kind: fieldKind(key, fieldSchema) }),
)
const fields: { key: string; kind: FieldKind; optional: boolean }[] = Object.entries(
schema.shape,
).map(([key, fieldSchema]) => ({
key,
kind: fieldKind(key, fieldSchema),
optional: fieldSchema.isOptional(),
}))

const [values, setValues] = useState<Record<string, string | boolean | string[] | null>>(() => {
const initial = (initialValues ?? {}) as Record<string, unknown>
Expand All @@ -112,14 +116,23 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
const [errors, setErrors] = useState<Record<string, string>>({})

// `tags` is already held as an array by TagsInput, so nothing is parsed out of
// a raw string here — the state shape per field kind is the parsed shape.
function parsedValue(key: string): unknown {
return values[key]
// a raw string here — the state shape per field kind is the parsed shape. An
// untouched optional text field stays '' in local state; sent as-is it fails
// schema checks meant only for a value someone actually typed (e.g. password's
// min-length), so a blank optional field reports absent, not empty.
function parsedValue(key: string, kind: FieldKind, optional: boolean): unknown {
const value = values[key]
if (optional && value === '' && (kind === 'text' || kind === 'textarea' || kind === 'password')) {
return undefined
}
return value
}

function handleSubmit(e: FormEvent) {
e.preventDefault()
const candidate = Object.fromEntries(fields.map(({ key }) => [key, parsedValue(key)]))
const candidate = Object.fromEntries(
fields.map(({ key, kind, optional }) => [key, parsedValue(key, kind, optional)]),
)
const result = schema.safeParse(candidate)
if (!result.success) {
setErrors(
Expand Down
62 changes: 62 additions & 0 deletions apps/client/src/components/patterns/LikeButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// The root vitest.config.ts declares no setupFiles, so apps/client/src/test/setup.ts
// never runs — every client test file wires jest-dom and cleanup itself, as
// apps/client/src/components/ui/button.test.tsx does.
import '@testing-library/jest-dom/vitest'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LikeButton } from './LikeButton.js'
import type { ReactElement } from 'react'

function renderWithClient(ui: ReactElement) {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
return render(<QueryClientProvider client={client}>{ui}</QueryClientProvider>)
}

describe('LikeButton', () => {
afterEach(() => {
cleanup()
vi.unstubAllGlobals()
})

it('shows an outline heart and calls the like (PUT) mutation when not liked', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
renderWithClient(<LikeButton slug="s" likeCount={2} liked={false} />)

const button = screen.getByRole('button')
expect(button.querySelector('svg')).not.toHaveClass('fill-current')

fireEvent.click(button)
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/posts/s/likes'),
expect.objectContaining({ method: 'PUT' }),
),
)
})

it('shows a filled heart and calls the unlike (DELETE) mutation when liked', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
renderWithClient(<LikeButton slug="s" likeCount={3} liked={true} />)

const button = screen.getByRole('button')
expect(button.querySelector('svg')).toHaveClass('fill-current')

fireEvent.click(button)
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining('/api/v1/posts/s/likes'),
expect.objectContaining({ method: 'DELETE' }),
),
)
})

it('defaults to unliked when the `liked` prop is omitted (anonymous-viewer DTO)', () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {})))
renderWithClient(<LikeButton slug="s" likeCount={0} />)

expect(screen.getByRole('button').querySelector('svg')).not.toHaveClass('fill-current')
})
})
35 changes: 27 additions & 8 deletions apps/client/src/components/patterns/LikeButton.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,37 @@
import { Heart } from 'lucide-react'
import { useLikePost } from '../../hooks/use-likes.js'
import { useLikePost, useUnlikePost } from '../../hooks/use-likes.js'
import { Button } from '../ui/button.js'

/**
* No `liked` prop: `PostDto` carries no per-viewer "did I like this" flag, so
* the button shows the raw count and lets the mutation run either way. Like is
* idempotent server-side (unique index on `(user, post)`), so a second click is
* a no-op 200, not a double count.
* `liked` picks which mutation a click fires and fills the heart when true.
* Both directions are idempotent server-side, so a double click race is a
* no-op, not a double count or a stuck toggle.
*/
export function LikeButton({ slug, likeCount }: { slug: string; likeCount: number }) {
export function LikeButton({
slug,
likeCount,
liked = false,
}: {
slug: string
likeCount: number
liked?: boolean
}) {
const like = useLikePost(slug)
const unlike = useUnlikePost(slug)
const pending = like.isPending || unlike.isPending

return (
<Button variant="outline" size="sm" onClick={() => like.mutate()} disabled={like.isPending}>
<Heart className="mr-1 size-4" /> {likeCount}
<Button
variant="outline"
size="sm"
onClick={() => (liked ? unlike.mutate() : like.mutate())}
disabled={pending}
aria-pressed={liked}
>
<Heart
className={liked ? 'mr-1 size-4 fill-current text-[var(--primary)]' : 'mr-1 size-4'}
/>{' '}
{likeCount}
</Button>
)
}
100 changes: 99 additions & 1 deletion apps/client/src/hooks/use-likes.test.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useLikePost } from './use-likes.js'
import { useLikePost, useUnlikePost } from './use-likes.js'
import { queryKeys } from '../lib/query-client.js'
import type { Post } from '../api/posts.js'

Expand Down Expand Up @@ -46,6 +46,16 @@ describe('useLikePost', () => {
)
})

it('flips liked to true immediately, before the request resolves', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
const { client, wrapper } = makeWrapper()
const { result } = renderHook(() => useLikePost('s'), { wrapper })
result.current.mutate()
await waitFor(() =>
expect(client.getQueryData<Post>(queryKeys.posts.detail('s'))?.liked).toBe(true),
)
})

it('rolls back likeCount if the request fails', async () => {
vi.stubGlobal(
'fetch',
Expand Down Expand Up @@ -93,3 +103,91 @@ describe('useLikePost', () => {
expect(list?.[0]?.likeCount).toBe(2)
})
})

const likedPost: Post = { ...post, liked: true }

function makeLikedWrapper() {
const client = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
})
client.setQueryData(queryKeys.posts.detail('s'), likedPost)
client.setQueryData(queryKeys.posts.list({}), [likedPost])
const wrapper = ({ children }: { children: React.ReactNode }) => (
<QueryClientProvider client={client}>{children}</QueryClientProvider>
)
return { client, wrapper }
}

// Mirrors the useLikePost suite above: same optimistic/rollback shape, just
// starting from a liked post and moving the other way.
describe('useUnlikePost', () => {
afterEach(() => vi.unstubAllGlobals())

it('decrements likeCount immediately, before the request resolves', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
const { client, wrapper } = makeLikedWrapper()
const { result } = renderHook(() => useUnlikePost('s'), { wrapper })
result.current.mutate()
await waitFor(() =>
expect(client.getQueryData<Post>(queryKeys.posts.detail('s'))?.likeCount).toBe(1),
)
})

it('flips liked to false immediately, before the request resolves', async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
const { client, wrapper } = makeLikedWrapper()
const { result } = renderHook(() => useUnlikePost('s'), { wrapper })
result.current.mutate()
await waitFor(() =>
expect(client.getQueryData<Post>(queryKeys.posts.detail('s'))?.liked).toBe(false),
)
})

it('rolls back likeCount and liked if the request fails', async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'nope' } }), { status: 500 }),
),
)
const { client, wrapper } = makeLikedWrapper()
const { result } = renderHook(() => useUnlikePost('s'), { wrapper })
result.current.mutate()
await waitFor(() => expect(result.current.isError).toBe(true))
const detail = client.getQueryData<Post>(queryKeys.posts.detail('s'))
expect(detail?.likeCount).toBe(2)
expect(detail?.liked).toBe(true)
})

it("also decrements the feed's cached copy of this post", async () => {
vi.stubGlobal('fetch', vi.fn(() => new Promise(() => {}))) // never resolves
const { client, wrapper } = makeLikedWrapper()
const { result } = renderHook(() => useUnlikePost('s'), { wrapper })
result.current.mutate()
await waitFor(() => {
const list = client.getQueryData<Post[]>(queryKeys.posts.list({}))
expect(list?.[0]?.likeCount).toBe(1)
expect(list?.[0]?.liked).toBe(false)
})
})

it("rolls back the feed's cached copy too, alongside the detail cache", async () => {
vi.stubGlobal(
'fetch',
vi
.fn()
.mockResolvedValue(
new Response(JSON.stringify({ error: { message: 'nope' } }), { status: 500 }),
),
)
const { client, wrapper } = makeLikedWrapper()
const { result } = renderHook(() => useUnlikePost('s'), { wrapper })
result.current.mutate()
await waitFor(() => expect(result.current.isError).toBe(true))
const list = client.getQueryData<Post[]>(queryKeys.posts.list({}))
expect(list?.[0]?.likeCount).toBe(2)
expect(list?.[0]?.liked).toBe(true)
})
})
45 changes: 44 additions & 1 deletion apps/client/src/hooks/use-likes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function useLikePost(slug: string) {
queryClient.setQueryData<Post>(queryKeys.posts.detail(slug), {
...previousDetail,
likeCount: previousDetail.likeCount + 1,
liked: true,
})
}

Expand All @@ -36,7 +37,9 @@ export function useLikePost(slug: string) {
// rollback snapshot — this must be captured separately, first.
const previousLists = queryClient.getQueriesData<Post[]>({ queryKey: queryKeys.posts.lists })
queryClient.setQueriesData<Post[]>({ queryKey: queryKeys.posts.lists }, (old) =>
old?.map((post) => (post.slug === slug ? { ...post, likeCount: post.likeCount + 1 } : post)),
old?.map((post) =>
post.slug === slug ? { ...post, likeCount: post.likeCount + 1, liked: true } : post,
),
)

return { previousDetail, previousLists }
Expand All @@ -52,3 +55,43 @@ export function useLikePost(slug: string) {
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.posts.all }),
})
}

/**
* Mirrors useLikePost: same optimistic-update/rollback shape, but decrements
* likeCount and flips `liked` to false instead of true.
*/
export function useUnlikePost(slug: string) {
const queryClient = useQueryClient()

return useMutation({
mutationFn: () => postsApi.unlike(slug),
onMutate: async () => {
await queryClient.cancelQueries({ queryKey: queryKeys.posts.all })

const previousDetail = queryClient.getQueryData<Post>(queryKeys.posts.detail(slug))
if (previousDetail) {
queryClient.setQueryData<Post>(queryKeys.posts.detail(slug), {
...previousDetail,
likeCount: previousDetail.likeCount - 1,
liked: false,
})
}

const previousLists = queryClient.getQueriesData<Post[]>({ queryKey: queryKeys.posts.lists })
queryClient.setQueriesData<Post[]>({ queryKey: queryKeys.posts.lists }, (old) =>
old?.map((post) =>
post.slug === slug ? { ...post, likeCount: post.likeCount - 1, liked: false } : post,
),
)

return { previousDetail, previousLists }
},
onError: (_err, _vars, context) => {
if (context?.previousDetail) {
queryClient.setQueryData(queryKeys.posts.detail(slug), context.previousDetail)
}
context?.previousLists?.forEach(([key, data]) => queryClient.setQueryData(key, data))
},
onSettled: () => queryClient.invalidateQueries({ queryKey: queryKeys.posts.all }),
})
}
2 changes: 1 addition & 1 deletion apps/client/src/pages/PostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export function PostPage() {
)}

<div className="flex items-center gap-4 border-t border-[var(--border)] pt-5 text-sm text-[var(--muted-foreground)]">
<LikeButton slug={post.slug} likeCount={post.likeCount} />
<LikeButton slug={post.slug} likeCount={post.likeCount} liked={post.liked} />
{isOwner && (
<>
<Link to={`/blog/${post.slug}/edit`} className="underline">
Expand Down
Loading
Loading