Work on many entries at once
Rows carry a checkbox, the bar above publishes, withdraws, resets to draft, sets the audience or deletes drafts for the whole selection and reports what went through.
This commit is contained in:
@@ -508,6 +508,13 @@
|
||||
"scheduled": "Termin setzen",
|
||||
"published": "Jetzt veröffentlichen",
|
||||
"archived": "Zurückziehen"
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, =0 {nichts ausgewählt} one {# ausgewählt} other {# ausgewählt}}",
|
||||
"selectAll": "Alle auf dieser Seite auswählen",
|
||||
"audience": "Zielgruppe setzen",
|
||||
"delete": "Entwürfe löschen",
|
||||
"result": "{done} erledigt, {blocked} nicht freigegeben, {failed} fehlgeschlagen."
|
||||
}
|
||||
},
|
||||
"brands": {
|
||||
|
||||
@@ -508,6 +508,13 @@
|
||||
"scheduled": "Schedule",
|
||||
"published": "Publish now",
|
||||
"archived": "Withdraw"
|
||||
},
|
||||
"bulk": {
|
||||
"selected": "{count, plural, =0 {nothing selected} one {# selected} other {# selected}}",
|
||||
"selectAll": "Select everything on this page",
|
||||
"audience": "Set audience",
|
||||
"delete": "Delete drafts",
|
||||
"result": "{done} done, {blocked} not releasable, {failed} failed."
|
||||
}
|
||||
},
|
||||
"brands": {
|
||||
|
||||
@@ -5,7 +5,7 @@ import { TbArrowDown, TbArrowUp, TbPlus } from 'react-icons/tb'
|
||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||
import { EntryFilters } from '~/components/admin/EntryFilters'
|
||||
import { EntryStatusControl } from '~/components/admin/EntryStatusControl'
|
||||
import { EntryTable } from '~/components/admin/EntryTable'
|
||||
import { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
@@ -115,7 +115,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
||||
query.set('dir', 'asc')
|
||||
}
|
||||
|
||||
function sortPath(key: SortKey): Route {
|
||||
function sortPath(key: SortKey): string {
|
||||
const next = new URLSearchParams(query)
|
||||
|
||||
next.set('sort', key)
|
||||
@@ -127,9 +127,11 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
||||
next.delete('dir')
|
||||
}
|
||||
|
||||
return `${adminEntriesPath}?${next.toString()}` as Route
|
||||
return `${adminEntriesPath}?${next.toString()}`
|
||||
}
|
||||
|
||||
const sortHrefs = Object.fromEntries(sortable.map(key => [key, sortPath(key)])) as Record<SortKey, string>
|
||||
|
||||
function pagePath(target: number): Route {
|
||||
const next = new URLSearchParams(query)
|
||||
|
||||
@@ -196,76 +198,12 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
||||
{filtered ? t('emptyFiltered') : t('empty')}
|
||||
</AdminNotice>
|
||||
) : (
|
||||
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||
<table className="w-full min-w-4xl border-collapse text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
{(['number', 'title', 'project'] as SortKey[]).map(key => (
|
||||
<th key={key} scope="col" className={headCellClass}>
|
||||
<Link href={sortPath(key)} className="inline-flex items-center gap-1 text-inherit no-underline hover:text-ink">
|
||||
{t(`columns.${key}`)}
|
||||
{sort === key ? (
|
||||
descending
|
||||
? <TbArrowDown aria-hidden="true" className="size-3.5" />
|
||||
: <TbArrowUp aria-hidden="true" className="size-3.5" />
|
||||
) : null}
|
||||
</Link>
|
||||
</th>
|
||||
))}
|
||||
<th scope="col" className={headCellClass}>{t('columns.type')}</th>
|
||||
<th scope="col" className={headCellClass}>{t('columns.audience')}</th>
|
||||
{(['status', 'date', 'author'] as SortKey[]).map(key => (
|
||||
<th key={key} scope="col" className={headCellClass}>
|
||||
<Link href={sortPath(key)} className="inline-flex items-center gap-1 text-inherit no-underline hover:text-ink">
|
||||
{t(`columns.${key}`)}
|
||||
{sort === key ? (
|
||||
descending
|
||||
? <TbArrowDown aria-hidden="true" className="size-3.5" />
|
||||
: <TbArrowUp aria-hidden="true" className="size-3.5" />
|
||||
) : null}
|
||||
</Link>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{result.items.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className={`${cellClass} whitespace-nowrap font-mono text-micro uppercase tracking-mark`}>
|
||||
{entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)}
|
||||
</td>
|
||||
<td className={cellClass}>
|
||||
<Link
|
||||
href={adminEntryPath(item.id)}
|
||||
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={projectStyle(item.projectColor)}
|
||||
className={`size-2.5 shrink-0 ${projectSurface}`}
|
||||
/>
|
||||
{item.projectName}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>{postTypeLabel(item.type, locale)}</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>{t(`audience.${item.audience}`)}</td>
|
||||
<td className={cellClass}>
|
||||
<EntryStatusControl id={item.id} status={item.status} />
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>
|
||||
<EntryDate date={item.publishAt ?? item.updatedAt} />
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>{item.authorName ?? t('noAuthor')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroller>
|
||||
<EntryTable
|
||||
items={result.items}
|
||||
sortHrefs={sortHrefs}
|
||||
sort={sort ?? ''}
|
||||
descending={descending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{pages > 1 ? (
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useTransition } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useLocale, useTranslations } from 'next-intl'
|
||||
import { TbArchive, TbArrowDown, TbArrowUp, TbRotate2, TbSend, TbTrash } from 'react-icons/tb'
|
||||
import { EntryStatusControl } from './EntryStatusControl'
|
||||
import { Select } from './Select'
|
||||
import { cellClass, checkboxClass, ghostButtonClass, headCellClass } from './styles'
|
||||
import { EntryDate } from '~/components/ui/EntryDate'
|
||||
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||
import { Scroller } from '~/components/ui/Scroller'
|
||||
import { postTypeLabel } from '~/domain/post-type'
|
||||
import { adminEntryPath } from '~/lib/admin-routes'
|
||||
import { bulkEntries } from '~/lib/entry-actions'
|
||||
import type { Route } from 'next'
|
||||
import type { EntryListItem } from '~/data/repositories/entries'
|
||||
import type { Audience } from '~/domain/types'
|
||||
import type { BulkAction, BulkOutcome } from '~/lib/entries'
|
||||
import type { Locale } from '~/i18n/config'
|
||||
|
||||
export type EntrySortKey = 'number' | 'title' | 'project' | 'status' | 'date' | 'author'
|
||||
|
||||
export type EntryTableProps = {
|
||||
items: EntryListItem[]
|
||||
sortHrefs: Record<EntrySortKey, string>
|
||||
sort: string
|
||||
descending: boolean
|
||||
}
|
||||
|
||||
const audiences: Audience[] = ['internal', 'customer', 'public']
|
||||
|
||||
const leading: EntrySortKey[] = ['number', 'title', 'project']
|
||||
|
||||
const trailing: EntrySortKey[] = ['status', 'date', 'author']
|
||||
|
||||
export function EntryTable({ items, sortHrefs, sort, descending }: EntryTableProps) {
|
||||
const t = useTranslations('admin.entries')
|
||||
const locale = useLocale() as Locale
|
||||
const [chosen, setChosen] = useState<string[]>([])
|
||||
const [outcome, setOutcome] = useState<BulkOutcome | null>(null)
|
||||
const [pending, start] = useTransition()
|
||||
|
||||
const ids = items.map(item => item.id)
|
||||
const selected = chosen.filter(id => ids.includes(id))
|
||||
const all = selected.length === items.length && items.length > 0
|
||||
|
||||
function toggle(id: string) {
|
||||
setOutcome(null)
|
||||
setChosen(current => (current.includes(id) ? current.filter(entry => entry !== id) : [...current, id]))
|
||||
}
|
||||
|
||||
function toggleAll() {
|
||||
setOutcome(null)
|
||||
setChosen(all ? [] : ids)
|
||||
}
|
||||
|
||||
function apply(action: BulkAction, audience?: Audience) {
|
||||
if (selected.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setOutcome(null)
|
||||
|
||||
start(async () => {
|
||||
const result = await bulkEntries(selected, action, audience)
|
||||
|
||||
setOutcome(result)
|
||||
setChosen([])
|
||||
})
|
||||
}
|
||||
|
||||
function head(key: EntrySortKey) {
|
||||
return (
|
||||
<th key={key} scope="col" className={headCellClass}>
|
||||
<Link href={sortHrefs[key] as Route} className="inline-flex items-center gap-1 text-inherit no-underline hover:text-ink">
|
||||
{t(`columns.${key}`)}
|
||||
{sort === key ? (
|
||||
descending
|
||||
? <TbArrowDown aria-hidden="true" className="size-3.5" />
|
||||
: <TbArrowUp aria-hidden="true" className="size-3.5" />
|
||||
) : null}
|
||||
</Link>
|
||||
</th>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-x-5 gap-y-3">
|
||||
<span className="font-mono text-micro uppercase tracking-label text-ink-3">
|
||||
{t('bulk.selected', { count: selected.length })}
|
||||
</span>
|
||||
|
||||
{selected.length > 0 ? (
|
||||
<>
|
||||
<button type="button" disabled={pending} onClick={() => apply('publish')} className={ghostButtonClass}>
|
||||
<TbSend aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('actions.publish')}
|
||||
</button>
|
||||
<button type="button" disabled={pending} onClick={() => apply('archive')} className={ghostButtonClass}>
|
||||
<TbArchive aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('actions.archive')}
|
||||
</button>
|
||||
<button type="button" disabled={pending} onClick={() => apply('draft')} className={ghostButtonClass}>
|
||||
<TbRotate2 aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('actions.resume')}
|
||||
</button>
|
||||
<Select
|
||||
value=""
|
||||
disabled={pending}
|
||||
aria-label={t('fields.audience')}
|
||||
onChange={event => apply('audience', event.target.value as Audience)}
|
||||
className="max-w-44"
|
||||
>
|
||||
<option value="">{t('bulk.audience')}</option>
|
||||
{audiences.map(item => (
|
||||
<option key={item} value={item}>
|
||||
{t(`audience.${item}`)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pending}
|
||||
onClick={() => apply('delete')}
|
||||
className={`${ghostButtonClass} hover:border-signal hover:text-signal`}
|
||||
>
|
||||
<TbTrash aria-hidden="true" className="size-4 shrink-0" />
|
||||
{t('bulk.delete')}
|
||||
</button>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{outcome ? (
|
||||
<span role="status" className="font-mono text-micro text-ink-2">
|
||||
{t('bulk.result', { done: outcome.done, blocked: outcome.blocked, failed: outcome.failed })}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
||||
<table className="w-full min-w-4xl border-collapse text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className={headCellClass}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={all}
|
||||
aria-label={t('bulk.selectAll')}
|
||||
onChange={toggleAll}
|
||||
className={checkboxClass}
|
||||
/>
|
||||
</th>
|
||||
{leading.map(head)}
|
||||
<th scope="col" className={headCellClass}>{t('columns.type')}</th>
|
||||
<th scope="col" className={headCellClass}>{t('columns.audience')}</th>
|
||||
{trailing.map(head)}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className={selected.includes(item.id) ? 'bg-surface' : undefined}>
|
||||
<td className={cellClass}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(item.id)}
|
||||
aria-label={item.title}
|
||||
onChange={() => toggle(item.id)}
|
||||
className={checkboxClass}
|
||||
/>
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap font-mono text-micro uppercase tracking-mark`}>
|
||||
{entryMark({ code: item.projectCode, slug: item.projectSlug }, item.number)}
|
||||
</td>
|
||||
<td className={cellClass}>
|
||||
<Link
|
||||
href={adminEntryPath(item.id)}
|
||||
className="font-display text-base font-semibold text-ink no-underline hover:text-signal"
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>
|
||||
<span className="flex items-center gap-2">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={projectStyle(item.projectColor)}
|
||||
className={`size-2.5 shrink-0 ${projectSurface}`}
|
||||
/>
|
||||
{item.projectName}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>{postTypeLabel(item.type, locale)}</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>{t(`audience.${item.audience}`)}</td>
|
||||
<td className={cellClass}>
|
||||
<EntryStatusControl id={item.id} status={item.status} />
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap font-mono text-micro`}>
|
||||
<EntryDate date={item.publishAt ?? item.updatedAt} />
|
||||
</td>
|
||||
<td className={`${cellClass} whitespace-nowrap`}>{item.authorName ?? t('noAuthor')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Scroller>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -278,6 +278,16 @@ export async function moveEntryToProject(id: string, projectId: string): Promise
|
||||
return moved[0]
|
||||
}
|
||||
|
||||
export async function setEntryAudience(id: string, audience: Audience): Promise<Post | undefined> {
|
||||
const rows = await db
|
||||
.update(posts)
|
||||
.set({ audience, updatedAt: new Date() })
|
||||
.where(eq(posts.id, id))
|
||||
.returning()
|
||||
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export async function updateEntry(id: string, values: EntryValues): Promise<Post | undefined> {
|
||||
const rows = await db
|
||||
.update(posts)
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
findEntryBySlug,
|
||||
listEntryBlocks,
|
||||
replaceEntryBlocks,
|
||||
setEntryAudience,
|
||||
setEntryStatus,
|
||||
moveEntryToProject,
|
||||
updateEntry,
|
||||
@@ -392,3 +393,69 @@ export async function performDeleteEntry(viewer: Viewer, id: string): Promise<En
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
export type BulkAction = 'publish' | 'archive' | 'draft' | 'delete' | 'audience'
|
||||
|
||||
export type BulkOutcome = {
|
||||
done: number
|
||||
failed: number
|
||||
blocked: number
|
||||
}
|
||||
|
||||
export async function performBulkEntries(
|
||||
viewer: Viewer,
|
||||
ids: string[],
|
||||
action: BulkAction,
|
||||
audience?: Audience,
|
||||
): Promise<BulkOutcome> {
|
||||
const outcome: BulkOutcome = { done: 0, failed: 0, blocked: 0 }
|
||||
|
||||
for (const id of ids) {
|
||||
if (action === 'audience') {
|
||||
const owned = await loadOwned(viewer, id)
|
||||
|
||||
if (!owned || !audience || !isAudience(audience)) {
|
||||
outcome.failed += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const saved = await setEntryAudience(id, audience)
|
||||
|
||||
if (saved) {
|
||||
outcome.done += 1
|
||||
} else {
|
||||
outcome.failed += 1
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const removed = await performDeleteEntry(viewer, id)
|
||||
|
||||
if (removed.ok) {
|
||||
outcome.done += 1
|
||||
} else {
|
||||
outcome.failed += 1
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
const result = action === 'publish'
|
||||
? await performPublishEntry(viewer, { id })
|
||||
: action === 'archive'
|
||||
? await performArchiveEntry(viewer, { id })
|
||||
: await performResumeEntry(viewer, { id })
|
||||
|
||||
if (result.ok) {
|
||||
outcome.done += 1
|
||||
} else if (result.error === 'blocked') {
|
||||
outcome.blocked += 1
|
||||
} else {
|
||||
outcome.failed += 1
|
||||
}
|
||||
}
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -9,15 +9,19 @@ import { readViewer } from './auth-guards'
|
||||
import { adminEntriesPath, adminEntryPath, lastProjectCookie } from './admin-routes'
|
||||
import {
|
||||
performArchiveEntry,
|
||||
performBulkEntries,
|
||||
performDeleteEntry,
|
||||
performPublishEntry,
|
||||
performResumeEntry,
|
||||
performSaveEntry,
|
||||
performScheduleEntry,
|
||||
performUnscheduleEntry,
|
||||
type BulkAction,
|
||||
type BulkOutcome,
|
||||
} from './entries'
|
||||
import { toEntryMediaList, toEntryMedia } from './entry-media'
|
||||
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
|
||||
import type { Audience } from '~/domain/types'
|
||||
import type {
|
||||
EntryDeleteResult,
|
||||
EntryInput,
|
||||
@@ -216,3 +220,21 @@ export async function removeEntry(id: string): Promise<EntryDeleteResult> {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export async function bulkEntries(
|
||||
ids: string[],
|
||||
action: BulkAction,
|
||||
audience?: Audience,
|
||||
): Promise<BulkOutcome> {
|
||||
const viewer = await readViewer()
|
||||
|
||||
if (!viewer) {
|
||||
return { done: 0, failed: ids.length, blocked: 0 }
|
||||
}
|
||||
|
||||
const outcome = await performBulkEntries(viewer, ids, action, audience)
|
||||
|
||||
revalidatePath(adminEntriesPath)
|
||||
|
||||
return outcome
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { findEntry, listEntryBlocks } from '~/data/repositories/entries'
|
||||
import {
|
||||
performArchiveEntry,
|
||||
performBulkEntries,
|
||||
performPublishEntry,
|
||||
performResumeEntry,
|
||||
performSaveEntry,
|
||||
@@ -376,3 +377,40 @@ describe('performSaveEntry wechselt das Projekt', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('performBulkEntries', () => {
|
||||
it('veröffentlicht mehrere Entwürfe auf einmal', async () => {
|
||||
const first = await ready()
|
||||
const second = await performSaveEntry(adminViewer(), entryInput(first.project.id, { title: 'Zweiter Beitrag' }))
|
||||
|
||||
if (!second.ok) {
|
||||
throw new Error('Anlegen fehlgeschlagen')
|
||||
}
|
||||
|
||||
const outcome = await performBulkEntries(adminViewer(), [first.entry.id, second.entry.id], 'publish')
|
||||
|
||||
expect(outcome).toMatchObject({ done: 2, failed: 0, blocked: 0 })
|
||||
expect((await findEntry(first.entry.id))?.status).toBe('published')
|
||||
expect((await findEntry(second.entry.id))?.status).toBe('published')
|
||||
})
|
||||
|
||||
it('setzt die Zielgruppe für eine Auswahl', async () => {
|
||||
const { entry } = await ready()
|
||||
|
||||
const outcome = await performBulkEntries(adminViewer(), [entry.id], 'audience', 'public')
|
||||
|
||||
expect(outcome).toMatchObject({ done: 1 })
|
||||
expect((await findEntry(entry.id))?.audience).toBe('public')
|
||||
})
|
||||
|
||||
it('löscht nur Entwürfe', async () => {
|
||||
const { entry } = await ready()
|
||||
|
||||
await performPublishEntry(adminViewer(), { id: entry.id })
|
||||
|
||||
const outcome = await performBulkEntries(adminViewer(), [entry.id], 'delete')
|
||||
|
||||
expect(outcome).toMatchObject({ done: 0, failed: 1 })
|
||||
expect(await findEntry(entry.id)).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user