Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add create questionnaire route and enhance questionnaire editor functionality #10363

Merged
merged 1 commit into from
Feb 2, 2025
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 src/Routers/routes/questionnaireRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { AppRoutes } from "@/Routers/AppRouter";

const QuestionnaireRoutes: AppRoutes = {
"/questionnaire": () => <QuestionnaireList />,
"/questionnaire/create": () => <QuestionnaireEditor />,
"/questionnaire/:id": ({ id }) => <QuestionnaireShow id={id} />,
"/questionnaire/:id/edit": ({ id }) => <QuestionnaireEditor id={id} />,
};
Expand Down
207 changes: 189 additions & 18 deletions src/components/Questionnaire/QuestionnaireEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { DragDropContext, Draggable, Droppable } from "@hello-pangea/dnd";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Building, Check, Loader2, X } from "lucide-react";
import { useNavigate } from "raviger";
import { useEffect, useState } from "react";
import { toast } from "sonner";
Expand All @@ -15,6 +16,14 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from "@/components/ui/command";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Expand All @@ -32,6 +41,7 @@ import Loading from "@/components/Common/Loading";

import mutate from "@/Utils/request/mutate";
import query from "@/Utils/request/query";
import organizationApi from "@/types/organization/organizationApi";
import {
AnswerOption,
EnableWhen,
Expand All @@ -46,10 +56,11 @@ import {
} from "@/types/questionnaire/questionnaire";
import questionnaireApi from "@/types/questionnaire/questionnaireApi";

import ManageQuestionnaireOrganizationsSheet from "./ManageQuestionnaireOrganizationsSheet";
import { QuestionnaireForm } from "./QuestionnaireForm";

interface QuestionnaireEditorProps {
id: string;
id?: string;
}

export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
Expand All @@ -58,6 +69,8 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
const [expandedQuestions, setExpandedQuestions] = useState<Set<string>>(
new Set(),
);
const [selectedOrgIds, setSelectedOrgIds] = useState<string[]>([]);
const [orgSearchQuery, setOrgSearchQuery] = useState("");

const {
data: initialQuestionnaire,
Expand All @@ -66,13 +79,36 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
} = useQuery({
queryKey: ["questionnaireDetail", id],
queryFn: query(questionnaireApi.detail, {
pathParams: { id },
pathParams: { id: id! },
}),
enabled: !!id,
});

const { data: availableOrganizations, isLoading: isLoadingOrganizations } =
useQuery({
queryKey: ["organizations", orgSearchQuery],
queryFn: query(organizationApi.list, {
queryParams: {
org_type: "role",
name: orgSearchQuery || undefined,
},
}),
});

const { mutate: createQuestionnaire, isPending: isCreating } = useMutation({
mutationFn: mutate(questionnaireApi.create),
onSuccess: (data: QuestionnaireDetail) => {
toast.success("Questionnaire created successfully");
navigate(`/questionnaire/${data.slug}`);
},
onError: (_error) => {
toast.error("Failed to create questionnaire");
},
});

const { mutate: updateQuestionnaire, isPending } = useMutation({
const { mutate: updateQuestionnaire, isPending: isUpdating } = useMutation({
mutationFn: mutate(questionnaireApi.update, {
pathParams: { id },
pathParams: { id: id! },
}),
onSuccess: () => {
toast.success("Questionnaire updated successfully");
Expand All @@ -83,15 +119,30 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
});

const [questionnaire, setQuestionnaire] =
useState<QuestionnaireDetail | null>(null);
useState<QuestionnaireDetail | null>(() => {
if (!id) {
return {
id: "",
title: "",
description: "",
status: "draft",
version: "1.0",
subject_type: "patient",
questions: [],
slug: "",
tags: [],
} as QuestionnaireDetail;
}
return null;
});

useEffect(() => {
if (initialQuestionnaire) {
setQuestionnaire(initialQuestionnaire);
}
}, [initialQuestionnaire]);

if (isLoading) return <Loading />;
if (id && isLoading) return <Loading />;
if (error) {
return (
<Alert variant="destructive">
Expand Down Expand Up @@ -122,8 +173,19 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
setQuestionnaire((prev) => (prev ? { ...prev, [field]: value } : null));
};

const handleSave = () => {
if (id) {
updateQuestionnaire(questionnaire);
} else {
createQuestionnaire({
...questionnaire,
organizations: selectedOrgIds,
});
}
};

const handleCancel = () => {
navigate(`/questionnaire/${id}`);
navigate(id ? `/questionnaire/${id}` : "/questionnaire");
};

const handleDragEnd = (result: any) => {
Expand All @@ -148,25 +210,31 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
});
};

const handleToggleOrganization = (orgId: string) => {
setSelectedOrgIds((current) =>
current.includes(orgId)
? current.filter((id) => id !== orgId)
: [...current, orgId],
);
};

return (
<div className="container mx-auto px-4 py-6">
{/* Top bar: Title + Buttons */}
<div className="mb-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">Edit Questionnaire</h1>
<h1 className="text-2xl font-bold">
{id ? "Edit Questionnaire" : "Create Questionnaire"}
</h1>
<p className="text-sm text-gray-500">{questionnaire.description}</p>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
<CareIcon icon="l-arrow-left" className="mr-2 h-4 w-4" />
Cancel
</Button>
<Button
onClick={() => updateQuestionnaire(questionnaire)}
disabled={isPending}
>
<Button onClick={handleSave} disabled={isCreating || isUpdating}>
<CareIcon icon="l-save" className="mr-2 h-4 w-4" />
Save
{id ? "Save" : "Create"}
</Button>
</div>
</div>
Expand All @@ -182,7 +250,6 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {

<TabsContent value="edit">
<div className="grid gap-6 lg:grid-cols-[300px,1fr]">
{/* Left Sidebar: Navigation */}
<div className="space-y-4">
<Card>
<CardHeader>
Expand Down Expand Up @@ -318,11 +385,100 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
</SelectContent>
</Select>
</div>

<div>
<Label>Organizations</Label>
{id ? (
<ManageQuestionnaireOrganizationsSheet
questionnaireId={id}
trigger={
<Button
variant="outline"
className="w-full justify-start"
>
<Building className="mr-2 h-4 w-4" />
Manage Organizations
</Button>
}
/>
) : (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
{selectedOrgIds.length > 0 ? (
availableOrganizations?.results
.filter((org) => selectedOrgIds.includes(org.id))
.map((org) => (
<Badge
key={org.id}
variant="secondary"
className="flex items-center gap-1"
>
{org.name}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() =>
handleToggleOrganization(org.id)
}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))
) : (
<p className="text-sm text-muted-foreground">
No organizations selected
</p>
)}
</div>

<Command className="rounded-lg border shadow-md">
<CommandInput
placeholder="Search organizations..."
onValueChange={setOrgSearchQuery}
/>
<CommandList>
<CommandEmpty>No organizations found.</CommandEmpty>
<CommandGroup>
{isLoadingOrganizations ? (
<div className="flex items-center justify-center py-6">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
availableOrganizations?.results.map((org) => (
<CommandItem
key={org.id}
value={org.id}
onSelect={() =>
handleToggleOrganization(org.id)
}
>
<div className="flex flex-1 items-center gap-2">
<Building className="h-4 w-4" />
<span>{org.name}</span>
{org.description && (
<span className="text-xs text-muted-foreground">
- {org.description}
</span>
)}
</div>
{selectedOrgIds.includes(org.id) && (
<Check className="h-4 w-4" />
)}
</CommandItem>
))
)}
</CommandGroup>
</CommandList>
</Command>
</div>
)}
</div>
</CardContent>
</Card>
</div>

{/* Main Content */}
<div className="space-y-6">
<Card>
<CardHeader>
Expand All @@ -340,6 +496,22 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
/>
</div>

<div>
<Label htmlFor="slug">Slug</Label>
<Input
id="slug"
value={questionnaire.slug}
onChange={(e) =>
updateQuestionnaireField("slug", e.target.value)
}
placeholder="unique-identifier-for-questionnaire"
className="font-mono"
/>
<p className="text-sm text-muted-foreground mt-1">
A unique URL-friendly identifier for this questionnaire
</p>
</div>

<div>
<Label htmlFor="desc">Description</Label>
<Textarea
Expand Down Expand Up @@ -368,7 +540,7 @@ export default function QuestionnaireEditor({ id }: QuestionnaireEditorProps) {
onClick={() => {
const newQuestion: Question = {
id: crypto.randomUUID(),
link_id: `Q-${Date.now()}`,
link_id: `${questionnaire.questions.length + 1}`,
text: "New Question",
type: "string",
questions: [],
Expand Down Expand Up @@ -619,7 +791,6 @@ function QuestionEditor({
<Select
value={type}
onValueChange={(val: QuestionType) => {
// Reset questions array when changing from group to another type
if (val !== "group") {
updateField("type", val, { questions: [] });
} else {
Expand Down
2 changes: 1 addition & 1 deletion src/components/Questionnaire/show.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ export function QuestionnaireShow({ id }: QuestionnaireShowProps) {
</div>
</TabsContent>

<TabsContent value="preview" className="max-w-3xl mx-auto">
<TabsContent value="preview" className="mx-auto">
<Card>
<CardHeader>
<CardTitle>{questionnaire.title}</CardTitle>
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive:
"bg-red-500 text-gray-50 shadow-sm hover:bg-red-500/90 dark:bg-red-900 dark:text-gray-50 dark:hover:bg-red-900/90",
outline:
"border border-gray-400/75 bg-white shadow-sm hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50",
"border border-gray-200 bg-white shadow-sm hover:bg-gray-100 hover:text-gray-900 dark:border-gray-800 dark:bg-gray-950 dark:hover:bg-gray-800 dark:hover:text-gray-50",
primary:
"bg-primary-700 text-white shadow hover:bg-primary-700/90 dark:bg-primary-100 dark:text-primary-900 dark:hover:bg-primary-100/90",
secondary:
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex w-full rounded-md border border-gray-400/75 bg-white px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-gray-950 placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-gray-800 dark:file:text-gray-50 dark:placeholder:text-gray-400 dark:focus-visible:ring-gray-300",
"flex w-full rounded-md border border-gray-200 bg-white px-3 py-2 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-gray-950 placeholder:text-gray-500 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-gray-950 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm dark:border-gray-800 dark:file:text-gray-50 dark:placeholder:text-gray-400 dark:focus-visible:ring-gray-300",
className,
)}
ref={ref}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/phone-input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ const InputComponent = React.forwardRef<
>(({ className, ...props }, ref) => (
<Input
className={cn(
"rounded-e-md rounded-s-none focus-visible:ring-0 focus-visible:outline-none focus-visible:border-gray-400/75",
"rounded-e-md rounded-s-none focus-visible:ring-0 focus-visible:outline-none focus-visible:border-gray-200",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-400/75 bg-white px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-gray-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-offset-gray-950 dark:placeholder:text-gray-400 dark:focus-visible:ring-gray-300 [&>span]:line-clamp-1",
"flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-gray-200 bg-white px-3 py-2 text-sm shadow-sm transition-colors placeholder:text-gray-500 focus:border-primary-500 focus:outline-none focus:ring-1 focus:ring-primary-900 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-800 dark:bg-gray-950 dark:ring-offset-gray-950 dark:placeholder:text-gray-400 dark:focus-visible:ring-gray-300 [&>span]:line-clamp-1",
className,
)}
{...props}
Expand Down
2 changes: 1 addition & 1 deletion src/components/ui/textarea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-gray-400/75 bg-white px-3 py-1 text-base shadow-sm transition-colors placeholder:text-gray-500 focus-visible:ring-1 focus-visible:border-primary-500 focus-visible:outline-none focus-visible:ring-primary-500 md:text-sm disabled:opacity-50",
"flex min-h-[80px] w-full rounded-md border border-gray-200 bg-white px-3 py-1 text-base shadow-sm transition-colors placeholder:text-gray-500 focus-visible:ring-1 focus-visible:border-primary-500 focus-visible:outline-none focus-visible:ring-primary-500 md:text-sm disabled:opacity-50",
className,
)}
ref={ref}
Expand Down
Loading