feat(cms): clarify sponsor ordering
This commit is contained in:
@@ -35,6 +35,29 @@ This inventory records the frontend or platform owner for every retained `Pages`
|
||||
| `netzwerk.partners.{eyebrow,heading,description,detailLabel,tiers[].{label,note},modal}` | All visible sponsor-section editorial copy, including each rendered tier heading/note and the shared card CTA, in `src/spa/pages/Netzwerk.tsx`; partner names, descriptions, links, logos, tier assignment, and ordering remain Partner collection-owned. Counts are derived and are not separately editable. |
|
||||
| `netzwerk.sponsoringForm.*` | The two visible form steps, validation, success state, and footer copy in `src/spa/components/forms/SponsoringForm.tsx`. |
|
||||
|
||||
## Partner collection administration
|
||||
|
||||
The Partners list defaults to `name`, `tier`, `sortOrder`, `_status`, `active`, `showOnHomepage`, and `updatedAt`, so editors can review presentation order and visibility without opening every record. `tier` accepts only the numeric values 1, 2, and 3. Within each tier, lower `sortOrder` values render first; tiers remain separate presentation bands.
|
||||
|
||||
The current published local CMS data was verified on 2026-07-31 in this client-review order:
|
||||
|
||||
| Review order | Partner | Tier | `sortOrder` |
|
||||
| ---: | --- | ---: | ---: |
|
||||
| 1 | RSM Ebner Stolz | 1 | 1 |
|
||||
| 2 | WWK | 1 | 2 |
|
||||
| 3 | Radio Gong 96.3 | 2 | 3 |
|
||||
| 4 | münchen.tv | 2 | 4 |
|
||||
| 5 | METZLER | 3 | 5 |
|
||||
| 6 | Dr. Wieselhuber & Partner | 3 | 6 |
|
||||
| 7 | Fristads | 3 | 7 |
|
||||
| 8 | New Edge | 3 | 8 |
|
||||
| 9 | Moodtalk | 3 | 9 |
|
||||
| 10 | Deutsche Bank | 3 | 10 |
|
||||
| 11 | Bionorica | 3 | 11 |
|
||||
| 12 | Primus | 3 | 12 |
|
||||
|
||||
Future sponsor order changes are CMS content operations: edit the relevant Partner records' `tier` and `sortOrder` values. They do not require frontend code changes.
|
||||
|
||||
## Global ownership
|
||||
|
||||
| Global | Consumer or contract |
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import type { CollectionConfig } from 'payload'
|
||||
import type { CollectionConfig, NumberFieldSingleValidation } from 'payload'
|
||||
|
||||
import { authenticated } from '@/access/authenticated'
|
||||
import { authenticatedOrPublished } from '@/access/authenticatedOrPublished'
|
||||
import { populatePublishedAt } from '@/hooks/populatePublishedAt'
|
||||
import { sponsorTierNumbers } from '@/spa/sponsorHierarchy'
|
||||
|
||||
export const validatePartnerTier: NumberFieldSingleValidation = (value) =>
|
||||
typeof value === 'number' && sponsorTierNumbers.some((tier) => tier === value)
|
||||
? true
|
||||
: 'Tier must be one of 1, 2, or 3.'
|
||||
|
||||
export const Partners: CollectionConfig = {
|
||||
slug: 'partners',
|
||||
@@ -17,7 +23,15 @@ export const Partners: CollectionConfig = {
|
||||
update: authenticated,
|
||||
},
|
||||
admin: {
|
||||
defaultColumns: ['name', 'tier', 'sortOrder', 'active', 'showOnHomepage', 'updatedAt'],
|
||||
defaultColumns: [
|
||||
'name',
|
||||
'tier',
|
||||
'sortOrder',
|
||||
'_status',
|
||||
'active',
|
||||
'showOnHomepage',
|
||||
'updatedAt',
|
||||
],
|
||||
group: 'Inhalte',
|
||||
useAsTitle: 'name',
|
||||
},
|
||||
@@ -40,14 +54,21 @@ export const Partners: CollectionConfig = {
|
||||
name: 'tier',
|
||||
type: 'number',
|
||||
defaultValue: 3,
|
||||
min: 1,
|
||||
max: 3,
|
||||
required: true,
|
||||
validate: validatePartnerTier,
|
||||
admin: {
|
||||
description: 'Only tiers 1, 2, and 3 are supported.',
|
||||
step: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'sortOrder',
|
||||
type: 'number',
|
||||
defaultValue: 100,
|
||||
admin: {
|
||||
description: 'Lower numbers appear earlier on partner surfaces.',
|
||||
description: 'Lower numbers appear earlier within the selected tier on partner surfaces.',
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -68,7 +89,8 @@ export const Partners: CollectionConfig = {
|
||||
relationTo: 'media',
|
||||
required: true,
|
||||
admin: {
|
||||
description: 'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.',
|
||||
description:
|
||||
'Partner logo displayed on partner cards, the detail modal, and the homepage ticker.',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -2171,9 +2171,12 @@ export interface Partner {
|
||||
* Stable machine-readable key used when migrating or matching partner records.
|
||||
*/
|
||||
stableId?: string | null;
|
||||
/**
|
||||
* Only tiers 1, 2, and 3 are supported.
|
||||
*/
|
||||
tier: number;
|
||||
/**
|
||||
* Lower numbers appear earlier on partner surfaces.
|
||||
* Lower numbers appear earlier within the selected tier on partner surfaces.
|
||||
*/
|
||||
sortOrder?: number | null;
|
||||
active?: boolean | null;
|
||||
|
||||
@@ -35,13 +35,13 @@ const partner = (name: string, tier: 1 | 2 | 3, sortOrder: number) => ({
|
||||
})
|
||||
|
||||
const partners = [
|
||||
partner('Tier One B', 1, 2),
|
||||
partner('Tier One B', 1, 200),
|
||||
partner('Tier Three H', 3, 8),
|
||||
partner('Tier Two B', 2, 2),
|
||||
partner('Tier Two B', 2, 20),
|
||||
partner('Tier Three A', 3, 1),
|
||||
partner('Tier One A', 1, 1),
|
||||
partner('Tier One A', 1, 100),
|
||||
partner('Tier Three C', 3, 3),
|
||||
partner('Tier Two A', 2, 1),
|
||||
partner('Tier Two A', 2, 10),
|
||||
partner('Tier Three B', 3, 2),
|
||||
partner('Tier Three D', 3, 4),
|
||||
partner('Tier Three E', 3, 5),
|
||||
@@ -102,7 +102,7 @@ describe('responsive sponsor hierarchy', () => {
|
||||
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('groups by Partner tier and sorts each band by Partner sortOrder', () => {
|
||||
it('keeps tiers separate and applies Partner sortOrder only within each tier', () => {
|
||||
const grouped = groupSponsorsByTier(partners)
|
||||
|
||||
expect(grouped[1].map(({ name }) => name)).toEqual(['Tier One A', 'Tier One B'])
|
||||
@@ -117,14 +117,32 @@ describe('responsive sponsor hierarchy', () => {
|
||||
'Tier Three G',
|
||||
'Tier Three H',
|
||||
])
|
||||
expect([...grouped[1], ...grouped[2], ...grouped[3]].map(({ name }) => name)).toEqual([
|
||||
'Tier One A',
|
||||
'Tier One B',
|
||||
'Tier Two A',
|
||||
'Tier Two B',
|
||||
'Tier Three A',
|
||||
'Tier Three B',
|
||||
'Tier Three C',
|
||||
'Tier Three D',
|
||||
'Tier Three E',
|
||||
'Tier Three F',
|
||||
'Tier Three G',
|
||||
'Tier Three H',
|
||||
])
|
||||
})
|
||||
|
||||
it('defines the approved columns and conservative narrow-mobile fallback', () => {
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'desktop'))).toEqual([2, 3, 4])
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'tablet'))).toEqual([2, 2, 3])
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'mobile'))).toEqual([1, 1, 2])
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([1, 1, 1])
|
||||
expect(sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true })).toBe('tablet')
|
||||
expect([1, 2, 3].map((tier) => sponsorColumns(tier as 1 | 2 | 3, 'narrow-mobile'))).toEqual([
|
||||
1, 1, 1,
|
||||
])
|
||||
expect(
|
||||
sponsorViewport({ isMobile: false, isNarrowMobile: false, isTabletOrMobile: true }),
|
||||
).toBe('tablet')
|
||||
expect(sponsorCardBasis(3, 16)).toBe('calc((100% - 32px) / 3)')
|
||||
})
|
||||
|
||||
@@ -140,7 +158,9 @@ describe('responsive sponsor hierarchy', () => {
|
||||
})
|
||||
expect(screen.getAllByText(sponsorCopy.detailLabel)).toHaveLength(partners.length)
|
||||
expect(screen.queryByText(netzwerkContent.partners.description)).toBeNull()
|
||||
netzwerkContent.partners.tiers.forEach(({ label }) => expect(screen.queryByText(label)).toBeNull())
|
||||
netzwerkContent.partners.tiers.forEach(({ label }) =>
|
||||
expect(screen.queryByText(label)).toBeNull(),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
48
tests/int/partners-admin-ordering.int.spec.ts
Normal file
48
tests/int/partners-admin-ordering.int.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { Field, NumberField, NumberFieldSingleValidation } from 'payload'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { Partners } from '@/collections/Partners'
|
||||
|
||||
function partnerField(name: string): Field {
|
||||
const field = Partners.fields.find((candidate) => 'name' in candidate && candidate.name === name)
|
||||
if (!field) throw new Error(`Missing Partners field: ${name}`)
|
||||
return field
|
||||
}
|
||||
|
||||
describe('Partners admin ordering contract', () => {
|
||||
it('shows the title and ordering state needed for content operations', () => {
|
||||
expect(Partners.admin?.useAsTitle).toBe('name')
|
||||
expect(Partners.admin?.defaultColumns).toEqual([
|
||||
'name',
|
||||
'tier',
|
||||
'sortOrder',
|
||||
'_status',
|
||||
'active',
|
||||
'showOnHomepage',
|
||||
'updatedAt',
|
||||
])
|
||||
})
|
||||
|
||||
it('accepts only numeric tiers 1, 2, and 3', async () => {
|
||||
const tierField = partnerField('tier')
|
||||
|
||||
expect(tierField).toMatchObject({
|
||||
type: 'number',
|
||||
min: 1,
|
||||
max: 3,
|
||||
required: true,
|
||||
admin: { step: 1 },
|
||||
})
|
||||
|
||||
const validate = (tierField as NumberField).validate as NumberFieldSingleValidation | undefined
|
||||
expect(validate).toBeTypeOf('function')
|
||||
|
||||
for (const value of [1, 2, 3]) {
|
||||
expect(await validate?.(value, {} as never)).toBe(true)
|
||||
}
|
||||
|
||||
for (const value of [0, 1.5, 4, Number.NaN, null, undefined]) {
|
||||
expect(await validate?.(value, {} as never)).toBe('Tier must be one of 1, 2, or 3.')
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user