V0.3
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { fr } from 'date-fns/locale'
|
||||
import { supa } from '../lib/supabase'
|
||||
import { humanize, mutate, run } from '../lib/db'
|
||||
import { useApp } from '../lib/AppContext'
|
||||
import { useFeedback } from '../lib/feedback'
|
||||
import { notifyBug } from '../lib/notify'
|
||||
import {
|
||||
Bug,
|
||||
BugSeverity,
|
||||
BugStatus,
|
||||
BUG_STATUS_LABELS,
|
||||
BUG_STATUS_ORDER,
|
||||
Pole,
|
||||
SEVERITY_HINTS,
|
||||
SEVERITY_LABELS,
|
||||
SEVERITY_ORDER,
|
||||
severityBadge
|
||||
} from '../lib/types'
|
||||
import { Badge, Button, Field, Input } from './ui'
|
||||
import { Modal, ModalActions } from './Modal'
|
||||
import { Select } from './Select'
|
||||
import { AssigneePicker, PoleChips } from './people'
|
||||
import { AttachmentPanel, CommentThread, TimeSection } from './EntityExtras'
|
||||
|
||||
interface Draft {
|
||||
title: string
|
||||
description: string
|
||||
severity: BugSeverity
|
||||
status: BugStatus
|
||||
poles: Pole[]
|
||||
assignees: string[]
|
||||
}
|
||||
|
||||
function draftOf(bug: Bug | null): Draft {
|
||||
return {
|
||||
title: bug?.title ?? '',
|
||||
description: bug?.description ?? '',
|
||||
severity: bug?.severity ?? 'medium',
|
||||
status: bug?.status ?? 'open',
|
||||
poles: bug?.poles ?? [],
|
||||
assignees: bug?.assignee_ids ?? []
|
||||
}
|
||||
}
|
||||
|
||||
export default function BugModal({
|
||||
bug,
|
||||
projectId,
|
||||
onClose,
|
||||
onSaved
|
||||
}: {
|
||||
bug: Bug | null
|
||||
projectId: string
|
||||
onClose: () => void
|
||||
onSaved: () => Promise<void> | void
|
||||
}): JSX.Element {
|
||||
const { profile, members, membersLoading, memberById } = useApp()
|
||||
const { toast, confirm } = useFeedback()
|
||||
|
||||
const initial = useMemo(() => draftOf(bug), [bug])
|
||||
const [d, setD] = useState<Draft>(initial)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const patch = (p: Partial<Draft>): void => setD((cur) => ({ ...cur, ...p }))
|
||||
const dirty = useMemo(() => JSON.stringify(d) !== JSON.stringify(initial), [d, initial])
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
if (!d.title.trim()) {
|
||||
toast('Donne un titre au bug.', 'error')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
const payload = {
|
||||
project_id: projectId,
|
||||
title: d.title.trim(),
|
||||
description: d.description.trim() || null,
|
||||
severity: d.severity,
|
||||
status: d.status,
|
||||
poles: d.poles,
|
||||
assignee_ids: d.assignees
|
||||
}
|
||||
|
||||
try {
|
||||
let entityId = bug?.id ?? null
|
||||
if (bug) {
|
||||
await mutate(supa().from('bugs').update(payload).eq('id', bug.id))
|
||||
} else {
|
||||
const created = await run<{ id: string }>(
|
||||
supa()
|
||||
.from('bugs')
|
||||
.insert({ ...payload, reporter_id: profile?.id ?? null })
|
||||
.select('id')
|
||||
.single()
|
||||
)
|
||||
entityId = created?.id ?? null
|
||||
}
|
||||
|
||||
if (entityId) {
|
||||
const newPoles = d.poles.filter((p) => !(bug?.poles ?? []).includes(p))
|
||||
const newAssignees = d.assignees.filter((a) => !(bug?.assignee_ids ?? []).includes(a))
|
||||
if (newPoles.length || newAssignees.length) {
|
||||
await notifyBug({
|
||||
projectId,
|
||||
bugId: entityId,
|
||||
title: d.title.trim(),
|
||||
members,
|
||||
poles: newPoles,
|
||||
assigneeIds: newAssignees,
|
||||
actorId: profile?.id ?? null,
|
||||
isNew: !bug
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
await onSaved()
|
||||
toast(bug ? 'Bug enregistré.' : 'Bug signalé.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (): Promise<void> => {
|
||||
if (!bug) return
|
||||
const ok = await confirm({
|
||||
title: 'Supprimer ce bug ?',
|
||||
message: `« ${bug.title} » et tout ce qui y est rattaché (temps, discussion, captures) seront supprimés.`,
|
||||
danger: true,
|
||||
confirmLabel: 'Supprimer'
|
||||
})
|
||||
if (!ok) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await mutate(supa().from('bugs').delete().eq('id', bug.id))
|
||||
await onSaved()
|
||||
toast('Bug supprimé.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
/** Passe le bug en résolu en un clic : le geste le plus fréquent. */
|
||||
const resolve = async (): Promise<void> => {
|
||||
if (!bug) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await mutate(supa().from('bugs').update({ status: 'resolved' }).eq('id', bug.id))
|
||||
await onSaved()
|
||||
toast('Bug marqué comme résolu.', 'success')
|
||||
onClose()
|
||||
} catch (e) {
|
||||
toast(humanize(e), 'error')
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const reporter = memberById(bug?.reporter_id)
|
||||
const subtitle = bug ? (
|
||||
<span>
|
||||
Signalé {reporter ? `par ${reporter.full_name} ` : ''}
|
||||
le {format(new Date(bug.created_at), 'd MMM yyyy', { locale: fr })}
|
||||
</span>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={bug ? 'Bug' : 'Signaler un bug'}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
size="lg"
|
||||
dirty={dirty && !busy}
|
||||
footer={
|
||||
<ModalActions onDelete={bug ? remove : undefined} onCancel={onClose}>
|
||||
{bug && bug.status !== 'resolved' && bug.status !== 'closed' && (
|
||||
<Button onClick={resolve} disabled={busy}>
|
||||
Marquer résolu
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="primary" loading={busy} disabled={!d.title.trim()} onClick={save}>
|
||||
{bug ? 'Enregistrer' : 'Signaler'}
|
||||
</Button>
|
||||
</ModalActions>
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Field label="Que se passe-t-il ?" required>
|
||||
{({ id }) => (
|
||||
<Input
|
||||
id={id}
|
||||
autoFocus
|
||||
value={d.title}
|
||||
maxLength={160}
|
||||
placeholder="Le joueur traverse le mur en sautant contre la porte"
|
||||
onChange={(e) => patch({ title: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Étapes pour reproduire"
|
||||
hint="Ce que tu faisais, ce qui devait arriver, ce qui est arrivé. Plus c'est précis, plus c'est vite corrigé."
|
||||
>
|
||||
{({ id }) => (
|
||||
<textarea
|
||||
id={id}
|
||||
className="input h-28 resize-none"
|
||||
value={d.description}
|
||||
placeholder={'1. Aller au niveau 2\n2. Sauter contre la porte de droite\n→ Le personnage passe à travers'}
|
||||
onChange={(e) => patch({ description: e.target.value })}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{/* ---- Sévérité : un seul axe d'importance, expliqué ---- */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Sévérité" hint={SEVERITY_HINTS[d.severity]}>
|
||||
<Select
|
||||
value={d.severity}
|
||||
onChange={(severity) => patch({ severity })}
|
||||
ariaLabel="Sévérité du bug"
|
||||
options={SEVERITY_ORDER.map((s) => ({
|
||||
value: s,
|
||||
label: SEVERITY_LABELS[s],
|
||||
hint: SEVERITY_HINTS[s]
|
||||
}))}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Statut">
|
||||
<Select
|
||||
value={d.status}
|
||||
onChange={(status) => patch({ status })}
|
||||
ariaLabel="Statut du bug"
|
||||
options={BUG_STATUS_ORDER.map((s) => ({ value: s, label: BUG_STATUS_LABELS[s] }))}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={severityBadge(d.severity)}>{SEVERITY_LABELS[d.severity]}</Badge>
|
||||
<span className="text-xs text-subtle">{SEVERITY_HINTS[d.severity]}</span>
|
||||
</div>
|
||||
|
||||
{/* ---- Qui ? ---- */}
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<Field
|
||||
label={`Pôle(s) concerné(s)${d.poles.length ? ` · ${d.poles.length}` : ''}`}
|
||||
hint="Tous les membres du pôle reçoivent une notification."
|
||||
>
|
||||
<PoleChips
|
||||
selected={d.poles}
|
||||
onToggle={(p) =>
|
||||
patch({
|
||||
poles: d.poles.includes(p) ? d.poles.filter((x) => x !== p) : [...d.poles, p]
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label={`Assigné(s)${d.assignees.length ? ` · ${d.assignees.length}` : ''}`}>
|
||||
<AssigneePicker
|
||||
members={members}
|
||||
loading={membersLoading}
|
||||
selected={d.assignees}
|
||||
onChange={(assignees) => patch({ assignees })}
|
||||
poles={d.poles}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{bug ? (
|
||||
<div className="space-y-3 border-t border-border pt-4">
|
||||
<AttachmentPanel
|
||||
entity={{
|
||||
kind: 'bug',
|
||||
id: bug.id,
|
||||
projectId,
|
||||
title: bug.title,
|
||||
involvedIds: bug.assignee_ids
|
||||
}}
|
||||
/>
|
||||
<TimeSection target={{ kind: 'bug', id: bug.id }} projectId={projectId} />
|
||||
<CommentThread
|
||||
entity={{
|
||||
kind: 'bug',
|
||||
id: bug.id,
|
||||
projectId,
|
||||
title: bug.title,
|
||||
involvedIds: [...bug.assignee_ids, ...(bug.reporter_id ? [bug.reporter_id] : [])]
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-lg border border-dashed border-border px-3 py-2.5 text-xs text-subtle">
|
||||
Une fois le bug créé, tu pourras y coller des captures d'écran (Ctrl+V), suivre le temps
|
||||
passé et en discuter avec l'équipe.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user