From 3eaa56f0306ead818f64c3d99fc6d230d9b970a4 Mon Sep 17 00:00:00 2001 From: Max Nanis Date: Wed, 18 Feb 2026 20:42:03 -0500 Subject: HERE WE GO, HERE WE GO, HERE WE GO --- jb-ui/src/components/Profiling.tsx | 759 +++++++++++++++++++++++++++++++++++++ 1 file changed, 759 insertions(+) create mode 100644 jb-ui/src/components/Profiling.tsx (limited to 'jb-ui/src/components/Profiling.tsx') diff --git a/jb-ui/src/components/Profiling.tsx b/jb-ui/src/components/Profiling.tsx new file mode 100644 index 0000000..9cefdda --- /dev/null +++ b/jb-ui/src/components/Profiling.tsx @@ -0,0 +1,759 @@ +import { + UpkQuestionChoice +} from "@/api_fsb"; +import { + ChevronRightIcon +} from "lucide-react"; +import { KeyboardEvent, MouseEvent, useEffect, useRef, useState } from "react"; + +import { useAppDispatch, useAppSelector } from "@/hooks"; +import { bpid } from "@/lib/utils"; +import { + createColumnHelper, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getSortedRowModel, + RowModel, + RowSelectionState, + useReactTable, +} from '@tanstack/react-table'; +import { clsx } from "clsx"; + +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink +} from "@/components/ui/pagination"; + +import { + addAnswers, + fetchNewQuestions, + goToNextQuestion, + ProfileQuestion, + questionsSelectors, + saveAnswer, + selectActiveQuestion, + selectActiveQuestionValidation, + selectAllQuestions, + setActiveQuestion +} from "@/models/profilingQuestionsSlice"; +import { useSelector } from "react-redux"; +// import {motion} from "framer-motion" + +const TextEntry = () => { + const dispatch = useAppDispatch() + + const activeQuestion = useSelector(selectActiveQuestion); + // const selectAnswer = useMemo(() => selectAnswerForQuestion(question), [question]); + // const selectAnswer = useSelector(selectAnswerForQuestion(question)); + const metadata = activeQuestion?._metadata + const validation = activeQuestion?._validation + const bpuid = useAppSelector(state => state.app.bpuid) + + if (!metadata) return null + if (!validation) return null + + const error = validation?.errors.length > 0 + + const handleInputChange = (event: React.KeyboardEvent) => { + if (!activeQuestion.question_id) return; + + const target = event.target as HTMLInputElement; + dispatch(addAnswers({ questionId: activeQuestion.question_id!, answers: [target.value] as string[] })) + }; + + const handleKeyPress = (event: KeyboardEvent) => { + if (!activeQuestion.question_id) return; + if (!bpuid) return; + + if (event.key === 'Enter') { + dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid })) + } + }; + + return ( +
+ + + { + validation.errors.map(e => { + return ( +

{e.message}

+ ) + }) + } +
+ ) +} + + +export default function useKeyboardMode( + rowModel: RowModel, +) { + // const [keyboardEnabled, setKeyboardEnabled] = useState( + // localStorage.getItem(keyboardMode) === "enabled", + // ); + const rowRefs = useRef<(HTMLTableRowElement | null)[]>([]); + + const dispatch = useAppDispatch() + const activeQuestion = useSelector(selectActiveQuestion); + const bpuid = useAppSelector(state => state.app.bpuid) + + const keyboardSelectionIdxRef = useRef(0); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + + if (!activeQuestion) return; + if (!bpuid) return; + + // if (!keyboardEnabled) { + // return; + // } + + const { key } = event; + const totalRows = rowRefs.current.length; + const prevIdx = keyboardSelectionIdxRef.current; + let nextIdx = prevIdx; + + if (key === "ArrowDown") nextIdx = Math.min(prevIdx + 1, totalRows - 1); + if (key === "ArrowUp") nextIdx = Math.max(prevIdx - 1, 0); + if (key === " ") { + const active = document.activeElement as HTMLElement | null; + + if (active?.tabIndex === 0) { + return; + } else { + rowModel.rows[keyboardSelectionIdxRef.current].toggleSelected(); + } + } + + if (event.key === 'Enter') { + dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid })) + } + + + if (nextIdx !== prevIdx) { + + rowRefs.current[prevIdx]?.classList.remove("bg-blue-400"); + rowRefs.current[nextIdx]?.classList.add("bg-blue-400"); + + rowRefs.current[nextIdx]?.scrollIntoView({ + behavior: "smooth", + block: "center", + }); + keyboardSelectionIdxRef.current = nextIdx; + } + }; + + // I really don't know, and I don't really care enough to make this + // technically type'd correctly. - Max + + // @ts-ignore + window.addEventListener("keydown", handleKeyDown); + // @ts-ignore + return () => window.removeEventListener("keydown", handleKeyDown); + + }, [rowModel.rows]); + + return { + // keyboardEnabled, + // setKeyboardEnabled, + rowRefs, + keyboardSelectionIdxRef, + }; +} + +const MultipleChoice = () => { + const dispatch = useAppDispatch() + const activeQuestion = useSelector(selectActiveQuestion); + + // const error: Boolean = answer?.error_msg.length > 0 + // console.log("Rendering MultipleChoice with answer:", answer, error) + + // TODO! Retrieve store values to use as the initial state for the table + // This is useful for if they tab around before submitting the answer + const [rowSelection, setRowSelection] = useState({}); + const [globalFilter, setGlobalFilter] = useState(''); + + const handleRowSelectionChange = (updaterOrValue: RowSelectionState | ((old: RowSelectionState) => RowSelectionState)) => { + setRowSelection((oldSelection) => { + // Get the new selection state + const newSelection = typeof updaterOrValue === 'function' + ? updaterOrValue(oldSelection) + : updaterOrValue; + + // Find which row was just selected/deselected + const changedRowId = Object.keys(newSelection).find( + key => newSelection[key] !== oldSelection[key] + ); + + if (!changedRowId) return newSelection; + + // Get the actual row data + const row = table.getRow(changedRowId); + const rowData = row.original; // Your row data object + + // Check if "None of the Above" was selected + const normalized = rowData.choice_text.trim().toLowerCase(); + const isNoneOfTheAbove = (normalized === "none of the above") || (rowData.exclusive ?? false); + if (isNoneOfTheAbove && newSelection[changedRowId]) { + // Clear all other selections, keep only this one + return { [changedRowId]: true }; + } + + // Check if a regular option was selected while "None of the Above" exists + const noneOfTheAboveId = Object.keys(newSelection).find(id => { + const r = table.getRow(id); + const rowData = r.original as UpkQuestionChoice; + + const normalized = rowData.choice_text.trim().toLowerCase(); + const isNoneOfTheAbove = (normalized === "none of the above") || (rowData.exclusive ?? false); + + return isNoneOfTheAbove && newSelection[id]; + }); + + if (noneOfTheAboveId && changedRowId !== noneOfTheAboveId) { + // Remove "None of the Above" selection + const { [noneOfTheAboveId]: _, ...rest } = newSelection; + return rest; + } + + return newSelection; + }); + }; + + useEffect(() => { + if (!activeQuestion?.question_id) return; + // Run this anytime a rowSelection changes, to update + // the answer in the store + const selectedChoices = Object.keys(rowSelection) + dispatch(addAnswers({ questionId: activeQuestion.question_id, answers: selectedChoices })) + }, [rowSelection]) + + // Define columns + const columnHelper = createColumnHelper() + const columns = [ + columnHelper.display({ + id: 'select', + cell: ({ row }) => { + const canSelected = row.getCanSelect() + + return ( + + ) + }, + size: 20, + meta: { + align: 'center' + } + }), + + columnHelper.accessor("choice_text", { + header: () => 'Choice Text', + cell: (info) => { + const text = info.getValue(); + const selected = info.row.getIsSelected() + const canSelected = info.row.getCanSelect() + + return ( +

+ {text} +

+ ) + }, + size: 380, + meta: { + align: 'left' + } + }), + ] + + const table = useReactTable({ + 'data': activeQuestion!.choices ?? [], + columns, + getRowId: (row) => row.choice_id, + state: { + rowSelection, + globalFilter, + }, + // enableRowSelection: true, + enableRowSelection: (row) => { + // Allow deselection always, but limit new selections + const currentSelectionCount = Object.keys(rowSelection).length; + const isRowSelected = rowSelection[row.id]; + + const max_selection = activeQuestion?.selector === "SA" ? 1 : activeQuestion?.choices?.length ?? 10 + + // Allow if: row is already selected OR we haven't hit the limit + return isRowSelected || currentSelectionCount < max_selection; + }, + onRowSelectionChange: handleRowSelectionChange, + onGlobalFilterChange: setGlobalFilter, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + }); + + const selectedCount = table.getSelectedRowModel().rows.length; + + //const { rowRefs, setKeyboardEnabled, keyboardSelectionIdxRef } = useKeyboardMode(table.getRowModel()); + const { rowRefs } = useKeyboardMode(table.getRowModel()); + + return ( + <> + + {/* Controls */} +
+
+ + {/* Search */} +
+ setGlobalFilter(e.target.value)} + className="w-full px-4 py-1 + border border-blue-900 bg-blue-400 rounded + text-white text-sm + focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ + {/* Selected Count */} + {selectedCount > 0 && ( +
setRowSelection({})}> + {selectedCount} selected + +
+ )} + +
+
+ + {/* Table */} +
+ + + {table.getRowModel().rows.map((row, idx) => ( + { + rowRefs.current[idx] = el; + }} + onClick={() => row.toggleSelected()} + className={`cursor-pointer transition-colors w-full + ${row.getIsSelected() ? 'bg-blue-600 ' : ' ' + }`} + > + + {row.getVisibleCells().map((cell) => { + const align = cell.column.columnDef.meta?.align || 'left'; + const alignClass = align === 'center' ? 'text-center' : + align === 'right' ? 'text-right' : 'text-left'; + + return ( + + ) + })} + + + ))} + +
+ {flexRender(cell.column.columnDef.cell, cell.getContext())} +
+
+ + {/* Empty State */} + {table.getRowModel().rows.length === 0 && ( +
+ No items found matching your search +
+ )} + + {/* Footer */} +
+ + Showing {table.getRowModel().rows.length} of {activeQuestion?.choices?.length} choices + + {selectedCount > 0 && ( + + {selectedCount} choice{selectedCount !== 1 ? 's' : ''} selected + + )} +
+ + + ) + +} + +const ProfilingQuestionTitle = () => { + const activeQuestion = useSelector(selectActiveQuestion); + const question_active_tasks = activeQuestion?.importance?.task_count ?? 0 + + if (!activeQuestion) return null; + + let text = activeQuestion?.question_text + + if (activeQuestion.explanation_template && activeQuestion._validation?.isComplete && activeQuestion._metadata.answers.length == 1) { + const template = activeQuestion.explanation_template; + const pre_code = activeQuestion._metadata.answers[0]; + + const choice = activeQuestion.choices?.find(c => c.choice_id === pre_code) + const choice_text = choice ? choice.choice_text : "your answer" + + text = template.replace("{answer}", choice_text) + } + + + return ( +
+ +

+ {text} +

+ +
+

+ {question_active_tasks.toLocaleString()} +

+
+ Surveys use + this question +
+
+
+ ) +} + +const ProfilingQuestionSubmitButton = () => { + const dispatch = useAppDispatch() + + const activeQuestion = useSelector(selectActiveQuestion); + const activeQuestionValidation = useSelector(selectActiveQuestionValidation) + const bpuid = useAppSelector(state => state.app.bpuid) + + const handleOnClick = () => { + if (!activeQuestion) return + if (!activeQuestionValidation?.isComplete) return + if (!bpuid) return; + + dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid })) + } + + return ( + + ) +} + + +export const ProfileQuestionFull = () => { + const activeQuestion = useSelector(selectActiveQuestion); + + const renderContent = () => { + if (!activeQuestion) return null; + switch (activeQuestion.question_type) { + case 'TE': + return + case 'MC': + return + default: + return + } + }; + + if (!activeQuestion) return null + + return ( + <> + {/* {answer && answer.processing && ( + setLoading(false)} + /> + )} */} + + {renderContent()} + + ) +} + +const PaginationIcon: React.FC<{ + question: ProfileQuestion, idx: number, +}> = ({ question, idx }) => { + const dispatch = useAppDispatch() + + // const answers = useAppSelector(state => state.answers) + const isActive = question._metadata?.isActive ?? false + const completed = question._validation?.isComplete ?? false + + const handleSetQuestion = (evt: MouseEvent) => { + if (completed) { + evt.preventDefault() + } else { + dispatch(setActiveQuestion(question.question_id!)) + } + } + + return ( + + + {idx + 1} + + + ) +} + +const ProfilingQuestionPagination = () => { + const dispatch = useAppDispatch() + + const questions = useSelector(selectAllQuestions) + const activeQuestion = useSelector(selectActiveQuestion) + + // All the variables needed for a sliding window + const q_idx = questions.findIndex(q => q.question_id === activeQuestion!.question_id) + + const handleNextClick = () => { + dispatch(goToNextQuestion()); + }; + + const questionsWindow = ( + items: ProfileQuestion[], currentIndex: number, windowSize: number = 5 + ): ProfileQuestion[] => { + const half: number = Math.floor(windowSize / 2) + const total: number = items.length + let start: number = currentIndex - half + let end: number = currentIndex + half + 1 + + if (start < 0) { + end += Math.abs(start) + start = 0 + } + + // Adjust if window goes past the end + if (end > total) { + const overflow: number = end - total + start = Math.max(0, start - overflow) + end = total + } + + return items.slice(start, end) + } + + return ( + + + { + questionsWindow(questions, q_idx).map(q => { + return qq.question_id === q.question_id)} + /> + }) + } + + + + Next + + + + + + + ) +} + +const ProfilingHeader = () => { + const questions = useSelector(questionsSelectors.selectAll); + + return ( +

+ {questions.length} Profiling Questions +

+ ) +} + +const ProfilingModalBody = () => { + const activeQuestion = useSelector(selectActiveQuestion); + + if (!activeQuestion) { + return ( +
+

No Profiling Questions Available

+

Please check back later.

+
+ ) + } + + return ( +
+ {/* */} + +
+ +
+ + {/* Scrollable Content */} +
+ +
+ + {/* Sticky Footer */} +
+
+ +
+
+ +
+
+ +
+ ) +} + +const Profiling = () => { + /* This is the main UI for any User Profiling (before entering a task). It's + primary window and position should be dedictated by it's parent so this + can be positioned as a window or a sidebar as needed. + */ + const [showProfiling, setShowProfiling] = useState(false); + + const dispatch = useAppDispatch() + const bpuid = useAppSelector(state => state.app.bpuid) + + useEffect(() => { + if (!bpuid) return; + dispatch(fetchNewQuestions(bpuid)); + }, [bpid, bpuid]) + + if (!bpuid) return null; + + return ( +
+ +
setShowProfiling(!showProfiling)} + > + + +

+ (Click to {showProfiling ? 'hide' : 'show'}) +

+
+ + {showProfiling && ( + + )} + +
+ ) +} + +export { + Profiling +}; -- cgit v1.2.3