From 35a572c037d64ca6dad70590ca3d852018a03ea5 Mon Sep 17 00:00:00 2001 From: Matthias G Date: Sat, 1 Aug 2026 13:49:34 +0200 Subject: [PATCH] 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. --- src/app/admin/entries/page.tsx | 4 +- src/app/api/v1/media/route.ts | 8 +- src/app/api/v1/posts/[slug]/route.ts | 4 +- src/components/admin/EntryStatusControl.tsx | 81 +++++++++++++++++++ src/lib/api-problem.ts | 2 +- .../components/entry-status-control.test.tsx | 34 ++++++++ 6 files changed, 124 insertions(+), 9 deletions(-) create mode 100644 src/components/admin/EntryStatusControl.tsx create mode 100644 tests/components/entry-status-control.test.tsx diff --git a/src/app/admin/entries/page.tsx b/src/app/admin/entries/page.tsx index ba47294..6091070 100644 --- a/src/app/admin/entries/page.tsx +++ b/src/app/admin/entries/page.tsx @@ -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: {postTypeLabel(item.type, locale)} {t(`audience.${item.audience}`)} - + diff --git a/src/app/api/v1/media/route.ts b/src/app/api/v1/media/route.ts index 10d5a3b..df47c55 100644 --- a/src/app/api/v1/media/route.ts +++ b/src/app/api/v1/media/route.ts @@ -7,9 +7,9 @@ import type { Media } from '~/data/schema' const failures: Record = { 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.') } } diff --git a/src/app/api/v1/posts/[slug]/route.ts b/src/app/api/v1/posts/[slug]/route.ts index c6ace22..66b728a 100644 --- a/src/app/api/v1/posts/[slug]/route.ts +++ b/src/app/api/v1/posts/[slug]/route.ts @@ -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) diff --git a/src/components/admin/EntryStatusControl.tsx b/src/components/admin/EntryStatusControl.tsx new file mode 100644 index 0000000..e96cdd3 --- /dev/null +++ b/src/components/admin/EntryStatusControl.tsx @@ -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 +} + +const moves: Partial> = { + draft: { key: 'publish', icon: , run: id => publishEntry({ id }) }, + review: { key: 'publish', icon: , run: id => publishEntry({ id }) }, + scheduled: { key: 'unschedule', icon: , run: id => unscheduleEntry({ id }) }, + published: { key: 'archive', icon: , run: id => archiveEntry({ id }) }, + archived: { key: 'resume', icon: , run: id => resumeEntry({ id }) }, +} + +export function EntryStatusControl({ id, status }: EntryStatusControlProps) { + const t = useTranslations('admin.entries') + const [pending, start] = useTransition() + const [error, setError] = useState(null) + const [blockers, setBlockers] = useState([]) + 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 ( + + + + {move ? ( + + ) : null} + + + {error ? ( + + {blockers.length > 0 ? blockers.map(key => t(`checks.${key}`)).join(' ') : t(`errors.${error}`)} + + ) : null} + + ) +} diff --git a/src/lib/api-problem.ts b/src/lib/api-problem.ts index e5129c1..983cc50 100644 --- a/src/lib/api-problem.ts +++ b/src/lib/api-problem.ts @@ -30,7 +30,7 @@ const details: Record = { 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 { diff --git a/tests/components/entry-status-control.test.tsx b/tests/components/entry-status-control.test.tsx new file mode 100644 index 0000000..bea900e --- /dev/null +++ b/tests/components/entry-status-control.test.tsx @@ -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() +} + +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') + }) +})