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",
|
"scheduled": "Termin setzen",
|
||||||
"published": "Jetzt veröffentlichen",
|
"published": "Jetzt veröffentlichen",
|
||||||
"archived": "Zurückziehen"
|
"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": {
|
"brands": {
|
||||||
|
|||||||
@@ -508,6 +508,13 @@
|
|||||||
"scheduled": "Schedule",
|
"scheduled": "Schedule",
|
||||||
"published": "Publish now",
|
"published": "Publish now",
|
||||||
"archived": "Withdraw"
|
"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": {
|
"brands": {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { TbArrowDown, TbArrowUp, TbPlus } from 'react-icons/tb'
|
|||||||
import { AdminHeading } from '~/components/admin/AdminHeading'
|
import { AdminHeading } from '~/components/admin/AdminHeading'
|
||||||
import { AdminNotice } from '~/components/admin/AdminNotice'
|
import { AdminNotice } from '~/components/admin/AdminNotice'
|
||||||
import { EntryFilters } from '~/components/admin/EntryFilters'
|
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 { cellClass, ghostButtonClass, headCellClass, quietLinkClass } from '~/components/admin/styles'
|
||||||
import { EntryDate } from '~/components/ui/EntryDate'
|
import { EntryDate } from '~/components/ui/EntryDate'
|
||||||
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
import { entryMark, projectStyle, projectSurface } from '~/components/ui/Plate'
|
||||||
@@ -115,7 +115,7 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
|||||||
query.set('dir', 'asc')
|
query.set('dir', 'asc')
|
||||||
}
|
}
|
||||||
|
|
||||||
function sortPath(key: SortKey): Route {
|
function sortPath(key: SortKey): string {
|
||||||
const next = new URLSearchParams(query)
|
const next = new URLSearchParams(query)
|
||||||
|
|
||||||
next.set('sort', key)
|
next.set('sort', key)
|
||||||
@@ -127,9 +127,11 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
|||||||
next.delete('dir')
|
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 {
|
function pagePath(target: number): Route {
|
||||||
const next = new URLSearchParams(query)
|
const next = new URLSearchParams(query)
|
||||||
|
|
||||||
@@ -196,76 +198,12 @@ export default async function AdminEntriesPage({ searchParams }: { searchParams:
|
|||||||
{filtered ? t('emptyFiltered') : t('empty')}
|
{filtered ? t('emptyFiltered') : t('empty')}
|
||||||
</AdminNotice>
|
</AdminNotice>
|
||||||
) : (
|
) : (
|
||||||
<Scroller options={{ overflow: { y: 'hidden' } }}>
|
<EntryTable
|
||||||
<table className="w-full min-w-4xl border-collapse text-left">
|
items={result.items}
|
||||||
<thead>
|
sortHrefs={sortHrefs}
|
||||||
<tr>
|
sort={sort ?? ''}
|
||||||
{(['number', 'title', 'project'] as SortKey[]).map(key => (
|
descending={descending}
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{pages > 1 ? (
|
{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]
|
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> {
|
export async function updateEntry(id: string, values: EntryValues): Promise<Post | undefined> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.update(posts)
|
.update(posts)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
findEntryBySlug,
|
findEntryBySlug,
|
||||||
listEntryBlocks,
|
listEntryBlocks,
|
||||||
replaceEntryBlocks,
|
replaceEntryBlocks,
|
||||||
|
setEntryAudience,
|
||||||
setEntryStatus,
|
setEntryStatus,
|
||||||
moveEntryToProject,
|
moveEntryToProject,
|
||||||
updateEntry,
|
updateEntry,
|
||||||
@@ -392,3 +393,69 @@ export async function performDeleteEntry(viewer: Viewer, id: string): Promise<En
|
|||||||
|
|
||||||
return { ok: true }
|
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 { adminEntriesPath, adminEntryPath, lastProjectCookie } from './admin-routes'
|
||||||
import {
|
import {
|
||||||
performArchiveEntry,
|
performArchiveEntry,
|
||||||
|
performBulkEntries,
|
||||||
performDeleteEntry,
|
performDeleteEntry,
|
||||||
performPublishEntry,
|
performPublishEntry,
|
||||||
performResumeEntry,
|
performResumeEntry,
|
||||||
performSaveEntry,
|
performSaveEntry,
|
||||||
performScheduleEntry,
|
performScheduleEntry,
|
||||||
performUnscheduleEntry,
|
performUnscheduleEntry,
|
||||||
|
type BulkAction,
|
||||||
|
type BulkOutcome,
|
||||||
} from './entries'
|
} from './entries'
|
||||||
import { toEntryMediaList, toEntryMedia } from './entry-media'
|
import { toEntryMediaList, toEntryMedia } from './entry-media'
|
||||||
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
|
import { readUploadBody, uploadMedia, type UploadFailure } from './media-upload'
|
||||||
|
import type { Audience } from '~/domain/types'
|
||||||
import type {
|
import type {
|
||||||
EntryDeleteResult,
|
EntryDeleteResult,
|
||||||
EntryInput,
|
EntryInput,
|
||||||
@@ -216,3 +220,21 @@ export async function removeEntry(id: string): Promise<EntryDeleteResult> {
|
|||||||
|
|
||||||
return result
|
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 { findEntry, listEntryBlocks } from '~/data/repositories/entries'
|
||||||
import {
|
import {
|
||||||
performArchiveEntry,
|
performArchiveEntry,
|
||||||
|
performBulkEntries,
|
||||||
performPublishEntry,
|
performPublishEntry,
|
||||||
performResumeEntry,
|
performResumeEntry,
|
||||||
performSaveEntry,
|
performSaveEntry,
|
||||||
@@ -376,3 +377,40 @@ describe('performSaveEntry wechselt das Projekt', () => {
|
|||||||
expect(result.ok).toBe(false)
|
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