Switch an entry status straight from the table
The list showed the status but only the editor could change it. Each row now carries the next sensible step and names the blockers when publishing is not possible. Also real umlauts in the API texts.
This commit is contained in:
@@ -5,7 +5,7 @@ import { TbPlus } from 'react-icons/tb'
|
||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||
import { EntryFilters } from '~/components/admin/EntryFilters'
|
||||
import { EntryStatusChip } from '~/components/admin/EntryStatusChip'
|
||||
import { EntryStatusControl } from '~/components/admin/EntryStatusControl'
|
||||
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
@@ -190,7 +190,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
||||
<td className={cellClass}>{postTypeLabel(item.type, locale)}</td>
|
||||
<td className={cellClass}>{t(`audience.${item.audience}`)}</td>
|
||||
<td className={cellClass}>
|
||||
<EntryStatusChip status={item.status} label={t(`status.${item.status}`)} />
|
||||
<EntryStatusControl id={item.id} status={item.status} />
|
||||
</td>
|
||||
<td className={`${cellClass} font-mono text-micro`}>
|
||||
<EntryDate date={item.publishAt ?? item.updatedAt} />
|
||||
|
||||
@@ -7,9 +7,9 @@ import type { Media } from '~/data/schema'
|
||||
|
||||
const failures: Record<UploadFailure, { status: number, type: string, detail: string }> = {
|
||||
file_missing: { status: 400, type: 'file_missing', detail: 'Es wurde keine Datei im Feld file mitgeschickt.' },
|
||||
file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist groesser als ${maxUploadBytes} Bytes.` },
|
||||
file_too_large: { status: 413, type: 'file_too_large', detail: `Die Datei ist größer als ${maxUploadBytes} Bytes.` },
|
||||
unsupported_type: { status: 415, type: 'unsupported_type', detail: 'Dieses Dateiformat wird nicht angenommen.' },
|
||||
unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei laesst sich nicht als Bild lesen.' },
|
||||
unreadable_image: { status: 422, type: 'unreadable_image', detail: 'Die Datei lässt sich nicht als Bild lesen.' },
|
||||
}
|
||||
|
||||
function body(item: Media) {
|
||||
@@ -61,7 +61,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
if (file.size > maxUploadBytes) {
|
||||
return problem(413, 'file_too_large', `Die Datei ist groesser als ${maxUploadBytes} Bytes.`)
|
||||
return problem(413, 'file_too_large', `Die Datei ist größer als ${maxUploadBytes} Bytes.`)
|
||||
}
|
||||
|
||||
const requested = String(form.get('project') ?? '').trim()
|
||||
@@ -83,7 +83,7 @@ export async function POST(request: Request) {
|
||||
const project = await findProjectBySlug(requested)
|
||||
|
||||
if (!project || project.id !== projectId) {
|
||||
return problem(403, 'forbidden', 'Dieses Token gehoert zu einem anderen Projekt.')
|
||||
return problem(403, 'forbidden', 'Dieses Token gehört zu einem anderen Projekt.')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ export async function PATCH(request: Request, context: { params: Promise<{ slug:
|
||||
const { slug } = await context.params
|
||||
|
||||
if (!idPattern.test(slug)) {
|
||||
return problem(400, 'id_required', 'Zum Aendern wird die Kennung des Beitrags gebraucht, nicht der Slug.')
|
||||
return problem(400, 'id_required', 'Zum Ändern wird die Kennung des Beitrags gebraucht, nicht der Slug.')
|
||||
}
|
||||
|
||||
const parsed = updateSchema.safeParse(await request.json().catch(() => null))
|
||||
@@ -91,7 +91,7 @@ export async function DELETE(request: Request, context: { params: Promise<{ slug
|
||||
const { slug } = await context.params
|
||||
|
||||
if (!idPattern.test(slug)) {
|
||||
return problem(400, 'id_required', 'Zum Loeschen wird die Kennung des Beitrags gebraucht, nicht der Slug.')
|
||||
return problem(400, 'id_required', 'Zum Löschen wird die Kennung des Beitrags gebraucht, nicht der Slug.')
|
||||
}
|
||||
|
||||
const result = await apiDeleteEntry(client, slug)
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { TbArchive, TbRotate2, TbSend } from 'react-icons/tb'
|
||||
import { EntryStatusChip } from './EntryStatusChip'
|
||||
import { archiveEntry, publishEntry, resumeEntry, unscheduleEntry } from '~/lib/entry-actions'
|
||||
import type { EntryErrorKey, EntryResult } from '~/lib/entry-types'
|
||||
import type { PostStatus } from '~/domain/types'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
export type EntryStatusControlProps = {
|
||||
id: string
|
||||
status: PostStatus
|
||||
}
|
||||
|
||||
type Move = {
|
||||
key: string
|
||||
icon: ReactNode
|
||||
run: (id: string) => Promise<EntryResult>
|
||||
}
|
||||
|
||||
const moves: Partial<Record<PostStatus, Move>> = {
|
||||
draft: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) },
|
||||
review: { key: 'publish', icon: <TbSend className="size-4 shrink-0" />, run: id => publishEntry({ id }) },
|
||||
scheduled: { key: 'unschedule', icon: <TbRotate2 className="size-4 shrink-0" />, run: id => unscheduleEntry({ id }) },
|
||||
published: { key: 'archive', icon: <TbArchive className="size-4 shrink-0" />, run: id => archiveEntry({ id }) },
|
||||
archived: { key: 'resume', icon: <TbRotate2 className="size-4 shrink-0" />, run: id => resumeEntry({ id }) },
|
||||
}
|
||||
|
||||
export function EntryStatusControl({ id, status }: EntryStatusControlProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const [pending, start] = useTransition()
|
||||
const [error, setError] = useState<EntryErrorKey | null>(null)
|
||||
const [blockers, setBlockers] = useState<string[]>([])
|
||||
const move = moves[status]
|
||||
|
||||
function apply() {
|
||||
if (!move) {
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
setBlockers([])
|
||||
|
||||
start(async () => {
|
||||
const result = await move.run(id)
|
||||
|
||||
if (!result.ok) {
|
||||
setError(result.error)
|
||||
setBlockers(result.blockers ?? [])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="flex flex-col items-start gap-1">
|
||||
<span className="flex items-center gap-2">
|
||||
<EntryStatusChip status={status} label={t(`status.${status}`)} />
|
||||
{move ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={apply}
|
||||
title={t(`actions.${move.key}`)}
|
||||
aria-label={t(`actions.${move.key}`)}
|
||||
className="inline-flex cursor-pointer items-center border-0 bg-transparent p-1 text-ink-3 hover:text-ink disabled:cursor-progress disabled:opacity-50"
|
||||
>
|
||||
{move.icon}
|
||||
</button>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
{error ? (
|
||||
<span role="alert" className="font-mono text-micro text-signal">
|
||||
{blockers.length > 0 ? blockers.map(key => t(`checks.${key}`)).join(' ') : t(`errors.${error}`)}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -30,7 +30,7 @@ const details: Record<ApiEntryError, string> = {
|
||||
blocks_invalid: 'Mindestens ein Block hat einen unbekannten Typ oder fehlende Felder.',
|
||||
too_many_blocks: 'Zu viele Bloecke.',
|
||||
not_found: 'Nicht gefunden.',
|
||||
already_published: 'Der Beitrag ist bereits veroeffentlicht und laesst sich so nicht mehr aendern.',
|
||||
already_published: 'Der Beitrag ist bereits veröffentlicht und lässt sich so nicht mehr ändern.',
|
||||
}
|
||||
|
||||
export function entryProblem(error: ApiEntryError): Response {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { renderToStaticMarkup } from 'react-dom/server'
|
||||
import type { PostStatus } from '~/domain/types'
|
||||
|
||||
vi.mock('next-intl', () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}))
|
||||
|
||||
vi.mock('~/lib/entry-actions', () => ({
|
||||
archiveEntry: vi.fn(),
|
||||
publishEntry: vi.fn(),
|
||||
resumeEntry: vi.fn(),
|
||||
unscheduleEntry: vi.fn(),
|
||||
}))
|
||||
|
||||
const { EntryStatusControl } = await import('~/components/admin/EntryStatusControl')
|
||||
|
||||
function render(status: PostStatus): string {
|
||||
return renderToStaticMarkup(<EntryStatusControl id="e1f7f2f0-0000-4000-8000-000000000001" status={status} />)
|
||||
}
|
||||
|
||||
describe('EntryStatusControl', () => {
|
||||
it('bietet zu jedem Status den passenden Schritt an', () => {
|
||||
expect(render('draft')).toContain('actions.publish')
|
||||
expect(render('scheduled')).toContain('actions.unschedule')
|
||||
expect(render('published')).toContain('actions.archive')
|
||||
expect(render('archived')).toContain('actions.resume')
|
||||
})
|
||||
|
||||
it('zeigt den Status immer als Marke', () => {
|
||||
expect(render('draft')).toContain('status.draft')
|
||||
expect(render('published')).toContain('status.published')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user