Wire admin studio SSR and search infrastructure

This commit is contained in:
2026-05-01 11:46:06 +02:00
parent 257b0dbef6
commit 18cea8b0f0
329 changed files with 197465 additions and 2741 deletions

View File

@@ -21,7 +21,8 @@ function formatShortDate(value) {
}
function TrendChart({ title, subtitle, points, colorClass, fillClass, icon }) {
const values = (points || []).map((point) => Number(point.value || 0))
const normalizedPoints = Array.isArray(points) ? points : []
const values = normalizedPoints.map((point) => Number(point.value || 0))
const maxValue = Math.max(...values, 1)
return (
@@ -36,21 +37,27 @@ function TrendChart({ title, subtitle, points, colorClass, fillClass, icon }) {
</div>
</div>
<div className="mt-5 flex h-52 items-end gap-2">
{(points || []).map((point) => {
{normalizedPoints.length === 0 ? (
<div className="mt-5 flex h-52 items-center justify-center rounded-[22px] border border-dashed border-white/10 bg-black/20 px-6 text-sm text-slate-400">
No analytics points are available for this time window yet.
</div>
) : (
<div className="mt-5 flex h-52 items-end gap-2">
{normalizedPoints.map((point) => {
const height = `${Math.max(8, Math.round((Number(point.value || 0) / maxValue) * 100))}%`
return (
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div key={point.date} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div className="text-[10px] font-medium text-slate-500">{Number(point.value || 0).toLocaleString()}</div>
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className="flex w-full flex-1 items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className={`w-full rounded-t-[16px] ${fillClass}`} style={{ height }} />
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
</div>
)
})}
</div>
})}
</div>
)}
</section>
)
}

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupChallengeEditor() {
@@ -82,8 +83,8 @@ export default function StudioGroupChallengeEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.start_at} onChange={(nextValue) => form.setData('start_at', nextValue)} placeholder="Challenge start" clearable className="bg-black/20" />
<DateTimePicker value={form.data.end_at} onChange={(nextValue) => form.setData('end_at', nextValue)} placeholder="Challenge end" clearable className="bg-black/20" />
</div>
<textarea value={form.data.rules_text} onChange={(event) => form.setData('rules_text', event.target.value)} placeholder="Rules" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<textarea value={form.data.submission_instructions} onChange={(event) => form.setData('submission_instructions', event.target.value)} placeholder="Submission instructions" rows={4} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -2,6 +2,7 @@ import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import GroupStudioPromoCard from '../../components/groups/GroupStudioPromoCard'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function slugifyGroupValue(value) {
@@ -159,10 +160,10 @@ export default function StudioGroupCreate() {
<span>Type / category</span>
<input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-200">
<div className="grid gap-2 text-sm text-slate-200">
<span>Founded date</span>
<input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<DateTimePicker value={form.founded_at} onChange={(nextValue) => setForm((current) => ({ ...current, founded_at: nextValue }))} mode="date" placeholder="Pick the founding date" clearable className="bg-black/20" />
</div>
</div>
<label className="grid gap-2 text-sm text-slate-200">
<span>Website</span>

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
export default function StudioGroupEventEditor() {
@@ -50,8 +51,8 @@ export default function StudioGroupEventEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="datetime-local" value={form.data.start_at} onChange={(event) => form.setData('start_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="datetime-local" value={form.data.end_at} onChange={(event) => form.setData('end_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.start_at} onChange={(nextValue) => form.setData('start_at', nextValue)} placeholder="Event start" clearable className="bg-black/20" />
<DateTimePicker value={form.data.end_at} onChange={(nextValue) => form.setData('end_at', nextValue)} placeholder="Event end" clearable className="bg-black/20" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input value={form.data.timezone} onChange={(event) => form.setData('timezone', event.target.value)} placeholder="Timezone" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -1,6 +1,7 @@
import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function normalizeIds(values) {
@@ -53,8 +54,8 @@ export default function StudioGroupProjectEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
</div>
<div className="grid gap-4 md:grid-cols-2">
<input type="date" value={form.data.start_date} onChange={(event) => form.setData('start_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<input type="date" value={form.data.target_date} onChange={(event) => form.setData('target_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.start_date} onChange={(nextValue) => form.setData('start_date', nextValue)} mode="date" placeholder="Project start" clearable className="bg-black/20" />
<DateTimePicker value={form.data.target_date} onChange={(nextValue) => form.setData('target_date', nextValue)} mode="date" placeholder="Target date" clearable className="bg-black/20" />
</div>
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
@@ -103,7 +104,7 @@ export default function StudioGroupProjectEditor() {
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={milestoneForm.data.due_date} onChange={(nextValue) => milestoneForm.setData('due_date', nextValue)} mode="date" placeholder="Due date" clearable className="bg-black/20" />
</div>
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function toDateTimeInput(value) {
@@ -56,7 +57,7 @@ export default function StudioGroupReleaseEditor() {
<NovaSelect value={form.data.status} onChange={(val) => form.setData('status', val)} options={props.statusOptions || []} searchable={false} />
<NovaSelect value={form.data.current_stage} onChange={(val) => form.setData('current_stage', val)} options={props.stageOptions || []} searchable={false} />
</div>
<input type="datetime-local" value={form.data.planned_release_at} onChange={(event) => form.setData('planned_release_at', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={form.data.planned_release_at} onChange={(nextValue) => form.setData('planned_release_at', nextValue)} placeholder="Planned release" clearable className="bg-black/20" />
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect value={String(form.data.lead_user_id || '')} onChange={(val) => form.setData('lead_user_id', val)} placeholder="No release lead" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<NovaSelect value={String(form.data.linked_project_id || '')} onChange={(val) => form.setData('linked_project_id', val)} placeholder="No linked project" options={(props.projectOptions || []).map((o) => ({ value: String(o.id), label: o.title }))} />
@@ -116,7 +117,7 @@ export default function StudioGroupReleaseEditor() {
<textarea value={milestoneForm.data.summary} onChange={(event) => milestoneForm.setData('summary', event.target.value)} placeholder="Summary" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="grid gap-3 md:grid-cols-2">
<NovaSelect value={milestoneForm.data.status} onChange={(val) => milestoneForm.setData('status', val)} searchable={false} options={['pending', 'active', 'blocked', 'completed', 'cancelled'].map((s) => ({ value: s, label: s }))} />
<input type="date" value={milestoneForm.data.due_date} onChange={(event) => milestoneForm.setData('due_date', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<DateTimePicker value={milestoneForm.data.due_date} onChange={(nextValue) => milestoneForm.setData('due_date', nextValue)} mode="date" placeholder="Due date" clearable className="bg-black/20" />
</div>
<NovaSelect value={String(milestoneForm.data.owner_user_id || '')} onChange={(val) => milestoneForm.setData('owner_user_id', val)} placeholder="No owner" options={(props.memberOptions || []).map((o) => ({ value: String(o.id), label: o.name || o.username }))} />
<textarea value={milestoneForm.data.notes} onChange={(event) => milestoneForm.setData('notes', event.target.value)} placeholder="Notes" rows={3} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />

View File

@@ -1,6 +1,7 @@
import React, { useMemo, useRef, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import NovaSelect from '../../components/ui/NovaSelect'
function resolveMediaPreviewUrl(path, filesCdnUrl) {
@@ -116,7 +117,7 @@ export default function StudioGroupSettings() {
<label className="grid gap-2 text-sm text-slate-200"><span>About</span><textarea value={form.bio} onChange={(event) => setForm((current) => ({ ...current, bio: event.target.value }))} rows={6} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-200"><span>Type / category</span><input value={form.type} onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<label className="grid gap-2 text-sm text-slate-200"><span>Founded date</span><input type="date" value={form.founded_at} onChange={(event) => setForm((current) => ({ ...current, founded_at: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-2 text-sm text-slate-200"><span>Founded date</span><DateTimePicker value={form.founded_at} onChange={(nextValue) => setForm((current) => ({ ...current, founded_at: nextValue }))} mode="date" placeholder="Pick the founding date" clearable className="bg-black/20" /></div>
</div>
<label className="grid gap-2 text-sm text-slate-200"><span>Website</span><input value={form.website_url} onChange={(event) => setForm((current) => ({ ...current, website_url: event.target.value }))} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" /></label>
<div className="grid gap-5 md:grid-cols-2">

View File

@@ -21,29 +21,36 @@ function formatShortDate(value) {
}
function TrendBars({ title, subtitle, points, colorClass }) {
const values = (points || []).map((point) => Number(point.value || point.count || 0))
const normalizedPoints = Array.isArray(points) ? points : []
const values = normalizedPoints.map((point) => Number(point.value || point.count || 0))
const maxValue = Math.max(...values, 1)
return (
<section className="rounded-[28px] border border-white/10 bg-white/[0.03] p-6">
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="mt-1 text-sm text-slate-400">{subtitle}</p>
<div className="mt-5 flex h-52 items-end gap-2">
{(points || []).map((point) => {
{normalizedPoints.length === 0 ? (
<div className="mt-5 flex h-52 items-center justify-center rounded-[22px] border border-dashed border-white/10 bg-black/20 px-6 text-sm text-slate-400">
No growth points are available for this time window yet.
</div>
) : (
<div className="mt-5 flex h-52 items-end gap-2">
{normalizedPoints.map((point) => {
const value = Number(point.value || point.count || 0)
const height = `${Math.max(8, Math.round((value / maxValue) * 100))}%`
return (
<div key={point.date} className="flex min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div key={point.date} className="flex h-full min-w-0 flex-1 flex-col items-center justify-end gap-2">
<div className="text-[10px] font-medium text-slate-500">{value.toLocaleString()}</div>
<div className="flex h-full w-full items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className="flex w-full flex-1 items-end rounded-t-[18px] bg-white/[0.03] px-[2px]">
<div className={`w-full rounded-t-[16px] ${colorClass}`} style={{ height }} />
</div>
<div className="text-[10px] uppercase tracking-[0.14em] text-slate-500">{formatShortDate(point.date)}</div>
</div>
)
})}
</div>
})}
</div>
)}
</section>
)
}

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import Checkbox from '../../components/ui/Checkbox'
function replacePattern(pattern, token, value) {
return String(pattern || '').replace(token, String(value))
@@ -57,7 +58,7 @@ export default function StudioNewsTaxonomies() {
<textarea value={categoryForm.data.description} onChange={(event) => categoryForm.setData('description', event.target.value)} rows={3} placeholder="Description" className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="flex flex-wrap items-center gap-3">
<input type="number" value={categoryForm.data.position} onChange={(event) => categoryForm.setData('position', event.target.value)} min="0" className="w-28 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2 text-sm text-white"><input type="checkbox" checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} /> Active</label>
<Checkbox checked={categoryForm.data.is_active} onChange={(event) => categoryForm.setData('is_active', event.target.checked)} label="Active" />
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Create category</button>
</div>
</form>
@@ -72,7 +73,7 @@ export default function StudioNewsTaxonomies() {
<textarea value={category.description || ''} onChange={(event) => updateCategory(index, 'description', event.target.value)} rows={2} className="mt-3 w-full rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<div className="mt-3 flex flex-wrap items-center gap-3 text-sm text-slate-300">
<input type="number" value={category.position || 0} min="0" onChange={(event) => updateCategory(index, 'position', event.target.value)} className="w-24 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<label className="flex items-center gap-2"><input type="checkbox" checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} /> Active</label>
<Checkbox checked={Boolean(category.is_active)} onChange={(event) => updateCategory(index, 'is_active', event.target.checked)} label="Active" />
<span className="text-xs uppercase tracking-[0.14em] text-slate-500">{Number(category.published_count || 0).toLocaleString()} published</span>
<button type="button" onClick={() => saveCategory(category)} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm font-semibold text-white">Save</button>
</div>

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useState } from 'react'
import { router, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import DateTimePicker from '../../components/ui/DateTimePicker'
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
import { formatReleaseCountdown, formatScheduledDate } from '../../utils/scheduleCountdown'
import NovaSelect from '../../components/ui/NovaSelect'
@@ -151,14 +152,14 @@ export default function StudioScheduled() {
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Date range</span>
<NovaSelect value={filters.range || 'upcoming'} onChange={(val) => updateFilters({ range: val })} options={rangeOptions} searchable={false} />
</div>
<label className="space-y-2 text-sm text-slate-300">
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Start date</span>
<input type="date" value={filters.start_date || ''} onChange={(event) => updateFilters({ range: 'custom', start_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
</label>
<label className="space-y-2 text-sm text-slate-300">
<DateTimePicker value={filters.start_date || ''} onChange={(nextValue) => updateFilters({ range: 'custom', start_date: nextValue })} mode="date" placeholder="Start date" clearable className="bg-black/20" />
</div>
<div className="space-y-2 text-sm text-slate-300">
<span className="block text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">End date</span>
<input type="date" value={filters.end_date || ''} onChange={(event) => updateFilters({ range: 'custom', end_date: event.target.value })} className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white" />
</label>
<DateTimePicker value={filters.end_date || ''} onChange={(nextValue) => updateFilters({ range: 'custom', end_date: nextValue })} mode="date" placeholder="End date" clearable className="bg-black/20" />
</div>
<div className="flex items-end">
<button type="button" onClick={() => updateFilters({ q: '', module: 'all', range: 'upcoming', start_date: '', end_date: '' })} className="w-full rounded-2xl border border-white/10 px-4 py-3 text-sm text-slate-200">Reset</button>
</div>

View File

@@ -0,0 +1,362 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act, cleanup, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import StudioUploadQueue from '../StudioUploadQueue'
let pageMock = { props: {} }
vi.mock('@inertiajs/react', () => ({
usePage: () => pageMock,
}))
vi.mock('../../../Layouts/StudioLayout', () => ({
default: ({ children }) => <div>{children}</div>,
}))
function makeQueueProps(overrides = {}) {
return {
title: 'Upload Queue',
description: 'Queue drafts',
chunkSize: 5242880,
chunkRequestTimeoutMs: 45000,
contentTypes: [
{
name: 'Photography',
categories: [
{ id: 10, name: 'Portraits', children: [] },
],
},
],
queue: {
filters: { batch_id: 1, status: 'all', sort: 'newest' },
batches: [{ id: 1, name: 'Spring Set' }],
current_batch: {
id: 1,
name: 'Spring Set',
status: 'completed_with_errors',
total_items: 2,
ready_items: 1,
processing_items: 0,
needs_review_items: 1,
failed_items: 0,
updated_at: '2026-04-18T10:00:00Z',
},
status_options: [
{ value: 'all', label: 'All' },
{ value: 'ready', label: 'Ready' },
{ value: 'needs_review', label: 'Needs review' },
],
sort_options: [
{ value: 'newest', label: 'Newest first' },
{ value: 'filename', label: 'Filename' },
],
items: [
{
id: 101,
title: 'Ready draft',
original_filename: 'ready.webp',
status: 'ready',
processing_stage: 'finalized',
metadata_label: '100% complete',
is_ready_to_publish: true,
missing: [],
error_message: null,
updated_at: '2026-04-18T10:00:00Z',
edit_url: '/studio/artworks/1/edit',
actions: {
can_edit: true,
can_publish: true,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
{
id: 102,
title: 'Needs review draft',
original_filename: 'review.webp',
status: 'needs_review',
processing_stage: 'finalized',
metadata_label: '75% complete',
is_ready_to_publish: false,
missing: ['Needs maturity review'],
error_message: null,
updated_at: '2026-04-18T11:00:00Z',
edit_url: '/studio/artworks/2/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
],
...overrides.queue,
},
...overrides,
}
}
describe('StudioUploadQueue', () => {
beforeEach(() => {
pageMock = { props: makeQueueProps() }
window.axios = {
get: vi.fn().mockResolvedValue({ data: pageMock.props.queue }),
post: vi.fn().mockResolvedValue({ data: { success: 1, failed: 0, errors: [] } }),
}
window.confirm = vi.fn(() => true)
window.prompt = vi.fn(() => 'DELETE')
})
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})
it('renders mixed queue states and item actions', () => {
render(<StudioUploadQueue />)
expect(screen.getByText('Ready draft')).not.toBeNull()
expect(screen.getByText('Needs review draft')).not.toBeNull()
expect(screen.getAllByText('Ready to publish')[0]).not.toBeNull()
expect(screen.getByText('Needs maturity review')).not.toBeNull()
expect(screen.getAllByRole('link', { name: /edit in studio/i })).toHaveLength(2)
expect(screen.getAllByRole('button', { name: /^generate ai$/i })).toHaveLength(3)
})
it('reloads the queue when filters change', async () => {
const user = userEvent.setup()
render(<StudioUploadQueue />)
await user.selectOptions(screen.getByRole('combobox', { name: /filter/i }), 'ready')
await waitFor(() => {
expect(window.axios.get).toHaveBeenCalledWith('/api/studio/upload-queue', {
params: expect.objectContaining({
batch_id: 1,
status: 'ready',
sort: 'newest',
}),
})
})
})
it('shows a publish confirmation summary before bulk publish', async () => {
const user = userEvent.setup()
render(<StudioUploadQueue />)
const checkboxes = screen.getAllByRole('checkbox')
const itemCheckboxes = checkboxes.slice(-2)
await user.click(itemCheckboxes[0])
await user.click(itemCheckboxes[1])
await user.click(screen.getByRole('button', { name: /publish selected/i }))
expect(window.confirm).toHaveBeenCalledWith([
'Publish 1 ready draft(s)?',
'Selected: 2',
'Ready now: 1',
'Blocked and skipped: 1',
'Needs review: 1',
'Blocked drafts will not be published.',
].join('\n'))
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
action: 'publish',
item_ids: [101, 102],
}))
})
})
it('does not attempt bulk publish when no selected drafts are ready', async () => {
const user = userEvent.setup()
pageMock = {
props: makeQueueProps({
queue: {
items: [
{
id: 202,
title: 'Blocked draft',
original_filename: 'blocked.webp',
status: 'needs_metadata',
processing_stage: 'finalized',
metadata_label: '50% complete',
is_ready_to_publish: false,
missing: ['Missing title'],
error_message: null,
updated_at: '2026-04-18T10:00:00Z',
edit_url: '/studio/artworks/3/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
],
},
}),
}
render(<StudioUploadQueue />)
const checkboxes = screen.getAllByRole('checkbox')
await user.click(checkboxes.at(-1))
await user.click(screen.getByRole('button', { name: /publish selected/i }))
expect(window.confirm).not.toHaveBeenCalled()
expect(window.axios.post).not.toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
action: 'publish',
}))
expect(screen.getByText('None of the selected drafts are ready to publish yet.')).not.toBeNull()
})
it('shows the correct Studio links and publish readiness state per item', () => {
render(<StudioUploadQueue />)
const studioLinks = screen.getAllByRole('link', { name: /edit in studio/i })
expect(studioLinks).toHaveLength(2)
expect(studioLinks[0].getAttribute('href')).toBe('/studio/artworks/1/edit')
expect(studioLinks[1].getAttribute('href')).toBe('/studio/artworks/2/edit')
expect(screen.getAllByRole('button', { name: /^publish$/i })).toHaveLength(1)
})
it('bulk actions apply only to selected queue items', async () => {
const user = userEvent.setup()
render(<StudioUploadQueue />)
const checkboxes = screen.getAllByRole('checkbox')
const itemCheckboxes = checkboxes.slice(-2)
await user.click(itemCheckboxes[0])
await user.click(screen.getAllByRole('button', { name: /^generate ai$/i })[0])
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/bulk', expect.objectContaining({
action: 'generate_ai',
item_ids: [101],
}))
})
})
it('shows failed items clearly and lets creators retry them', async () => {
const user = userEvent.setup()
pageMock = {
props: makeQueueProps({
queue: {
current_batch: {
id: 1,
name: 'Spring Set',
status: 'completed_with_errors',
total_items: 1,
ready_items: 0,
processing_items: 0,
needs_review_items: 0,
failed_items: 1,
updated_at: '2026-04-18T12:00:00Z',
},
items: [
{
id: 303,
title: 'Broken draft',
original_filename: 'broken.webp',
status: 'failed',
processing_stage: 'finalized',
metadata_label: '25% complete',
is_ready_to_publish: false,
missing: ['Processing incomplete'],
error_message: 'Derivative generation failed.',
updated_at: '2026-04-18T12:00:00Z',
edit_url: '/studio/artworks/4/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: true,
can_generate_ai: true,
},
},
],
},
}),
}
render(<StudioUploadQueue />)
expect(screen.getByText('Derivative generation failed.')).not.toBeNull()
expect(screen.getByText('Processing incomplete')).not.toBeNull()
await user.click(screen.getByRole('button', { name: /retry/i }))
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith('/api/studio/upload-queue/items/303/retry')
})
})
it('polls the queue while processing items are still running', async () => {
vi.useFakeTimers()
pageMock = {
props: makeQueueProps({
queue: {
current_batch: {
id: 1,
name: 'Spring Set',
status: 'processing',
total_items: 1,
ready_items: 0,
processing_items: 1,
needs_review_items: 0,
failed_items: 0,
updated_at: '2026-04-18T12:15:00Z',
},
items: [
{
id: 404,
title: 'Processing draft',
original_filename: 'processing.webp',
status: 'processing',
processing_stage: 'maturity_check',
metadata_label: '50% complete',
is_ready_to_publish: false,
missing: ['Maturity analysis pending'],
error_message: null,
updated_at: '2026-04-18T12:15:00Z',
edit_url: '/studio/artworks/5/edit',
actions: {
can_edit: true,
can_publish: false,
can_delete: true,
can_retry_processing: false,
can_generate_ai: true,
},
},
],
},
}),
}
render(<StudioUploadQueue />)
expect(screen.getByText('Maturity analysis pending')).not.toBeNull()
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
await act(async () => {
vi.advanceTimersByTime(3000)
await Promise.resolve()
})
expect(window.axios.get).toHaveBeenCalledWith('/api/studio/upload-queue', {
params: expect.objectContaining({
batch_id: 1,
status: 'all',
sort: 'newest',
}),
})
})
})