Skip to main content
Glama
TaskBoard.tsx12 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { Flex, Paper, Group, Button, Divider, ActionIcon, ScrollArea, Stack, Skeleton, Text, Box, Pagination, Center, } from '@mantine/core'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { JSX } from 'react'; import cx from 'clsx'; import classes from './TaskBoard.module.css'; import type { CodeableConcept, Task } from '@medplum/fhirtypes'; import { Operator, parseSearchRequest } from '@medplum/core'; import type { SearchRequest } from '@medplum/core'; import { Link, useNavigate } from 'react-router'; import { useMedplum } from '@medplum/react'; import { showErrorNotification } from '../../utils/notifications'; import { TaskFilterType } from './TaskFilterMenu.utils'; import type { TaskFilterValue } from './TaskFilterMenu.utils'; import { TaskFilterMenu } from './TaskFilterMenu'; import { IconPlus } from '@tabler/icons-react'; import { TaskListItem } from './TaskListItem'; import { TaskSelectEmpty } from './TaskSelectEmpty'; import { NewTaskModal } from './NewTaskModal'; import { TaskDetailPanel } from './TaskDetailPanel'; interface FilterState { performerType: CodeableConcept | undefined; } /** * TaskBoardProps is the props for the TaskBoard component. * @property query - The query string for the search request. * @property selectedTaskId - The ID of the selected task. * @property onDelete - The function to call when a task is deleted. * @property onNew - The function to call when a new task is created. * @property onChange - The function to call when the search request changes. * @property getTaskUri - The function to call to get the URI of a task. * @property myTasksUri - The URI for the my tasks search request. * @property allTasksUri - The URI for the all tasks search request. * @returns The TaskBoard component. */ interface TaskBoardProps { query: string; selectedTaskId: string | undefined; onDelete: (task: Task) => void; onNew: (task: Task) => void; onChange: (search: SearchRequest) => void; getTaskUri: (task: Task) => string; myTasksUri: string; allTasksUri: string; } export function TaskBoard({ query, selectedTaskId, onDelete, onNew, onChange, getTaskUri, myTasksUri, allTasksUri, }: TaskBoardProps): JSX.Element { const medplum = useMedplum(); const navigate = useNavigate(); const [tasks, setTasks] = useState<Task[]>([]); const [selectedTask, setSelectedTask] = useState<Task | undefined>(undefined); const [loading, setLoading] = useState<boolean>(false); const [performerTypes, setPerformerTypes] = useState<CodeableConcept[]>([]); const [newTaskModalOpened, setNewTaskModalOpened] = useState<boolean>(false); const [total, setTotal] = useState<number | undefined>(undefined); const requestIdRef = useRef<number>(0); const fetchingRef = useRef<boolean>(false); const [filters, setFilters] = useState<FilterState>({ performerType: undefined, }); // Parse pagination and status filters from query const searchParams = useMemo(() => new URLSearchParams(query), [query]); const itemsPerPage = Number.parseInt(searchParams.get('_count') || '20', 10); const currentOffset = Number.parseInt(searchParams.get('_offset') || '0', 10); const currentPage = Math.floor(currentOffset / itemsPerPage) + 1; const isMyTasks = searchParams.has('owner'); // Parse current search from query string const currentSearch = useMemo(() => parseSearchRequest(`Task?${query}`), [query]); // Parse status filters from query string const selectedStatuses = useMemo(() => { const statusFilters = currentSearch.filters?.filter((f) => f.code === 'status') || []; const statuses: Task['status'][] = []; statusFilters.forEach((filter) => { const values = filter.value.split(','); values.forEach((value) => { const trimmedValue = value.trim(); if (trimmedValue && !statuses.includes(trimmedValue as Task['status'])) { statuses.push(trimmedValue as Task['status']); } }); }); return statuses; }, [currentSearch]); const fetchTasks = useCallback(async (): Promise<void> => { if (fetchingRef.current) { return; } fetchingRef.current = true; const currentRequestId = ++requestIdRef.current; try { const bundle = await medplum.search('Task', query, { cache: 'no-cache' }); if (currentRequestId !== requestIdRef.current) { return; } let results: Task[] = []; if (bundle.entry) { results = bundle.entry.map((entry) => entry.resource as Task).filter((r): r is Task => r !== undefined); } if (bundle.total !== undefined) { setTotal(bundle.total); } const allPerformerTypes = results.flatMap((task) => task.performerType || []); if (filters.performerType) { results = results.filter( (task) => task.performerType?.[0]?.coding?.[0]?.code === filters.performerType?.coding?.[0]?.code ); } setPerformerTypes(allPerformerTypes); setTasks(results); } catch (error) { if (currentRequestId === requestIdRef.current) { throw error; } } finally { fetchingRef.current = false; } }, [medplum, filters.performerType, query]); useEffect(() => { setLoading(true); fetchTasks() .catch(showErrorNotification) .finally(() => setLoading(false)); }, [fetchTasks]); useEffect(() => { const handleTaskSelection = async (): Promise<void> => { if (selectedTaskId) { const task = tasks.find((task: Task) => task.id === selectedTaskId); if (task) { setSelectedTask(task); } else { const task = await medplum.readResource('Task', selectedTaskId); setSelectedTask(task); } } else { setSelectedTask(undefined); } }; handleTaskSelection().catch(() => { setSelectedTask(undefined); }); }, [selectedTaskId, tasks, medplum, navigate]); const handleNewTaskCreated = (task: Task): void => { fetchTasks().catch(showErrorNotification); onNew(task); }; const handleTaskChange = async (_task: Task): Promise<void> => { await fetchTasks().catch(showErrorNotification); }; const handleDeleteTask = async (task: Task): Promise<void> => { await fetchTasks().catch(showErrorNotification); onDelete(task); }; const handleFilterChange = (filterType: TaskFilterType, value: TaskFilterValue): void => { switch (filterType) { case TaskFilterType.STATUS: { const statusValue = value as Task['status']; const newStatuses = selectedStatuses.includes(statusValue) ? selectedStatuses.filter((s) => s !== statusValue) : [...selectedStatuses, statusValue]; const otherFilters = currentSearch.filters?.filter((f) => f.code !== 'status') || []; const newFilters = [...otherFilters]; if (newStatuses.length > 0) { newFilters.push({ code: 'status', operator: Operator.EQUALS, value: newStatuses.join(','), }); } onChange({ ...currentSearch, filters: newFilters, offset: 0, }); break; } case TaskFilterType.PERFORMER_TYPE: { const performerTypeCode = filters.performerType?.coding?.[0]?.code; const valueCode = (value as CodeableConcept)?.coding?.[0]?.code; setFilters((prev) => ({ ...prev, performerType: performerTypeCode !== valueCode ? (value as CodeableConcept) : undefined, })); break; } default: break; } }; return ( <Box w="100%" h="100%"> <Flex h="100%"> <Box w={350} h="100%"> <Flex direction="column" h="100%" className={classes.borderRight}> <Paper> <Flex h={64} align="center" justify="space-between" p="md"> <Group gap="xs"> <Button component={Link} to={myTasksUri} className={cx(classes.button, { [classes.selected]: isMyTasks })} h={32} radius="xl" > My Tasks </Button> <Button component={Link} to={allTasksUri} className={cx(classes.button, { [classes.selected]: !isMyTasks })} h={32} radius="xl" > All Tasks </Button> <TaskFilterMenu statuses={selectedStatuses} performerType={filters.performerType} performerTypes={performerTypes} onFilterChange={handleFilterChange} /> </Group> <ActionIcon radius="50%" variant="filled" color="blue" onClick={() => setNewTaskModalOpened(true)}> <IconPlus size={16} /> </ActionIcon> </Flex> </Paper> <Divider /> <Paper style={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}> <ScrollArea style={{ flex: 1 }} id="task-list-scrollarea"> {loading && <TaskListSkeleton />} {!loading && tasks.length === 0 && <EmptyTasksState />} {!loading && tasks.length > 0 && tasks.map((task, index) => ( <React.Fragment key={task.id}> <TaskListItem task={task} selectedTask={selectedTask} getTaskUri={getTaskUri} /> {index < tasks.length - 1 && <Divider />} </React.Fragment> ))} </ScrollArea> {!loading && total !== undefined && total > itemsPerPage && ( <Box p="md"> <Center> <Pagination value={currentPage} total={Math.ceil(total / itemsPerPage)} onChange={(page) => { const offset = (page - 1) * itemsPerPage; onChange({ ...currentSearch, offset, }); }} size="sm" siblings={1} boundaries={1} /> </Center> </Box> )} </Paper> </Flex> </Box> {selectedTask ? ( <TaskDetailPanel task={selectedTask} onTaskChange={handleTaskChange} onDeleteTask={handleDeleteTask} /> ) : ( <Flex direction="column" h="100%" style={{ flex: 1 }}> <TaskSelectEmpty /> </Flex> )} </Flex> <NewTaskModal opened={newTaskModalOpened} onClose={() => setNewTaskModalOpened(false)} onTaskCreated={handleNewTaskCreated} /> </Box> ); } function EmptyTasksState(): JSX.Element { return ( <Flex direction="column" h="100%" justify="center" align="center" pt="xl"> <Text c="dimmed" fw={500}> No tasks available. </Text> </Flex> ); } function TaskListSkeleton(): JSX.Element { return ( <Stack gap="md" p="md"> {Array.from({ length: 6 }).map((_, index) => ( <Stack key={index}> <Flex direction="column" gap="xs" align="flex-start"> <Skeleton height={16} width={`${Math.random() * 40 + 60}%`} /> <Skeleton height={14} width={`${Math.random() * 50 + 40}%`} /> <Skeleton height={14} width={`${Math.random() * 50 + 40}%`} /> </Flex> <Divider /> </Stack> ))} </Stack> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server