Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/**
* @jest-environment jsdom
*/
// The organization switcher settles its scope shortly after mount, which bumps
// the scope version from 0 and reloads the project. When that reload lands after
// the user has requested access, it must not fall back to the full-page loader:
// that unmounts the guard state and resets "Request sent" to an enabled "Request
// access", inviting a second request for one already sent. A switch between two
// known scopes is different — nothing loaded for the old scope may stay up.
import * as React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
import TimesheetProjectDetailPage from '../page'

const PROJECT_ID = '11111111-1111-4111-8111-111111111111'

let mockScopeVersion = 0
let holdProjectLoads = false
const heldProjectLoads: Array<() => void> = []

const mockTranslate = (key: string, fallback?: string) => fallback ?? key

jest.mock('@open-mercato/shared/lib/i18n/context', () => ({ useT: () => mockTranslate }))

jest.mock('@open-mercato/shared/lib/frontend/useOrganizationScope', () => ({
useOrganizationScopeVersion: () => mockScopeVersion,
}))

jest.mock('next/navigation', () => ({
useRouter: () => ({ push: jest.fn(), replace: jest.fn() }),
usePathname: () => `/backend/staff/time-tracking/projects/${PROJECT_ID}`,
useSearchParams: () => new URLSearchParams(),
}))

jest.mock('@open-mercato/ui/backend/FlashMessages', () => ({ flash: jest.fn() }))

jest.mock('@open-mercato/ui/backend/injection/useGuardedMutation', () => ({
useGuardedMutation: () => ({
runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
retryLastMutation: async () => true,
}),
}))

jest.mock('@open-mercato/ui/backend/confirm-dialog', () => ({
useConfirmDialog: () => ({ confirm: jest.fn(async () => true), ConfirmDialogElement: null }),
}))

jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
apiCall: jest.fn(),
apiCallOrThrow: jest.fn(),
readApiResultOrThrow: jest.fn(),
withScopedApiRequestHeaders: jest.fn(
async (_headers: Record<string, string>, run: () => Promise<unknown>) => run(),
),
}))

jest.mock('@open-mercato/ui/backend/charts', () => ({
KpiCard: ({ label }: { label: React.ReactNode }) => <div>{label}</div>,
Sparkline: () => null,
}))

jest.mock('../../../../../../lib/time-tracking-ui/ProjectTeamDrawer', () => ({
ProjectTeamDrawer: () => null,
}))

const apiCallMock = apiCall as unknown as jest.Mock
const apiCallOrThrowMock = apiCallOrThrow as unknown as jest.Mock

function projectRequests(): unknown[][] {
return apiCallMock.mock.calls.filter(([url]) => String(url).startsWith('/api/staff/timesheets/time-projects?'))
}

beforeEach(() => {
jest.clearAllMocks()
mockScopeVersion = 0
holdProjectLoads = false
heldProjectLoads.length = 0
apiCallMock.mockImplementation(async (url: string) => {
if (url.startsWith('/api/staff/timesheets/time-projects?')) {
if (holdProjectLoads) await new Promise<void>((resolve) => { heldProjectLoads.push(resolve) })
return { ok: false, status: 404, result: { error: 'Not found', reason: 'no_project_access' }, response: {} }
}
return { ok: true, status: 200, result: { items: [], granted: [] }, response: {} }
})
apiCallOrThrowMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true }, response: {} })
})

async function renderAndRequestAccess() {
const view = render(<TimesheetProjectDetailPage params={{ id: PROJECT_ID }} />)
const requestButton = await screen.findByRole('button', { name: 'Request access' })
await act(async () => { fireEvent.click(requestButton) })
expect((await screen.findByRole('button', { name: 'Request sent' })).hasAttribute('disabled')).toBe(true)
expect(projectRequests()).toHaveLength(1)
return view
}

