From 63387ce3357f0fe6c9abfe70520ae7d0341ade79 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Mon, 13 Jul 2026 20:49:06 +0200 Subject: [PATCH] feat: implement jury member selection and sorting functionality, update related database migrations --- src/collections/Pages/netzwerkFields.ts | 18 +-- ...20260713_213000_netzwerk_jury_selection.ts | 133 ++++++++++++++++++ src/migrations/index.ts | 6 + src/payload-types.ts | 25 +--- src/scripts/preload-netzwerk-page-cms.ts | 40 +++++- src/spa/juryMembers.ts | 30 ++++ src/spa/pages/Netzwerk.tsx | 43 +++--- tests/int/jury-members.int.spec.ts | 33 +++++ 8 files changed, 273 insertions(+), 55 deletions(-) create mode 100644 src/migrations/20260713_213000_netzwerk_jury_selection.ts create mode 100644 src/spa/juryMembers.ts create mode 100644 tests/int/jury-members.int.spec.ts diff --git a/src/collections/Pages/netzwerkFields.ts b/src/collections/Pages/netzwerkFields.ts index 3b79272..665c5a9 100644 --- a/src/collections/Pages/netzwerkFields.ts +++ b/src/collections/Pages/netzwerkFields.ts @@ -48,14 +48,6 @@ const personFields: Field[] = [ text('institution', 'Institution'), ] -const juryMemberFields: Field[] = [ - text('name', 'Name'), - text('role', 'Role'), - textarea('bio', 'Bio'), - uploadField('imageUpload', 'Image'), - text('image', 'Image URL'), -] - const rowFields: Field[] = [text('num', 'Number'), text('label', 'Label'), textarea('body', 'Body')] const benefitFields: Field[] = [text('num', 'Number'), text('label', 'Label'), text('sub', 'Subline')] @@ -152,10 +144,12 @@ export const netzwerkFields: Field[] = [ { name: 'members', label: 'Members', - type: 'array', - dbName: 'network_jury', - defaultValue: netzwerkContent.jury.members, - fields: juryMemberFields, + type: 'relationship', + relationTo: 'jury-mitglieder', + hasMany: true, + admin: { + description: 'Choose the jury members rendered on this page. Members with a quote are displayed before members without one.', + }, }, ], }, diff --git a/src/migrations/20260713_213000_netzwerk_jury_selection.ts b/src/migrations/20260713_213000_netzwerk_jury_selection.ts new file mode 100644 index 0000000..3d0c9e8 --- /dev/null +++ b/src/migrations/20260713_213000_netzwerk_jury_selection.ts @@ -0,0 +1,133 @@ +import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite' + +type MigrationDB = MigrateUpArgs['db'] + +const ident = (value: string) => `\`${value.replace(/`/g, '``')}\`` +const literal = (value: string) => `'${value.replace(/'/g, "''")}'` + +async function tableExists(db: MigrationDB, tableName: string) { + const rows = (await db.all( + sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`), + )) as Array<{ name: string }> + + return rows.length > 0 +} + +async function columnExists(db: MigrationDB, tableName: string, columnName: string) { + if (!(await tableExists(db, tableName))) return false + + const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }> + return columns.some((column) => column.name === columnName) +} + +async function addJuryRelationshipColumn(db: MigrationDB, tableName: string) { + if (!(await tableExists(db, tableName))) return + + if (!(await columnExists(db, tableName, 'jury_mitglieder_id'))) { + await db.run( + sql.raw( + `ALTER TABLE ${ident(tableName)} ADD ${ident('jury_mitglieder_id')} integer REFERENCES ${ident('jury_mitglieder')}(${ident('id')});`, + ), + ) + } + + await db.run( + sql.raw( + `CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_jury_mitglieder_id_idx`)} ON ${ident(tableName)} (${ident('jury_mitglieder_id')});`, + ), + ) +} + +async function backfillLivePageSelections(db: MigrationDB) { + if (!(await tableExists(db, 'pages_rels')) || !(await tableExists(db, 'jury_mitglieder'))) return + + await db.run(sql.raw(`DELETE FROM ${ident('pages_rels')} WHERE ${ident('path')} = ${literal('netzwerk.jury.members')};`)) + await db.run(sql.raw(` + INSERT INTO ${ident('pages_rels')} ( + ${ident('order')}, + ${ident('parent_id')}, + ${ident('path')}, + ${ident('jury_mitglieder_id')} + ) + SELECT + ROW_NUMBER() OVER ( + PARTITION BY page.${ident('id')} + ORDER BY + CASE WHEN LENGTH(TRIM(COALESCE(member.${ident('quote')}, ''))) > 0 THEN 0 ELSE 1 END, + COALESCE(member.${ident('sort_order')}, 100), + member.${ident('id')} + ), + page.${ident('id')}, + ${literal('netzwerk.jury.members')}, + member.${ident('id')} + FROM ${ident('pages')} AS page + CROSS JOIN ${ident('jury_mitglieder')} AS member + WHERE + (page.${ident('spa_path')} = ${literal('/netzwerk')} + OR page.${ident('slug')} IN (${literal('netzwerk')}, ${literal('network')})) + AND COALESCE(member.${ident('active')}, 1) = 1; + `)) +} + +async function backfillVersionSelections(db: MigrationDB) { + if (!(await tableExists(db, '_pages_v_rels')) || !(await tableExists(db, 'jury_mitglieder'))) return + + await db.run( + sql.raw(`DELETE FROM ${ident('_pages_v_rels')} WHERE ${ident('path')} = ${literal('version.netzwerk.jury.members')};`), + ) + await db.run(sql.raw(` + INSERT INTO ${ident('_pages_v_rels')} ( + ${ident('order')}, + ${ident('parent_id')}, + ${ident('path')}, + ${ident('jury_mitglieder_id')} + ) + SELECT + ROW_NUMBER() OVER ( + PARTITION BY version.${ident('id')} + ORDER BY + CASE WHEN LENGTH(TRIM(COALESCE(member.${ident('quote')}, ''))) > 0 THEN 0 ELSE 1 END, + COALESCE(member.${ident('sort_order')}, 100), + member.${ident('id')} + ), + version.${ident('id')}, + ${literal('version.netzwerk.jury.members')}, + member.${ident('id')} + FROM ${ident('_pages_v')} AS version + CROSS JOIN ${ident('jury_mitglieder')} AS member + WHERE + (version.${ident('version_spa_path')} = ${literal('/netzwerk')} + OR version.${ident('version_slug')} IN (${literal('netzwerk')}, ${literal('network')})) + AND COALESCE(member.${ident('active')}, 1) = 1; + `)) +} + +async function removeJuryRelationshipColumn(db: MigrationDB, tableName: string) { + if (!(await tableExists(db, tableName))) return + + await db.run(sql.raw(`DROP INDEX IF EXISTS ${ident(`${tableName}_jury_mitglieder_id_idx`)};`)) + if (await columnExists(db, tableName, 'jury_mitglieder_id')) { + await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident('jury_mitglieder_id')};`)) + } +} + +export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise { + await addJuryRelationshipColumn(db, 'pages_rels') + await addJuryRelationshipColumn(db, '_pages_v_rels') + await backfillLivePageSelections(db) + await backfillVersionSelections(db) +} + +export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise { + if (await tableExists(db, 'pages_rels')) { + await db.run(sql.raw(`DELETE FROM ${ident('pages_rels')} WHERE ${ident('path')} = ${literal('netzwerk.jury.members')};`)) + } + if (await tableExists(db, '_pages_v_rels')) { + await db.run( + sql.raw(`DELETE FROM ${ident('_pages_v_rels')} WHERE ${ident('path')} = ${literal('version.netzwerk.jury.members')};`), + ) + } + + await removeJuryRelationshipColumn(db, '_pages_v_rels') + await removeJuryRelationshipColumn(db, 'pages_rels') +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index e1c03ef..0b32304 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -13,6 +13,7 @@ import * as migration_20260713_133000_newsletter_form_and_subscriptions from './ import * as migration_20260713_155500_shared_application_form from './20260713_155500_shared_application_form'; import * as migration_20260713_163000_participation_supporting_awards_copy from './20260713_163000_participation_supporting_awards_copy'; import * as migration_20260713_194500_contact_submissions from './20260713_194500_contact_submissions'; +import * as migration_20260713_213000_netzwerk_jury_selection from './20260713_213000_netzwerk_jury_selection'; export const migrations = [ { @@ -90,4 +91,9 @@ export const migrations = [ down: migration_20260713_194500_contact_submissions.down, name: '20260713_194500_contact_submissions', }, + { + up: migration_20260713_213000_netzwerk_jury_selection.up, + down: migration_20260713_213000_netzwerk_jury_selection.down, + name: '20260713_213000_netzwerk_jury_selection', + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index 03b2959..f72e082 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -1765,16 +1765,10 @@ export interface Page { description?: string | null; chairBadge?: string | null; note?: string | null; - members?: - | { - name?: string | null; - role?: string | null; - bio?: string | null; - imageUpload?: (number | null) | Media; - image?: string | null; - id?: string | null; - }[] - | null; + /** + * Choose the jury members rendered on this page. Members with a quote are displayed before members without one. + */ + members?: (number | JuryMitglieder)[] | null; }; /** * Evaluation process rows and quote. @@ -4398,16 +4392,7 @@ export interface PagesSelect { description?: T; chairBadge?: T; note?: T; - members?: - | T - | { - name?: T; - role?: T; - bio?: T; - imageUpload?: T; - image?: T; - id?: T; - }; + members?: T; }; expertise?: | T diff --git a/src/scripts/preload-netzwerk-page-cms.ts b/src/scripts/preload-netzwerk-page-cms.ts index 6ed87e5..6dbd493 100644 --- a/src/scripts/preload-netzwerk-page-cms.ts +++ b/src/scripts/preload-netzwerk-page-cms.ts @@ -76,6 +76,44 @@ async function upsertPartners(payload: Payload) { ) } +async function juryMembersForPage(payload: Payload) { + const existing = await payload.find({ + collection: 'jury-mitglieder', + limit: 300, + pagination: false, + draft: true, + overrideAccess: true, + sort: 'sortOrder', + }) + + if (existing.docs.length) { + return existing.docs.filter((member) => member.active !== false).map((member) => member.id) + } + + const membersWithUploads = await itemsWithImageUploads(payload, netzwerkContent.jury.members) + const created = await Promise.all( + membersWithUploads.map((member, index) => + payload.create({ + collection: 'jury-mitglieder', + overrideAccess: true, + context: { disableRevalidate: true }, + data: { + name: member.name, + image: member.imageUpload, + imageAlt: member.name, + role: member.role, + bio: member.bio, + sortOrder: index, + active: true, + _status: 'published', + }, + }), + ), + ) + + return created.map((member) => member.id) +} + async function main() { const payload = await getPayload({ config }) @@ -100,7 +138,7 @@ async function main() { const { items: _partnerItems, ...partnerSectionContent } = netzwerkContent.partners const [patronPeople, juryMembers] = await Promise.all([ itemsWithImageUploads(payload, netzwerkContent.patronage.people), - itemsWithImageUploads(payload, netzwerkContent.jury.members), + juryMembersForPage(payload), ]) await payload.update({ diff --git a/src/spa/juryMembers.ts b/src/spa/juryMembers.ts new file mode 100644 index 0000000..32228d4 --- /dev/null +++ b/src/spa/juryMembers.ts @@ -0,0 +1,30 @@ +import { resolveCmsRelationship, type CmsRouteDoc } from '@/spa/cmsRoute' + +const hasText = (value: unknown) => typeof value === 'string' && value.trim().length > 0 + +export function selectJuryDocs(selection: unknown, docs: CmsRouteDoc[]) { + if (selection === undefined) return docs + if (!Array.isArray(selection)) return [] + + return selection + .map((entry) => resolveCmsRelationship(entry, docs)) + .filter( + (member): member is CmsRouteDoc => + Boolean(member && docs.some((doc) => String(doc.id) === String(member.id))), + ) +} + +export function sortJuryMembersQuoteFirst(members: T[]) { + return members + .map((member, index) => ({ member, index })) + .sort((a, b) => { + const aSortable = a.member as { quote?: unknown; sortOrder?: number | null } + const bSortable = b.member as { quote?: unknown; sortOrder?: number | null } + const quoteOrder = Number(hasText(bSortable.quote)) - Number(hasText(aSortable.quote)) + if (quoteOrder !== 0) return quoteOrder + + const sortOrder = Number(aSortable.sortOrder ?? a.index) - Number(bSortable.sortOrder ?? b.index) + return sortOrder !== 0 ? sortOrder : a.index - b.index + }) + .map(({ member }) => member) +} diff --git a/src/spa/pages/Netzwerk.tsx b/src/spa/pages/Netzwerk.tsx index 3778951..b66a91f 100644 --- a/src/spa/pages/Netzwerk.tsx +++ b/src/spa/pages/Netzwerk.tsx @@ -7,6 +7,7 @@ import { useIsMobile } from '@/spa/hooks/useIsMobile'; import { useCmsCollection, useCmsRoute, type CmsRouteDoc } from '@/spa/cmsRoute'; import { mediaAlt, mediaUrl } from '@/spa/cmsMediaField'; import { homeContent } from '@/spa/homeContent'; +import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers'; import { netzwerkContent } from '@/spa/netzwerkContent'; import Image from '@/spa/components/ui/UnoptimizedImage' import { PartnerLogoMark, type PartnerLogoData } from '@/spa/components/ui/partner-logo'; @@ -16,8 +17,9 @@ const FB = '"Inter", sans-serif'; const NAVY = '#111D55'; const GOLD = '#EFBF04'; -type NetzwerkCms = Partial & { +type NetzwerkCms = Omit, 'hero' | 'jury' | 'sponsoring'> & { hero?: Partial & { image?: unknown } + jury?: Partial> & { members?: unknown[] | null } sponsoring?: Partial & { image?: unknown } } @@ -76,14 +78,15 @@ const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => { const organization = fallbackText(member.organization, '') const rawRole = fallbackText(member.role, '') const role = rawRole && organization && rawRole !== organization ? `${rawRole} / ${organization}` : fallbackText(rawRole, organization) + const image = member.image ?? member.imageUpload return { name: fallbackText(member.name, ''), role, bio: fallbackText(member.bio, ''), - image: mediaUrl(member.image, juryImageFallback), + image: mediaUrl(image, juryImageFallback), imageAlt: fallbackText(member.imageAlt, fallbackText(member.name, 'Jury-Mitglied')), - imageUpload: member.image, + imageUpload: image, organization, quote: fallbackText(member.quote, ''), sortOrder: typeof member.sortOrder === 'number' ? member.sortOrder : undefined, @@ -91,18 +94,6 @@ const normalizeJuryMember = (member: CmsRouteDoc): JuryMember => { } } const hasJuryText = (value: unknown) => typeof value === 'string' && value.trim().length > 0 -const hasCompleteJuryProfile = (member: JuryMember) => hasJuryText(member.bio) && hasJuryText(member.quote) -const sortedJuryMembers = (members: JuryMember[]) => - members - .map((member, index) => ({ member, index })) - .sort((a, b) => { - const completenessOrder = Number(hasCompleteJuryProfile(b.member)) - Number(hasCompleteJuryProfile(a.member)) - if (completenessOrder !== 0) return completenessOrder - - const sortOrder = Number(a.member.sortOrder ?? a.index) - Number(b.member.sortOrder ?? b.index) - return sortOrder !== 0 ? sortOrder : a.index - b.index - }) - .map(({ member }) => member) function Lines({ text, highlight }: { text: string; highlight?: string }) { return ( <> @@ -494,10 +485,16 @@ const Netzwerk: React.FC = () => { const partnerModal = { ...netzwerkContent.partners.modal, ...((cms.partners as typeof netzwerkContent.partners | undefined)?.modal || {}) }; const sponsoring = { ...netzwerkContent.sponsoring, ...(cms.sponsoring || {}) }; const patronPeople = fallbackArray(patronage.people, netzwerkContent.patronage.people); - const juryCollectionMembers = cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name); - const juryMembers = sortedJuryMembers(juryCollectionMembers.length - ? juryCollectionMembers - : fallbackArray(jury.members, netzwerkContent.jury.members)); + const cmsJurySelection = cms.jury?.members; + const hasCmsJurySelection = cmsJurySelection !== undefined; + const sourcedJuryDocs = selectJuryDocs(cmsJurySelection, cmsJuryMembers); + const normalizedJuryMembers = sourcedJuryDocs + .map(normalizeJuryMember) + .filter((member) => member.active !== false && member.name); + const jurySourceMembers: JuryMember[] = normalizedJuryMembers.length || hasCmsJurySelection + ? normalizedJuryMembers + : netzwerkContent.jury.members; + const juryMembers = sortJuryMembersQuoteFirst(jurySourceMembers); const expertiseRows = fallbackArray(expertise.rows, netzwerkContent.expertise.rows); const partners = sortedPartners(cmsPartners) .filter((partner) => partner.active !== false) @@ -710,9 +707,11 @@ const Netzwerk: React.FC = () => { {/* ── Prominenter Vorsitz-Bereich ─────────────────────────────────── */} {/* TODO: echten Jury-Vorsitzenden bestätigen (Name & Foto sind Platzhalter) */} -
- -
+ {juryMembers.length ? ( +
+ +
+ ) : null} {/* ── Übrige Jury-Mitglieder ──────────────────────────────────────── */}
diff --git a/tests/int/jury-members.int.spec.ts b/tests/int/jury-members.int.spec.ts new file mode 100644 index 0000000..b2fd670 --- /dev/null +++ b/tests/int/jury-members.int.spec.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' + +import { selectJuryDocs, sortJuryMembersQuoteFirst } from '@/spa/juryMembers' + +describe('Netzwerk jury members', () => { + const docs = [ + { id: 1, name: 'First' }, + { id: 2, name: 'Second' }, + { id: 3, name: 'Third' }, + ] + + it('uses the CMS relationship selection when it is present', () => { + expect(selectJuryDocs([3, { id: 1 }], docs).map((member) => member.id)).toEqual([3, 1]) + expect(selectJuryDocs([], docs)).toEqual([]) + expect(selectJuryDocs(undefined, docs)).toEqual(docs) + }) + + it('places non-empty quotes first and preserves sort order within each group', () => { + const members = [ + { name: 'Unquoted early', quote: '', sortOrder: 1 }, + { name: 'Quoted later', quote: 'Quote', sortOrder: 20 }, + { name: 'Quoted earlier', quote: ' Another quote ', sortOrder: 10 }, + { name: 'Unquoted later', quote: ' ', sortOrder: 30 }, + ] + + expect(sortJuryMembersQuoteFirst(members).map((member) => member.name)).toEqual([ + 'Quoted earlier', + 'Quoted later', + 'Unquoted early', + 'Unquoted later', + ]) + }) +})