feat: implement jury member selection and sorting functionality, update related database migrations
This commit is contained in:
@@ -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.',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
133
src/migrations/20260713_213000_netzwerk_jury_selection.ts
Normal file
133
src/migrations/20260713_213000_netzwerk_jury_selection.ts
Normal 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')
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
30
src/spa/juryMembers.ts
Normal 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)
|
||||
}
|
||||
@@ -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)' }}>
|
||||
|
||||
33
tests/int/jury-members.int.spec.ts
Normal file
33
tests/int/jury-members.int.spec.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user