diff --git a/frontend/package-lock.json b/frontend/package-lock.json index a3cf6988d..be3e242f4 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -3103,6 +3103,8 @@ }, "node_modules/@popperjs/core": { "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", "license": "MIT", "funding": { "type": "opencollective", @@ -4395,7 +4397,9 @@ "license": "MIT" }, "node_modules/clsx": { - "version": "2.0.0", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "license": "MIT", "peer": true, "engines": { diff --git a/frontend/package.json b/frontend/package.json index 858b5a3a2..e8615d6ad 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -105,5 +105,6 @@ "prettier --write", "git add" ] - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/frontend/src/components/QuestionComponents/Dropdown/index.tsx b/frontend/src/components/QuestionComponents/Dropdown/index.tsx new file mode 100644 index 000000000..aded2ca32 --- /dev/null +++ b/frontend/src/components/QuestionComponents/Dropdown/index.tsx @@ -0,0 +1,100 @@ +import React, { useState } from 'react'; +import tw from 'twin.macro'; +import { ChevronDownIcon, CheckIcon } from '@heroicons/react/24/solid'; + +interface DropdownOption { + id: string | number; + label: string; +} + +interface DropdownProps { + id: number; + question: string; + description?: string; + options: DropdownOption[]; + required?: boolean; + defaultValue?: string | number; + onChange?: (value: string | number) => void; + onSubmit?: (questionId: number, value: string | number) => void; + disabled?: boolean; +} + +const Dropdown: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue, + onChange, + onSubmit, + disabled = false, +}) => { + const [value, setValue] = useState(defaultValue); + const [isOpen, setIsOpen] = useState(false); + + const handleSelect = (optionId: string | number) => { + setValue(optionId); + setIsOpen(false); + + if (onChange) onChange(optionId); + if (onSubmit) onSubmit(id, optionId); + }; + + const selectedOption = options.find(option => option.id === value); + + return ( +
+
+ + {required && *} +
+ + {description && ( +

{description}

+ )} + +
+ + + {isOpen && ( +
+ {options.map((option) => { + // Check if this option is selected + const isSelected = option.id === value; + + return ( +
handleSelect(option.id)} + // Basic styling for all options + tw="px-4 py-1 cursor-pointer hover:bg-blue-50 text-sm flex items-center justify-between" + // Apply conditional styling based on selection state + css={isSelected ? tw`bg-blue-50 font-medium` : undefined} + > + {option.label} + {/* Show checkmark for selected option */} + {isSelected && ( + + )} +
+ ); + })} +
+ )} +
+
+ ); +}; + +export default Dropdown; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/MultiChoice/index.tsx b/frontend/src/components/QuestionComponents/MultiChoice/index.tsx new file mode 100644 index 000000000..8cb42d208 --- /dev/null +++ b/frontend/src/components/QuestionComponents/MultiChoice/index.tsx @@ -0,0 +1,78 @@ +import React, { useState } from 'react'; +import tw from 'twin.macro'; + +interface Option { + id: string | number; + label: string; +} + +interface MultiChoiceProps { + id: number; + question: string; + description?: string; + options: Option[]; + required?: boolean; + defaultValue?: string | number; + onChange?: (value: string | number) => void; + onSubmit?: (questionId: number, value: string | number) => void; + disabled?: boolean; +} + +const MultiChoice: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue, + onChange, + onSubmit, + disabled = false, +}) => { + const [selectedOption, setSelectedOption] = useState(defaultValue); + + const handleChange = (optionId: string | number) => { + setSelectedOption(optionId); + + if (onChange) onChange(optionId); + if (onSubmit) onSubmit(id, optionId); + }; + + return ( +
+
+ + {required && *} +
+ + {description && ( +

{description}

+ )} + +
+ {options.map((option) => ( +
+ handleChange(option.id)} + disabled={disabled} + tw="h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500" + /> + +
+ ))} +
+
+ ); +}; + +export default MultiChoice; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/MultiSelect/index.tsx b/frontend/src/components/QuestionComponents/MultiSelect/index.tsx new file mode 100644 index 000000000..13e400843 --- /dev/null +++ b/frontend/src/components/QuestionComponents/MultiSelect/index.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import tw from 'twin.macro'; + +interface Option { + id: string | number; + label: string; +} + +interface MultiSelectProps { + id: number; + question: string; + description?: string; + options: Option[]; + required?: boolean; + defaultValue?: Array; + onChange?: (value: Array) => void; + onSubmit?: (questionId: number, value: Array) => void; + disabled?: boolean; +} + +const MultiSelect: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue = [], + onChange, + onSubmit, + disabled = false, +}) => { + const [selectedOptions, setSelectedOptions] = useState>(defaultValue); + + const handleChange = (optionId: string | number) => { + let newSelectedOptions: Array = []; + + if (selectedOptions.includes(optionId)) { + newSelectedOptions = selectedOptions.filter(id => id !== optionId); + } else { + newSelectedOptions = [...selectedOptions, optionId]; + } + + setSelectedOptions(newSelectedOptions); + + if (onChange) onChange(newSelectedOptions); + if (onSubmit) onSubmit(id, newSelectedOptions); + }; + + return ( +
+
+ + {required && *} +
+ + {description && ( +

{description}

+ )} + +
+ {options.map((option) => ( +
+ handleChange(option.id)} + disabled={disabled} + tw="h-4 w-4 text-blue-600 rounded border-gray-300 focus:ring-blue-500" + /> + +
+ ))} +
+
+ ); +}; + +export default MultiSelect; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/Ranking/index.tsx b/frontend/src/components/QuestionComponents/Ranking/index.tsx new file mode 100644 index 000000000..8ba14bf14 --- /dev/null +++ b/frontend/src/components/QuestionComponents/Ranking/index.tsx @@ -0,0 +1,135 @@ +import React, { useState, useEffect } from 'react'; +import tw, {css} from 'twin.macro'; +import { DragDropContext, Droppable, Draggable } from '@hello-pangea/dnd'; + +interface Option { + id: string | number; + label: string; +} + +interface RankedOption extends Option { + rank: number; +} + +interface RankingProps { + id: number; + question: string; + description?: string; + options: Option[]; + required?: boolean; + defaultValue?: Array; + onChange?: (value: Array) => void; + onSubmit?: (questionId: number, value: Array) => void; + disabled?: boolean; +} + +const Ranking: React.FC = ({ + id, + question, + description, + options, + required = false, + defaultValue = [], + onChange, + onSubmit, + disabled = false, +}) => { + const [rankedOptions, setRankedOptions] = useState([]); + + // Initialize ranked options on mount or when options change + useEffect(() => { + if (defaultValue.length > 0) { + const initialRanked = defaultValue.map((optionId, index) => { + const option = options.find(opt => opt.id === optionId); + return option ? { ...option, rank: index + 1 } : null; + }).filter(Boolean) as RankedOption[]; + + const rankedIds = initialRanked.map(o => o.id); + const unrankedOptions = options + .filter(opt => !rankedIds.includes(opt.id)) + .map((opt, i) => ({ ...opt, rank: initialRanked.length + i + 1 })); + + setRankedOptions([...initialRanked, ...unrankedOptions]); + } else { + setRankedOptions(options.map((opt, i) => ({ ...opt, rank: i + 1 }))); + } + }, [options, defaultValue]); + + const handleDragEnd = (result: any) => { + if (!result.destination) { + return; + } + + if (result.destination.index === result.source.index) { + return; + } + + const newRankedOptions = Array.from(rankedOptions); + const [removed] = newRankedOptions.splice(result.source.index, 1); + newRankedOptions.splice(result.destination.index, 0, removed); + + const updatedOptions = newRankedOptions.map((opt, idx) => ({ + ...opt, + rank: idx + 1 + })); + + setRankedOptions(updatedOptions); + + const orderedIds = updatedOptions.map(opt => opt.id); + if (onChange) onChange(orderedIds); + if (onSubmit) onSubmit(id, orderedIds); + }; + + const sortedOptions = [...rankedOptions].sort((a, b) => a.rank - b.rank); + + return ( +
+
+ + {required && *} +
+ + {description && ( +

{description}

+ )} + + + + {(provided, snapshot) => ( +
+ {sortedOptions.map((option, index) => ( + + {(provided, snapshot) => ( +
+ + {option.rank} + + {option.label} +
+ )} +
+ ))} + {provided.placeholder} +
+ )} +
+
+
+ ); +}; + +export default Ranking; \ No newline at end of file diff --git a/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx b/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx new file mode 100644 index 000000000..5e5324d7d --- /dev/null +++ b/frontend/src/components/QuestionComponents/ShortAnswer/index.tsx @@ -0,0 +1,77 @@ +import React, { useState, ChangeEvent } from 'react'; +import tw from 'twin.macro'; +import Textarea from 'components/Textarea'; + +interface ShortAnswerProps { + id: number; + question: string; + description?: string; + required?: boolean; + defaultValue?: string; + onChange?: (value: string) => void; + onSubmit?: (questionId: number, value: string) => void; + disabled?: boolean; + rows?: number; + columns?: number; +} + +const ShortAnswer: React.FC = ({ + id, + question, + description, + required = false, + defaultValue = '', + onChange, + onSubmit, + disabled = false, + rows = 3, // Fix: expand to 3 rows + columns = 77, +}) => { + const [value, setValue] = useState(defaultValue); + const [isFocused, setIsFocused] = useState(false); + + // Changed to handle textarea instead of input + const handleChange = (e: ChangeEvent) => { + setValue(e.target.value); + if (onChange) onChange(e.target.value); + }; + + const handleBlur = () => { + if (onSubmit && value.trim() !== '') { + onSubmit(id, value); + } + }; + const handleFocus = () => { + setIsFocused(true); + }; + + return ( +
+
+ + {required && *} +
+ + {description && ( +

{description}

+ )} +