feat: add task editing
This commit is contained in:
@ -550,6 +550,247 @@ test('statusfel behåller tidigare status och ansvarig och visas på kortet', as
|
||||
expect(within(card).getByText('Ta uppgift')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('redigeringsknappen öppnar rätt uppgift med aktuella värden och titelfokus', async () => {
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = mockUsersAndTasks(users, [tasks[0], tasks[1]])
|
||||
render(<App />)
|
||||
|
||||
const editButton = await screen.findByRole('button', { name: 'Redigera Dammsuga' })
|
||||
expect(editButton.querySelector('svg')).toBeInTheDocument()
|
||||
fireEvent.pointerDown(editButton)
|
||||
fireEvent.click(editButton)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name: 'Redigera uppgift' })
|
||||
expect(within(dialog).getByLabelText('Titel')).toHaveValue('Dammsuga')
|
||||
expect(within(dialog).getByLabelText('Titel')).toHaveFocus()
|
||||
expect(within(dialog).getByLabelText('Beskrivning (valfri)')).toHaveValue('Bottenvåningen')
|
||||
expect(within(dialog).getByLabelText('Poäng')).toHaveValue(7)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: 'Avbryt' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Redigera Diska' }))
|
||||
expect(screen.getByLabelText('Beskrivning (valfri)')).toHaveValue('')
|
||||
})
|
||||
|
||||
test('redigeringsmodal stängs normalt och kastar osparade ändringar', async () => {
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
|
||||
const { container } = render(<App />)
|
||||
const editButton = await screen.findByRole('button', { name: 'Redigera Dammsuga' })
|
||||
|
||||
fireEvent.click(editButton)
|
||||
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Osparad' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Stäng' }))
|
||||
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(editButton)
|
||||
expect(screen.getByLabelText('Titel')).toHaveValue('Dammsuga')
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(editButton)
|
||||
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
|
||||
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test.each([
|
||||
{
|
||||
field: 'Titel',
|
||||
value: ' ',
|
||||
message: 'Titeln måste innehålla mellan 1 och 100 tecken.',
|
||||
},
|
||||
{
|
||||
field: 'Titel',
|
||||
value: 'a'.repeat(101),
|
||||
message: 'Titeln måste innehålla mellan 1 och 100 tecken.',
|
||||
},
|
||||
{
|
||||
field: 'Beskrivning (valfri)',
|
||||
value: 'a'.repeat(501),
|
||||
message: 'Beskrivningen får innehålla högst 500 tecken.',
|
||||
},
|
||||
{
|
||||
field: 'Poäng',
|
||||
value: '1.5',
|
||||
message: 'Poäng måste vara ett heltal mellan 1 och 99.',
|
||||
},
|
||||
{
|
||||
field: 'Poäng',
|
||||
value: '',
|
||||
message: 'Poäng måste vara ett heltal mellan 1 och 99.',
|
||||
},
|
||||
{
|
||||
field: 'Poäng',
|
||||
value: '100',
|
||||
message: 'Poäng måste vara ett heltal mellan 1 och 99.',
|
||||
},
|
||||
])('ogiltig redigeringsdata i $field blockerar API-anrop', async ({ field, value, message }) => {
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
|
||||
fireEvent.change(screen.getByLabelText(field), { target: { value } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent(message)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
test('redigering är serverbekräftad, låser samma task och ersätter hela svaret på samma plats', async () => {
|
||||
const otherTask = {
|
||||
...tasks[0],
|
||||
id: '00000000-0000-0000-0000-000000000010',
|
||||
title: 'Putsa fönster',
|
||||
}
|
||||
const serverTask = {
|
||||
...tasks[0],
|
||||
title: 'Dammsuga övervåningen',
|
||||
description: null,
|
||||
points: 9,
|
||||
assignee: { id: users[1].id, name: users[1].name },
|
||||
}
|
||||
let resolveEdit!: (response: Response) => void
|
||||
const editResponse = new Promise<Response>((resolve) => {
|
||||
resolveEdit = resolve
|
||||
})
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(users))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0], otherTask]))
|
||||
fetchMock.mockReturnValueOnce(editResponse)
|
||||
const { container } = render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
|
||||
fireEvent.change(screen.getByLabelText('Titel'), {
|
||||
target: { value: ' Dammsuga övervåningen ' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Beskrivning (valfri)'), {
|
||||
target: { value: ' ' },
|
||||
})
|
||||
fireEvent.change(screen.getByLabelText('Poäng'), { target: { value: '9' } })
|
||||
const save = screen.getByRole('button', { name: 'Spara' })
|
||||
fireEvent.click(save)
|
||||
fireEvent.click(save)
|
||||
|
||||
const oldCard = screen.getByText('Dammsuga').closest('article')!
|
||||
const otherCard = screen.getByText('Putsa fönster').closest('article')!
|
||||
const dialog = screen.getByRole('dialog', { name: 'Redigera uppgift' })
|
||||
expect(oldCard).toHaveAttribute('aria-busy', 'true')
|
||||
expect(within(oldCard).getByRole('button', { name: 'Redigera Dammsuga' })).toBeDisabled()
|
||||
expect(within(oldCard).getByRole('button', { name: 'Radera Dammsuga' })).toBeDisabled()
|
||||
expect(within(oldCard).getByRole('button', { name: 'Påbörja' })).toBeDisabled()
|
||||
expect(
|
||||
within(oldCard).getByRole('button', { name: 'Ändra ansvarig för Dammsuga' }),
|
||||
).toBeDisabled()
|
||||
expect(dragAndDrop.disabledTaskIds.has(tasks[0].id)).toBe(true)
|
||||
expect(dragAndDrop.disabledTaskIds.has(otherTask.id)).toBe(false)
|
||||
expect(within(otherCard).getByRole('button', { name: 'Redigera Putsa fönster' })).toBeEnabled()
|
||||
expect(within(dialog).getByLabelText('Titel')).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'Stäng' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'Avbryt' })).toBeDisabled()
|
||||
expect(within(dialog).getByRole('button', { name: 'Sparar…' })).toBeDisabled()
|
||||
expect(screen.getByText('Dammsuga')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Dammsuga övervåningen')).not.toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/details`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Dammsuga övervåningen',
|
||||
description: null,
|
||||
points: 9,
|
||||
}),
|
||||
})
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape' })
|
||||
fireEvent.mouseDown(container.querySelector('.modal-backdrop')!)
|
||||
expect(screen.getByRole('dialog', { name: 'Redigera uppgift' })).toBeInTheDocument()
|
||||
|
||||
await act(async () => resolveEdit(jsonResponse(serverTask)))
|
||||
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
|
||||
const waitingCards = within(screen.getByRole('region', { name: 'Väntande' })).getAllByRole(
|
||||
'article',
|
||||
)
|
||||
expect(within(waitingCards[0]).getByText('Dammsuga övervåningen')).toBeInTheDocument()
|
||||
expect(within(waitingCards[0]).getByText('Anna')).toBeInTheDocument()
|
||||
expect(within(waitingCards[1]).getByText('Putsa fönster')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('oförändrad redigering skickar alla detaljfälten', async () => {
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(users))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(tasks[0]))
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenLastCalledWith(`/api/tasks/${tasks[0].id}/details`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: 'Dammsuga',
|
||||
description: 'Bottenvåningen',
|
||||
points: 7,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
test('vanligt redigeringsfel behåller inmatning och kan återförsökas', async () => {
|
||||
const updatedTask = { ...tasks[0], title: 'Ny titel' }
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(users))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse({ message: 'Serverfel' }, 500))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(updatedTask))
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
|
||||
fireEvent.change(screen.getByLabelText('Titel'), { target: { value: 'Ny titel' } })
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Serverfel')
|
||||
expect(screen.getByLabelText('Titel')).toHaveValue('Ny titel')
|
||||
expect(screen.getByRole('button', { name: 'Spara' })).toBeEnabled()
|
||||
expect(screen.getByText('Dammsuga')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
expect(await screen.findByText('Ny titel')).toBeInTheDocument()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4)
|
||||
})
|
||||
|
||||
test('endast 404 TASK_NOT_FOUND tar bort ett inaktuellt kort vid redigering', async () => {
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch')
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse(users))
|
||||
fetchMock.mockResolvedValueOnce(jsonResponse([tasks[0]]))
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ code: 'OTHER_NOT_FOUND', message: 'Annat fel' }, 404),
|
||||
)
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
jsonResponse({ code: 'TASK_NOT_FOUND', message: 'Uppgiften finns inte.' }, 404),
|
||||
)
|
||||
render(<App />)
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'Redigera Dammsuga' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
expect(await screen.findByRole('alert')).toHaveTextContent('Annat fel')
|
||||
expect(screen.getByText('Dammsuga')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Spara' }))
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('button', { name: 'Redigera Dammsuga' })).not.toBeInTheDocument(),
|
||||
)
|
||||
expect(screen.queryByRole('dialog', { name: 'Redigera uppgift' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('sopkorgsknappen öppnar delete-modal med Avbryt i fokus och utan delete-anrop', async () => {
|
||||
window.localStorage.setItem('hemhub.activeUserId', users[0].id)
|
||||
const fetchMock = mockUsersAndTasks(users, [tasks[0]])
|
||||
|
||||
@ -28,6 +28,12 @@ type ApiError = {
|
||||
message?: string
|
||||
}
|
||||
|
||||
type TaskDetails = {
|
||||
title: string
|
||||
description: string | null
|
||||
points: number
|
||||
}
|
||||
|
||||
type TaskBoardProps = {
|
||||
activeUserId: string
|
||||
activeUserName: string
|
||||
@ -45,6 +51,8 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
const [tasks, setTasks] = useState<Task[]>([])
|
||||
const [loadState, setLoadState] = useState<'loading' | 'ready' | 'error'>('loading')
|
||||
const [showCreateTask, setShowCreateTask] = useState(false)
|
||||
const [editingTask, setEditingTask] = useState<Task | null>(null)
|
||||
const [editError, setEditError] = useState('')
|
||||
const [deletingTask, setDeletingTask] = useState<Task | null>(null)
|
||||
const [deleteError, setDeleteError] = useState('')
|
||||
const [editingAssigneeTaskId, setEditingAssigneeTaskId] = useState<string | null>(null)
|
||||
@ -197,6 +205,59 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
void updateStatus(task, status, 'optimistic')
|
||||
}
|
||||
|
||||
const openEditTask = (task: Task) => {
|
||||
if (pendingTaskIdsRef.current.has(task.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setEditError('')
|
||||
setEditingTask(task)
|
||||
}
|
||||
|
||||
const closeEditTask = () => {
|
||||
if (editingTask && pendingTaskIdsRef.current.has(editingTask.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setEditError('')
|
||||
setEditingTask(null)
|
||||
}
|
||||
|
||||
const updateDetails = async (task: Task, details: TaskDetails) => {
|
||||
if (!beginTaskRequest(task.id)) {
|
||||
return
|
||||
}
|
||||
|
||||
setEditError('')
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/tasks/${task.id}/details`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(details),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError = (await response.json().catch(() => ({}))) as ApiError
|
||||
if (response.status === 404 && apiError.code === 'TASK_NOT_FOUND') {
|
||||
setTasks((current) => current.filter((candidate) => candidate.id !== task.id))
|
||||
setEditingTask(null)
|
||||
return
|
||||
}
|
||||
|
||||
setEditError(apiError.message ?? 'Det gick inte att spara ändringarna. Försök igen.')
|
||||
return
|
||||
}
|
||||
|
||||
replaceTask((await response.json()) as Task)
|
||||
setEditingTask(null)
|
||||
} catch {
|
||||
setEditError('Det gick inte att spara ändringarna. Försök igen.')
|
||||
} finally {
|
||||
finishTaskRequest(task.id)
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteTask = (task: Task) => {
|
||||
if (pendingTaskIdsRef.current.has(task.id)) {
|
||||
return
|
||||
@ -293,6 +354,7 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
onChangeStatus={(task, status) =>
|
||||
void updateStatus(task, status, 'server-confirmed')
|
||||
}
|
||||
onEdit={openEditTask}
|
||||
onDelete={openDeleteTask}
|
||||
/>
|
||||
))}
|
||||
@ -310,6 +372,16 @@ function TaskBoard({ activeUserId, activeUserName, users, onLogOut }: TaskBoardP
|
||||
/>
|
||||
)}
|
||||
|
||||
{editingTask && (
|
||||
<EditTaskModal
|
||||
task={editingTask}
|
||||
pending={pendingTaskIds.has(editingTask.id)}
|
||||
error={editError}
|
||||
onClose={closeEditTask}
|
||||
onSave={(details) => void updateDetails(editingTask, details)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{deletingTask && (
|
||||
<DeleteTaskModal
|
||||
task={deletingTask}
|
||||
@ -333,6 +405,7 @@ type TaskColumnProps = {
|
||||
onEditAssignee: (taskId: string) => void
|
||||
onChangeAssignee: (task: Task, assigneeId: string) => void
|
||||
onChangeStatus: (task: Task, status: TaskStatus) => void
|
||||
onEdit: (task: Task) => void
|
||||
onDelete: (task: Task) => void
|
||||
}
|
||||
|
||||
@ -346,6 +419,7 @@ function TaskColumn({
|
||||
onEditAssignee,
|
||||
onChangeAssignee,
|
||||
onChangeStatus,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TaskColumnProps) {
|
||||
const { ref, isDropTarget } = useTaskColumnDropTarget(column.status)
|
||||
@ -369,6 +443,7 @@ function TaskColumn({
|
||||
onEditAssignee={() => onEditAssignee(task.id)}
|
||||
onChangeAssignee={(assigneeId) => onChangeAssignee(task, assigneeId)}
|
||||
onChangeStatus={(status) => onChangeStatus(task, status)}
|
||||
onEdit={() => onEdit(task)}
|
||||
onDelete={() => onDelete(task)}
|
||||
/>
|
||||
))}
|
||||
@ -386,6 +461,7 @@ type TaskCardProps = {
|
||||
onEditAssignee: () => void
|
||||
onChangeAssignee: (assigneeId: string) => void
|
||||
onChangeStatus: (status: TaskStatus) => void
|
||||
onEdit: () => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
@ -398,6 +474,7 @@ function TaskCard({
|
||||
onEditAssignee,
|
||||
onChangeAssignee,
|
||||
onChangeStatus,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: TaskCardProps) {
|
||||
const { ref, isDragging } = useTaskDraggable(task.id, pending)
|
||||
@ -415,6 +492,16 @@ function TaskCard({
|
||||
<h3>{task.title}</h3>
|
||||
<div className="task-card-actions">
|
||||
<span className="points-badge">{task.points} p</span>
|
||||
<button
|
||||
type="button"
|
||||
className="task-edit-button"
|
||||
aria-label={`Redigera ${task.title}`}
|
||||
disabled={pending}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<EditIcon />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="task-delete-button"
|
||||
@ -446,6 +533,27 @@ function TaskCard({
|
||||
)
|
||||
}
|
||||
|
||||
function EditIcon() {
|
||||
return (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="19"
|
||||
height="19"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
>
|
||||
<path
|
||||
d="m4 20 4.5-1 10-10a2.1 2.1 0 0 0-3-3l-10 10L4 20Zm10-12 3 3"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg
|
||||
@ -467,6 +575,149 @@ function TrashIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
type EditTaskModalProps = {
|
||||
task: Task
|
||||
pending: boolean
|
||||
error: string
|
||||
onClose: () => void
|
||||
onSave: (details: TaskDetails) => void
|
||||
}
|
||||
|
||||
function EditTaskModal({ task, pending, error, onClose, onSave }: EditTaskModalProps) {
|
||||
const [title, setTitle] = useState(task.title)
|
||||
const [description, setDescription] = useState(task.description ?? '')
|
||||
const [points, setPoints] = useState(String(task.points))
|
||||
const [validationError, setValidationError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const closeOnEscape = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !pending) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', closeOnEscape)
|
||||
return () => window.removeEventListener('keydown', closeOnEscape)
|
||||
}, [onClose, pending])
|
||||
|
||||
const closeFromBackdrop = (event: MouseEvent<HTMLDivElement>) => {
|
||||
if (event.target === event.currentTarget && !pending) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const submit = (event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault()
|
||||
if (pending) {
|
||||
return
|
||||
}
|
||||
|
||||
const trimmedTitle = title.trim()
|
||||
const trimmedDescription = description.trim()
|
||||
const numericPoints = Number(points)
|
||||
|
||||
if (!trimmedTitle || [...trimmedTitle].length > 100) {
|
||||
setValidationError('Titeln måste innehålla mellan 1 och 100 tecken.')
|
||||
return
|
||||
}
|
||||
if ([...trimmedDescription].length > 500) {
|
||||
setValidationError('Beskrivningen får innehålla högst 500 tecken.')
|
||||
return
|
||||
}
|
||||
if (
|
||||
!points.trim() ||
|
||||
!Number.isInteger(numericPoints) ||
|
||||
numericPoints < 1 ||
|
||||
numericPoints > 99
|
||||
) {
|
||||
setValidationError('Poäng måste vara ett heltal mellan 1 och 99.')
|
||||
return
|
||||
}
|
||||
|
||||
setValidationError('')
|
||||
onSave({
|
||||
title: trimmedTitle,
|
||||
description: trimmedDescription || null,
|
||||
points: numericPoints,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-backdrop" onMouseDown={closeFromBackdrop}>
|
||||
<section
|
||||
className="modal edit-task-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="edit-task-title"
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2 id="edit-task-title">Redigera uppgift</h2>
|
||||
<button
|
||||
type="button"
|
||||
className="close-button"
|
||||
aria-label="Stäng"
|
||||
disabled={pending}
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<form noValidate onSubmit={submit}>
|
||||
<label htmlFor="edit-task-title-field">Titel</label>
|
||||
<input
|
||||
id="edit-task-title-field"
|
||||
autoFocus
|
||||
value={title}
|
||||
disabled={pending}
|
||||
onChange={(event) => setTitle(event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="edit-task-description">Beskrivning (valfri)</label>
|
||||
<textarea
|
||||
id="edit-task-description"
|
||||
rows={5}
|
||||
value={description}
|
||||
disabled={pending}
|
||||
onChange={(event) => setDescription(event.target.value)}
|
||||
/>
|
||||
|
||||
<label htmlFor="edit-task-points">Poäng</label>
|
||||
<input
|
||||
id="edit-task-points"
|
||||
type="number"
|
||||
min="1"
|
||||
max="99"
|
||||
step="1"
|
||||
value={points}
|
||||
disabled={pending}
|
||||
onChange={(event) => setPoints(event.target.value)}
|
||||
/>
|
||||
|
||||
{(validationError || error) && (
|
||||
<p className="error" role="alert">
|
||||
{validationError || error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="edit-task-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="secondary compact"
|
||||
disabled={pending}
|
||||
onClick={onClose}
|
||||
>
|
||||
Avbryt
|
||||
</button>
|
||||
<button type="submit" disabled={pending}>
|
||||
{pending ? 'Sparar…' : 'Spara'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type DeleteTaskModalProps = {
|
||||
task: Task
|
||||
pending: boolean
|
||||
|
||||
@ -267,6 +267,7 @@ textarea {
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.task-edit-button,
|
||||
.task-delete-button {
|
||||
display: inline-grid;
|
||||
width: 2.5rem;
|
||||
@ -277,6 +278,17 @@ textarea {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.task-edit-button:hover,
|
||||
.task-edit-button:focus-visible {
|
||||
color: #1d4ed8;
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.task-edit-button:focus-visible {
|
||||
outline: 2px solid #2563eb;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.task-delete-button:hover,
|
||||
.task-delete-button:focus-visible {
|
||||
color: #991b1b;
|
||||
@ -359,6 +371,17 @@ textarea {
|
||||
margin: 0 0 1rem;
|
||||
}
|
||||
|
||||
.edit-task-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.edit-task-actions .secondary {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.delete-task-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
|
||||
Reference in New Issue
Block a user