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
21 changes: 18 additions & 3 deletions apps/client/src/api/users.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,29 @@
import { request } from './client.js'
import type { z } from 'zod'
import { UpdateUserSchema } from '@blog/zod-shared'
import { DeleteUserSchema, UpdateUserSchema } from '@blog/zod-shared'

export type UserProfile = { id: string; username: string; bio?: string; avatar?: string }
/**
* `email`/`hasPassword`/`oauthProvider` are only ever present when the
* requester is viewing their own account — see userService.getPublicProfile's
* viewerId gate. Anyone else's profile arrives without them.
*/
export type UserProfile = {
id: string
username: string
bio?: string
image?: string
createdAt: string
email?: string
hasPassword?: boolean
oauthProvider?: 'google' | 'facebook' | null
}

export const usersApi = {
get: (id: string) => request<UserProfile>(`/api/v1/users/${id}`),

update: (id: string, input: z.infer<typeof UpdateUserSchema>) =>
request<UserProfile>(`/api/v1/users/${id}`, { method: 'PATCH', body: JSON.stringify(input) }),

remove: (id: string) => request<void>(`/api/v1/users/${id}`, { method: 'DELETE' }),
remove: (id: string, confirmation: z.infer<typeof DeleteUserSchema>) =>
request<void>(`/api/v1/users/${id}`, { method: 'DELETE', body: JSON.stringify(confirmation) }),
}
4 changes: 3 additions & 1 deletion apps/client/src/components/layouts/PageShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,9 @@ export function PageShell({ children }: { children: React.ReactNode }) {
New post
</NavLink>
<span aria-hidden="true" className="h-4 w-px bg-[var(--border)]" />
<span className="text-[var(--ink-faint)]">{me.username}</span>
<Link to="/account" className="text-[var(--ink-faint)] hover:text-[var(--foreground)]">
{me.username}
</Link>
{/* There is no /logout route — logging out is a POST, not a
page. Navigating there rendered a dead end. */}
<button
Expand Down
28 changes: 14 additions & 14 deletions apps/client/src/components/patterns/AutoForm.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const schema = z.object({
})

function addTag(tag: string) {
const input = screen.getByLabelText('tags')
const input = screen.getByLabelText('Tags')
fireEvent.change(input, { target: { value: tag } })
fireEvent.keyDown(input, { key: 'Enter' })
}
Expand All @@ -24,15 +24,15 @@ describe('AutoForm', () => {

it('renders one labeled field per schema key', () => {
render(<AutoForm schema={schema} onSubmit={vi.fn()} />)
expect(screen.getByLabelText('title')).toBeInTheDocument()
expect(screen.getByLabelText('draft')).toBeInTheDocument()
expect(screen.getByLabelText('tags')).toBeInTheDocument()
expect(screen.getByLabelText('Title')).toBeInTheDocument()
expect(screen.getByLabelText('Draft')).toBeInTheDocument()
expect(screen.getByLabelText('Tags')).toBeInTheDocument()
})

it('derives the control from the schema type, not the field name', () => {
render(<AutoForm schema={schema} onSubmit={vi.fn()} />)
expect(screen.getByLabelText('draft')).toHaveAttribute('type', 'checkbox')
expect(screen.getByLabelText('tags')).toHaveAttribute('type', 'text')
expect(screen.getByLabelText('Draft')).toHaveAttribute('type', 'checkbox')
expect(screen.getByLabelText('Tags')).toHaveAttribute('type', 'text')
})

// `.partial()` wraps every field in ZodOptional *outside* the ZodDefault that
Expand All @@ -41,19 +41,19 @@ describe('AutoForm', () => {
it('still derives field kinds through a .partial() schema', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema.partial()} onSubmit={onSubmit} />)
expect(screen.getByLabelText('draft')).toHaveAttribute('type', 'checkbox')
expect(screen.getByLabelText('Draft')).toHaveAttribute('type', 'checkbox')

addTag('express')
addTag('testing')
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
fireEvent.click(screen.getByText('Save'))
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ tags: ['express', 'testing'] }))
})

