feat: implement jury member selection and sorting functionality, update related database migrations

This commit is contained in:
syntaxbullet
2026-07-13 20:49:06 +02:00
parent 98af6423c5
commit 63387ce335
8 changed files with 273 additions and 55 deletions

View File

@@ -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.',
},
},
],
},

View File

@@ -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<void> {
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<void> {
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')
}

View File

@@ -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',
},
];

View File

@@ -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<T extends boolean = true> {
description?: T;
chairBadge?: T;
note?: T;
members?:
| T
| {
name?: T;
role?: T;
bio?: T;
imageUpload?: T;
image?: T;
id?: T;
};
members?: T;
};
expertise?:
| T

View File

@@ -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({

30
src/spa/juryMembers.ts Normal file
View File

@@ -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<T extends object>(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)
}

View File

@@ -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<typeof netzwerkContent> & {
type NetzwerkCms = Omit<Partial<typeof netzwerkContent>, 'hero' | 'jury' | 'sponsoring'> & {
hero?: Partial<typeof netzwerkContent.hero> & { image?: unknown }
jury?: Partial<Omit<typeof netzwerkContent.jury, 'members'>> & { members?: unknown[] | null }
sponsoring?: Partial<typeof netzwerkContent.sponsoring> & { 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<PatronPerson>(patronage.people, netzwerkContent.patronage.people);
const juryCollectionMembers = cmsJuryMembers.map(normalizeJuryMember).filter((member) => member.active !== false && member.name);
const juryMembers = sortedJuryMembers(juryCollectionMembers.length
? juryCollectionMembers
: fallbackArray<JuryMember>(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<ExpertiseRow>(expertise.rows, netzwerkContent.expertise.rows);
const partners = sortedPartners(cmsPartners)
.filter((partner) => partner.active !== false)
@@ -710,9 +707,11 @@ const Netzwerk: React.FC = () => {
</div>
{/* ── Prominenter Vorsitz-Bereich ─────────────────────────────────── */}
{/* TODO: echten Jury-Vorsitzenden bestätigen (Name & Foto sind Platzhalter) */}
<div style={{ padding: isMobile ? '28px 24px 8px' : '64px 80px 16px' }}>
<JuryChairCard member={juryMembers[0]} badge={fallbackText(jury.chairBadge, netzwerkContent.jury.chairBadge)} />
</div>
{juryMembers.length ? (
<div style={{ padding: isMobile ? '28px 24px 8px' : '64px 80px 16px' }}>
<JuryChairCard member={juryMembers[0]} badge={fallbackText(jury.chairBadge, netzwerkContent.jury.chairBadge)} />
</div>
) : null}
{/* ── Übrige Jury-Mitglieder ──────────────────────────────────────── */}
<div style={{ display: isMobile ? 'flex' : 'grid', gridTemplateColumns: isMobile ? undefined : 'repeat(auto-fill, minmax(260px, 1fr))', overflowX: isMobile ? 'auto' : undefined, scrollSnapType: isMobile ? 'x mandatory' : undefined, gap: isMobile ? 12 : 0, padding: isMobile ? '24px 24px 36px' : '0 0 56px', WebkitOverflowScrolling: 'touch', scrollbarWidth: 'none', borderTop: isMobile ? 'none' : '1px solid rgba(255,255,255,0.07)' }}>

View File

@@ -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',
])
})
})