Skip to content

Commit d359fa0

Browse files
Match lab PI rows to members with paper and removal controls
1 parent 4f298bf commit d359fa0

3 files changed

Lines changed: 58 additions & 5 deletions

File tree

‎docs/guides/labs.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,3 +175,10 @@ Directory cards show a muted, comma-separated PI byline beneath the lab name.
175175
Labs without PIs omit the byline. The paginated list reads the existing
176176
`lab_details` view in one database request, including current PI names;
177177
there are no per-card detail requests and no new migration.
178+
179+
180+
PI and member entries share the same compact row and icon controls. The paper
181+
icon opens the author's existing-paper picker, including for PIs who are not
182+
members. Removing a PI clears only their PI assignment, preserves other PIs
183+
and paper associations, and refreshes the member list to reveal any separate
184+
membership they already held. Errors leave the PI visible for retry.

‎frontend/src/pages/Labs.jsx‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,13 @@ function AddMembers({ labId, onChanged, onPapersChanged, onClose }) {
229229
</WindowModal>
230230
}
231231

232+
function LabPersonRow({ person, isPi = false, busy, onChoosePapers, onRemove }) {
233+
return <li className="flex items-center justify-between gap-2 py-3 first:pt-0">
234+
<Link to={`/authors/${encodeURIComponent(person.authorId)}`} className="flex min-w-0 items-center gap-2.5 text-xs text-slate-700 hover:text-blue-600"><span aria-hidden="true" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 text-[10px] font-semibold text-slate-500">{initials(person.name)}</span><span className="min-w-0 break-words font-medium">{person.name}</span></Link>
235+
<div className="flex shrink-0 gap-0.5"><button className="rounded p-1 text-slate-400 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-40" aria-label={`Choose papers by ${person.name}`} title={`Choose papers by ${person.name}`} disabled={!!busy} onClick={() => onChoosePapers(person)}><Icon name="article" className="text-[15px]" /></button><button className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-40" aria-label={`Remove ${person.name}${isPi ? ' as PI' : ''}`} title={`Remove ${person.name} ${isPi ? 'as PI' : 'from lab'}`} disabled={!!busy} onClick={() => onRemove(person.authorId)}><Icon name="person_remove" className="text-[15px]" /></button></div>
236+
</li>
237+
}
238+
232239
function LabDetail({ id, onDeleted, backTo }) {
233240
const [memberOffset, setMemberOffset] = useState(0)
234241
const [paperOffset, setPaperOffset] = useState(0)
@@ -266,6 +273,21 @@ function LabDetail({ id, onDeleted, backTo }) {
266273
setBusy(null)
267274
}
268275
}
276+
async function removePi(authorId) {
277+
setBusy(authorId)
278+
setError(null)
279+
try {
280+
const saved = await labsApi.update(id, {
281+
piAuthorIds: lab.data.principalInvestigators.filter(pi => pi.authorId !== authorId).map(pi => pi.authorId),
282+
})
283+
lab.setData(saved)
284+
membershipChanged()
285+
} catch (err) {
286+
setError(err.message)
287+
} finally {
288+
setBusy(null)
289+
}
290+
}
269291
async function removePaper(paperId) {
270292
setBusy(paperId)
271293
setError(null)
@@ -312,16 +334,13 @@ function LabDetail({ id, onDeleted, backTo }) {
312334
<aside aria-label="Lab people" className="min-w-0 space-y-5">
313335
<section aria-label="Principal investigators" className="rounded-xl border border-slate-200 bg-white p-3">
314336
<div className="mb-4 flex items-center gap-2"><span className="flex h-7 w-7 items-center justify-center rounded-lg bg-indigo-50 text-indigo-500"><Icon name="school" className="text-[15px]" /></span><h2 className="text-xs font-semibold text-slate-800">Principal investigators (PIs)</h2></div>
315-
{lab.data.principalInvestigators?.length ? <ul className="space-y-2">{lab.data.principalInvestigators.map(pi => <li key={pi.authorId}><Link to={`/authors/${encodeURIComponent(pi.authorId)}`} className="group flex items-center gap-2 rounded-lg py-1 text-xs text-slate-700 hover:text-blue-600"><span aria-hidden="true" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-indigo-50 text-xs font-semibold text-indigo-500">{initials(pi.name)}</span><span className="min-w-0 flex-1 break-words font-medium">{pi.name}</span><Icon name="arrow_outward" className="shrink-0 text-[15px] text-slate-300 group-hover:text-blue-500" /></Link></li>)}</ul> : <p className="text-xs leading-5 text-slate-400">No PIs selected. Use Edit lab to choose authors.</p>}
337+
{lab.data.principalInvestigators?.length ? <ul className="divide-y divide-slate-100">{lab.data.principalInvestigators.map(pi => <LabPersonRow key={pi.authorId} person={pi} isPi busy={busy} onChoosePapers={author => setPaperPicker({ author })} onRemove={removePi} />)}</ul> : <p className="text-xs leading-5 text-slate-400">No PIs selected. Use Edit lab to choose authors.</p>}
316338
</section>
317339
<section aria-label="Lab members" className="rounded-xl border border-slate-200 bg-white p-3">
318340
<div className="mb-4 flex flex-wrap items-center justify-between gap-2"><h2 className="text-xs font-semibold text-slate-800">Members{!members.loading && !members.error && ` (${members.data.total})`}</h2><button className="inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-blue-600 hover:bg-blue-50 disabled:opacity-40" disabled={!!busy} onClick={() => setAdding(true)}><Icon name="add" className="text-[15px]" />Add members</button></div>
319341
{members.loading ? <p className="py-3 text-xs text-slate-400">Loading members…</p> : members.error ? <LoadError resource={members} /> : <>
320342
{!members.data.items.length && <p className="py-2 text-xs leading-5 text-slate-400">No members yet. You can still add papers directly.</p>}
321-
<ul className="divide-y divide-slate-100">{members.data.items.map(member => <li key={member.authorId} className="flex items-center justify-between gap-2 py-3 first:pt-0">
322-
<Link to={`/authors/${encodeURIComponent(member.authorId)}`} className="flex min-w-0 items-center gap-2.5 text-xs text-slate-700 hover:text-blue-600"><span aria-hidden="true" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-slate-100 text-[10px] font-semibold text-slate-500">{initials(member.name)}</span><span className="min-w-0 break-words font-medium">{member.name}</span></Link>
323-
<div className="flex shrink-0 gap-0.5"><button className="rounded p-1 text-slate-400 hover:bg-blue-50 hover:text-blue-600 disabled:opacity-40" aria-label={`Choose papers by ${member.name}`} title={`Choose papers by ${member.name}`} disabled={!!busy} onClick={() => setPaperPicker({ author: member })}><Icon name="article" className="text-[15px]" /></button><button className="rounded p-1 text-slate-400 hover:bg-red-50 hover:text-red-600 disabled:opacity-40" aria-label={`Remove ${member.name}`} title={`Remove ${member.name} from lab`} disabled={!!busy} onClick={() => removeMember(member.authorId)}><Icon name="person_remove" className="text-[15px]" /></button></div>
324-
</li>)}</ul>
343+
<ul className="divide-y divide-slate-100">{members.data.items.map(member => <LabPersonRow key={member.authorId} person={member} busy={busy} onChoosePapers={author => setPaperPicker({ author })} onRemove={removeMember} />)}</ul>
325344
<Pagination page={members.data} onChange={setMemberOffset} label="Members" />
326345
</>}
327346
</section>

‎frontend/src/pages/Labs.test.jsx‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,33 @@ describe('Labs', () => {
179179
expect(labsApi.list).not.toHaveBeenCalled()
180180
})
181181

182+
it('lets a PI choose papers without membership and removes only their PI assignment with retry', async () => {
183+
const otherPi = { authorId: 'pi_2', name: 'Alex Lee' }
184+
labsApi.get.mockResolvedValue({ ...lab, principalInvestigators: [member, otherPi] })
185+
labsApi.members.mockResolvedValueOnce(page([], 50)).mockResolvedValue(page([member], 50))
186+
labsApi.update.mockRejectedValueOnce(new Error('Unable to remove PI')).mockResolvedValue({ ...lab, principalInvestigators: [otherPi] })
187+
mount()
188+
const piSection = within(await screen.findByRole('region', { name: 'Principal investigators' }))
189+
fireEvent.click(piSection.getByRole('button', { name: 'Choose papers by Jane Smith' }))
190+
fireEvent.click(await screen.findByRole('checkbox', { name: 'Select Available paper' }))
191+
expect(labsApi.paperOptions).toHaveBeenCalledWith('lab_1', { search: '', author_id: 'a_1', offset: 0, limit: 25 })
192+
fireEvent.click(screen.getByRole('button', { name: 'Add selected papers (1)' }))
193+
await waitFor(() => expect(labsApi.papers).toHaveBeenCalledTimes(2))
194+
expect(labsApi.addPapers).toHaveBeenCalledWith('lab_1', ['p_2'], 'a_1')
195+
expect(labsApi.addMember).not.toHaveBeenCalled()
196+
fireEvent.click(piSection.getByRole('button', { name: 'Remove Jane Smith as PI' }))
197+
expect(await screen.findByRole('alert')).toHaveTextContent('Unable to remove PI')
198+
expect(piSection.getByRole('link', { name: 'Jane Smith' })).toBeInTheDocument()
199+
fireEvent.click(piSection.getByRole('button', { name: 'Remove Jane Smith as PI' }))
200+
await waitFor(() => expect(piSection.queryByRole('link', { name: 'Jane Smith' })).not.toBeInTheDocument())
201+
expect(piSection.getByRole('link', { name: 'Alex Lee' })).toBeInTheDocument()
202+
expect(labsApi.update).toHaveBeenLastCalledWith('lab_1', { piAuthorIds: ['pi_2'] })
203+
expect(await within(screen.getByRole('region', { name: 'Lab members' })).findByRole('link', { name: 'Jane Smith' })).toBeInTheDocument()
204+
expect(labsApi.removeMember).not.toHaveBeenCalled()
205+
expect(labsApi.removePaper).not.toHaveBeenCalled()
206+
expect(labsApi.papers).toHaveBeenCalledTimes(2)
207+
})
208+
182209
it('keeps the paper page after member removal and resets only after unlinking a paper', async () => {
183210
labsApi.papers.mockResolvedValueOnce(page([paper], 25, 26)).mockResolvedValueOnce(page([paper], 25, 26, 25)).mockResolvedValue(page([], 25))
184211
mount()

0 commit comments

Comments
 (0)