it('shows the schema error message and does not call onSubmit for invalid input', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'ab' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'ab' } })
fireEvent.click(screen.getByText('Save'))
expect(screen.getByText('Title must be at least 3 characters')).toBeInTheDocument()
expect(onSubmit).not.toHaveBeenCalled()
Expand All @@ -62,7 +62,7 @@ describe('AutoForm', () => {
it('submits the tags added one at a time as a string array', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
addTag('express')
addTag('testing')
expect(screen.getByText('express')).toBeInTheDocument()
Expand All @@ -75,7 +75,7 @@ describe('AutoForm', () => {
it('drops a tag removed with its × button', () => {
const onSubmit = vi.fn()
render(<AutoForm schema={schema} onSubmit={onSubmit} />)
fireEvent.change(screen.getByLabelText('title'), { target: { value: 'A valid title' } })
fireEvent.change(screen.getByLabelText('Title'), { target: { value: 'A valid title' } })
addTag('keep')
addTag('drop')
fireEvent.click(screen.getByLabelText('Remove tag drop'))
Expand All @@ -92,11 +92,11 @@ describe('AutoForm', () => {
submitLabel="Save changes"
/>,
)
expect(screen.getByLabelText('title')).toHaveValue('Seeded')
expect(screen.getByLabelText('draft')).toBeChecked()
expect(screen.getByLabelText('Title')).toHaveValue('Seeded')
expect(screen.getByLabelText('Draft')).toBeChecked()
expect(screen.getByText('a')).toBeInTheDocument()
expect(screen.getByText('b')).toBeInTheDocument()
expect(screen.getByLabelText('tags')).toHaveValue('')
expect(screen.getByLabelText('Tags')).toHaveValue('')
expect(screen.getByText('Save changes')).toBeInTheDocument()
})
})
18 changes: 16 additions & 2 deletions apps/client/src/components/patterns/AutoForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,18 @@ import { ZodFirstPartyTypeKind, type z, type ZodObject, type ZodRawShape, type Z
import { Button } from '../ui/button.js'
import { Input } from '../ui/input.js'
import { Label } from '../ui/label.js'
import { PasswordInput } from '../ui/password-input.js'
import { Textarea } from '../ui/textarea.js'
import { ImageUpload } from './ImageUpload.js'
import { TagsInput } from './TagsInput.js'

type FieldKind = 'checkbox' | 'textarea' | 'tags' | 'image' | 'text'
type FieldKind = 'checkbox' | 'textarea' | 'tags' | 'image' | 'password' | 'text'

/** "currentPassword" -> "Current Password"; "coverImage" -> "Cover Image". */
function humanize(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
return spaced.charAt(0).toUpperCase() + spaced.slice(1)
}

/**
* `ZodTypeDef` is the public (near-empty) shape of `_def`; the discriminant and
Expand Down Expand Up @@ -53,6 +60,7 @@ function fieldKind(key: string, schema: ZodTypeAny): FieldKind {
// Keyed by name, not by type: a public ID is a plain string to zod, and a raw
// text box for one would be unusable — nobody types a Cloudinary ID by hand.
if (key === 'coverImage' || key === 'avatar') return 'image'
if (key.toLowerCase().includes('password')) return 'password'
return 'text'
}

Expand Down Expand Up @@ -129,7 +137,7 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
return (
<div key={key} className="flex flex-col gap-1">
{/* ImageUpload renders its own label and controls. */}
{kind !== 'image' && <Label htmlFor={key}>{key}</Label>}
{kind !== 'image' && <Label htmlFor={key}>{humanize(key)}</Label>}
{kind === 'image' ? (
<ImageUpload
value={typeof values[key] === 'string' ? (values[key] as string) : null}
Expand Down Expand Up @@ -157,6 +165,12 @@ export function AutoForm<S extends ZodObject<ZodRawShape>>({
value={String(values[key] ?? '')}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
/>
) : kind === 'password' ? (
<PasswordInput
id={key}
value={String(values[key] ?? '')}
onChange={(e) => setValues((v) => ({ ...v, [key]: e.target.value }))}
/>
) : (
<Input
id={key}
Expand Down
11 changes: 10 additions & 1 deletion apps/client/src/components/patterns/ChatRoom.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,19 @@
// apps/client/src/components/patterns/CommentForm.test.tsx does.
import '@testing-library/jest-dom/vitest'
import type { ChatMessage } from '@blog/zod-shared'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { cleanup, fireEvent, render as rtlRender, screen, type RenderOptions } from '@testing-library/react'
import type { ReactElement } from 'react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatStatus, ChatUser } from '../../hooks/use-chat.js'

// Every ChatRoom render needs a Router context now that author names link to
// /users/:id — wrapped once here rather than passing { wrapper: MemoryRouter }
// at every call site below.
function render(ui: ReactElement, options?: RenderOptions) {
return rtlRender(ui, { wrapper: MemoryRouter, ...options })
}

// Mocked so each test can drive ChatRoom through a specific hook state
// directly, instead of exercising the real socket.
vi.mock('../../hooks/use-chat.js', () => ({ useChat: vi.fn() }))
Expand Down
6 changes: 5 additions & 1 deletion apps/client/src/components/patterns/ChatRoom.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { Button } from '../ui/button.js'
import { Input } from '../ui/input.js'
import { useChat } from '../../hooks/use-chat.js'
Expand Down Expand Up @@ -46,7 +47,10 @@ export function ChatRoom() {
<li key={m.id} className="text-sm">
{m.author.username ? (
<>
<span className="font-semibold">{m.author.username}</span> {m.body}
<Link to={`/users/${m.author.id}`} className="font-semibold hover:underline">
{m.author.username}
</Link>{' '}
{m.body}
</>
) : (
m.body
Expand Down
5 changes: 4 additions & 1 deletion apps/client/src/components/patterns/CommentThread.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import '@testing-library/jest-dom/vitest'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildCommentTree, CommentThread } from './CommentThread.js'
import { queryKeys } from '../../lib/query-client.js'
Expand Down Expand Up @@ -67,7 +68,9 @@ function renderThread(comments: Comment[], me: AuthUser | null) {
client.setQueryData(queryKeys.me, me)
return render(
<QueryClientProvider client={client}>
<CommentThread slug="my-post" comments={buildCommentTree(comments)} />
<MemoryRouter>
<CommentThread slug="my-post" comments={buildCommentTree(comments)} />
</MemoryRouter>
</QueryClientProvider>,
)
}
Expand Down
6 changes: 5 additions & 1 deletion apps/client/src/components/patterns/CommentThread.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { Link } from 'react-router'
import { toast } from 'sonner'
import { ApiError } from '../../api/client.js'
import { useMe } from '../../hooks/use-auth.js'
Expand Down Expand Up @@ -54,7 +55,10 @@ function CommentItem({ slug, node, depth }: { slug: string; node: CommentNode; d
<li className="flex flex-col gap-2">
<article className="flex flex-col gap-1 border border-[var(--border)] p-3">
<header className="text-xs text-[var(--muted-foreground)]">
{node.author.username} · {new Date(node.createdAt).toLocaleDateString()}
<Link to={`/users/${node.author.id}`} className="hover:text-[var(--foreground)]">
{node.author.username}
</Link>{' '}
· {new Date(node.createdAt).toLocaleDateString()}
</header>

{editing ? (
Expand Down
4 changes: 3 additions & 1 deletion apps/client/src/components/patterns/PostCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export function PostCard({ post, featured = false }: { post: Post; featured?: bo
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 font-mono text-xs tracking-[0.09em] text-[var(--ink-faint)] uppercase tabular-nums">
<time dateTime={post.createdAt}>{formatDate(post.createdAt)}</time>
<span aria-hidden="true">·</span>
<span>{post.author.username}</span>
<Link to={`/users/${post.author.id}`} className="hover:text-[var(--foreground)]">
{post.author.username}
</Link>
<span aria-hidden="true">·</span>
<span>
{post.likeCount} {post.likeCount === 1 ? 'like' : 'likes'}
Expand Down
1 change: 1 addition & 0 deletions apps/client/src/components/ui/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { Button, type ButtonProps } from './button.js'
export { Input } from './input.js'
export { PasswordInput } from './password-input.js'
export { Label } from './label.js'
export { Textarea } from './textarea.js'
export { Card } from './card.js'
Expand Down
35 changes: 35 additions & 0 deletions apps/client/src/components/ui/password-input.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Eye, EyeOff } from 'lucide-react'
import { forwardRef, useState, type InputHTMLAttributes } from 'react'
import { cn } from '../../lib/cn.js'
import { Input } from './input.js'

/**
* A password field hidden by default, with a toggle to reveal it — the same
* control on every password input in the app (login, signup, account).
*/
export const PasswordInput = forwardRef<
HTMLInputElement,
Omit<InputHTMLAttributes<HTMLInputElement>, 'type'>
>(({ className, ...props }, ref) => {
const [visible, setVisible] = useState(false)

return (
<div className="relative">
<Input
ref={ref}
type={visible ? 'text' : 'password'}
className={cn('pr-10', className)}
{...props}
/>
<button
type="button"
onClick={() => setVisible((v) => !v)}
aria-label={visible ? 'Hide password' : 'Show password'}
className="absolute inset-y-0 right-0 flex w-10 items-center justify-center text-[var(--muted-foreground)] hover:text-[var(--foreground)]"
>
{visible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
)
})
PasswordInput.displayName = 'PasswordInput'
34 changes: 34 additions & 0 deletions apps/client/src/hooks/use-users.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type { DeleteUser, UpdateUser } from '@blog/zod-shared'
import { usersApi } from '../api/users.js'
import { queryKeys } from '../lib/query-client.js'

/**
* Shared by `AccountPage` (self) and `UserProfilePage` (anyone) — the payload
* just varies by viewer, decided server-side (see userService.getPublicProfile).
*/
export function useUserProfile(id: string) {
return useQuery({
queryKey: queryKeys.users.detail(id),
queryFn: () => usersApi.get(id),
enabled: Boolean(id),
})
}

export function useUpdateUser(id: string) {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (input: UpdateUser) => usersApi.update(id, input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.users.detail(id) })
// A username change must show up in PageShell's header immediately.
queryClient.invalidateQueries({ queryKey: queryKeys.me })
},
})
}

export function useDeleteUser(id: string) {
return useMutation({
mutationFn: (confirmation: DeleteUser) => usersApi.remove(id, confirmation),
})
}
Loading
Loading