describe('project detail — access request survives a scope reload', () => {
it('keeps "Request sent" after the organization scope settles', async () => {
const view = await renderAndRequestAccess()

mockScopeVersion = 1
view.rerender(<TimesheetProjectDetailPage params={{ id: PROJECT_ID }} />)

await waitFor(() => expect(projectRequests()).toHaveLength(2))
expect(screen.queryByText('Loading project...')).toBeNull()
await act(async () => { await Promise.resolve() })
expect(screen.getByRole('button', { name: 'Request sent' }).hasAttribute('disabled')).toBe(true)
expect(apiCallOrThrowMock).toHaveBeenCalledTimes(1)
})

it('reloads through the loader when switching between two known scopes', async () => {
mockScopeVersion = 1
const view = await renderAndRequestAccess()

mockScopeVersion = 2
view.rerender(<TimesheetProjectDetailPage params={{ id: PROJECT_ID }} />)

expect(screen.getByText('Loading project...')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Request sent' })).toBeNull()
await waitFor(() => expect(projectRequests()).toHaveLength(2))
expect(await screen.findByRole('button', { name: 'Request access' })).toBeTruthy()
})

it('reloads through the loader when a second switch cancels the first settlement', async () => {
const view = await renderAndRequestAccess()
holdProjectLoads = true

mockScopeVersion = 1
view.rerender(<TimesheetProjectDetailPage params={{ id: PROJECT_ID }} />)
await waitFor(() => expect(projectRequests()).toHaveLength(2))
expect(screen.getByRole('button', { name: 'Request sent' })).toBeTruthy()

mockScopeVersion = 2
view.rerender(<TimesheetProjectDetailPage params={{ id: PROJECT_ID }} />)

expect(screen.getByText('Loading project...')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Request sent' })).toBeNull()

holdProjectLoads = false
await act(async () => { heldProjectLoads.splice(0).forEach((release) => release()) })
expect(await screen.findByRole('button', { name: 'Request access' })).toBeTruthy()
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* @jest-environment jsdom
*/
// The board keys its project query on the scope version, so the organization
// switcher settling the scope (version 0 → 1) re-keys it. For a caller without
// access the previous answer is `null`, and the guard state only survives the
// re-key because react-query treats `null` placeholder data as data: were the
// placeholder dropped (or the denial answered with `undefined`), the board would
// fall back to its loader and reset "Request sent" to an enabled "Request access".
// A switch between two known scopes must still go through the loader.
import * as React from 'react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { apiCall, apiCallOrThrow } from '@open-mercato/ui/backend/utils/apiCall'
import ProjectBoardPage from '../page'

const PROJECT_ID = '11111111-1111-4111-8111-111111111111'

let mockScopeVersion = 0

const mockTranslate = (key: string, fallback?: string) => fallback ?? key

jest.mock('@open-mercato/shared/lib/i18n/context', () => ({ useT: () => mockTranslate }))

jest.mock('@open-mercato/shared/lib/frontend/useOrganizationScope', () => ({
useOrganizationScopeVersion: () => mockScopeVersion,
}))

jest.mock('@open-mercato/ui/backend/FlashMessages', () => ({ flash: jest.fn() }))

jest.mock('@open-mercato/ui/backend/injection/useGuardedMutation', () => ({
useGuardedMutation: () => ({
runMutation: async ({ operation }: { operation: () => Promise<unknown> }) => operation(),
retryLastMutation: async () => true,
}),
}))

jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
apiCall: jest.fn(),
apiCallOrThrow: jest.fn(),
}))

jest.mock('../../../../../../../lib/time-tracking-ui/TaskBoardScreen', () => ({
TaskBoardScreen: () => <div data-testid="task-board-screen" />,
}))

const apiCallMock = apiCall as unknown as jest.Mock
const apiCallOrThrowMock = apiCallOrThrow as unknown as jest.Mock

function projectRequests(): unknown[][] {
return apiCallMock.mock.calls.filter(([url]) => String(url).startsWith('/api/staff/timesheets/time-projects?'))
}

beforeEach(() => {
jest.clearAllMocks()
mockScopeVersion = 0
apiCallMock.mockResolvedValue({
ok: false,
status: 404,
result: { error: 'Not found', reason: 'no_project_access' },
response: {},
})
apiCallOrThrowMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true }, response: {} })
})

async function renderAndRequestAccess() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
const page = () => (
<QueryClientProvider client={queryClient}>
<ProjectBoardPage params={{ id: PROJECT_ID }} />
</QueryClientProvider>
)
const view = render(page())
const requestButton = await screen.findByRole('button', { name: 'Request access' })
await act(async () => { fireEvent.click(requestButton) })
expect((await screen.findByRole('button', { name: 'Request sent' })).hasAttribute('disabled')).toBe(true)
expect(projectRequests()).toHaveLength(1)
return { rerender: () => view.rerender(page()) }
}

