Wire admin studio SSR and search infrastructure
This commit is contained in:
@@ -2,6 +2,8 @@ import React, { useEffect, useState } from 'react'
|
||||
import { router } from '@inertiajs/react'
|
||||
import { studioSurface, trackStudioEvent } from '../../utils/studioEvents'
|
||||
import ConfirmDangerModal from './ConfirmDangerModal'
|
||||
import NovaSelect from '../ui/NovaSelect'
|
||||
import Checkbox from '../ui/Checkbox'
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return 'Unscheduled'
|
||||
@@ -79,6 +81,11 @@ function bulkErrorMessage(payload, fallback = 'Bulk action failed.') {
|
||||
|| fallback
|
||||
}
|
||||
|
||||
function stripHtml(value) {
|
||||
if (typeof value !== 'string') return ''
|
||||
return value.replace(/<[^>]*>/g, '').trim()
|
||||
}
|
||||
|
||||
function ActionLink({ href, icon, label, onClick }) {
|
||||
if (!href) return null
|
||||
|
||||
@@ -176,7 +183,7 @@ function GridCard({ item, onExecuteAction, busyKey }) {
|
||||
)}
|
||||
|
||||
<p className="line-clamp-2 min-h-[2.5rem] text-sm text-slate-300/90">
|
||||
{item.description || 'No description yet.'}
|
||||
{stripHtml(item.description) || 'No description yet.'}
|
||||
</p>
|
||||
|
||||
{Array.isArray(readiness?.missing) && readiness.missing.length > 0 && (
|
||||
@@ -266,7 +273,7 @@ function ListRow({ item, onExecuteAction, busyKey }) {
|
||||
</div>
|
||||
<h3 className="mt-3 truncate text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 text-sm text-slate-400">{item.subtitle || item.visibility || 'Untitled metadata'}</p>
|
||||
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{item.description || 'No description yet.'}</p>
|
||||
<p className="mt-3 line-clamp-2 text-sm text-slate-300/90">{stripHtml(item.description) || 'No description yet.'}</p>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{readiness && (
|
||||
<span className={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-medium ${readinessClasses(readiness)}`}>
|
||||
@@ -324,31 +331,48 @@ function materializeFilter(filter, pendingFilters) {
|
||||
}
|
||||
}
|
||||
|
||||
function selectOptions(options = []) {
|
||||
return options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
group: option.group,
|
||||
disabled: option.disabled,
|
||||
icon: option.icon,
|
||||
}))
|
||||
}
|
||||
|
||||
function FilterField({ label, children }) {
|
||||
return (
|
||||
<div className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AdvancedFilterControl({ filter, onChange, value }) {
|
||||
const controlValue = value ?? filter.value
|
||||
|
||||
if (filter.type === 'select') {
|
||||
const options = selectOptions(filter.options || [])
|
||||
const searchable = filter.searchable ?? options.length > 8
|
||||
|
||||
return (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||
<select
|
||||
<FilterField label={filter.label}>
|
||||
<NovaSelect
|
||||
id={`studio-filter-${filter.key}`}
|
||||
options={options}
|
||||
value={controlValue || 'all'}
|
||||
onChange={(event) => onChange(filter.key, event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(filter.options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => onChange(filter.key, nextValue ?? 'all')}
|
||||
placeholder={filter.label}
|
||||
searchable={searchable}
|
||||
/>
|
||||
</FilterField>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">{filter.label}</span>
|
||||
<FilterField label={filter.label}>
|
||||
<input
|
||||
type="search"
|
||||
value={controlValue || ''}
|
||||
@@ -356,7 +380,7 @@ function AdvancedFilterControl({ filter, onChange, value }) {
|
||||
placeholder={filter.placeholder || filter.label}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
||||
/>
|
||||
</label>
|
||||
</FilterField>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -817,8 +841,7 @@ export default function StudioContentBrowser({
|
||||
<section className="rounded-[30px] border border-white/10 bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.12),_transparent_35%),radial-gradient(circle_at_bottom_right,_rgba(56,189,248,0.12),_transparent_40%),linear-gradient(135deg,_rgba(15,23,42,0.86),_rgba(2,6,23,0.96))] p-5 shadow-[0_22px_60px_rgba(2,6,23,0.28)] lg:p-6">
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_auto] lg:items-end">
|
||||
<div className={`grid gap-3 md:grid-cols-2 ${filterGridClass}`}>
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Search</span>
|
||||
<FilterField label="Search">
|
||||
<input
|
||||
type="search"
|
||||
value={pendingFilters.q}
|
||||
@@ -826,56 +849,44 @@ export default function StudioContentBrowser({
|
||||
placeholder="Title, description, module"
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-sky-300/40 focus:bg-black/30"
|
||||
/>
|
||||
</label>
|
||||
</FilterField>
|
||||
|
||||
{!hideModuleFilter && (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Module</span>
|
||||
<select
|
||||
<FilterField label="Module">
|
||||
<NovaSelect
|
||||
id="studio-filter-module"
|
||||
options={selectOptions(listing?.module_options || [])}
|
||||
value={filters.module || 'all'}
|
||||
onChange={(event) => updateQuery({ module: event.target.value })}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.module_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => updateQuery({ module: nextValue ?? 'all' })}
|
||||
placeholder="All content"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
|
||||
{!hideBucketFilter && (
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Status</span>
|
||||
<select
|
||||
<FilterField label="Status">
|
||||
<NovaSelect
|
||||
id="studio-filter-status"
|
||||
options={selectOptions(listing?.bucket_options || [])}
|
||||
value={pendingFilters.bucket}
|
||||
onChange={(event) => setPendingFilter('bucket', event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.bucket_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => setPendingFilter('bucket', nextValue ?? 'all')}
|
||||
placeholder="All"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
|
||||
<label className="space-y-2 text-sm text-slate-300">
|
||||
<span className="block text-[11px] font-semibold uppercase tracking-[0.2em] text-slate-500">Sort</span>
|
||||
<select
|
||||
<FilterField label="Sort">
|
||||
<NovaSelect
|
||||
id="studio-filter-sort"
|
||||
options={selectOptions(listing?.sort_options || [])}
|
||||
value={pendingFilters.sort}
|
||||
onChange={(event) => setPendingFilter('sort', event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm text-white outline-none transition focus:border-sky-300/40 focus:bg-black/30"
|
||||
>
|
||||
{(listing?.sort_options || []).map((option) => (
|
||||
<option key={option.value} value={option.value} className="bg-slate-900">
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
onChange={(nextValue) => setPendingFilter('sort', nextValue ?? 'updated_desc')}
|
||||
placeholder="Recently updated"
|
||||
searchable={false}
|
||||
/>
|
||||
</FilterField>
|
||||
|
||||
{advancedFilters.map((filter) => {
|
||||
const resolvedFilter = materializeFilter(filter, pendingFilters)
|
||||
@@ -960,15 +971,13 @@ export default function StudioContentBrowser({
|
||||
{viewMode === 'table' && supportsArtworkBulk && (
|
||||
<section className="flex flex-wrap items-center justify-between gap-3 rounded-[24px] border border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-300">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
<div className="inline-flex items-center gap-2 text-sm text-slate-300">
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleSelectAllVisible}
|
||||
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
label="Select page"
|
||||
/>
|
||||
<span>Select page</span>
|
||||
</label>
|
||||
</div>
|
||||
<span className="text-slate-500">
|
||||
{selectedIds.length > 0 ? `${selectedIds.length} selected` : 'Select artworks to run bulk actions'}
|
||||
</span>
|
||||
@@ -1014,11 +1023,9 @@ export default function StudioContentBrowser({
|
||||
<tr>
|
||||
{supportsArtworkBulk && (
|
||||
<th scope="col" className="w-12 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={allVisibleSelected}
|
||||
onChange={toggleSelectAllVisible}
|
||||
className="h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
aria-label="Select all artworks on this page"
|
||||
/>
|
||||
</th>
|
||||
@@ -1039,11 +1046,9 @@ export default function StudioContentBrowser({
|
||||
<tr key={item.id} className="align-top transition hover:bg-white/[0.03]">
|
||||
{supportsArtworkBulk && (
|
||||
<td className="px-4 py-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={() => toggleSelected(Number(item.numeric_id))}
|
||||
className="mt-1 h-4 w-4 rounded border-white/20 bg-slate-950/60 text-sky-400 focus:ring-sky-400/40"
|
||||
aria-label={`Select ${item.title}`}
|
||||
/>
|
||||
</td>
|
||||
|
||||
100
resources/js/components/Studio/StudioContentBrowser.test.jsx
Normal file
100
resources/js/components/Studio/StudioContentBrowser.test.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import React from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import StudioContentBrowser from './StudioContentBrowser'
|
||||
|
||||
const routerGet = vi.fn()
|
||||
const routerReload = vi.fn()
|
||||
|
||||
vi.mock('@inertiajs/react', () => ({
|
||||
router: {
|
||||
get: routerGet,
|
||||
reload: routerReload,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../../utils/studioEvents', () => ({
|
||||
studioSurface: () => '/studio/artworks',
|
||||
trackStudioEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./ConfirmDangerModal', () => ({
|
||||
default: () => null,
|
||||
}))
|
||||
|
||||
describe('StudioContentBrowser filters', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('renders artwork filter dropdowns with NovaSelect instead of native selects', () => {
|
||||
const { container } = render(
|
||||
<StudioContentBrowser
|
||||
hideModuleFilter
|
||||
listing={{
|
||||
filters: {
|
||||
module: 'artworks',
|
||||
bucket: 'all',
|
||||
q: '',
|
||||
sort: 'updated_desc',
|
||||
content_type: 'all',
|
||||
category: 'all',
|
||||
tag: '',
|
||||
},
|
||||
items: [],
|
||||
meta: {
|
||||
current_page: 1,
|
||||
last_page: 1,
|
||||
per_page: 24,
|
||||
total: 0,
|
||||
},
|
||||
bucket_options: [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'published', label: 'Published' },
|
||||
],
|
||||
sort_options: [
|
||||
{ value: 'updated_desc', label: 'Recently updated' },
|
||||
{ value: 'views_desc', label: 'Most viewed' },
|
||||
],
|
||||
advanced_filters: [
|
||||
{
|
||||
key: 'content_type',
|
||||
label: 'Content type',
|
||||
type: 'select',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ value: 'all', label: 'All content types' },
|
||||
{ value: '3d', label: '3D' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
label: 'Category',
|
||||
type: 'select',
|
||||
value: 'all',
|
||||
options: [
|
||||
{ value: 'all', label: 'All categories' },
|
||||
{ value: 'abstract', label: 'Abstract', content_type_slug: 'all' },
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'tag',
|
||||
label: 'Tag',
|
||||
type: 'search',
|
||||
value: '',
|
||||
placeholder: 'Filter by tag',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(container.querySelectorAll('select')).toHaveLength(0)
|
||||
expect(screen.getAllByRole('combobox')).toHaveLength(4)
|
||||
expect(screen.getByText('Status')).not.toBeNull()
|
||||
expect(screen.getByText('Sort')).not.toBeNull()
|
||||
expect(screen.getByText('Content type')).not.toBeNull()
|
||||
expect(screen.getByText('Category')).not.toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user