Skip to content
Merged
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
1 change: 1 addition & 0 deletions backend/api/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -271,4 +271,5 @@ class ApplyTaskGraphInput:
patient_id: strawberry.ID
preset_id: strawberry.ID | None = None
graph: TaskGraphInput | None = None
source_preset_id: strawberry.ID | None = None
assign_to_current_user: bool = False
29 changes: 28 additions & 1 deletion backend/api/resolvers/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -1065,6 +1065,11 @@ async def apply_task_graph(
"Provide exactly one of presetId or graph",
extensions={"code": "BAD_REQUEST"},
)
if data.preset_id and data.source_preset_id is not None:
raise GraphQLError(
"sourcePresetId is only allowed when graph is provided",
extensions={"code": "BAD_REQUEST"},
)
graph_dict: dict[str, Any]
if data.preset_id:
pr = await info.context.db.execute(
Expand All @@ -1091,7 +1096,29 @@ async def apply_task_graph(
)
validate_task_graph_dict(graph_dict)
assignee_id = user.id if data.assign_to_current_user else None
source_preset_id = str(data.preset_id) if data.preset_id else None
source_preset_id: str | None
if data.preset_id:
source_preset_id = str(data.preset_id)
elif data.source_preset_id is not None:
pr_src = await info.context.db.execute(
select(models.TaskPreset).where(
models.TaskPreset.id == data.source_preset_id,
),
)
preset_src = pr_src.scalars().first()
if not preset_src:
raise GraphQLError(
"Preset not found",
extensions={"code": "NOT_FOUND"},
)
if (
preset_src.scope == DbTaskPresetScope.PERSONAL.value
and preset_src.owner_user_id != user.id
):
raise_forbidden()
source_preset_id = str(data.source_preset_id)
else:
source_preset_id = None
return await apply_task_graph_to_patient(
info.context.db,
str(data.patient_id),
Expand Down
1 change: 1 addition & 0 deletions backend/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ input ApplyTaskGraphInput {
patientId: ID!
presetId: ID = null
graph: TaskGraphInput = null
sourcePresetId: ID = null
assignToCurrentUser: Boolean! = false
}

Expand Down
1 change: 1 addition & 0 deletions web/api/gql/generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type ApplyTaskGraphInput = {
graph?: InputMaybe<TaskGraphInput>;
patientId: Scalars['ID']['input'];
presetId?: InputMaybe<Scalars['ID']['input']>;
sourcePresetId?: InputMaybe<Scalars['ID']['input']>;
};

export type AuditLogType = {
Expand Down
203 changes: 164 additions & 39 deletions web/components/patients/LoadTaskPresetDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
Button,
Checkbox,
Chip,
Dialog,
FocusTrapWrapper,
Select,
SelectOption
} from '@helpwave/hightide'
import { Check, Plus, UserPlus, X } from 'lucide-react'
import { useTasksTranslation } from '@/i18n/useTasksTranslation'
import { useApplyTaskGraph, useTaskPresets } from '@/data'
import type { TaskPresetsQuery } from '@/api/gql/generated'
import { GetPatientDocument, type TaskPresetsQuery } from '@/api/gql/generated'
import { useSystemSuggestionTasks } from '@/context/SystemSuggestionTasksContext'
import { presetGraphToTaskGraphInput } from '@/utils/taskGraph'

type PresetRow = TaskPresetsQuery['taskPresets'][number]

Expand All @@ -26,25 +31,36 @@ export function LoadTaskPresetDialog({
onSuccess,
}: LoadTaskPresetDialogProps) {
const translation = useTasksTranslation()
const { data, loading } = useTaskPresets()
const { showToast } = useSystemSuggestionTasks()
const { data, loading, refetch: refetchPresets } = useTaskPresets()
const [applyTaskGraph, { loading: applying }] = useApplyTaskGraph()
const [selectedId, setSelectedId] = useState<string | undefined>(undefined)
const wasDialogOpenRef = useRef(false)
const [selectedId, setSelectedId] = useState<string | null>(null)
const [confirmOpen, setConfirmOpen] = useState(false)
const [selectedNodeIds, setSelectedNodeIds] = useState<Set<string>>(() => new Set())

const presets = useMemo(() => data?.taskPresets ?? [], [data?.taskPresets])

useEffect(() => {
if (!isOpen) {
setSelectedId(undefined)
setSelectedId(null)
setConfirmOpen(false)
}
}, [isOpen])

useEffect(() => {
if (isOpen && !wasDialogOpenRef.current) {
void refetchPresets()
}
wasDialogOpenRef.current = isOpen
}, [isOpen, refetchPresets])

useEffect(() => {
if (!isOpen || presets.length === 0) return
setSelectedId(prev => {
setSelectedId((prev) => {
const first = presets[0]
if (!first) return prev
return prev && presets.some(p => p.id === prev) ? prev : first.id
return prev != null && presets.some((p) => p.id === prev) ? prev : first.id
})
}, [isOpen, presets])

Expand All @@ -54,27 +70,59 @@ export function LoadTaskPresetDialog({
)

const taskCount = selected?.graph.nodes.length ?? 0
const selectedApplyCount = selectedNodeIds.size

useEffect(() => {
if (!confirmOpen || !selected) return
setSelectedNodeIds(new Set(selected.graph.nodes.map((n) => n.id)))
}, [confirmOpen, selected])

const handlePrimary = useCallback(() => {
if (!selectedId || taskCount === 0) return
setConfirmOpen(true)
}, [selectedId, taskCount])

const handleConfirmApply = useCallback(async () => {
if (!selectedId) return
await applyTaskGraph({
variables: {
data: {
patientId,
presetId: selectedId,
assignToCurrentUser: false,
},
},
const toggleNode = useCallback((nodeId: string) => {
setSelectedNodeIds((prev) => {
const next = new Set(prev)
if (next.has(nodeId)) next.delete(nodeId)
else next.add(nodeId)
return next
})
setConfirmOpen(false)
onClose()
onSuccess?.()
}, [applyTaskGraph, patientId, selectedId, onClose, onSuccess])
}, [])

const handleConfirmApply = useCallback(
async (assignToCurrentUser: boolean) => {
if (!selected) return
const graph = presetGraphToTaskGraphInput(selected.graph, selectedNodeIds)
if (!graph) return
await applyTaskGraph({
variables: {
data: {
patientId,
graph,
sourcePresetId: selected.id,
assignToCurrentUser,
},
},
refetchQueries: [{ query: GetPatientDocument, variables: { id: patientId } }],
})
showToast(translation('tasksCreatedFromPreset'))
setConfirmOpen(false)
onClose()
onSuccess?.()
},
[
applyTaskGraph,
patientId,
selected,
selectedNodeIds,
showToast,
translation,
onClose,
onSuccess,
]
)

return (
<>
Expand All @@ -97,8 +145,8 @@ export function LoadTaskPresetDialog({
<div className="flex flex-col gap-2">
<span className="typography-label-lg">{translation('taskPresets')}</span>
<Select
value={selectedId}
onValueChange={v => setSelectedId(v)}
value={selectedId ?? null}
onValueChange={(v) => setSelectedId(v)}
buttonProps={{
className: 'w-full',
}}
Expand All @@ -115,14 +163,22 @@ export function LoadTaskPresetDialog({
</div>
)}
<div className="flex flex-wrap gap-3 justify-end pt-2">
<Button color="neutral" coloringStyle="outline" onClick={onClose}>
<Button
color="neutral"
coloringStyle="outline"
className="inline-flex items-center gap-2"
onClick={onClose}
>
<X className="size-4 shrink-0" aria-hidden />
{translation('cancel')}
</Button>
<Button
color="primary"
className="inline-flex items-center gap-2"
onClick={handlePrimary}
disabled={!selectedId || taskCount === 0 || applying}
>
<Check className="size-4 shrink-0" aria-hidden />
{translation('confirm')}
</Button>
</div>
Expand All @@ -133,25 +189,94 @@ export function LoadTaskPresetDialog({
<Dialog
isOpen={confirmOpen}
onClose={() => setConfirmOpen(false)}
titleElement={translation('confirm')}
description={null}
titleElement={translation('loadTaskPresetConfirmTitle')}
description={
selected?.name ? (
<span className="block typography-title-md font-semibold text-label max-w-full break-words">
{selected.name}
</span>
) : null
}
position="center"
isModal={true}
className="max-w-md w-full"
className="max-w-2xl w-full max-h-[90vh] flex flex-col"
>
<div className="flex flex-col gap-4 py-2">
<p className="text-description">
{translation('loadTaskPresetConfirm', { count: taskCount })}
</p>
<div className="flex flex-wrap gap-3 justify-end">
<Button color="neutral" coloringStyle="outline" onClick={() => setConfirmOpen(false)}>
{translation('cancel')}
</Button>
<Button color="primary" onClick={handleConfirmApply} disabled={applying}>
{translation('confirm')}
</Button>
<FocusTrapWrapper active={confirmOpen}>
<div className="flex flex-col gap-4 py-2 overflow-hidden flex-1 min-h-0">
<p className="text-description shrink-0">
{translation('loadTaskPresetConfirm', { count: selectedApplyCount })}
</p>
<section className="flex flex-col min-h-0 flex-1">
<div className="text-sm font-medium text-label mb-3 shrink-0">
{translation('loadTaskPresetSelectTasks')}
</div>
<div className="flex flex-col gap-3 min-h-[12rem] max-h-96 overflow-y-auto pr-1">
{selected?.graph.nodes.map((task) => (
<div
key={task.id}
role="checkbox"
aria-checked={selectedNodeIds.has(task.id)}
tabIndex={0}
className="flex items-center gap-4 p-4 rounded-lg border border-border hover:bg-surface-variant cursor-pointer text-left w-full outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2"
onClick={() => toggleNode(task.id)}
onKeyDown={(e) => {
if (e.key === ' ') {
e.preventDefault()
toggleNode(task.id)
}
if (e.key === 'Enter') {
toggleNode(task.id)
}
}}
>
<Checkbox
value={selectedNodeIds.has(task.id)}
onValueChange={() => toggleNode(task.id)}
className="shrink-0 pointer-events-none select-none"
tabIndex={-1}
aria-hidden={true}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{task.title}</span>
<Chip color="secondary" coloringStyle="tonal" size="xs">
{translation('loadTaskPresetTaskLabel')}
</Chip>
</div>
{task.description && (
<p className="text-sm text-description mt-1.5">{task.description}</p>
)}
</div>
</div>
))}
</div>
</section>
<div className="flex flex-wrap gap-3 justify-end pt-2 border-t border-divider shrink-0">
<Button color="neutral" coloringStyle="outline" onClick={() => setConfirmOpen(false)}>
{translation('cancel')}
</Button>
<Button
color="primary"
coloringStyle="outline"
className="inline-flex items-center gap-2"
onClick={() => void handleConfirmApply(true)}
disabled={selectedApplyCount === 0 || applying}
>
<UserPlus className="size-4 shrink-0" aria-hidden />
{translation('loadTaskPresetCreateAndAssign')}
</Button>
<Button
color="primary"
className="inline-flex items-center gap-2"
onClick={() => void handleConfirmApply(false)}
disabled={selectedApplyCount === 0 || applying}
>
<Plus className="size-4 shrink-0" aria-hidden />
{translation('create')}
</Button>
</div>
</div>
</div>
</FocusTrapWrapper>
</Dialog>
</>
)
Expand Down
6 changes: 3 additions & 3 deletions web/components/tables/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -730,9 +730,9 @@ export const TaskList = forwardRef<TaskListRef, TaskListProps>(({ tasks: initial
</TaskRowRefreshingGate>
)
},
minSize: 200,
size: 250,
maxSize: 350,
minSize: 240,
size: 300,
maxSize: 440,
})
}

Expand Down
5 changes: 5 additions & 0 deletions web/data/cache/policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ export function buildCacheConfig(): InMemoryCacheConfig {
},
users: { keyArgs: [] },
me: { keyArgs: [] },
taskPresets: {
merge: (_existing, incoming) => incoming,
},
taskPreset: { keyArgs: ['id'] },
},
},
Task: { keyFields: ['id'] },
Expand Down Expand Up @@ -87,6 +91,7 @@ export function buildCacheConfig(): InMemoryCacheConfig {
TaskGraphType: { keyFields: false },
TaskGraphNodeType: { keyFields: false },
TaskGraphEdgeType: { keyFields: false },
TaskPresetType: { keyFields: ['id'] },
},
}
}
9 changes: 9 additions & 0 deletions web/data/cache/taskPresetMutationDefaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { TaskPresetsDocument } from '@/api/gql/generated'
import { getParsedDocument } from '../hooks/queryHelpers'

const taskPresetsQuery = getParsedDocument(TaskPresetsDocument)

export const refetchTaskPresetsMutationOptions = {
refetchQueries: [{ query: taskPresetsQuery }],
awaitRefetchQueries: true,
}
Loading
Loading