summaryrefslogtreecommitdiff
path: root/jb-ui/src/components/Profiling.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'jb-ui/src/components/Profiling.tsx')
-rw-r--r--jb-ui/src/components/Profiling.tsx759
1 files changed, 759 insertions, 0 deletions
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<HTMLInputElement>) => {
+ 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<HTMLInputElement>) => {
+ if (!activeQuestion.question_id) return;
+ if (!bpuid) return;
+
+ if (event.key === 'Enter') {
+ dispatch(saveAnswer({ questionId: activeQuestion.question_id!, bpuid: bpuid }))
+ }
+ };
+
+ return (
+ <div className="px-6 my-6">
+ <input type="text"
+ id="text-entry-input"
+ aria-describedby=""
+ // defaultValue={answer?.values.length ? answer?.values[0] : ""}
+ onKeyUp={handleInputChange}
+ onKeyDown={handleKeyPress} // Use onKeyDown to listen for the key press
+ // title={error ? answer?.error_msg : ""}
+ className={clsx(
+ 'w-full border-1 rounded px-2 py-1',
+ error ?
+ 'border-red-500 focus-visible:ring-red-500' :
+ 'border-blue-50'
+ )}
+ />
+
+ {
+ validation.errors.map(e => {
+ return (
+ <p className="text-sm text-red-100 my-2">{e.message}</p>
+ )
+ })
+ }
+ </div>
+ )
+}
+
+
+export default function useKeyboardMode(
+ rowModel: RowModel<UpkQuestionChoice>,
+) {
+ // 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<RowSelectionState>({});
+ 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<UpkQuestionChoice>()
+ const columns = [
+ columnHelper.display({
+ id: 'select',
+ cell: ({ row }) => {
+ const canSelected = row.getCanSelect()
+
+ return (
+ <input
+ type="checkbox"
+ checked={row.getIsSelected()}
+ disabled={!row.getCanSelect()}
+ onChange={row.getToggleSelectedHandler()}
+ className={`w-4 h-4 rounded-0 border-slate-300 cursor-pointer
+ focus:outline-none focus:border-blue-500 focus:ring-blue-500
+ ${canSelected ? ' ' : 'hidden'}
+ `}
+ />
+ )
+ },
+ 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 (
+ <p className={`
+ w-max truncate
+ text-sm text-left pl-1
+ ${selected ? 'font-bold' : 'font-medium'}
+ ${canSelected ? 'text-blue-950' : 'text-blue-600'}
+ `}>
+ {text}
+ </p>
+ )
+ },
+ 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 */}
+ <div className="py-1">
+ <div className="flex flex-wrap gap-2">
+
+ {/* Search */}
+ <div className="flex-1">
+ <input
+ type="text"
+ placeholder="Search choices..."
+ value={globalFilter ?? ''}
+ onChange={e => 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"
+ />
+ </div>
+
+ {/* Selected Count */}
+ {selectedCount > 0 && (
+ <div className="flex items-center gap-2 px-2 py-1
+ border border-blue-900 text-blue-700 rounded font-medium
+ text-[10px]
+ hover:cursor-pointer hover:bg-blue-800 hover:text-white
+ "
+ onClick={() => setRowSelection({})}>
+ <span>{selectedCount} selected</span>
+ <button className="m-0 p-0 font-bold">✕</button>
+ </div>
+ )}
+
+ </div>
+ </div>
+
+ {/* Table */}
+ <div className="border border-blue-200 rounded max-h-[400px] overflow-y-auto
+ [&::-webkit-scrollbar]:h-[4px]
+ [&::-webkit-scrollbar]:w-1
+
+ [&::-webkit-scrollbar-track]:bg-blue-100
+ [&::-webkit-scrollbar-thumb]:bg-blue-300
+ ">
+ <table className="text-xs table-fixed border-collapse w-full">
+ <tbody className="w-full">
+ {table.getRowModel().rows.map((row, idx) => (
+ <tr key={row.id}
+ ref={(el) => {
+ 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 (
+ <td key={cell.id}
+ className={`py-2 ${alignClass}`}
+ style={{
+ width: `${cell.column.getSize()}px`,
+ }}
+ >
+ {flexRender(cell.column.columnDef.cell, cell.getContext())}
+ </td>
+ )
+ })}
+
+ </tr>
+ ))}
+ </tbody>
+ </table>
+ </div>
+
+ {/* Empty State */}
+ {table.getRowModel().rows.length === 0 && (
+ <div className="p-12 text-center text-blue-50 font-bold">
+ No items found matching your search
+ </div>
+ )}
+
+ {/* Footer */}
+ <div className="px-2 py-2 flex justify-between items-center
+ text-xs font-mono font-semibold text-blue-950
+ ">
+ <span>
+ Showing {table.getRowModel().rows.length} of {activeQuestion?.choices?.length} choices
+ </span>
+ {selectedCount > 0 && (
+ <span>
+ {selectedCount} choice{selectedCount !== 1 ? 's' : ''} selected
+ </span>
+ )}
+ </div>
+
+ </>
+ )
+
+}
+
+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 (
+ <div className="flex justify-between items-center px-2">
+
+ <h3 className="text-left text-sm font-bold m-0 p-0
+ inline-block
+ ">
+ {text}
+ </h3>
+
+ <div className="max-w-[70px] text-center m-0 p-0">
+ <h4 className="text-xs font-bold">
+ {question_active_tasks.toLocaleString()}
+ </h4>
+ <h5 className="text-[10px]">
+ Surveys use
+ this question
+ </h5>
+ </div>
+ </div>
+ )
+}
+
+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 (
+ <button
+ type="submit"
+ className="cursor-pointer text-sm font-semibold
+ bg-blue-700 text-white py-1 px-4 rounded
+ hover:bg-blue-900 min-w-[148px]
+
+ disabled:bg-blue-700 disabled:text-rose-300
+ disabled:cursor-not-allowed
+ "
+ disabled={!activeQuestionValidation?.isComplete}
+ onClick={handleOnClick}
+ >
+ Answer Question
+ </button>
+ )
+}
+
+
+export const ProfileQuestionFull = () => {
+ const activeQuestion = useSelector(selectActiveQuestion);
+
+ const renderContent = () => {
+ if (!activeQuestion) return null;
+ switch (activeQuestion.question_type) {
+ case 'TE':
+ return <TextEntry />
+ case 'MC':
+ return <MultipleChoice />
+ default:
+ return <MultipleChoice />
+ }
+ };
+
+ if (!activeQuestion) return null
+
+ return (
+ <>
+ {/* {answer && answer.processing && (
+ <motion.div
+ className="bg-gray-300"
+ initial={{ width: "0%" }}
+ animate={{ width: "100%" }}
+ transition={{ duration: 1, ease: "easeInOut" }}
+ // onAnimationComplete={() => 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<HTMLAnchorElement>) => {
+ if (completed) {
+ evt.preventDefault()
+ } else {
+ dispatch(setActiveQuestion(question.question_id!))
+ }
+ }
+
+ return (
+ <PaginationItem>
+ <PaginationLink
+ href="#"
+ title={question.question_text}
+ isActive={isActive}
+ aria-disabled={!!completed}
+
+ onClick={handleSetQuestion}
+ className={clsx("cursor-pointer rounded border border-blue-300 text-blue-300 text-[10px] w-7 h-4",
+ {
+ "pointer-events-none cursor-not-allowed": completed,
+ "bg-blue-800 border-blue-800": isActive,
+ })}
+ >
+ {idx + 1}
+ </PaginationLink>
+ </PaginationItem>
+ )
+}
+
+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 (
+ <Pagination>
+ <PaginationContent>
+ {
+ questionsWindow(questions, q_idx).map(q => {
+ return <PaginationIcon
+ key={q.question_id}
+ question={q}
+ idx={questions.findIndex(qq => qq.question_id === q.question_id)}
+ />
+ })
+ }
+
+ <PaginationItem>
+ <PaginationLink
+ aria-label="Go to next page"
+ size="default"
+
+ className="rounded text-[10px] p-0 m-0
+ text-blue-300
+ border border-blue-100 text-blue-100
+ hover:bg-blue-800 hover:border-blue-800 hover:text-white
+ gap-1 px-2.5 sm:pr-2.5
+ h-4
+ w-13 sm:w-14
+ "
+ onClick={handleNextClick}
+ href="#"
+ >
+ <span className="block">Next</span>
+ <ChevronRightIcon />
+ </PaginationLink>
+
+ </PaginationItem>
+ </PaginationContent>
+ </Pagination>
+ )
+}
+
+const ProfilingHeader = () => {
+ const questions = useSelector(questionsSelectors.selectAll);
+
+ return (
+ <h2 className="text-xs font-mono font-bold p-0 m-0">
+ {questions.length} Profiling Questions
+ </h2>
+ )
+}
+
+const ProfilingModalBody = () => {
+ const activeQuestion = useSelector(selectActiveQuestion);
+
+ if (!activeQuestion) {
+ return (
+ <div className="p-5">
+ <h3 className="text-lg font-bold">No Profiling Questions Available</h3>
+ <p className="my-0 text-sm text-gray-600">Please check back later.</p>
+ </div>
+ )
+ }
+
+ return (
+ <div className="relative flex flex-col">
+ {/* <ProfilingQuestionPagination /> */}
+
+ <div className="flex-shrink-0">
+ <ProfilingQuestionTitle />
+ </div>
+
+ {/* Scrollable Content */}
+ <div className="flex-1 p-1">
+ <ProfileQuestionFull key={activeQuestion.question_id} />
+ </div>
+
+ {/* Sticky Footer */}
+ <div className="flex-shrink-0 bg-blue-600 p-1 flex justify-between
+ ">
+ <div className="justify-start">
+ <ProfilingQuestionPagination />
+ </div>
+ <div className="justify-end">
+ <ProfilingQuestionSubmitButton />
+ </div>
+ </div>
+
+ </div>
+ )
+}
+
+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 (
+ <div className={clsx(
+ "fixed rounded bg-blue-500 text-white shadow-lg z-60 \
+ bottom-1 sm:bottom-6 \
+ left-1 sm:left-2",
+ showProfiling ? "w-[98vw] sm:w-[400px]" : "w-[220px]"
+ )}
+ >
+
+ <div className="bg-blue-600 rounded border-white-300
+ cursor-pointer py-1 px-4
+ hover:bg-blue-700 transition-colors duration-200"
+ onClick={() => setShowProfiling(!showProfiling)}
+ >
+ <ProfilingHeader />
+
+ <h4 className="text-[10px] text-center italic text-white-300 py-0">
+ (Click to {showProfiling ? 'hide' : 'show'})
+ </h4>
+ </div>
+
+ {showProfiling && (
+ <ProfilingModalBody />
+ )}
+
+ </div>
+ )
+}
+
+export {
+ Profiling
+};