Skip to content

Commit 8e87183

Browse files
CopilotStarefossen
andauthored
Add prerequisites field for workshop proposals (#342)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Starefossen <968267+Starefossen@users.noreply.github.com> Co-authored-by: Hans Kristian Flaatten <hans@flaatten.org>
1 parent 7536d9c commit 8e87183

7 files changed

Lines changed: 152 additions & 1 deletion

File tree

__tests__/lib/proposal/schemas.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,60 @@ describe('ProposalInputSchema (strict)', () => {
138138
expect(result.success).toBe(true)
139139
})
140140

141+
it('accepts workshop format with prerequisites', () => {
142+
const result = ProposalInputSchema.safeParse({
143+
...fullProposal,
144+
format: Format.workshop_120,
145+
capacity: 30,
146+
prerequisites: 'Bring a computer with Docker installed',
147+
})
148+
expect(result.success).toBe(true)
149+
})
150+
151+
it('accepts non-workshop format without prerequisites', () => {
152+
const result = ProposalInputSchema.safeParse({
153+
...fullProposal,
154+
format: Format.presentation_40,
155+
prerequisites: undefined,
156+
})
157+
expect(result.success).toBe(true)
158+
})
159+
160+
it('rejects prerequisites for non-workshop formats', () => {
161+
const result = ProposalInputSchema.safeParse({
162+
...fullProposal,
163+
format: Format.presentation_40,
164+
prerequisites: 'Should not be allowed',
165+
})
166+
expect(result.success).toBe(false)
167+
})
168+
169+
it('normalizes empty prerequisites to undefined', () => {
170+
const result = ProposalInputSchema.safeParse({
171+
...fullProposal,
172+
format: Format.workshop_120,
173+
capacity: 30,
174+
prerequisites: ' ',
175+
})
176+
expect(result.success).toBe(true)
177+
if (result.success) {
178+
expect(result.data.prerequisites).toBeUndefined()
179+
}
180+
})
181+
182+
it('trims whitespace from prerequisites', () => {
183+
const result = ProposalInputSchema.safeParse({
184+
...fullProposal,
185+
format: Format.workshop_120,
186+
capacity: 30,
187+
prerequisites: ' Docker required ',
188+
})
189+
expect(result.success).toBe(true)
190+
if (result.success) {
191+
expect(result.data.prerequisites).toBe('Docker required')
192+
}
193+
})
194+
141195
it('rejects missing title', () => {
142196
const { title: _, ...withoutTitle } = fullProposal
143197
const result = ProposalInputSchema.safeParse(withoutTitle)

sanity/schemaTypes/talk.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,23 @@ export default defineType({
212212
return true
213213
}),
214214
}),
215+
defineField({
216+
name: 'prerequisites',
217+
title: 'Prerequisites',
218+
type: 'text',
219+
description:
220+
'Prerequisites for workshop participants (e.g., "Bring a computer with Docker installed")',
221+
hidden: ({ document }) => {
222+
const format = document?.format as string | undefined
223+
const prerequisites = document?.prerequisites as string | undefined
224+
const isWorkshop =
225+
format === 'workshop_120' || format === 'workshop_240'
226+
return !(
227+
isWorkshop ||
228+
(prerequisites !== undefined && prerequisites !== null)
229+
)
230+
},
231+
}),
215232
defineField({
216233
name: 'audienceFeedback',
217234
title: 'Audience Feedback',

src/components/cfp/ProposalForm.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ export function ProposalForm({
169169
audiences: proposal.audiences,
170170
tos: proposal.tos,
171171
capacity: proposal.capacity,
172+
prerequisites: proposal.prerequisites,
172173
topics: topicRefs,
173174
speakers: allSpeakers.map((id) => ({
174175
_type: 'reference' as const,

src/components/program/TalkCard.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
XMarkIcon,
1111
} from '@heroicons/react/24/outline'
1212
import { TrackTalk } from '@/lib/conference/types'
13-
import { Status } from '@/lib/proposal/types'
13+
import { isWorkshopFormat, Status } from '@/lib/proposal/types'
1414
import { SpeakerAvatars } from '@/components/SpeakerAvatars'
1515
import { ClickableSpeakerNames } from '@/components/ClickableSpeakerNames'
1616
import { BookmarkButton } from '@/components/BookmarkButton'
@@ -436,6 +436,21 @@ export function TalkCard({
436436
</div>
437437
)}
438438

439+
{!compact &&
440+
isConfirmed &&
441+
talkData.format &&
442+
isWorkshopFormat(talkData.format) &&
443+
talkData.prerequisites && (
444+
<div className="rounded-md border border-blue-200 bg-blue-50 p-4 dark:border-blue-800/50 dark:bg-blue-900/20">
445+
<h4 className="text-sm font-semibold text-blue-900 dark:text-blue-200">
446+
Prerequisites
447+
</h4>
448+
<p className="mt-1 text-sm whitespace-pre-wrap text-blue-800 dark:text-blue-300">
449+
{talkData.prerequisites}
450+
</p>
451+
</div>
452+
)}
453+
439454
{!compact && !isConfirmed && !isWithdrawnOrRejected && (
440455
<div className="flex-1">
441456
<div className="text-sm text-gray-400 italic dark:text-gray-500">

src/components/proposal/ProposalDetailsForm.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
audiences as audiencesMap,
77
Format,
88
formats,
9+
isWorkshopFormat,
910
Language,
1011
languages,
1112
Level,
@@ -57,6 +58,17 @@ export function ProposalDetailsForm({
5758
)
5859
const [outline, setOutline] = useState(proposal?.outline ?? '')
5960
const [tos, setTos] = useState(proposal?.tos ?? false)
61+
const [prerequisites, setPrerequisites] = useState(
62+
proposal?.prerequisites ?? '',
63+
)
64+
65+
// Clear prerequisites when format changes to non-workshop
66+
useEffect(() => {
67+
if (!isWorkshopFormat(format)) {
68+
// eslint-disable-next-line react-hooks/set-state-in-effect -- Clear stale prerequisites when switching to non-workshop format
69+
setPrerequisites('')
70+
}
71+
}, [format])
6072

6173
// Push local state changes to parent
6274
useEffect(() => {
@@ -70,6 +82,7 @@ export function ProposalDetailsForm({
7082
topics,
7183
outline,
7284
tos,
85+
prerequisites: prerequisites.trim() || undefined,
7386
})
7487
}, [
7588
title,
@@ -81,6 +94,7 @@ export function ProposalDetailsForm({
8194
topics,
8295
outline,
8396
tos,
97+
prerequisites,
8498
setProposal,
8599
])
86100

@@ -202,6 +216,17 @@ export function ProposalDetailsForm({
202216
{outline || 'No outline provided'}
203217
</p>
204218
</div>
219+
220+
{isWorkshopFormat(format) && (
221+
<div>
222+
<label className="block text-sm font-medium text-gray-900 dark:text-white">
223+
Prerequisites
224+
</label>
225+
<p className="mt-2 whitespace-pre-wrap text-gray-900 dark:text-white">
226+
{prerequisites || 'No prerequisites specified'}
227+
</p>
228+
</div>
229+
)}
205230
</div>
206231
) : (
207232
<div className="mt-10 grid grid-cols-1 gap-x-6 gap-y-8 sm:grid-cols-6">
@@ -315,6 +340,23 @@ export function ProposalDetailsForm({
315340
</HelpText>
316341
</div>
317342

343+
{isWorkshopFormat(format) && (
344+
<div className="col-span-full">
345+
<Textarea
346+
name="prerequisites"
347+
label="Prerequisites"
348+
rows={3}
349+
value={prerequisites}
350+
setValue={setPrerequisites}
351+
/>
352+
<HelpText>
353+
List any prerequisites participants should meet before attending
354+
your workshop (e.g., &quot;Bring a computer with Docker
355+
installed&quot;, &quot;Basic knowledge of Kubernetes&quot;).
356+
</HelpText>
357+
</div>
358+
)}
359+
318360
<div className="col-span-full">
319361
<Checkbox
320362
name="tos"

src/lib/proposal/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ interface Proposal {
8484
tos: boolean
8585
video?: string
8686
capacity?: number
87+
prerequisites?: string
8788
audienceFeedback?: AudienceFeedback
8889
attachments?: Attachment[]
8990
}

src/server/schemas/proposal.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,15 @@ const ProposalInputBaseSchema = z.object({
5151
}),
5252
video: z.string().nullable().optional().transform(nullToUndefined),
5353
capacity: z.number().nullable().optional().transform(nullToUndefined),
54+
prerequisites: z
55+
.string()
56+
.nullable()
57+
.optional()
58+
.transform((val) => {
59+
if (val === null || val === undefined) return undefined
60+
const trimmed = val.trim()
61+
return trimmed.length > 0 ? trimmed : undefined
62+
}),
5463
speakers: z
5564
.array(ReferenceSchema)
5665
.nullable()
@@ -71,6 +80,18 @@ export const ProposalInputSchema = ProposalInputBaseSchema.refine(
7180
message: 'Workshop capacity is required for workshop formats',
7281
path: ['capacity'],
7382
},
83+
).refine(
84+
(data) => {
85+
// Prerequisites should only be provided for workshop formats
86+
if (data.prerequisites && !isWorkshopFormat(data.format)) {
87+
return false
88+
}
89+
return true
90+
},
91+
{
92+
message: 'Prerequisites are only allowed for workshop formats',
93+
path: ['prerequisites'],
94+
},
7495
)
7596

7697
// Admin-specific proposal creation (includes speaker IDs)

0 commit comments

Comments
 (0)