258 lines
9.2 KiB
TypeScript
258 lines
9.2 KiB
TypeScript
import { useEffect, useMemo, useState } from 'react'
|
|
import type { OrgCreateMemberInput, OrgSavedCreatePayload } from '../types/visual'
|
|
|
|
interface OrgCreateResult extends OrgSavedCreatePayload {
|
|
nonce: number
|
|
}
|
|
|
|
interface OrgCreateModalProps {
|
|
open: boolean
|
|
pending?: boolean
|
|
result?: OrgCreateResult | null
|
|
onClose: () => void
|
|
onCreate: (organizationName: string, members: OrgCreateMemberInput[]) => void
|
|
}
|
|
|
|
type MemberDraft = {
|
|
name: string
|
|
responsibility: string
|
|
prompt: string
|
|
reportsToIndex: number | null
|
|
}
|
|
|
|
const INITIAL_MEMBERS: MemberDraft[] = [
|
|
{ name: '', responsibility: '', prompt: '', reportsToIndex: null },
|
|
{ name: '', responsibility: '', prompt: '', reportsToIndex: 0 },
|
|
]
|
|
|
|
function slugLabel(value: string): string {
|
|
return value.trim() || 'Member'
|
|
}
|
|
|
|
export function OrgCreateModal({ open, pending, result, onClose, onCreate }: OrgCreateModalProps) {
|
|
const [step, setStep] = useState(1)
|
|
const [organizationName, setOrganizationName] = useState('')
|
|
const [members, setMembers] = useState<MemberDraft[]>(INITIAL_MEMBERS)
|
|
const [localError, setLocalError] = useState('')
|
|
|
|
useEffect(() => {
|
|
if (!open) return
|
|
setStep(1)
|
|
setOrganizationName('')
|
|
setMembers(INITIAL_MEMBERS)
|
|
setLocalError('')
|
|
}, [open])
|
|
|
|
useEffect(() => {
|
|
if (!open || !result) return
|
|
if (result.ok) {
|
|
onClose()
|
|
return
|
|
}
|
|
setLocalError(result.error || 'Failed to create organization')
|
|
}, [open, result, onClose])
|
|
|
|
const organizationValid = organizationName.trim().length > 0
|
|
const validMembers = useMemo(
|
|
() => members.map((member, index) => ({ member, index })).filter(item => item.member.name.trim()),
|
|
[members],
|
|
)
|
|
const originalIndexToCreateIndex = useMemo(
|
|
() => new Map(validMembers.map((item, createIndex) => [item.index, createIndex])),
|
|
[validMembers],
|
|
)
|
|
const membersValid = validMembers.length >= 2
|
|
const canCreate = organizationValid && membersValid && !pending
|
|
|
|
const previewMembers = useMemo(
|
|
() => validMembers.map(({ member, index }, createIndex) => {
|
|
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
|
|
return {
|
|
...member,
|
|
roleName: slugLabel(member.name),
|
|
managerName: mappedParent != null && mappedParent < createIndex
|
|
? slugLabel(validMembers[mappedParent]?.member.name || '')
|
|
: 'Owner',
|
|
index,
|
|
}
|
|
}),
|
|
[originalIndexToCreateIndex, validMembers],
|
|
)
|
|
|
|
if (!open) return null
|
|
|
|
const updateMember = (index: number, patch: Partial<MemberDraft>) => {
|
|
setMembers(prev => prev.map((member, idx) => idx === index ? { ...member, ...patch } : member))
|
|
}
|
|
|
|
const addMember = () => {
|
|
setMembers(prev => [...prev, { name: '', responsibility: '', prompt: '', reportsToIndex: 0 }])
|
|
}
|
|
|
|
const removeMember = (index: number) => {
|
|
setMembers(prev => {
|
|
const next = prev.filter((_, idx) => idx !== index)
|
|
return next.map((member, idx) => ({
|
|
...member,
|
|
reportsToIndex: member.reportsToIndex == null
|
|
? null
|
|
: member.reportsToIndex >= index
|
|
? Math.max(0, member.reportsToIndex - 1)
|
|
: member.reportsToIndex,
|
|
})).map((member, idx) => idx === 0 ? { ...member, reportsToIndex: null } : member)
|
|
})
|
|
}
|
|
|
|
const submit = () => {
|
|
if (!canCreate) return
|
|
setLocalError('')
|
|
onCreate(
|
|
organizationName.trim(),
|
|
validMembers.map(({ member }, createIndex) => {
|
|
const mappedParent = member.reportsToIndex == null ? null : originalIndexToCreateIndex.get(member.reportsToIndex)
|
|
return {
|
|
name: member.name.trim(),
|
|
responsibility: member.responsibility.trim(),
|
|
prompt: member.prompt.trim(),
|
|
reports_to_index: mappedParent != null && mappedParent < createIndex
|
|
? mappedParent
|
|
: createIndex === 0 ? null : 0,
|
|
}
|
|
}),
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="org-create-backdrop" role="presentation" onMouseDown={onClose}>
|
|
<div className="org-create-modal" role="dialog" aria-modal="true" aria-labelledby="org-create-title" onMouseDown={e => e.stopPropagation()}>
|
|
<div className="org-create-header">
|
|
<div>
|
|
<span className="org-create-eyebrow">New organization</span>
|
|
<h3 id="org-create-title" className="org-create-title">Create a saved org</h3>
|
|
</div>
|
|
<button type="button" className="org-create-close" onClick={onClose} aria-label="Close">x</button>
|
|
</div>
|
|
|
|
<div className="org-create-steps" aria-label="Create organization steps">
|
|
{[
|
|
['1', 'Name'],
|
|
['2', 'Members'],
|
|
['3', 'Review'],
|
|
].map(([id, label]) => (
|
|
<span key={id} className={`org-create-step${step === Number(id) ? ' org-create-step--active' : step > Number(id) ? ' org-create-step--done' : ''}`}>
|
|
<span>{id}</span>{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{step === 1 && (
|
|
<div className="org-create-panel">
|
|
<label className="org-create-field">
|
|
<span>Organization name</span>
|
|
<input
|
|
value={organizationName}
|
|
onChange={e => setOrganizationName(e.target.value)}
|
|
placeholder="HKU Research Lab"
|
|
autoFocus
|
|
/>
|
|
</label>
|
|
</div>
|
|
)}
|
|
|
|
{step === 2 && (
|
|
<div className="org-create-panel">
|
|
<div className="org-create-member-list">
|
|
{members.map((member, index) => (
|
|
<div className="org-create-member-row" key={index}>
|
|
<input
|
|
value={member.name}
|
|
onChange={e => updateMember(index, { name: e.target.value })}
|
|
placeholder={index === 0 ? 'Lead role' : 'Member role'}
|
|
/>
|
|
<input
|
|
value={member.responsibility}
|
|
onChange={e => updateMember(index, { responsibility: e.target.value })}
|
|
placeholder="Responsibility"
|
|
/>
|
|
<select
|
|
value={member.reportsToIndex == null ? 'owner' : String(member.reportsToIndex)}
|
|
onChange={e => updateMember(index, { reportsToIndex: e.target.value === 'owner' ? null : Number(e.target.value) })}
|
|
disabled={index === 0}
|
|
aria-label="Reports to"
|
|
>
|
|
<option value="owner">Owner</option>
|
|
{members.slice(0, index).map((candidate, candidateIndex) => (
|
|
<option key={candidateIndex} value={candidateIndex}>
|
|
{slugLabel(candidate.name)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="org-create-icon-btn"
|
|
onClick={() => removeMember(index)}
|
|
disabled={members.length <= 2}
|
|
title="Remove member"
|
|
aria-label="Remove member"
|
|
>
|
|
-
|
|
</button>
|
|
<textarea
|
|
value={member.prompt}
|
|
onChange={e => updateMember(index, { prompt: e.target.value })}
|
|
placeholder="Prompt optional"
|
|
aria-label={`${index === 0 ? 'Lead role' : 'Member role'} prompt optional`}
|
|
/>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<button type="button" className="org-create-add" onClick={addMember}>+ Add member</button>
|
|
</div>
|
|
)}
|
|
|
|
{step === 3 && (
|
|
<div className="org-create-panel">
|
|
<div className="org-create-review">
|
|
<div className="org-create-review-head">
|
|
<span>{organizationName.trim()}</span>
|
|
<b>{validMembers.length} members</b>
|
|
</div>
|
|
{previewMembers.map(member => (
|
|
<div className="org-create-review-row" key={member.index}>
|
|
<strong>{member.roleName}</strong>
|
|
<span>
|
|
{member.managerName}
|
|
{member.prompt.trim() ? <em>Prompt</em> : null}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{localError && <div className="org-create-error">{localError}</div>}
|
|
|
|
<div className="org-create-actions">
|
|
<button type="button" className="btn btn-ghost" onClick={step === 1 ? onClose : () => setStep(step - 1)}>
|
|
{step === 1 ? 'Cancel' : 'Back'}
|
|
</button>
|
|
{step < 3 ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn-primary"
|
|
onClick={() => setStep(step + 1)}
|
|
disabled={step === 1 ? !organizationValid : !membersValid}
|
|
>
|
|
Next
|
|
</button>
|
|
) : (
|
|
<button type="button" className="btn btn-primary" onClick={submit} disabled={!canCreate}>
|
|
{pending ? 'Creating...' : 'Create organization'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|