Skip to content

Commit 7299fca

Browse files
authored
Merge pull request #20 from wellmaintained/feature/can-publish
Feature: Can publish
2 parents 39eb1f2 + e07be3d commit 7299fca

File tree

19 files changed

+528
-176
lines changed

19 files changed

+528
-176
lines changed
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"UROwK21E1TkI5mvhghjABOsXJEKr","createdAt":"1740076420437","lastLoginAt":"1742125645396","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg","passwordHash":"fakeHash:salt=fakeSaltqKAUxV2CkCRe8e8iStQM:password=password123","salt":"fakeSaltqKAUxV2CkCRe8e8iStQM","passwordUpdatedAt":1742037935628,"providerUserInfo":[{"providerId":"password","email":"integration-test-user@example.com","federatedId":"integration-test-user@example.com","rawId":"integration-test-user@example.com","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg"}],"validSince":"1742037935","email":"integration-test-user@example.com","emailVerified":false,"disabled":false,"lastRefreshAt":"2025-03-16T11:47:25.396Z"},{"localId":"gzDyVhuHirc3oOumoSWS3hQHcGe6","createdAt":"1739972372247","lastLoginAt":"1742038156919","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c","passwordHash":"fakeHash:salt=fakeSalt6aL1yBgXjipNiXDyEQHO:password=password","salt":"fakeSalt6aL1yBgXjipNiXDyEQHO","passwordUpdatedAt":1742037935628,"providerUserInfo":[{"providerId":"google.com","rawId":"3578890099700199420545762490298610907447","federatedId":"3578890099700199420545762490298610907447","displayName":"David Laing (local emulator)","email":"mrdavidlaing@gmail.com"},{"providerId":"password","email":"mrdavidlaing@gmail.com","federatedId":"mrdavidlaing@gmail.com","rawId":"mrdavidlaing@gmail.com","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c"}],"validSince":"1742037935","email":"mrdavidlaing@gmail.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2025-03-16T11:44:45.070Z"}]}
1+
{"kind":"identitytoolkit#DownloadAccountResponse","users":[{"localId":"UROwK21E1TkI5mvhghjABOsXJEKr","createdAt":"1740076420437","lastLoginAt":"1742125645396","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg","passwordHash":"fakeHash:salt=fakeSaltqKAUxV2CkCRe8e8iStQM:password=password123","salt":"fakeSaltqKAUxV2CkCRe8e8iStQM","passwordUpdatedAt":1742636930759,"providerUserInfo":[{"providerId":"password","email":"integration-test-user@example.com","federatedId":"integration-test-user@example.com","rawId":"integration-test-user@example.com","displayName":"Integration Test User","photoUrl":"https://randomuser.me/api/portraits/men/3.jpg"}],"validSince":"1742636930","email":"integration-test-user@example.com","emailVerified":false,"disabled":false},{"localId":"gzDyVhuHirc3oOumoSWS3hQHcGe6","createdAt":"1739972372247","lastLoginAt":"1742637216773","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c","passwordHash":"fakeHash:salt=fakeSalt6aL1yBgXjipNiXDyEQHO:password=password","salt":"fakeSalt6aL1yBgXjipNiXDyEQHO","passwordUpdatedAt":1742636930759,"providerUserInfo":[{"providerId":"google.com","rawId":"3578890099700199420545762490298610907447","federatedId":"3578890099700199420545762490298610907447","displayName":"David Laing (local emulator)","email":"mrdavidlaing@gmail.com"},{"providerId":"password","email":"mrdavidlaing@gmail.com","federatedId":"mrdavidlaing@gmail.com","rawId":"mrdavidlaing@gmail.com","displayName":"David Laing (local emulator)","photoUrl":"https://lh3.googleusercontent.com/a/ACg8ocK4mNmS8Rre3cL6mwO1kMDiQ1DPqIVSaxjM46Jjg_f63IpK=s96-c"}],"validSince":"1742636930","email":"mrdavidlaing@gmail.com","emailVerified":true,"disabled":false,"lastRefreshAt":"2025-03-22T10:52:15.015Z"}]}
Binary file not shown.
Binary file not shown.

.eslintrc.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "next/core-web-vitals"
3+
}

.husky/pre-push

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
pnpm run pre:push

app/organisation/[organisationId]/decision/[id]/edit/EditDecisionClient.tsx

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,54 @@
22

33
import WorkflowAccordion from '@/components/workflow/WorkflowAccordion'
44
import HorizontalWorkflowProgress from '@/components/workflow/horizontal-workflow-progress'
5-
import { notFound } from 'next/navigation'
6-
import { useState } from 'react'
5+
import { notFound, useRouter } from 'next/navigation'
6+
import { useState, useEffect } from 'react'
77
import { DecisionWorkflowStep, DecisionWorkflowSteps } from '@/lib/domain/Decision'
8+
import { useDecision } from '@/hooks/useDecisions'
9+
import { useToast } from "@/components/ui/use-toast"
810

