This commit is contained in:
Saxon Fletcher committed 2026-03-06 11:33:58 +10:00
1 parent 2b52fbfd0c
commit 256fe4522c
12 files changed
+241 -42

No files matched your search

@@ -2,6 +2,7 @@ import { Plus, Search } from 'lucide-react'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import {
Badge,
Button,
Card,
Input,
@@ -22,6 +23,7 @@ import {
} from 'ui-patterns/PageHeader'
import { PageSection, PageSectionContent } from 'ui-patterns/PageSection'
import { deriveLatestReviewStatusDisplay } from '@/lib/marketplace/review-state'
import { getMarketplaceSidebarData } from '@/lib/marketplace/server'
type PartnerItemsPageProps = {
@@ -108,29 +110,41 @@ export default async function PartnerItemsPage({ params, searchParams }: Partner
<TableRow>
<TableHead>Item</TableHead>
<TableHead>Slug</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredItems.map((item) => (
<TableRow key={item.id} className="group">
<TableCell className="p-0 font-medium">
<Link
href={`/protected/${partner.slug}/items/${item.slug}`}
className="block px-4 py-4 group-hover:underline"
>
{item.title}
</Link>
</TableCell>
<TableCell className="p-0 text-muted-foreground">
<Link
href={`/protected/${partner.slug}/items/${item.slug}`}
className="block px-4 py-4 group-hover:underline"
>
/{item.slug}
</Link>
</TableCell>
</TableRow>
))}
{filteredItems.map((item) => {
const statusDisplay = deriveLatestReviewStatusDisplay(item.latestReviewStatus)
return (
<TableRow key={item.id} className="group">
<TableCell className="p-0 font-medium">
<Link
href={`/protected/${partner.slug}/items/${item.slug}`}
className="block px-4 py-4 group-hover:underline"
>
{item.title}
</Link>
</TableCell>
<TableCell className="p-0 text-muted-foreground">
<Link
href={`/protected/${partner.slug}/items/${item.slug}`}
className="block px-4 py-4 group-hover:underline"
>
/{item.slug}
</Link>
</TableCell>
<TableCell className="px-4 py-4">
{statusDisplay ? (
<Badge variant={statusDisplay.variant}>{statusDisplay.label}</Badge>
) : (
<span className="text-sm text-muted-foreground">No review</span>
)}
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</Card>
@@ -343,6 +343,29 @@ describe('protected actions', () => {
expect(result).toEqual({ itemId: 20, itemSlug: 'template-item', partnerSlug: 'acme' })
})
it('creates template drafts without a template package', async () => {
createClientMock.mockResolvedValue(
createSupabaseMock({
user: { id: 'user-1' },
fromHandler: (table, state) => {
if (table === 'items' && state.op === 'insert') {
return success({ id: 21, slug: 'draft-template' })
}
return success(null)
},
})
)
const formData = new FormData()
formData.set('partnerId', '1')
formData.set('partnerSlug', 'acme')
formData.set('title', 'Draft Template')
formData.set('type', 'template')
const result = await createItemDraftAction(formData)
expect(result).toEqual({ itemId: 21, itemSlug: 'draft-template', partnerSlug: 'acme' })
})
it('throws when template item review upsert fails', async () => {
createClientMock.mockResolvedValue(
createSupabaseMock({
@@ -400,6 +423,9 @@ describe('protected actions', () => {
createSupabaseMock({
user: { id: 'reviewer' },
fromHandler: (table, state) => {
if (table === 'items' && state.op === 'select') {
return success({ type: 'oauth', registry_item_url: null, url: 'https://example.com' })
}
if (table === 'item_reviews' && state.op === 'select') {
return success({ status: 'draft' })
}
@@ -570,6 +596,9 @@ describe('protected actions', () => {
createSupabaseMock({
user: { id: 'reviewer' },
fromHandler: (table, state) => {
if (table === 'items' && state.op === 'select') {
return success({ type: 'oauth', registry_item_url: null, url: 'https://example.com' })
}
if (table === 'item_reviews' && state.op === 'select') {
return failure('existing review query failed')
}
@@ -586,11 +615,37 @@ describe('protected actions', () => {
await expect(requestItemReviewAction(formData)).rejects.toThrow('existing review query failed')
})
it('rejects template review requests when no template package has been uploaded', async () => {
createClientMock.mockResolvedValue(
createSupabaseMock({
user: { id: 'reviewer' },
fromHandler: (table, state) => {
if (table === 'items' && state.op === 'select') {
return success({ type: 'template', registry_item_url: null, url: null })
}
return success(null)
},
})
)
const formData = new FormData()
formData.set('itemId', '7')
formData.set('itemSlug', 'my-item')
formData.set('partnerSlug', 'acme')
await expect(requestItemReviewAction(formData)).rejects.toThrow(
'Template items require a template ZIP package before publishing or requesting review'
)
})
it('throws when request-review upsert fails', async () => {
createClientMock.mockResolvedValue(
createSupabaseMock({
user: { id: 'reviewer' },
fromHandler: (table, state) => {
if (table === 'items' && state.op === 'select') {
return success({ type: 'oauth', registry_item_url: null, url: 'https://example.com' })
}
if (table === 'item_reviews' && state.op === 'select') {
return success({ status: 'rejected' })
}
+20 -1
View File
@@ -359,7 +359,7 @@ export async function createItemDraftAction(formData: FormData) {
const slugSource = typeof slugInput === 'string' && slugInput.trim() ? slugInput : title
const slug = slugify(slugSource)
ensureItemDraftConstraints({ type, slug, url, templateZip })
ensureItemDraftConstraints({ type, slug, url, templateZip, published, intent })
const { data: item, error } = await supabase
.from('items')
@@ -470,6 +470,7 @@ export async function updateItemDraftAction(formData: FormData) {
url,
templateZip,
existingRegistryItemUrl,
published,
})
const templateRegistryUrl =
@@ -559,6 +560,24 @@ export async function requestItemReviewAction(formData: FormData) {
const itemId = Number(parseRequiredString(formData, 'itemId'))
const itemSlug = parseRequiredString(formData, 'itemSlug')
const partnerSlug = parseRequiredString(formData, 'partnerSlug')
const { data: item, error: itemError } = await supabase
.from('items')
.select('type, registry_item_url, url')
.eq('id', itemId)
.single()
if (itemError || !item) {
throw new Error(itemError?.message ?? 'Unable to load item')
}
ensureItemDraftConstraints({
type: parseItemType(item.type),
slug: itemSlug,
url: item.url,
templateZip: null,
existingRegistryItemUrl: item.registry_item_url,
intent: 'request_review',
})
const { data: existingReview, error: existingReviewError } = await supabase
.from('item_reviews')
+11 -7
View File
@@ -380,14 +380,14 @@ export function ItemForm(props: ItemFormProps) {
setError(null)
setSuccess(null)
const intent = submitIntentRef.current
if (parsed.data.type === 'template') {
const hasExistingRegistryFile = Boolean(item?.registry_item_url)
if (isCreateMode && !templateZipFile) {
setError('Upload a template ZIP package that includes template.json.')
return
}
if (!isCreateMode && !templateZipFile && !hasExistingRegistryFile) {
setError('Upload a template ZIP package that includes template.json.')
const requiresTemplatePackage = parsed.data.published || intent === 'request_review'
if (requiresTemplatePackage && !templateZipFile && !hasExistingRegistryFile) {
setError(
'Upload a template ZIP package that includes template.json before publishing or requesting review.'
)
return
}
}
@@ -397,7 +397,6 @@ export function ItemForm(props: ItemFormProps) {
}
const formData = new FormData()
const intent = submitIntentRef.current
const trimmedSlug = parsed.data.slug?.trim()
formData.set('partnerId', String(props.partner.id))
@@ -764,6 +763,11 @@ export function ItemForm(props: ItemFormProps) {
Selected package: {templateZipFile.name}
</p>
) : null}
{!hasTemplateFilesForTree ? (
<p className="mt-2 text-xs text-muted-foreground">
Optional while drafting. Required before publishing or requesting review.
</p>
) : null}
</FormItemLayout>
</div>
)}
@@ -60,8 +60,9 @@ describe('item-draft utils', () => {
slug: 'template-item',
url: null,
templateZip: null,
published: true,
})
).toThrow('Template items require a template ZIP package')
).toThrow('Template items require a template ZIP package before publishing or requesting review')
expect(() =>
ensureItemDraftConstraints({
@@ -69,10 +70,34 @@ describe('item-draft utils', () => {
slug: 'template-item',
url: null,
templateZip: templateFile,
published: true,
})
).not.toThrow()
})
it('allows template drafts without a template package', () => {
expect(() =>
ensureItemDraftConstraints({
type: 'template',
slug: 'template-item',
url: null,
templateZip: null,
})
).not.toThrow()
})
it('requires a template package when requesting review', () => {
expect(() =>
ensureItemDraftConstraints({
type: 'template',
slug: 'template-item',
url: null,
templateZip: null,
intent: 'request_review',
})
).toThrow('Template items require a template ZIP package before publishing or requesting review')
})
it('parses template zip file only when provided', () => {
const formData = new FormData()
const zip = new File(['zip'], 'template.zip', { type: 'application/zip' })
@@ -56,12 +56,16 @@ export function ensureItemDraftConstraints({
url,
templateZip,
existingRegistryItemUrl,
published = false,
intent = 'save',
}: {
type: 'oauth' | 'template' | null
slug: string
url: string | null
templateZip: File | null
existingRegistryItemUrl?: string | null
published?: boolean
intent?: 'save' | 'request_review'
}): asserts type is 'oauth' | 'template' {
if (!slug) {
throw new Error('Item slug cannot be empty')
@@ -72,7 +76,8 @@ export function ensureItemDraftConstraints({
if (type === 'oauth' && !url) {
throw new Error('OAuth items require a listing URL')
}
if (type === 'template' && !templateZip && !existingRegistryItemUrl) {
throw new Error('Template items require a template ZIP package')
const requiresTemplatePackage = type === 'template' && (published || intent === 'request_review')
if (requiresTemplatePackage && !templateZip && !existingRegistryItemUrl) {
throw new Error('Template items require a template ZIP package before publishing or requesting review')
}
}
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
deriveLatestReviewStatusDisplay,
deriveOpenReviewState,
deriveReviewDecisionDefaults,
isReviewStatus,
@@ -34,6 +35,22 @@ describe('review-state utils', () => {
})
})
it('derives review status labels and badge variants', () => {
expect(deriveLatestReviewStatusDisplay('approved')).toEqual({
label: 'Approved',
variant: 'success',
})
expect(deriveLatestReviewStatusDisplay('rejected')).toEqual({
label: 'Rejected',
variant: 'destructive',
})
expect(deriveLatestReviewStatusDisplay('draft')).toEqual({
label: 'Draft',
variant: 'warning',
})
expect(deriveLatestReviewStatusDisplay(null)).toBeNull()
})
it('derives review decision defaults with safe fallback', () => {
expect(deriveReviewDecisionDefaults(null)).toEqual({
status: 'pending_review',
@@ -19,6 +19,24 @@ export function deriveOpenReviewState(latestStatus: string | null | undefined) {
return { hasOpenReview, isApproved, openReviewStatusLabel }
}
export function deriveLatestReviewStatusDisplay(latestStatus: string | null | undefined) {
const { isApproved, openReviewStatusLabel } = deriveOpenReviewState(latestStatus)
if (isApproved) {
return { label: 'Approved', variant: 'success' as const }
}
if (latestStatus === 'rejected') {
return { label: 'Rejected', variant: 'destructive' as const }
}
if (openReviewStatusLabel) {
return { label: openReviewStatusLabel, variant: 'warning' as const }
}
return null
}
export function deriveReviewDecisionDefaults(
latestReview:
| {
@@ -58,8 +58,14 @@ describe('getMarketplaceSidebarData', () => {
in: () => ({
order: async () => ({
data: [
{ id: 11, partner_id: 1, slug: 'a-item', title: 'A Item' },
{ id: 10, partner_id: 1, slug: 'b-item', title: 'B Item' },
{
id: 11,
partner_id: 1,
slug: 'a-item',
title: 'A Item',
item_reviews: { status: 'approved' },
},
{ id: 10, partner_id: 1, slug: 'b-item', title: 'B Item', item_reviews: null },
],
error: null,
}),
@@ -75,6 +81,10 @@ describe('getMarketplaceSidebarData', () => {
const result = await getMarketplaceSidebarData()
expect(result.partners.map((partner) => partner.slug)).toEqual(['acme', 'reviewers'])
expect(result.partners[0]?.items.map((item) => item.slug)).toEqual(['a-item', 'b-item'])
expect(result.partners[0]?.items.map((item) => item.latestReviewStatus)).toEqual([
'approved',
null,
])
expect(result.isReviewerMember).toBe(true)
})
})
+20 -2
View File
@@ -1,4 +1,5 @@
import { createClient } from '@/lib/supabase/server'
import type { ReviewStatus } from '@/lib/marketplace/review-state'
export type PartnerSidebarData = {
id: number
@@ -10,6 +11,7 @@ export type PartnerSidebarData = {
id: number
slug: string
title: string
latestReviewStatus: ReviewStatus | null
}>
}
@@ -58,7 +60,7 @@ export async function getMarketplaceSidebarData() {
if (partnerIds.length > 0) {
const { data: items, error: itemsError } = await supabase
.from('items')
.select('id, partner_id, slug, title')
.select('id, partner_id, slug, title, item_reviews(status)')
.in('partner_id', partnerIds)
.order('title', { ascending: true })
@@ -66,13 +68,29 @@ export async function getMarketplaceSidebarData() {
throw new Error(itemsError.message)
}
for (const item of items ?? []) {
for (const item of (items ?? []) as Array<{
id: number
partner_id: number
slug: string
title: string
item_reviews?:
| {
status: ReviewStatus | null
}
| Array<{
status: ReviewStatus | null
}>
| null
}>) {
const partner = partnerMap.get(item.partner_id)
if (!partner) continue
const latestReview = Array.isArray(item.item_reviews) ? item.item_reviews[0] : item.item_reviews
partner.items.push({
id: item.id,
slug: item.slug,
title: item.title,
latestReviewStatus: latestReview?.status ?? null,
})
}
}
+10 -2
View File
@@ -231,8 +231,16 @@ create table public.items (
updated_at timestamptz not null default now(),
constraint items_slug_format check (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
constraint items_type_destination_check check (
(type = 'oauth' and url is not null and registry_item_url is null)
or (type = 'template' and url is null)
(
type = 'oauth'
and registry_item_url is null
and (published = false or url is not null)
)
or (
type = 'template'
and url is null
and (published = false or registry_item_url is not null)
)
)
);
@@ -1,4 +1,4 @@
import { render, screen } from '@testing-library/react'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -32,16 +32,22 @@ describe('ItemForm', () => {
vi.clearAllMocks()
})
it('shows template zip validation error in create mode', async () => {
it('allows creating a template draft without a zip package', async () => {
const user = userEvent.setup()
createItemDraftActionMock.mockResolvedValue({
itemId: 1,
itemSlug: 'auth-template',
partnerSlug: 'acme',
})
render(<ItemForm mode="create" partner={{ id: 1, slug: 'acme' }} />)
await user.type(screen.getByPlaceholderText('Authentication starter'), 'Auth Template')
await user.click(screen.getByRole('button', { name: 'Create item' }))
await waitFor(() => expect(createItemDraftActionMock).toHaveBeenCalledTimes(1))
expect(
await screen.findByText('Upload a template ZIP package that includes template.json.')
).toBeInTheDocument()
expect(createItemDraftActionMock).not.toHaveBeenCalled()
screen.queryByText('Upload a template ZIP package that includes template.json.')
).not.toBeInTheDocument()
})
})