-
Notifications
You must be signed in to change notification settings - Fork 0
#364 Add An Application Status Transition Graph With Next-Step Quick Actions #499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
cielbellerose
merged 11 commits into
dev
from
364-add-application-status-transition-graph
Aug 19, 2026
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
9136740
#364 add the application status transition graph to constants
cielbellerose 32e5866
#364 enforce the status transition graph in application actions
cielbellerose 9f4d9af
#364 replace the status dropdown with graph-driven quick actions
cielbellerose 6e800b2
#364 add tests for the application status transition graph
cielbellerose a3200a2
#364 address review feedback
cielbellerose 75af200
#364 fix bulk moves to back-only targets like applied
cielbellerose 71f8cd9
#364 address review feedback
cielbellerose ce630b9
#364 apply human-directed status label, quick-action, and bulk copy f…
cielbellerose c4a5cc4
#364 fix bulk skip messaging to use forward-only sources
cielbellerose 6971dad
#364 fix stale bulk-skip message assertion
cielbellerose 06531be
#364 stop claiming skipped rows stay selected on a source mismatch
cielbellerose File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,266 @@ | ||
| 'use client'; | ||
|
|
||
| import { useState, useTransition } from 'react'; | ||
|
|
||
| import { Loader2, MoreHorizontal } from 'lucide-react'; | ||
| import { toast } from 'sonner'; | ||
|
|
||
| import { updateApplicationStatus } from '@/prisma/actions/applications'; | ||
| import type { $Enums } from '@/prisma/client'; | ||
|
|
||
| import { | ||
| APPLICATION_STATUS_ACTION_LABELS, | ||
| APPLICATION_STATUS_LABELS, | ||
| APPLICATION_STATUS_TRANSITIONS, | ||
| NON_REVIEWABLE_APPLICATION_STATUS_NOTES, | ||
| REJECTABLE_APPLICATION_STATUSES, | ||
| TERMINAL_DECISION_STATUS_NOTES, | ||
| isNonReviewableApplicationStatus, | ||
| } from '@/lib/constants'; | ||
|
|
||
| import { Button } from '@/components/ui/button'; | ||
| import { ConfirmDialog } from '@/components/ui/confirm-dialog'; | ||
| import { | ||
| DropdownMenu, | ||
| DropdownMenuContent, | ||
| DropdownMenuItem, | ||
| DropdownMenuLabel, | ||
| DropdownMenuSeparator, | ||
| DropdownMenuTrigger, | ||
| } from '@/components/ui/dropdown-menu'; | ||
|
|
||
| interface ApplicationStatusActionsProps { | ||
| applicationId: string; | ||
| currentStatus: $Enums.ApplicationStatus; | ||
| applicantName?: string; | ||
| compact?: boolean; | ||
| } | ||
|
|
||
| const CONFIRM_COPY = { | ||
| accepted: { | ||
| title: (name: string) => `Accept ${name}?`, | ||
| description: | ||
| "They'll see Accepted on their application and can no longer withdraw it. You can move this back later.", | ||
| confirmLabel: 'Accept', | ||
| pendingLabel: 'Accepting…', | ||
| }, | ||
| rejected: { | ||
| title: (name: string) => `Reject ${name}?`, | ||
| description: | ||
| "They'll see Rejected on their application and can no longer withdraw it. You can move this back later.", | ||
| confirmLabel: 'Reject', | ||
| pendingLabel: 'Rejecting…', | ||
| }, | ||
| } as const; | ||
|
|
||
| export function ApplicationStatusActions({ | ||
| applicationId, | ||
| currentStatus, | ||
| applicantName, | ||
| compact, | ||
| }: ApplicationStatusActionsProps) { | ||
| const [isPending, startTransition] = useTransition(); | ||
| const [pendingTarget, setPendingTarget] = | ||
| useState<$Enums.ApplicationStatus | null>(null); | ||
| const [menuOpen, setMenuOpen] = useState(false); | ||
| const [confirmTarget, setConfirmTarget] = useState<'accepted' | 'rejected'>( | ||
| 'accepted', | ||
| ); | ||
| const [confirmOpen, setConfirmOpen] = useState(false); | ||
|
|
||
| const displayName = applicantName ?? 'this application'; | ||
|
|
||
| function performMove( | ||
| target: $Enums.ApplicationStatus, | ||
| onSettled?: () => void, | ||
| ) { | ||
| setPendingTarget(target); | ||
| startTransition(async () => { | ||
| try { | ||
| const result = await updateApplicationStatus({ | ||
| applicationId, | ||
| status: target, | ||
| }); | ||
| if (result && 'error' in result) { | ||
| toast.error(result.error); | ||
| return; | ||
| } | ||
| toast.success(`Moved to ${APPLICATION_STATUS_LABELS[target]}`); | ||
| } catch { | ||
| toast.error('Something went wrong. Please try again.'); | ||
| } finally { | ||
| setPendingTarget(null); | ||
| onSettled?.(); | ||
| } | ||
| }); | ||
| } | ||
|
|
||
| function openConfirm(target: 'accepted' | 'rejected') { | ||
| setConfirmTarget(target); | ||
| setConfirmOpen(true); | ||
| } | ||
|
|
||
| function handleForwardSelect(target: $Enums.ApplicationStatus) { | ||
| if (target === 'accepted') openConfirm('accepted'); | ||
| else performMove(target); | ||
| } | ||
|
|
||
| if (isNonReviewableApplicationStatus(currentStatus)) { | ||
| if (compact) return null; | ||
| return ( | ||
| <p className="text-muted-foreground text-xs"> | ||
| {NON_REVIEWABLE_APPLICATION_STATUS_NOTES[currentStatus]} | ||
| </p> | ||
| ); | ||
| } | ||
|
|
||
| const isTerminalDecision = | ||
| currentStatus === 'accepted' || currentStatus === 'rejected'; | ||
| const { forward, back } = APPLICATION_STATUS_TRANSITIONS[currentStatus]; | ||
| const isRejectable = ( | ||
| REJECTABLE_APPLICATION_STATUSES as readonly $Enums.ApplicationStatus[] | ||
| ).includes(currentStatus); | ||
|
|
||
| const confirmCopy = CONFIRM_COPY[confirmTarget]; | ||
|
|
||
| const confirmDialog = ( | ||
| <ConfirmDialog | ||
| open={confirmOpen} | ||
| onOpenChange={setConfirmOpen} | ||
| title={confirmCopy.title(displayName)} | ||
| description={confirmCopy.description} | ||
| confirmLabel={confirmCopy.confirmLabel} | ||
| pendingLabel={confirmCopy.pendingLabel} | ||
| destructive={confirmTarget === 'rejected'} | ||
| isPending={isPending && pendingTarget === confirmTarget} | ||
| onConfirm={() => performMove(confirmTarget, () => setConfirmOpen(false))} | ||
| /> | ||
| ); | ||
|
|
||
| if (compact) { | ||
| return ( | ||
| <> | ||
| <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> | ||
| <DropdownMenuTrigger asChild> | ||
| <Button | ||
| variant="ghost" | ||
| size="icon" | ||
| aria-label={`Change status for ${displayName}`} | ||
| > | ||
| <MoreHorizontal aria-hidden /> | ||
| </Button> | ||
| </DropdownMenuTrigger> | ||
| <DropdownMenuContent align="end"> | ||
| {forward.map((target) => ( | ||
| <DropdownMenuItem | ||
| key={target} | ||
| onSelect={(e) => { | ||
| e.preventDefault(); | ||
| setMenuOpen(false); | ||
| handleForwardSelect(target); | ||
| }} | ||
| > | ||
| {APPLICATION_STATUS_ACTION_LABELS[target]} | ||
| </DropdownMenuItem> | ||
| ))} | ||
| {isRejectable && ( | ||
| <> | ||
| <DropdownMenuSeparator /> | ||
| <DropdownMenuItem | ||
| variant="destructive" | ||
| onSelect={(e) => { | ||
| e.preventDefault(); | ||
| setMenuOpen(false); | ||
| openConfirm('rejected'); | ||
| }} | ||
| > | ||
| {APPLICATION_STATUS_ACTION_LABELS.rejected} | ||
| </DropdownMenuItem> | ||
| </> | ||
| )} | ||
| {back.length > 0 && ( | ||
| <> | ||
| <DropdownMenuSeparator /> | ||
| <DropdownMenuLabel>Move back</DropdownMenuLabel> | ||
| {back.map((target) => ( | ||
| <DropdownMenuItem | ||
| key={target} | ||
| onSelect={(e) => { | ||
| e.preventDefault(); | ||
| setMenuOpen(false); | ||
| performMove(target); | ||
| }} | ||
| > | ||
| Move back to {APPLICATION_STATUS_LABELS[target]} | ||
| </DropdownMenuItem> | ||
| ))} | ||
| </> | ||
| )} | ||
| </DropdownMenuContent> | ||
| </DropdownMenu> | ||
| {confirmDialog} | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <div className="flex flex-col gap-3"> | ||
| {isTerminalDecision && ( | ||
| <p className="text-muted-foreground text-xs"> | ||
| {TERMINAL_DECISION_STATUS_NOTES[currentStatus]} | ||
| </p> | ||
| )} | ||
| {!isTerminalDecision && ( | ||
| <div className="flex flex-col gap-2"> | ||
| {forward.map((target, i) => ( | ||
| <Button | ||
| key={target} | ||
| variant={i === 0 ? 'default' : 'outline'} | ||
| className="w-full" | ||
| disabled={isPending} | ||
| onClick={() => handleForwardSelect(target)} | ||
| > | ||
| {isPending && pendingTarget === target && ( | ||
| <Loader2 className="animate-spin" aria-hidden /> | ||
| )} | ||
| {APPLICATION_STATUS_ACTION_LABELS[target]} | ||
| </Button> | ||
| ))} | ||
| {isRejectable && ( | ||
| <Button | ||
| variant="outline" | ||
| className="text-destructive hover:text-destructive w-full" | ||
| disabled={isPending} | ||
| onClick={() => openConfirm('rejected')} | ||
| > | ||
| {isPending && pendingTarget === 'rejected' && ( | ||
| <Loader2 className="animate-spin" aria-hidden /> | ||
| )} | ||
| {APPLICATION_STATUS_ACTION_LABELS.rejected} | ||
| </Button> | ||
| )} | ||
| </div> | ||
| )} | ||
| {back.length > 0 && ( | ||
| <div className="flex flex-col gap-1 border-t pt-3"> | ||
| {back.map((target) => ( | ||
| <Button | ||
| key={target} | ||
| variant="ghost" | ||
| size="sm" | ||
| className="text-muted-foreground w-full justify-start" | ||
| disabled={isPending} | ||
| onClick={() => performMove(target)} | ||
| > | ||
| {isPending && pendingTarget === target && ( | ||
| <Loader2 className="animate-spin" aria-hidden /> | ||
| )} | ||
| Move back to {APPLICATION_STATUS_LABELS[target]} | ||
| </Button> | ||
| ))} | ||
| </div> | ||
| )} | ||
| {confirmDialog} | ||
| </div> | ||
| ); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.