911
interface EditDecisionClientProps {
1012
organisationId: string
1113
id: string
1214
}
1315

1416
export default function EditDecisionClient({ organisationId, id }: EditDecisionClientProps) {
17+
const router = useRouter()
18+
const { toast } = useToast()
19+
const { decision, loading } = useDecision(id, organisationId)
1520
const [currentStep, setCurrentStep] = useState<DecisionWorkflowStep>(DecisionWorkflowSteps.IDENTIFY)
21+
const [initialLoadComplete, setInitialLoadComplete] = useState(false)
22+
23+
useEffect(() => {
24+
if (decision && !initialLoadComplete) {
25+
setInitialLoadComplete(true)
26+
if (decision.isPublished()) {
27+
toast({
28+
title: "Decision is published",
29+
description: "This decision has been published and can no longer be edited.",
30+
variant: "default"
31+
})
32+
router.push(`/organisation/${organisationId}/decision/${id}/view`)
33+
} else if (decision.isSuperseded()) {
34+
const supersededByRelationship = decision.getSupersededByRelationship()
35+
toast({
36+
title: "Decision is superseded",
37+
description: `This decision has been superseded by "${supersededByRelationship?.targetDecisionTitle}" and can no longer be edited.`,
38+
variant: "default"
39+
})
40+
router.push(`/organisation/${organisationId}/decision/${id}/view`)
41+
}
42+
}
43+
}, [decision, organisationId, id, router, toast, initialLoadComplete])
1644

1745
if (!organisationId || !id) {
1846
return notFound()
1947
}
2048

49+
if (loading) {
50+
return <div>Loading...</div>
51+
}
52+
2153
const handleStepChange = (nextStep: DecisionWorkflowStep) => {
2254
setCurrentStep(nextStep)
2355
}
Lines changed: 44 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -1,168 +1,72 @@
11
'use client'
22

3-
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
4-
import { Button } from "@/components/ui/button"
5-
import Link from 'next/link'
63
import { useParams } from 'next/navigation'
74
import { useDecision } from '@/hooks/useDecisions'
85
import { useStakeholders } from '@/hooks/useStakeholders'
9-
import { SupportingMaterialIcon } from '@/components/supporting-material-icon'
10-
import { StakeholderRole } from '@/lib/domain/Decision'
6+
import { DecisionSummary } from '@/components/decision-summary'
7+
import Link from 'next/link'
8+
9+
function PublishedBanner() {
10+
return (
11+
<div className="bg-sky-100 p-4 rounded-md">
12+
<p className="text-slate-700">This decision has been published and can no longer be edited</p>
13+
</div>
14+
)
15+
}
1116

12-
function StakeholderGroup({ title, stakeholders }: { title: string, stakeholders: { id: string, displayName: string, photoURL?: string }[] }) {
17+
function SupersededBanner({ supersedingDecisionId, supersedingDecisionTitle, organisationId }: {
18+
supersedingDecisionId: string
19+
supersedingDecisionTitle: string
20+
organisationId: string
21+
}) {
1322
return (
14-
<div className="flex-1">
15-
<h3 className="text-lg font-semibold text-slate-700 mb-4">{title}</h3>
16-
<div className="space-y-3">
17-
{stakeholders.map((stakeholder) => (
18-
<div key={`stakeholder-${stakeholder.id}`} className="flex items-center gap-2">
19-
<Avatar className="h-8 w-8">
20-
<AvatarImage src={stakeholder.photoURL} alt={stakeholder.displayName} />
21-
<AvatarFallback>{stakeholder.displayName[0]}</AvatarFallback>
22-
</Avatar>
23-
<span className="text-slate-600">{stakeholder.displayName}</span>
24-
</div>
25-
))}
26-
</div>
23+
<div className="bg-amber-100 p-4 rounded-md">
24+
<p className="text-slate-700">
25+
This decision has been superseded by{' '}
26+
<Link
27+
href={`/organisation/${organisationId}/decision/${supersedingDecisionId}/view`}
28+
className="text-amber-800 hover:text-amber-900 underline"
29+
>
30+
{supersedingDecisionTitle}
31+
</Link>
32+
</p>
2733
</div>
2834
)
2935
}
3036

3137
export default function DecisionView() {
3238
const params = useParams()
33-
const decisionId = params.id as string
34-
const organisationId = params.organisationId as string
35-
36-
const { decision, loading, error } = useDecision(decisionId, organisationId)
39+
const { decision, loading } = useDecision(params.id as string, params.organisationId as string)
3740
const { stakeholders } = useStakeholders()
3841

3942
if (loading) {
4043
return <div>Loading...</div>
4144
}
4245

43-
if (error) {
44-
return <div>Error: {error.message}</div>
45-
}
46-
4746
if (!decision) {
4847
return <div>Decision not found</div>
4948
}
5049

51-
const deciderStakeholders = stakeholders?.filter(s =>
52-
decision.stakeholders.find(ds => ds.stakeholder_id === s.id && ds.role === ('decider' as StakeholderRole))
53-
) || []
54-
const consultedStakeholders = stakeholders?.filter(s =>
55-
decision.stakeholders.find(ds => ds.stakeholder_id === s.id && ds.role === ('consulted' as StakeholderRole))
56-
) || []
57-
const informedStakeholders = stakeholders?.filter(s =>
58-
decision.stakeholders.find(ds => ds.stakeholder_id === s.id && ds.role === ('informed' as StakeholderRole))
59-
) || []
50+
const supersededByRelationship = decision.getSupersededByRelationship()
6051

6152
return (
62-
<>
63-
<div className="space-y-8">
64-
<div className="space-y-4">
65-
<h1 className="text-2xl font-bold text-slate-900">{decision.title || 'Untitled Decision'}</h1>
66-
<p className="text-slate-600">{decision.description}</p>
67-
</div>
68-
69-
<section className="space-y-2">
70-
<h2 className="text-xl font-semibold text-slate-800">Decision</h2>
71-
<p className="text-slate-600">{decision.decision || 'No decision made yet'}</p>
72-
</section>
73-
74-
{decision.options.length > 0 && (
75-
<section className="space-y-4">
76-
<h2 className="text-xl font-semibold text-slate-800">Options considered</h2>
77-
<ol className="list-decimal list-inside space-y-2">
78-
{decision.options.map((option, index) => (
79-
<li key={`option-${index}`} className="text-slate-600">{option}</li>
80-
))}
81-
</ol>
82-
</section>
83-
)}
84-
85-
{decision.criteria.length > 0 && (
86-
<section className="space-y-4">
87-
<h2 className="text-xl font-semibold text-slate-800">Criteria</h2>
88-
<ol className="list-decimal list-inside space-y-2">
89-
{decision.criteria.map((criterion, index) => (
90-
<li key={`criterion-${index}`} className="text-slate-600">{criterion}</li>
91-
))}
92-
</ol>
93-
</section>
94-
)}
95-
96-
{decision.supportingMaterials.length > 0 && (
97-
<section className="space-y-4">
98-
<h2 className="text-xl font-semibold text-slate-800">Supporting Materials</h2>
99-
<div className="space-y-2">
100-
{decision.supportingMaterials.map((material, index) => (
101-
<div key={`material-${index}`} className="flex items-center gap-2 text-slate-600">
102-
<SupportingMaterialIcon mimeType={material.mimeType} />
103-
<a
104-
href={material.url}
105-
target="_blank"
106-
rel="noopener noreferrer"
107-
className="hover:underline"
108-
>
109-
{material.title}
110-
</a>
111-
</div>
112-
))}
113-
</div>
114-
</section>
115-
)}
116-
117-
<section className="space-y-2">
118-
<h2 className="text-xl font-semibold text-slate-800">Method</h2>
119-
<p className="text-slate-600">{decision.decisionMethod || 'No method selected'}</p>
120-
</section>
121-
122-
<section className="space-y-4">
123-
<h2 className="text-xl font-semibold text-slate-800">Stakeholders</h2>
124-
<div className="grid md:grid-cols-3 gap-8">
125-
{deciderStakeholders.length > 0 && (
126-
<StakeholderGroup
127-
title="Deciders"
128-
stakeholders={deciderStakeholders}
129-
/>
130-
)}
131-
{consultedStakeholders.length > 0 && (
132-
<StakeholderGroup
133-
title="Consulted"
134-
stakeholders={consultedStakeholders}
135-
/>
136-
)}
137-
{informedStakeholders.length > 0 && (
138-
<StakeholderGroup
139-
title="Informed"
140-
stakeholders={informedStakeholders}
141-
/>
142-
)}
143-
</div>
144-
</section>
145-
146-
{decision.status === 'published' && (
147-
<div className="bottom-0 right-0 bg-sky-100 p-4 flex items-center justify-between z-50">
148-
<p className="text-slate-700">This decision has been published and can no longer be edited</p>
149-
<Button variant="default" className="bg-blue-600 hover:bg-blue-700">
150-
<Link href={`/organisation/${organisationId}/decision/${decisionId}/edit`}>
151-
Un-publish
152-
</Link>
153-
</Button>
154-
</div>
155-
)}
156-
157-
<div className="flex justify-between items-center mb-8">
158-
<Button variant="outline" asChild>
159-
<Link href={`/organisation/${organisationId}`}>
160-
Back to Decisions
161-
</Link>
162-
</Button>
163-
</div>
164-
</div>
165-
</>
53+
<div className="space-y-8">
54+
<h1 className="text-2xl font-bold text-slate-900">{decision.title}</h1>
55+
56+
<DecisionSummary
57+
decision={decision}
58+
stakeholders={stakeholders}
59+
/>
60+
61+
{decision.isPublished() && <PublishedBanner />}
62+
{supersededByRelationship && (
63+
<SupersededBanner
64+
supersedingDecisionId={supersededByRelationship.targetDecision.id}
65+
supersedingDecisionTitle={supersededByRelationship.targetDecisionTitle}
66+
organisationId={params.organisationId as string}
67+
/>
68+
)}
69+
</div>
16670
)
16771
}
16872

components/decision-summary.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card"
2+
import { Decision } from "@/lib/domain/Decision"
3+
import { Stakeholder } from "@/lib/domain/Stakeholder"
4+
import { StakeholderRoleGroups } from "@/components/stakeholders/StakeholderRoleGroups"
5+
6+
interface DecisionSummaryProps {
7+
decision: Decision
8+
stakeholders?: Stakeholder[]
9+
compact?: boolean
10+
}
11+
12+
export function DecisionSummary({
13+
decision,
14+
stakeholders,
15+
compact = false,
16+
}: DecisionSummaryProps) {
17+
const supersedesRelationship = decision.getRelationshipsByType('supersedes')[0];
18+
const supersededByRelationship = decision.getRelationshipsByType('superseded_by')[0];
19+
return (
20+
<Card>
21+
<CardHeader>
22+
<CardTitle>Decision: {decision.title}</CardTitle>
23+
{supersedesRelationship && (
24+
<CardDescription>
25+
supersedes <em>{supersedesRelationship.targetDecisionTitle}</em>
26+
</CardDescription>
27+
)}
28+
{supersededByRelationship && (
29+
<CardDescription>
30+
superseded by <em>{supersededByRelationship.targetDecisionTitle}</em>
31+
</CardDescription>
32+
)}
33+
</CardHeader>
34+
<CardContent className="space-y-6">
35+
<div className="space-y-2">
36+
<h3 className="text-muted-foreground">Description</h3>
37+
<p>{decision.description}</p>
38+
</div>
39+
40+
{!compact && (
41+
<div className="grid grid-cols-2 gap-4">
42+
<div className="space-y-2">
43+
<h3 className="text-muted-foreground">Cost</h3>
44+
<p className="capitalize">{decision.cost}</p>
45+
</div>
46+
<div className="space-y-2">
47+
<h3 className="text-muted-foreground">Reversibility</h3>
48+
<p className="capitalize">{decision.reversibility}</p>
49+
</div>
50+
</div>
51+
)}
52+
53+
<div className="space-y-2">
54+
<h3 className="text-muted-foreground">Decision</h3>
55+
<div className="rounded-md bg-muted p-4">
56+
{decision.decision || "No decision recorded"}
57+
</div>
58+
</div>
59+
60+
{!compact && decision.supportingMaterials && decision.supportingMaterials.length > 0 && (
61+
<div className="space-y-2">
62+
<h3 className="text-muted-foreground">Supporting Materials</h3>
63+
<ul className="list-disc pl-4">
64+
{decision.supportingMaterials.map((material, index) => (
65+
<li key={index}>
66+
<a href={material.url} target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">
67+
{material.title || material.url}
68+
</a>
69+
</li>
70+
))}
71+
</ul>
72+
</div>
73+
)}
74+
75+
<div className="space-y-2">
76+
<h3 className="text-muted-foreground">Method</h3>
77+
<p className="capitalize">
78+
{decision.decisionMethod?.replace('_', ' ') || "No method selected"}
79+
</p>
80+
</div>
81+
82+
{stakeholders && (
83+
<div className="space-y-2">
84+
<h3 className="text-muted-foreground">Stakeholders</h3>
85+
<StakeholderRoleGroups
86+
decision={decision}
87+
stakeholders={stakeholders}
88+
/>
89+
</div>
90+
)}
91+
92+
{!compact && (
93+
<div className="grid grid-cols-2 gap-4 pt-4 border-t">
94+
<div className="space-y-2">
95+
<h3 className="text-muted-foreground">Created</h3>
96+
<p className="text-sm text-muted-foreground">
97+
{decision.createdAt.toLocaleDateString()}
98+
</p>
99+
</div>
100+
<div className="space-y-2">
101+
<h3 className="text-muted-foreground">Published</h3>
102+
<p className="text-sm text-muted-foreground">
103+
{decision.publishDate ? decision.publishDate.toLocaleDateString() : 'Not published'}
104+
</p>
105+
</div>
106+
</div>
107+
)}
108+
</CardContent>
109+
</Card>
110+
)
111+
}

0 commit comments

Comments
 (0)