From 95037e99089837a64a4315f6d2a467e8e5be61f5 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 31 Jul 2026 16:52:49 +0200 Subject: [PATCH] feat(cms): clarify sponsor ordering --- docs/payload-field-inventory.md | 23 +++++++++ src/collections/Partners.ts | 30 ++++++++++-- src/payload-types.ts | 5 +- .../netzwerk-sponsor-hierarchy.int.spec.tsx | 36 ++++++++++---- tests/int/partners-admin-ordering.int.spec.ts | 48 +++++++++++++++++++ 5 files changed, 129 insertions(+), 13 deletions(-) create mode 100644 tests/int/partners-admin-ordering.int.spec.ts diff --git a/docs/payload-field-inventory.md b/docs/payload-field-inventory.md index db9f13a..9540534 100644 --- a/docs/payload-field-inventory.md +++ b/docs/payload-field-inventory.md @@ -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 | diff --git a/src/collections/Partners.ts b/src/collections/Partners.ts index 609bb84..52470c9 100644 --- a/src/collections/Partners.ts +++ b/src/collections/Partners.ts @@ -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.', }, }, { diff --git a/src/payload-types.ts b/src/payload-types.ts index 02bfa8b..b9639c8 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -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; diff --git a/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx b/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx index eb47336..4fa26dc 100644 --- a/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx +++ b/tests/int/netzwerk-sponsor-hierarchy.int.spec.tsx @@ -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([ diff --git a/tests/int/partners-admin-ordering.int.spec.ts b/tests/int/partners-admin-ordering.int.spec.ts new file mode 100644 index 0000000..9f9a3dd --- /dev/null +++ b/tests/int/partners-admin-ordering.int.spec.ts @@ -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.') + } + }) +})