feat(frontend): toggle task activation state
Add task reactivation support to the local task API and update the edit task page to switch between Activate and Inactivate based on the current task state. Keep the separate entity-page inactive-visibility changes out of this commit so they can be reviewed independently.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
activateTask,
|
||||||
createTask,
|
createTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
getAllTasks,
|
getAllTasks,
|
||||||
@@ -152,6 +153,24 @@ describe('tasksApi', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should_markTaskActive_when_activateTaskCalled', async () => {
|
||||||
|
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||||
|
|
||||||
|
const updatedTask = await activateTask('task-2')
|
||||||
|
|
||||||
|
expect(updatedTask).toEqual({
|
||||||
|
...taskTwo,
|
||||||
|
active: true,
|
||||||
|
})
|
||||||
|
expect(JSON.parse(localStorage.getItem('condado:entity-tasks') ?? '[]')).toEqual([
|
||||||
|
taskOne,
|
||||||
|
{
|
||||||
|
...taskTwo,
|
||||||
|
active: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
it('should_removeTask_when_deleteTaskCalled', async () => {
|
it('should_removeTask_when_deleteTaskCalled', async () => {
|
||||||
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
localStorage.setItem('condado:entity-tasks', JSON.stringify([taskOne, taskTwo]))
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import EditTaskPage from '@/pages/EditTaskPage'
|
|||||||
import * as entitiesApi from '@/api/entitiesApi'
|
import * as entitiesApi from '@/api/entitiesApi'
|
||||||
import * as tasksApi from '@/api/tasksApi'
|
import * as tasksApi from '@/api/tasksApi'
|
||||||
|
|
||||||
|
type RenderPageOptions = {
|
||||||
|
task?: typeof mockTask
|
||||||
|
}
|
||||||
|
|
||||||
const mockNavigate = vi.fn()
|
const mockNavigate = vi.fn()
|
||||||
vi.mock('react-router-dom', async () => {
|
vi.mock('react-router-dom', async () => {
|
||||||
const actual = await vi.importActual('react-router-dom')
|
const actual = await vi.importActual('react-router-dom')
|
||||||
@@ -15,7 +19,7 @@ vi.mock('react-router-dom', async () => {
|
|||||||
vi.mock('@/api/entitiesApi')
|
vi.mock('@/api/entitiesApi')
|
||||||
vi.mock('@/api/tasksApi')
|
vi.mock('@/api/tasksApi')
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage(options: RenderPageOptions = {}) {
|
||||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
|
||||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -27,6 +31,8 @@ function renderPage() {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
vi.mocked(tasksApi.getTask).mockResolvedValue(options.task ?? mockTask)
|
||||||
|
|
||||||
render(<EditTaskPage />, { wrapper })
|
render(<EditTaskPage />, { wrapper })
|
||||||
|
|
||||||
return { queryClient }
|
return { queryClient }
|
||||||
@@ -57,8 +63,10 @@ const mockTask = {
|
|||||||
|
|
||||||
describe('EditTaskPage', () => {
|
describe('EditTaskPage', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
vi.mocked(entitiesApi.getEntity).mockResolvedValue(mockEntity)
|
||||||
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
|
vi.mocked(tasksApi.getTask).mockResolvedValue(mockTask)
|
||||||
|
vi.mocked(tasksApi.activateTask).mockResolvedValue({ ...mockTask, active: true })
|
||||||
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false })
|
vi.mocked(tasksApi.inactivateTask).mockResolvedValue({ ...mockTask, active: false })
|
||||||
vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined)
|
vi.mocked(tasksApi.deleteTask).mockResolvedValue(undefined)
|
||||||
mockNavigate.mockClear()
|
mockNavigate.mockClear()
|
||||||
@@ -137,15 +145,47 @@ describe('EditTaskPage', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should_renderActivateButton_when_taskIsInactive', async () => {
|
||||||
|
renderPage({ task: { ...mockTask, active: false } })
|
||||||
|
|
||||||
|
const activateButton = await screen.findByRole('button', { name: /activate/i })
|
||||||
|
|
||||||
|
expect(activateButton).toHaveClass('border-emerald-700')
|
||||||
|
expect(activateButton).toHaveClass('text-emerald-200')
|
||||||
|
expect(screen.queryByRole('button', { name: /^inactivate$/i })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
it('should_inactivateTaskAndNavigateBack_when_inactivateClicked', async () => {
|
it('should_inactivateTaskAndNavigateBack_when_inactivateClicked', async () => {
|
||||||
const { queryClient } = renderPage()
|
const { queryClient } = renderPage()
|
||||||
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
await screen.findByRole('link', { name: /back to entity a/i })
|
const inactivateButton = await screen.findByRole('button', { name: /^inactivate$/i })
|
||||||
fireEvent.click(screen.getByRole('button', { name: /inactivate/i }))
|
|
||||||
|
expect(inactivateButton).toHaveClass('border-amber-700')
|
||||||
|
expect(inactivateButton).toHaveClass('text-amber-200')
|
||||||
|
|
||||||
|
fireEvent.click(inactivateButton)
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-1')
|
expect(tasksApi.inactivateTask).toHaveBeenCalledWith('task-1')
|
||||||
|
expect(tasksApi.activateTask).not.toHaveBeenCalled()
|
||||||
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] })
|
||||||
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] })
|
||||||
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] })
|
||||||
|
expect(mockNavigate).toHaveBeenCalledWith('/entities/entity-1')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should_activateTaskAndNavigateBack_when_activateClicked', async () => {
|
||||||
|
const { queryClient } = renderPage({ task: { ...mockTask, active: false } })
|
||||||
|
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
|
||||||
|
|
||||||
|
const activateButton = await screen.findByRole('button', { name: /^activate$/i })
|
||||||
|
fireEvent.click(activateButton)
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(tasksApi.activateTask).toHaveBeenCalledWith('task-1')
|
||||||
|
expect(tasksApi.inactivateTask).not.toHaveBeenCalled()
|
||||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] })
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks', 'entity-1'] })
|
||||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] })
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-task', 'task-1'] })
|
||||||
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] })
|
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['entity-tasks'] })
|
||||||
|
|||||||
@@ -134,6 +134,24 @@ export async function inactivateTask(taskId: string): Promise<EntityTaskResponse
|
|||||||
return updatedTask
|
return updatedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Marks one scheduled task as active in local storage. */
|
||||||
|
export async function activateTask(taskId: string): Promise<EntityTaskResponse | null> {
|
||||||
|
const current = readTasks()
|
||||||
|
const existingTask = current.find((task) => task.id === taskId)
|
||||||
|
|
||||||
|
if (!existingTask) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedTask: EntityTaskResponse = {
|
||||||
|
...existingTask,
|
||||||
|
active: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
writeTasks(current.map((task) => (task.id === taskId ? updatedTask : task)))
|
||||||
|
return updatedTask
|
||||||
|
}
|
||||||
|
|
||||||
/** Deletes one scheduled task from local storage. */
|
/** Deletes one scheduled task from local storage. */
|
||||||
export async function deleteTask(taskId: string): Promise<void> {
|
export async function deleteTask(taskId: string): Promise<void> {
|
||||||
writeTasks(readTasks().filter((task) => task.id !== taskId))
|
writeTasks(readTasks().filter((task) => task.id !== taskId))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { getEntity } from '../api/entitiesApi'
|
import { getEntity } from '../api/entitiesApi'
|
||||||
import {
|
import {
|
||||||
|
activateTask,
|
||||||
deleteTask,
|
deleteTask,
|
||||||
generateTaskPreview,
|
generateTaskPreview,
|
||||||
getTask,
|
getTask,
|
||||||
@@ -165,6 +166,14 @@ export default function EditTaskPage() {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const activateTaskMutation = useMutation({
|
||||||
|
mutationFn: () => activateTask(taskId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await invalidateTaskQueries(queryClient, entityId, taskId)
|
||||||
|
navigate(`/entities/${entityId}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const deleteTaskMutation = useMutation({
|
const deleteTaskMutation = useMutation({
|
||||||
mutationFn: () => deleteTask(taskId),
|
mutationFn: () => deleteTask(taskId),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
@@ -417,16 +426,41 @@ export default function EditTaskPage() {
|
|||||||
<div className="flex justify-end gap-3 pb-8">
|
<div className="flex justify-end gap-3 pb-8">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => inactivateTaskMutation.mutate()}
|
onClick={() => {
|
||||||
disabled={!task.active || inactivateTaskMutation.isPending || deleteTaskMutation.isPending}
|
if (task.active) {
|
||||||
className="mr-auto rounded-md border border-amber-700 px-4 py-2 text-sm text-amber-200 hover:border-amber-500 disabled:opacity-50"
|
inactivateTaskMutation.mutate()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
activateTaskMutation.mutate()
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
inactivateTaskMutation.isPending ||
|
||||||
|
activateTaskMutation.isPending ||
|
||||||
|
deleteTaskMutation.isPending
|
||||||
|
}
|
||||||
|
className={`mr-auto rounded-md border px-4 py-2 text-sm disabled:opacity-50 ${
|
||||||
|
task.active
|
||||||
|
? 'border-amber-700 text-amber-200 hover:border-amber-500'
|
||||||
|
: 'border-emerald-700 text-emerald-200 hover:border-emerald-500'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
{inactivateTaskMutation.isPending ? 'Inactivating…' : 'Inactivate'}
|
{task.active
|
||||||
|
? inactivateTaskMutation.isPending
|
||||||
|
? 'Inactivating…'
|
||||||
|
: 'Inactivate'
|
||||||
|
: activateTaskMutation.isPending
|
||||||
|
? 'Activating…'
|
||||||
|
: 'Activate'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => deleteTaskMutation.mutate()}
|
onClick={() => deleteTaskMutation.mutate()}
|
||||||
disabled={deleteTaskMutation.isPending || inactivateTaskMutation.isPending}
|
disabled={
|
||||||
|
deleteTaskMutation.isPending ||
|
||||||
|
inactivateTaskMutation.isPending ||
|
||||||
|
activateTaskMutation.isPending
|
||||||
|
}
|
||||||
className="rounded-md border border-red-800 px-4 py-2 text-sm text-red-200 hover:border-red-600 disabled:opacity-50"
|
className="rounded-md border border-red-800 px-4 py-2 text-sm text-red-200 hover:border-red-600 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{deleteTaskMutation.isPending ? 'Deleting…' : 'Delete'}
|
{deleteTaskMutation.isPending ? 'Deleting…' : 'Delete'}
|
||||||
|
|||||||
Reference in New Issue
Block a user