describe('project board — access request survives a scope reload', () => {
it('keeps "Request sent" after the organization scope settles', async () => {
const view = await renderAndRequestAccess()

mockScopeVersion = 1
view.rerender()

expect(screen.queryByText('Loading the board…')).toBeNull()
await waitFor(() => expect(projectRequests()).toHaveLength(2))
await act(async () => { await Promise.resolve() })
expect(screen.getByRole('button', { name: 'Request sent' }).hasAttribute('disabled')).toBe(true)
expect(apiCallOrThrowMock).toHaveBeenCalledTimes(1)
})

it('reloads through the loader when switching between two known scopes', async () => {
mockScopeVersion = 1
const view = await renderAndRequestAccess()

mockScopeVersion = 2
view.rerender()

expect(screen.getByText('Loading the board…')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Request sent' })).toBeNull()
await waitFor(() => expect(projectRequests()).toHaveLength(2))
expect(await screen.findByRole('button', { name: 'Request access' })).toBeTruthy()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,21 @@ export default function ProjectBoardPage({ params }: { params?: { id?: string |
const rawId = params?.id
const projectId = Array.isArray(rawId) ? rawId[0] ?? null : rawId ?? null

const projectKey = projectId ?? 'none'
const projectQuery = useQuery<{ id: string; name: string } | null>({
queryKey: ['staff', 'time-tracking', 'board', 'project', `scope:${scopeVersion}`, projectId ?? 'none'],
queryKey: ['staff', 'time-tracking', 'board', 'project', `scope:${scopeVersion}`, projectKey],
enabled: !!projectId,
staleTime: 60_000,
// The organization switcher settles the scope just after mount, bumping the
// scope version from 0 and re-keying this query. Keep the screen resolved
// before that until the new answer arrives: the loading branch below would
// unmount it and drop its local state, such as an access request just sent.
// A switch between two known scopes still goes through the loader.
placeholderData: (previous, previousQuery) => (
Comment thread
maxidragon marked this conversation as resolved.
Resolved
previousQuery?.queryKey.at(-2) === 'scope:0' && previousQuery.queryKey.at(-1) === projectKey
? previous
: undefined
),
Comment thread
maxidragon marked this conversation as resolved.
Resolved
queryFn: async () => {
if (!projectId) return null
const call = await apiCall<Record<string, unknown>>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,34 +149,49 @@ export default function TimeTrackingProjectEditPage({ params }: { params?: { id?
)
const groups = React.useMemo(() => createProjectFormGroups(t), [t])

// The organization switcher settles the scope just after mount, bumping the
// scope version from 0 and reloading the project. A screen resolved before
// that stays up until the answer arrives: falling back to the full-page
// loader would unmount it and drop its local state — the form's unsaved
// edits, or an access request the user has just sent. A switch between two
// known scopes still goes through the loader, so no form outlives the scope
// it was loaded for. The decision looks at the load this one replaces,
// settled or not: a version-0 load cancelled by a quick second switch must
// not let that switch through.
const lastLoadRef = React.useRef<{ projectId: string; scopeVersion: number } | null>(null)
React.useEffect(() => {
if (!projectId) return
let cancelled = false
const replaced = lastLoadRef.current
lastLoadRef.current = { projectId, scopeVersion }
const keepScreen = replaced !== null && replaced.projectId === projectId && replaced.scopeVersion === 0
async function load() {
setLoading(true)
setError(null)
setIsNotFound(false)
setAccessDenied(false)
if (!keepScreen) setLoading(true)
const settle = (outcome: { values?: ProjectFormValues; accessDenied?: boolean; isNotFound?: boolean; error?: string }) => {
if (cancelled) return
setAccessDenied(outcome.accessDenied === true)
setIsNotFound(outcome.isNotFound === true)
setError(outcome.error ?? null)
if (outcome.values) setInitialValues(outcome.values)
setLoading(false)
}
try {
const record = await fetchProject()
if (!record) {
if (!cancelled) setIsNotFound(true)
settle({ isNotFound: true })
return
}
if (!cancelled) setInitialValues(toValues(record))
settle({ values: toValues(record) })
} catch (err) {
if (cancelled) return
if (err instanceof ProjectAccessDeniedError) {
setAccessDenied(true)
settle({ accessDenied: true })
return
}
setError(
err instanceof Error
settle({
error: err instanceof Error
? err.message
: t('staff.timesheets.projects.errors.load', 'Failed to load project.'),
)
} finally {
if (!cancelled) setLoading(false)
})
}
}
load()
Expand Down
Loading
Loading