Skip to content

Commit 7471aa4

Browse files
CopilotStarefossen
andauthored
Add waitlisted proposal status for backup speakers (#341)
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> Co-authored-by: Hans Kristian Flaatten <hans.kristian.flaatten@nav.no>
1 parent fcae930 commit 7471aa4

18 files changed

Lines changed: 313 additions & 24 deletions

__tests__/lib/proposal/state-machine.test.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ describe('actionStateMachine', () => {
4444
expect(result).toEqual({ status: Status.accepted, isValidAction: true })
4545
})
4646

47+
it('allows organizer to waitlist', () => {
48+
const result = actionStateMachine(Status.submitted, Action.waitlist, true)
49+
expect(result).toEqual({ status: Status.waitlisted, isValidAction: true })
50+
})
51+
4752
it('allows organizer to reject', () => {
4853
const result = actionStateMachine(Status.submitted, Action.reject, true)
4954
expect(result).toEqual({ status: Status.rejected, isValidAction: true })
@@ -54,6 +59,15 @@ describe('actionStateMachine', () => {
5459
expect(result.isValidAction).toBe(false)
5560
})
5661

62+
it('prevents non-organizer from waitlisting', () => {
63+
const result = actionStateMachine(
64+
Status.submitted,
65+
Action.waitlist,
66+
false,
67+
)
68+
expect(result.isValidAction).toBe(false)
69+
})
70+
5771
it('prevents non-organizer from rejecting', () => {
5872
const result = actionStateMachine(Status.submitted, Action.reject, false)
5973
expect(result.isValidAction).toBe(false)
@@ -110,6 +124,37 @@ describe('actionStateMachine', () => {
110124
})
111125
})
112126

127+
describe('waitlisted status', () => {
128+
it('allows organizer to accept', () => {
129+
const result = actionStateMachine(Status.waitlisted, Action.accept, true)
130+
expect(result).toEqual({ status: Status.accepted, isValidAction: true })
131+
})
132+
133+
it('allows organizer to reject', () => {
134+
const result = actionStateMachine(Status.waitlisted, Action.reject, true)
135+
expect(result).toEqual({ status: Status.rejected, isValidAction: true })
136+
})
137+
138+
it('allows speaker to withdraw', () => {
139+
const result = actionStateMachine(
140+
Status.waitlisted,
141+
Action.withdraw,
142+
false,
143+
)
144+
expect(result).toEqual({ status: Status.withdrawn, isValidAction: true })
145+
})
146+
147+
it('prevents non-organizer from accepting', () => {
148+
const result = actionStateMachine(Status.waitlisted, Action.accept, false)
149+
expect(result.isValidAction).toBe(false)
150+
})
151+
152+
it('prevents non-organizer from rejecting', () => {
153+
const result = actionStateMachine(Status.waitlisted, Action.reject, false)
154+
expect(result.isValidAction).toBe(false)
155+
})
156+
})
157+
113158
describe('full lifecycle: draft → submitted → accepted → confirmed', () => {
114159
it('transitions through the complete happy path', () => {
115160
let { status } = actionStateMachine(Status.draft, Action.submit, false)
@@ -119,5 +164,14 @@ describe('actionStateMachine', () => {
119164
;({ status } = actionStateMachine(status, Action.confirm, false))
120165
expect(status).toBe(Status.confirmed)
121166
})
167+
168+
it('transitions through waitlist path: draft → submitted → waitlisted → accepted', () => {
169+
let { status } = actionStateMachine(Status.draft, Action.submit, false)
170+
expect(status).toBe(Status.submitted)
171+
;({ status } = actionStateMachine(status, Action.waitlist, true))
172+
expect(status).toBe(Status.waitlisted)
173+
;({ status } = actionStateMachine(status, Action.accept, true))
174+
expect(status).toBe(Status.accepted)
175+
})
122176
})
123177
})

src/components/admin/AdminActionBar.tsx

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
CheckIcon,
66
XMarkIcon,
77
BellIcon,
8+
ClockIcon,
89
StarIcon,
910
UserPlusIcon,
1011
MapPinIcon,
@@ -94,10 +95,14 @@ export function AdminActionBar({
9495
setShowEditModal(false)
9596
}
9697

97-
const canApprove = proposal.status === 'submitted'
98+
const canApprove =
99+
proposal.status === 'submitted' || proposal.status === 'waitlisted'
100+
const canWaitlist = proposal.status === 'submitted'
98101
const canRemind = proposal.status === 'accepted'
99102
const canReject =
100-
proposal.status === 'submitted' || proposal.status === 'accepted'
103+
proposal.status === 'submitted' ||
104+
proposal.status === 'accepted' ||
105+
proposal.status === 'waitlisted'
101106

102107
// Keyboard shortcuts
103108
useEffect(() => {
@@ -158,11 +163,13 @@ export function AdminActionBar({
158163
className={`inline-flex items-center rounded-full px-2 py-1 text-xs font-medium ${
159164
proposal.status === 'accepted'
160165
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
161-
: proposal.status === 'rejected'
162-
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
163-
: proposal.status === 'submitted'
164-
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
165-
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
166+
: proposal.status === 'waitlisted'
167+
? 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200'
168+
: proposal.status === 'rejected'
169+
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
170+
: proposal.status === 'submitted'
171+
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
172+
: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
166173
}`}
167174
>
168175
{proposal.status.charAt(0).toUpperCase() +
@@ -298,6 +305,16 @@ export function AdminActionBar({
298305
Approve
299306
</AdminButton>
300307
)}
308+
{canWaitlist && (
309+
<AdminButton
310+
color="orange"
311+
size="xs"
312+
onClick={() => handleAction(Action.waitlist)}
313+
>
314+
<ClockIcon className="h-3 w-3" />
315+
Waitlist
316+
</AdminButton>
317+
)}
301318
{canRemind && (
302319
<AdminButton
303320
color="yellow"

src/components/admin/AdminButton.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ type AdminButtonColor =
55
| 'indigo'
66
| 'blue'
77
| 'green'
8+
| 'orange'
89
| 'red'
910
| 'purple'
1011
| 'yellow'
@@ -22,6 +23,8 @@ const colorStyles: Record<AdminButtonColor, string> = {
2223
blue: 'bg-blue-600 text-white shadow-sm hover:bg-blue-700 focus-visible:outline-blue-600',
2324
green:
2425
'bg-green-600 text-white shadow-sm hover:bg-green-700 focus-visible:outline-green-600',
26+
orange:
27+
'bg-orange-600 text-white shadow-sm hover:bg-orange-700 focus-visible:outline-orange-600',
2528
red: 'bg-red-600 text-white shadow-sm hover:bg-red-700 focus-visible:outline-red-600',
2629
purple:
2730
'bg-purple-600 text-white shadow-sm hover:bg-purple-700 focus-visible:outline-purple-600',

src/components/admin/ProposalActionModal.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,18 @@ export const RemindAction: Story = {
9898
},
9999
}
100100

101+
export const WaitlistAction: Story = {
102+
args: {
103+
open: true,
104+
close: fn(),
105+
proposal: mockProposal,
106+
action: Action.waitlist,
107+
adminUI: true,
108+
onAction: fn(),
109+
domain: 'cloudnativedays.no',
110+
},
111+
}
112+
101113
export const DeleteAction: Story = {
102114
args: {
103115
open: true,

src/components/admin/ProposalActionModal.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
HeartIcon,
1212
XMarkIcon,
1313
BellIcon,
14+
ClockIcon,
1415
} from '@heroicons/react/20/solid'
1516
import { Speaker } from '@/lib/speaker/types'
1617
import { TrashIcon } from '@heroicons/react/24/solid'
@@ -37,6 +38,12 @@ function colorForAction(action: Action): [string, string, string] {
3738
'text-green-600 dark:text-green-400',
3839
'bg-green-600 hover:bg-green-500 dark:bg-green-700 dark:hover:bg-green-600',
3940
]
41+
case Action.waitlist:
42+
return [
43+
'bg-orange-100 dark:bg-orange-900/30',
44+
'text-orange-600 dark:text-orange-400',
45+
'bg-orange-600 hover:bg-orange-500 dark:bg-orange-700 dark:hover:bg-orange-600',
46+
]
4047
case Action.remind:
4148
return [
4249
'bg-yellow-100 dark:bg-yellow-900/30',
@@ -69,6 +76,8 @@ function iconForAction(
6976
case Action.accept:
7077
case Action.confirm:
7178
return HeartIcon
79+
case Action.waitlist:
80+
return ClockIcon
7281
case Action.remind:
7382
return BellIcon
7483
case Action.reject:
@@ -174,6 +183,7 @@ export function ProposalActionModal({
174183
)}
175184
{(action === Action.accept ||
176185
action === Action.remind ||
186+
action === Action.waitlist ||
177187
action === Action.reject) &&
178188
notify &&
179189
createLocalhostWarning(domain, 'speakers') && (

src/components/admin/ProposalCard.stories.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,17 @@ export const Confirmed: Story = {
167167
},
168168
}
169169

170+
export const Waitlisted: Story = {
171+
args: {
172+
proposal: createMockProposal({
173+
status: Status.waitlisted,
174+
title: 'Cloud Native CI/CD Pipelines',
175+
format: Format.presentation_25,
176+
}),
177+
href: '/admin/proposals/proposal-8',
178+
},
179+
}
180+
170181
export const Rejected: Story = {
171182
args: {
172183
proposal: createMockProposal({
@@ -253,14 +264,22 @@ export const AllStatuses: Story = {
253264
<ProposalCard
254265
proposal={createMockProposal({
255266
_id: 'p5',
267+
status: Status.waitlisted,
268+
title: 'Waitlisted Proposal',
269+
})}
270+
href="#"
271+
/>
272+
<ProposalCard
273+
proposal={createMockProposal({
274+
_id: 'p6',
256275
status: Status.rejected,
257276
title: 'Rejected Proposal',
258277
})}
259278
href="#"
260279
/>
261280
<ProposalCard
262281
proposal={createMockProposal({
263-
_id: 'p6',
282+
_id: 'p7',
264283
status: Status.withdrawn,
265284
title: 'Withdrawn Proposal',
266285
})}

src/components/admin/ProposalsList.stories.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -195,17 +195,24 @@ export const AllStatuses: Story = {
195195
),
196196
createMockProposal(
197197
'p5',
198+
'Waitlisted Proposal',
199+
Status.waitlisted,
200+
Format.presentation_25,
201+
'Speaker 5',
202+
),
203+
createMockProposal(
204+
'p6',
198205
'Rejected Proposal',
199206
Status.rejected,
200207
Format.lightning_10,
201-
'Speaker 5',
208+
'Speaker 6',
202209
),
203210
createMockProposal(
204-
'p6',
211+
'p7',
205212
'Withdrawn Proposal',
206213
Status.withdrawn,
207214
Format.workshop_120,
208-
'Speaker 6',
215+
'Speaker 7',
209216
),
210217
],
211218
},

src/components/admin/hooks/useProposalSearch.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@ const STATUS_PRIORITY: Record<Status, number> = {
1010
[Status.accepted]: 0,
1111
[Status.confirmed]: 1,
1212
[Status.submitted]: 2,
13-
[Status.draft]: 3,
14-
[Status.rejected]: 4,
15-
[Status.withdrawn]: 5,
16-
[Status.deleted]: 6,
13+
[Status.waitlisted]: 3,
14+
[Status.draft]: 4,
15+
[Status.rejected]: 5,
16+
[Status.withdrawn]: 6,
17+
[Status.deleted]: 7,
1718
} as const
1819

1920
// Default priority for unknown status values (lowest priority)
20-
const DEFAULT_STATUS_PRIORITY = 7
21+
const DEFAULT_STATUS_PRIORITY = 8
2122

2223
export function useProposalSearch() {
2324
const [isSearching, setIsSearching] = useState(false)

src/components/cfp/CompactProposalList.stories.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ const mixedStatusProposals: ProposalExisting[] = [
8383
),
8484
createMockProposal(
8585
'talk-4',
86+
'Cloud Native CI/CD Pipelines',
87+
Status.waitlisted,
88+
Format.presentation_25,
89+
),
90+
createMockProposal(
91+
'talk-5',
8692
'Service Mesh Deep Dive',
8793
Status.rejected,
8894
Format.presentation_45,

src/components/cfp/CompactProposalList.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,10 @@ function getProposalStatusConfig(
5757
return hasApprovedTalk
5858
? { color: 'gray', icon: MinusCircleIcon, label: 'Not selected' }
5959
: { color: 'red', icon: XCircleIcon, label: 'Rejected' }
60+
case 'waitlisted':
61+
return { color: 'orange', icon: ClockIcon, label: 'Waitlisted' }
6062
case 'withdrawn':
61-
return { color: 'orange', icon: XCircleIcon, label: 'Withdrawn' }
63+
return { color: 'purple', icon: XCircleIcon, label: 'Withdrawn' }
6264
case 'draft':
6365
return { color: 'gray', icon: DocumentTextIcon, label: 'Draft' }
6466
default:
@@ -85,11 +87,12 @@ export function CompactProposalList({
8587
const statusOrder: Record<Status, number> = {
8688
confirmed: 1,
8789
accepted: 2,
88-
submitted: 3,
89-
draft: 4,
90-
rejected: 5,
91-
withdrawn: 6,
92-
deleted: 7,
90+
waitlisted: 3,
91+
submitted: 4,
92+
draft: 5,
93+
rejected: 6,
94+
withdrawn: 7,
95+
deleted: 8,
9396
}
9497
return statusOrder[a.status] - statusOrder[b.status]
9598
})

0 commit comments

Comments
 (0)