Stop the Plan page unmounting on every wish mutation
useLeaveWishes.fetch() runs again after every add/update/remove to refresh the list, and it flipped the same isLoading flag the initial load uses. Plan.tsx swaps the whole page for a spinner on that flag, so every mutation unmounted and remounted WishEditor -- wiping which rows were expanded and any save status. This predates auto-save (the old manual Save button did it too), but a debounce that fires on every edit turned an occasional flicker into the dominant experience, collapsing the row the user was editing. A background refetch now toggles a separate isRefreshing flag; isLoading stays true only for the initial load. Plan.tsx needed no change once the hook was right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, waitFor, act } from '@testing-library/react'
|
||||
import { Plan } from './Plan'
|
||||
import * as hooks from '@/hooks'
|
||||
import { useWorkspace } from '@/context'
|
||||
@@ -156,6 +156,7 @@ describe('Plan', () => {
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue({
|
||||
data: [mockWish],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
add: mockAddWish,
|
||||
@@ -188,6 +189,42 @@ describe('Plan', () => {
|
||||
expect(screen.getByText(/Laddar planering/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the loading spinner during the initial wishes load, before any data has arrived', () => {
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: true,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
add: mockAddWish,
|
||||
update: mockUpdateWish,
|
||||
remove: mockRemoveWish,
|
||||
} as any)
|
||||
renderPlan()
|
||||
expect(screen.getByText(/Laddar planering/)).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('wish-editor')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show the full-page spinner or unmount the wish editor during a mutation background refresh', () => {
|
||||
// useLeaveWishes flips isRefreshing (not isLoading) while it refetches
|
||||
// after add/update/remove. Plan must keep rendering the wish editor
|
||||
// (and whatever expanded/save-status state it holds) through that.
|
||||
vi.mocked(hooks.useLeaveWishes).mockReturnValue({
|
||||
data: [mockWish],
|
||||
isLoading: false,
|
||||
isRefreshing: true,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
add: mockAddWish,
|
||||
update: mockUpdateWish,
|
||||
remove: mockRemoveWish,
|
||||
} as any)
|
||||
renderPlan()
|
||||
expect(screen.queryByText(/Laddar planering/)).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('wish-editor')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('wish-card-lw1')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders the page title and the wish editor', () => {
|
||||
renderPlan()
|
||||
expect(screen.getByRole('heading', { name: 'Planering', level: 1 })).toBeInTheDocument()
|
||||
@@ -246,9 +283,20 @@ describe('Plan', () => {
|
||||
it('edits and removes a wish through the wish editor', async () => {
|
||||
renderPlan()
|
||||
|
||||
// WishEditor auto-saves edits after an 800ms debounce (no per-row Save
|
||||
// button anymore) — advance fake timers to let it fire.
|
||||
vi.useFakeTimers()
|
||||
fireEvent.change(screen.getByTestId('wish-weeks-lw1'), { target: { value: '8' } })
|
||||
fireEvent.click(screen.getByTestId('wish-save-lw1'))
|
||||
await waitFor(() => expect(mockUpdateWish).toHaveBeenCalledWith('lw1', expect.objectContaining({ targetWeeks: 8 })))
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(800)
|
||||
})
|
||||
expect(mockUpdateWish).toHaveBeenCalledWith('lw1', expect.objectContaining({ targetWeeks: 8 }))
|
||||
// Flush the microtask queue so Plan's `.finally(() => setIsSaving(false))`
|
||||
// runs and re-enables the remove button before it's clicked below.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
vi.useRealTimers()
|
||||
|
||||
fireEvent.click(screen.getByTestId('wish-remove-lw1'))
|
||||
await waitFor(() => expect(mockRemoveWish).toHaveBeenCalledWith('lw1'))
|
||||
@@ -263,6 +311,51 @@ describe('Plan', () => {
|
||||
await waitFor(() => expect(screen.getByTestId('wish-error')).toHaveTextContent('nätverksfel'))
|
||||
})
|
||||
|
||||
it('shows a generic error banner when adding a wish fails with a non-Error value', async () => {
|
||||
mockAddWish.mockRejectedValue('boom')
|
||||
renderPlan()
|
||||
|
||||
fireEvent.click(screen.getByTestId('wish-preset-sommar'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId('wish-error')).toHaveTextContent('Kunde inte lägga till önskemål'),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows a calculating label on the propose button while a proposal is in flight', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue({
|
||||
data: null,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
propose: mockPropose,
|
||||
} as any)
|
||||
renderPlan()
|
||||
expect(screen.getByTestId('propose-plan-button')).toHaveTextContent('Beräknar')
|
||||
})
|
||||
|
||||
it('treats a missing workspace as having no workspace id: skips the wish editor and refuses to apply', async () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspaceContext, workspace: null } as any)
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue({
|
||||
data: mockProposal,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
propose: mockPropose,
|
||||
} as any)
|
||||
renderPlan()
|
||||
|
||||
// workspace?.id ?? null falls back to null, so the wish editor (guarded
|
||||
// on workspaceId) must not render even though wishes/members data exist.
|
||||
expect(screen.queryByTestId('wish-editor')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByTestId('plan-apply-button'))
|
||||
|
||||
await waitFor(() => {
|
||||
// handleApplyPlan's `if (!workspaceId) throw` fires before ever
|
||||
// reaching the real apply() call.
|
||||
expect(mockApply).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('shows an error banner when the proposal fails', () => {
|
||||
vi.mocked(hooks.usePlanProposal).mockReturnValue({
|
||||
data: null,
|
||||
|
||||
Reference in New Issue
Block a user