feat(reg): inline MemberFields (+ add-row) replacing the chip input
Phase 1 members now entered as inline text rows with a '+ Add member' affordance and per-row remove, per the pre-deployed-devices onboarding rework. Parent store still receives only trimmed, non-empty names. Caps at 5. MemberChips retained (unused) for now. Tests updated. Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
1642fbce39
commit
1b31f6db1b
@@ -0,0 +1,50 @@
|
|||||||
|
import { describe, it, expect } from 'vitest'
|
||||||
|
import { render, screen } from '@testing-library/react'
|
||||||
|
import userEvent from '@testing-library/user-event'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { MemberFields } from './MemberFields'
|
||||||
|
|
||||||
|
function Wrapper({ initial = [] as string[] }) {
|
||||||
|
const [members, setMembers] = useState(initial)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<MemberFields members={members} onChange={setMembers} />
|
||||||
|
<output data-testid="committed">{members.join(',')}</output>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MemberFields', () => {
|
||||||
|
it('renders one empty row by default', () => {
|
||||||
|
render(<Wrapper />)
|
||||||
|
expect(screen.getByLabelText('Member 1')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('commits trimmed, non-empty names to the parent', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<Wrapper />)
|
||||||
|
await user.type(screen.getByLabelText('Member 1'), ' A. Rossi ')
|
||||||
|
expect(screen.getByTestId('committed')).toHaveTextContent('A. Rossi')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('adds a field below when "+" is clicked', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<Wrapper />)
|
||||||
|
await user.click(screen.getByRole('button', { name: /add member/i }))
|
||||||
|
expect(screen.getByLabelText('Member 2')).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('seeds a row per existing member and can remove one', async () => {
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<Wrapper initial={['A. Rossi', 'K. Tanaka']} />)
|
||||||
|
expect(screen.getByLabelText('Member 1')).toHaveValue('A. Rossi')
|
||||||
|
await user.click(screen.getByRole('button', { name: /remove member 1/i }))
|
||||||
|
expect(screen.getByTestId('committed')).toHaveTextContent('K. Tanaka')
|
||||||
|
expect(screen.getByTestId('committed')).not.toHaveTextContent('A. Rossi')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('caps at 5 members', () => {
|
||||||
|
render(<Wrapper initial={['A', 'B', 'C', 'D', 'E']} />)
|
||||||
|
expect(screen.getByRole('button', { name: /add member/i })).toBeDisabled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
const MAX_MEMBERS = 5
|
||||||
|
|
||||||
|
export interface MemberFieldsProps {
|
||||||
|
members: string[]
|
||||||
|
onChange: (next: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Inline member entry: one text field per member with a "+" to append another
|
||||||
|
* row below, and a "×" to remove a row. The parent store only ever sees the
|
||||||
|
* trimmed, non-empty names; empty rows are a local editing affordance.
|
||||||
|
*/
|
||||||
|
export function MemberFields({ members, onChange }: MemberFieldsProps) {
|
||||||
|
// Seed local rows from the parent (always at least one row to type into).
|
||||||
|
const [rows, setRows] = useState<string[]>(members.length ? members : [''])
|
||||||
|
|
||||||
|
const commit = (next: string[]) => {
|
||||||
|
setRows(next)
|
||||||
|
onChange(next.map((r) => r.trim()).filter(Boolean))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setRow = (i: number, value: string) => {
|
||||||
|
const next = rows.slice()
|
||||||
|
next[i] = value
|
||||||
|
commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const addRow = () => {
|
||||||
|
if (rows.length >= MAX_MEMBERS) return
|
||||||
|
setRows([...rows, '']) // don't commit — empty row adds nothing to the store
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeRow = (i: number) => {
|
||||||
|
const next = rows.length > 1 ? rows.filter((_, idx) => idx !== i) : ['']
|
||||||
|
commit(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
const full = rows.length >= MAX_MEMBERS
|
||||||
|
const filled = rows.filter((r) => r.trim()).length
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" data-testid="member-fields">
|
||||||
|
{rows.map((row, i) => (
|
||||||
|
<div key={i} className="flex gap-2 items-center">
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground w-4 shrink-0 text-right">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
aria-label={`Member ${i + 1}`}
|
||||||
|
placeholder="e.g. A. Rossi"
|
||||||
|
value={row}
|
||||||
|
onChange={(e) => setRow(i, e.target.value)}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={`Remove member ${i + 1}`}
|
||||||
|
onClick={() => removeRow(i)}
|
||||||
|
className="text-muted-foreground hover:text-destructive transition leading-none px-1.5 text-lg shrink-0"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex items-center justify-between pl-6">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={addRow}
|
||||||
|
disabled={full}
|
||||||
|
className="font-mono text-[11px] tracking-wider uppercase h-7 px-2"
|
||||||
|
>
|
||||||
|
+ Add member
|
||||||
|
</Button>
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground tracking-wider uppercase">
|
||||||
|
{filled} / {MAX_MEMBERS}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -52,8 +52,8 @@ describe('TeamRegistration', () => {
|
|||||||
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
const memberInput = screen.getByLabelText(/team member/i)
|
const memberInput = screen.getByLabelText('Member 1')
|
||||||
await user.type(memberInput, 'A. Rossi{Enter}')
|
await user.type(memberInput, 'A. Rossi')
|
||||||
expect(proceed).toBeDisabled()
|
expect(proceed).toBeDisabled()
|
||||||
|
|
||||||
// a claimed board satisfies the device requirement
|
// a claimed board satisfies the device requirement
|
||||||
@@ -97,7 +97,7 @@ describe('TeamRegistration', () => {
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
await user.type(screen.getByLabelText(/team name/i), 'team_x')
|
||||||
await user.type(screen.getByLabelText(/team member/i), 'A. Rossi{Enter}')
|
await user.type(screen.getByLabelText('Member 1'), 'A. Rossi')
|
||||||
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
act(() => useSession.getState().setDevice({ connected: true, port: 'board · KIT-01', uptimeS: 0 }))
|
||||||
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
await user.click(screen.getByRole('button', { name: /proceed/i }))
|
||||||
expect(useSession.getState().phases.reg).toBe(true)
|
expect(useSession.getState().phases.reg).toBe(true)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Button } from '@/components/ui/button'
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { MemberChips } from '@/components/MemberChips'
|
import { MemberFields } from '@/components/MemberFields'
|
||||||
import { KitSelector } from '@/components/KitSelector'
|
import { KitSelector } from '@/components/KitSelector'
|
||||||
import { PhaseStrip } from '@/components/PhaseStrip'
|
import { PhaseStrip } from '@/components/PhaseStrip'
|
||||||
import { BoardClaim } from '@/components/BoardClaim'
|
import { BoardClaim } from '@/components/BoardClaim'
|
||||||
@@ -83,7 +83,7 @@ export function TeamRegistration() {
|
|||||||
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
<div className="font-mono text-[10px] uppercase tracking-widest text-muted-foreground">
|
||||||
Members
|
Members
|
||||||
</div>
|
</div>
|
||||||
<MemberChips
|
<MemberFields
|
||||||
members={team.members}
|
members={team.members}
|
||||||
onChange={(members) => setTeam({ members })}
|
onChange={(members) => setTeam({ members })}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user