diff --git a/src/components/Collaboration/JobFormBuilder.module.css b/src/components/Collaboration/JobFormBuilder.module.css index 86bac9669f..f45d8a92fa 100644 --- a/src/components/Collaboration/JobFormBuilder.module.css +++ b/src/components/Collaboration/JobFormBuilder.module.css @@ -79,6 +79,84 @@ align-items: center; } +.roleSearch { + display: flex; + flex: 1 1 0; + min-width: 0; + align-items: center; +} + +.editorFields { + min-width: 0; +} + +.roleContext, +.roleError, +.overwriteWarning { + display: block; + width: 100%; + margin: 8px 0; + padding: 10px; + overflow-wrap: anywhere; + box-sizing: border-box; +} + +.roleContext { + color: #153a50; + background: #e8f4fc; +} + +.retryButton { + margin: 8px 0; + padding: 10px 16px; + border: 1px solid #245b86; + border-radius: 5px; + background: #245b86; + color: #fff; + font-weight: 600; + cursor: pointer; +} + +.retryButton:hover { + background: #194463; +} + +.retryButton:focus-visible { + outline: 3px solid #245b86; + outline-offset: 3px; +} + +.darkMode .retryButton { + background: #b5d9f5; + color: #153a50; + border-color: #b5d9f5; +} + +.darkMode .retryButton:hover { + background: #d5eaff; +} + +.darkMode .retryButton:focus-visible { + outline-color: #b5d9f5; +} + +.roleError, +.overwriteWarning { + color: #513400; + background: #fff3cd; +} + +.darkMode .roleContext { + color: #eaf5ff; + background: #253c55; +} + +.darkMode .roleError, +.darkMode .overwriteWarning { + color: #fff0cb; + background: #493913; +} + .jobformNavbar input { flex: 1 1 auto; min-width: 0; @@ -102,6 +180,11 @@ background-color: #f5f5f5; } +.jobformNavbar button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .jobformDesc { line-height: 2; } @@ -389,6 +472,12 @@ background-color: var(--jfb-lime-hover); } +.jobSubmitButton:disabled { + background-color: #52616b; + color: #fff; + cursor: not-allowed; +} + .saveSection { display: flex; justify-content: center; @@ -433,8 +522,6 @@ .darkMode .jobformSelect { appearance: none; - -webkit-appearance: none; - -moz-appearance: none; padding-right: 2.5rem; background-image: url("data:image/svg+xml;utf8,"); background-repeat: no-repeat; @@ -517,6 +604,12 @@ background-color: #6a7a8a; } +.darkMode .jobSubmitButton:disabled { + background-color: #52616b; + color: #fff; + cursor: not-allowed; +} + .darkMode .optionsList h4 { color: #f1f1f1; } @@ -537,7 +630,7 @@ padding-inline: 20px; } -@media(width <= 576px) { +@media (width <= 576px) { .previewSection { padding-inline: 12px; } @@ -563,10 +656,10 @@ } .saveSection { - padding: 20px + padding: 20px; } -@media(width <= 576px) { +@media (width <= 576px) { .saveSection { padding: 12px; } diff --git a/src/components/Collaboration/JobFormbuilder.jsx b/src/components/Collaboration/JobFormbuilder.jsx index 769d6f1ecd..ffc0b4d8ce 100644 --- a/src/components/Collaboration/JobFormbuilder.jsx +++ b/src/components/Collaboration/JobFormbuilder.jsx @@ -1,6 +1,6 @@ /* eslint-disable no-alert */ /* eslint-disable no-console */ -import { useState, useEffect, useMemo } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import axios from 'axios'; import { useDispatch, useSelector } from 'react-redux'; import { Prompt } from 'react-router-dom'; @@ -13,7 +13,8 @@ import QuestionSetManager from './QuestionSetManager'; import QuestionFieldActions from './QuestionFieldActions'; import QuestionEditModal from './QuestionEditModal'; import FormPreviewModal from './FormPreviewModal'; -import { JOB_FORM_POSITION_OPTIONS } from '../JobFormManagement/jobFormPositions'; +import useJobFormSelection from './useJobFormSelection'; +import questionnaireLabel from './jobFormSelectionLabels'; import { normalizeQuestionType, resolveInputType, @@ -29,6 +30,16 @@ import { import { hasUnsavedJobFormChanges } from './jobFormDirtyState'; import { permissions } from '../../utils/constants'; + +const initialNewField = { + questionText: '', + questionType: 'textbox', + options: [], + visible: true, + isRequired: false, + required: false, +}; + function JobFormBuilder() { const dispatch = useDispatch(); const { auth } = useSelector(state => state); @@ -49,27 +60,15 @@ function JobFormBuilder() { const [selectedTemplate, setSelectedTemplate] = useState(''); const [currentFormId, setCurrentFormId] = useState(null); const [showPreviewModal, setShowPreviewModal] = useState(false); - const [newField, setNewField] = useState({ - questionText: '', - questionType: 'textbox', - options: [], - visible: true, - isRequired: false, - required: false, - }); - - const initialNewField = { - questionText: '', - questionType: 'textbox', - options: [], - visible: true, - isRequired: false, - required: false, - }; + const [newField, setNewField] = useState(initialNewField); const [jobTitle, setJobTitle] = useState('Please Choose an option'); const [initialJobTitle, setInitialJobTitle] = useState('Please Choose an option'); - const jobPositions = JOB_FORM_POSITION_OPTIONS; + const [searchTitle, setSearchTitle] = useState(''); + const [searchError, setSearchError] = useState(''); + const [isSaving, setIsSaving] = useState(false); + const mutationInFlight = useRef(false); + const editorVersion = useRef(0); const [newOption, setNewOption] = useState(''); const [editModalOpen, setEditModalOpen] = useState(false); @@ -77,10 +76,91 @@ function JobFormBuilder() { const [editingIndex, setEditingIndex] = useState(null); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - const markAsSaved = (fields, savedJobTitle) => { + const markAsSaved = useCallback((fields, savedJobTitle) => { setInitialFormFields(structuredClone(fields)); if (savedJobTitle !== undefined) setInitialJobTitle(savedJobTitle); setHasUnsavedChanges(false); + }, []); + + const resetRole = useCallback( + title => { + editorVersion.current += 1; + setCurrentFormId(null); + setFormFields([]); + setJobTitle(title); + markAsSaved([], title); + setNewField(initialNewField); + setNewOption(''); + setTemplateName(''); + setSelectedTemplate(''); + setEditModalOpen(false); + setEditingQuestion(null); + setEditingIndex(null); + setShowPreviewModal(false); + }, + [markAsSaved], + ); + + const loadRole = useCallback( + form => { + const questions = normalizeLoadedQuestions(form.questions); + setFormFields(questions); + setJobTitle(form.title); + markAsSaved(questions, form.title); + setCurrentFormId(form._id || form.id); + }, + [markAsSaved], + ); + + const selection = useJobFormSelection({ + enabled: canManageJobForms, + onReset: resetRole, + onLoad: loadRole, + }); + const version = editorVersion.current; + const canEdit = canManageJobForms && selection.status === 'ready' && Boolean(currentFormId); + const canEditCurrentRole = () => + canEdit && version === editorVersion.current && !mutationInFlight.current; + + const selectRole = (title, targetId) => { + if (mutationInFlight.current) return; + if (hasUnsavedChanges && !window.confirm('Discard unsaved changes and switch job roles?')) + return; + setSearchError(''); + setSearchTitle(''); + selection.selectRole(title, targetId); + }; + + const searchRole = event => { + event.preventDefault(); + const query = searchTitle.trim().toLowerCase(); + if (!query) { + if (selection.selectedTitle) selectRole(selection.selectedTitle); + return; + } + const exact = selection.options.find(title => title.toLowerCase() === query); + const matches = exact + ? [exact] + : selection.options.filter(title => title.toLowerCase().includes(query)); + if (matches.length === 1) selectRole(matches[0]); + else + setSearchError( + matches.length + ? 'Several roles match. Choose the intended role from the dropdown.' + : 'No matching job role was found.', + ); + }; + + const startMutation = () => { + if (!canEditCurrentRole()) return false; + mutationInFlight.current = true; + setIsSaving(true); + return true; + }; + + const finishMutation = () => { + mutationInFlight.current = false; + setIsSaving(false); }; // Prevent refresh while unsaved changes exist @@ -110,38 +190,6 @@ function JobFormBuilder() { setNewOption(''); }; - // Auto-load existing form on component mount - useEffect(() => { - const loadFirstAvailableForm = async () => { - try { - const response = await axios.get(ENDPOINTS.GET_ALL_JOB_FORMS); - const forms = response.data?.forms ?? (Array.isArray(response.data) ? response.data : []); - - if (forms.length > 0) { - const firstForm = forms[0]; - const formId = firstForm._id || firstForm.id; - - setCurrentFormId(formId); - const loadedQuestions = normalizeLoadedQuestions(firstForm.questions || []); - const loadedTitle = firstForm.title || 'Please Choose an option'; - - setFormFields(loadedQuestions); - setJobTitle(loadedTitle); - markAsSaved(loadedQuestions, loadedTitle); - setNewField(initialNewField); - - console.log('Auto-loaded form:', formId); - } - } catch (error) { - console.error('Error auto-loading form:', error); - } - }; - - loadFirstAvailableForm().catch(error => { - console.error('Unexpected error auto-loading form:', error); - }); - }, []); - // Detect unsaved changes useEffect(() => { const changed = hasUnsavedJobFormChanges({ @@ -157,6 +205,11 @@ function JobFormBuilder() { setHasUnsavedChanges(changed); }, [formFields, initialFormFields, newField, templateName, jobTitle, initialJobTitle]); + // A per-question save must not mark unrelated local template/visibility changes as persisted. + const markQuestionChangeSaved = fields => { + if (JSON.stringify(formFields) === JSON.stringify(initialFormFields)) markAsSaved(fields); + }; + // CRUD Functions with Dynamic Form ID const moveField = async (index, direction) => { const newIndex = direction === 'up' ? index - 1 : index + 1; @@ -165,6 +218,7 @@ function JobFormBuilder() { (direction === 'up' && index > 0) || (direction === 'down' && index < formFields.length - 1) ) { + if (!startMutation()) return; // Update local state immediately const newFields = [...formFields]; [newFields[index], newFields[newIndex]] = [newFields[newIndex], newFields[index]]; @@ -178,15 +232,18 @@ function JobFormBuilder() { toIndex: newIndex, requestor: getRequestor(), }); - markAsSaved(newFields); + markQuestionChangeSaved(newFields); } catch (error) { console.error('Error reordering questions on server:', error); + } finally { + finishMutation(); } } } }; const deleteField = async index => { + if (!startMutation()) return; // Update local state immediately const newFields = [...formFields]; newFields.splice(index, 1); @@ -198,15 +255,18 @@ function JobFormBuilder() { await axios.delete(ENDPOINTS.DELETE_QUESTION(currentFormId, index), { data: { requestor: getRequestor() }, }); - markAsSaved(newFields); + markQuestionChangeSaved(newFields); console.log('Question deleted successfully'); } catch (error) { console.error('Error deleting question on server:', error); + } finally { + finishMutation(); } } }; const editField = (field, index) => { + if (!canEditCurrentRole()) return; // Transform the field structure to match what QuestionEditModal expects const questionForEdit = { label: stripLeadingQuestionNumber(field.questionText), @@ -222,6 +282,7 @@ function JobFormBuilder() { }; const handleSaveEditedQuestion = async editedQuestion => { + if (editingIndex === null || !startMutation()) return; const isRequired = Boolean(editedQuestion.required || editedQuestion.isRequired); const updatedField = normalizeQuestionForApi({ ...formFields[editingIndex], @@ -241,14 +302,16 @@ function JobFormBuilder() { // Sync with backend if form exists if (currentFormId) { try { - await axios.put(ENDPOINTS.UPDATE_QUESTION(currentFormId, editingIndex), { + await axios.patch(ENDPOINTS.UPDATE_QUESTION(currentFormId, editingIndex), { ...updatedField, requestor: getRequestor(), }); - markAsSaved(updatedFields); + markQuestionChangeSaved(updatedFields); console.log('Question updated successfully'); } catch (error) { console.error('Error updating question on server:', error); + } finally { + finishMutation(); } } @@ -266,6 +329,7 @@ function JobFormBuilder() { // Import questions from template const importQuestions = questions => { + if (!canEditCurrentRole()) return; setFormFields(normalizeLoadedQuestions(questions)); }; @@ -282,6 +346,7 @@ function JobFormBuilder() { }; const handleAddField = async () => { + if (!canEditCurrentRole()) return; if (newField.questionText.trim() === '') { alert('Field label is required!'); return; @@ -301,6 +366,7 @@ function JobFormBuilder() { const confirmAdd = window.confirm('You already have a similar question. Add Anyway?'); if (!confirmAdd) return; } + if (!startMutation()) return; const updatedFields = [...formFields, fieldToAdd]; setFormFields(updatedFields); @@ -312,9 +378,11 @@ function JobFormBuilder() { position: formFields.length, requestor: getRequestor(), }); - markAsSaved(updatedFields); + markQuestionChangeSaved(updatedFields); } catch (error) { console.error('Error adding question to server:', error); + } finally { + finishMutation(); } } @@ -329,6 +397,7 @@ function JobFormBuilder() { }; const changeVisiblity = (event, field) => { + if (!canEditCurrentRole()) return; const updatedFields = formFields.map(item => item.questionText === field.questionText && item.questionType === field.questionType ? { ...item, visible: event.target.checked } @@ -340,17 +409,23 @@ function JobFormBuilder() { const handleSubmit = async e => { e.preventDefault(); - if (!currentFormId) { + if (!canEditCurrentRole()) { alert('No form loaded to save. Please refresh or select a job position.'); return; } + if ( + !window.confirm( + `Replace the active questionnaire for ${jobTitle} with these ${formFields.length} questions?`, + ) + ) + return; + if (!startMutation()) return; try { await axios.put(ENDPOINTS.UPDATE_JOB_FORM, { formId: currentFormId, title: jobTitle, questions: formFields.map(normalizeQuestionForApi), - description: '', requestor: getRequestor(), }); @@ -364,9 +439,22 @@ function JobFormBuilder() { error.response?.data?.error?.message || 'Failed to save form. Please try again.'; alert(message); + } finally { + finishMutation(); } }; + let roleStatusText = 'Select a role to load its saved questionnaire.'; + if (canEdit) roleStatusText = `Editing questionnaire for: ${jobTitle}`; + else if (selection.status === 'loading') { + const target = selection.selectedTitle ? ` for: ${selection.selectedTitle}` : ' roles'; + roleStatusText = `Loading questionnaire${target}…`; + } else if (selection.selectedTitle) { + roleStatusText = selection.selectedFormId + ? `Questionnaire unavailable for: ${selection.selectedTitle}` + : `Choose a saved questionnaire for: ${selection.selectedTitle}`; + } + return (
+ {selection.error || searchError} +
+ )} + {selection.matchingForms.length > 1 && ( ++ Question actions update this role immediately. Save Form replaces the active + questionnaire for {jobTitle} and requires confirmation. +
+ )} + > + )}