From 245fec615a566885734892e8a1fa17651a239da5 Mon Sep 17 00:00:00 2001 From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com> Date: Mon, 2 Nov 2026 19:57:52 -0800 Subject: [PATCH 1/4] Fix Job Form Builder role and questionnaire binding Load and validate each selected role by its saved form ID. Ignore stale responses, disable unsafe editing states, and confirm questionnaire replacement so role changes cannot save the previous form data. Preserve unsaved drafts, align question edits with the PATCH API, and retain existing descriptions. Include original task evidence and regression tests. Refs Jobs Bug #68: https://docs.google.com/document/d/1zifX1otZPl-VJLQk0sZqZbQFginRytlQ2vKHeQAh_Lo/edit?tab=t.0#bookmark=id.fyd2i4dnaktl --- .../Collaboration/JobFormBuilder.module.css | 57 +- .../Collaboration/JobFormbuilder.jsx | 273 +++++++--- .../JobFormBuilder.roleSelection.test.jsx | 491 ++++++++++++++++++ .../Collaboration/useJobFormSelection.js | 125 +++++ 4 files changed, 866 insertions(+), 80 deletions(-) create mode 100644 src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx create mode 100644 src/components/Collaboration/useJobFormSelection.js diff --git a/src/components/Collaboration/JobFormBuilder.module.css b/src/components/Collaboration/JobFormBuilder.module.css index 86bac9669f5..ce7a9452860 100644 --- a/src/components/Collaboration/JobFormBuilder.module.css +++ b/src/components/Collaboration/JobFormBuilder.module.css @@ -79,6 +79,50 @@ 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; +} + +.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 +146,11 @@ background-color: #f5f5f5; } +.jobformNavbar button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + .jobformDesc { line-height: 2; } @@ -433,8 +482,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; @@ -537,7 +584,7 @@ padding-inline: 20px; } -@media(width <= 576px) { +@media (width <= 576px) { .previewSection { padding-inline: 12px; } @@ -563,10 +610,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 769d6f1ecd4..0522f93f664 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,7 @@ 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 { normalizeQuestionType, resolveInputType, @@ -29,6 +29,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 +59,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 +75,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 => { + if (mutationInFlight.current) return; + if (hasUnsavedChanges && !window.confirm('Discard unsaved changes and switch job roles?')) + return; + setSearchError(''); + setSearchTitle(''); + selection.selectRole(title); + }; + + 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 +189,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 +204,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 +217,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 +231,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 +254,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 +281,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 +301,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 +328,7 @@ function JobFormBuilder() { // Import questions from template const importQuestions = questions => { + if (!canEditCurrentRole()) return; setFormFields(normalizeLoadedQuestions(questions)); }; @@ -282,6 +345,7 @@ function JobFormBuilder() { }; const handleAddField = async () => { + if (!canEditCurrentRole()) return; if (newField.questionText.trim() === '') { alert('Field label is required!'); return; @@ -301,6 +365,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 +377,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 +396,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 +408,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 +438,18 @@ 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}…`; + } + return (
-
- - -
+
+ {canManageJobForms && ( + <> + {roleStatusText} + {(selection.error || searchError) && ( +

+ {selection.error || searchError} +

+ )} + {selection.error && !selection.selectedTitle && ( + + )} + {canEdit && ( +

+ Question actions update this role immediately. Save Form replaces the active + questionnaire for {jobTitle} and requires confirmation. +

+ )} + + )}

Job Question Set Builder

{canManageJobForms ? ( -
+

- Use this page to create or edit question templates for job postings. Default questions - are preselected automatically, and you can customize the form by adding, removing, or - rearranging fields. + Select a job role to edit its saved questionnaire. You can customize that role's + questions by adding, removing, or rearranging fields.

{ importQuestions(fields); - markAsSaved(fields); }} onTemplateSaved={() => { + if (!canEditCurrentRole()) return; markAsSaved(formFields); resetBuilderState(); }} @@ -662,7 +785,7 @@ function JobFormBuilder() { darkMode={darkMode} /> )} -
+ ) : (
diff --git a/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx b/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx new file mode 100644 index 00000000000..57be03eab2e --- /dev/null +++ b/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx @@ -0,0 +1,491 @@ +import React from 'react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import JobFormBuilder from '../JobFormbuilder'; +import { ENDPOINTS } from '../../../utils/URL'; + +const templateCallbacks = vi.hoisted(() => []); +vi.mock('axios', () => ({ + default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), patch: vi.fn(), delete: vi.fn() }, +})); +vi.mock('../QuestionSetManager', () => ({ + default: ({ onImportQuestions }) => { + templateCallbacks.push(onImportQuestions); + return ( + + ); + }, +})); +vi.mock('../QuestionEditModal', () => ({ + default: ({ onSave }) => ( + + ), +})); + +const electrical = { + _id: 'electrical-id', + title: '4-DOME CLUSTER ELECTRICAL DESIGN', + questions: [{ questionText: 'Electrical question', questionType: 'textbox' }], +}; +const software = { + _id: 'software-id', + title: 'SEEKING SOFTWARE POSITION', + questions: Array.from({ length: 12 }, (_, i) => ({ + questionText: `Software question ${i + 1}`, + questionType: 'textbox', + })), +}; +const general = { + _id: 'general-id', + title: 'GENERAL', + questions: [ + { questionText: 'General question', questionType: 'textbox' }, + { questionText: 'General second question', questionType: 'textbox' }, + ], +}; +let catalog; +let details; + +function deferred() { + let resolve; + let reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +function mount({ darkMode = false, allowed = true } = {}) { + const store = { + getState: () => ({ + theme: { darkMode }, + auth: { user: { userid: 'owner-id', role: 'Owner' } }, + role: { roles: [] }, + }), + subscribe: () => () => {}, + dispatch: vi.fn(() => allowed), + }; + return render( + + + + + , + ); +} +async function ready(title = electrical.title) { + await screen.findByText(`Editing questionnaire for: ${title}`); + await waitFor(() => expect(screen.getByRole('button', { name: 'Save Form' })).toBeEnabled()); +} +function select(title) { + fireEvent.change(screen.getByRole('combobox', { name: 'Job role' }), { + target: { value: title }, + }); +} +function search(text) { + fireEvent.change(screen.getByRole('textbox', { name: 'Search job roles' }), { + target: { value: text }, + }); + fireEvent.submit(screen.getByRole('form', { name: 'Find a job role' })); +} + +beforeEach(() => { + vi.clearAllMocks(); + templateCallbacks.length = 0; + catalog = [electrical, software, general]; + details = new Map(catalog.map(form => [ENDPOINTS.GET_JOB_FORM(form._id), form])); + axios.get.mockImplementation(async url => { + if (url === ENDPOINTS.GET_ALL_JOB_FORMS) return { data: { forms: catalog } }; + if (details.has(url)) return { data: { form: details.get(url) } }; + throw new Error('Unexpected read'); + }); + for (const method of ['post', 'put', 'patch', 'delete']) + axios[method].mockResolvedValue({ data: {} }); + vi.spyOn(window, 'confirm').mockReturnValue(true); + vi.spyOn(window, 'alert').mockImplementation(() => {}); +}); + +describe('job role and questionnaire binding', () => { + it('regression: dropdown selection replaces the previous questionnaire', async () => { + mount(); + await screen.findByText('1.) Electrical question'); + fireEvent.change(screen.getAllByRole('combobox')[0], { target: { value: software.title } }); + await screen.findByText('1.) Software question 1'); + expect(screen.queryByText('1.) Electrical question')).not.toBeInTheDocument(); + }); + + it('regression: Go searches for and loads the role questionnaire', async () => { + mount(); + await screen.findByText('1.) Electrical question'); + fireEvent.change(screen.getByPlaceholderText('Enter Job Title'), { + target: { value: 'SOFTWARE' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Go' })); + await screen.findByText('1.) Software question 1'); + expect(screen.queryByText('1.) Electrical question')).not.toBeInTheDocument(); + }); + it.each([false, true])( + 'loads fresh questions for the initial role and every dropdown change, darkMode=%s', + async darkMode => { + catalog = [ + { ...electrical, questions: [{ questionText: 'Stale catalog question' }] }, + software, + general, + ]; + mount({ darkMode }); + await ready(); + expect(screen.getByText(/1\.\) Electrical question/)).toBeInTheDocument(); + expect(screen.queryByText(/Stale catalog question/)).not.toBeInTheDocument(); + select(software.title); + await ready(software.title); + expect(screen.getAllByText(/\d+\.\) Software question/)).toHaveLength(12); + expect(screen.queryByText(/Electrical question/)).not.toBeInTheDocument(); + select(general.title); + await ready(general.title); + select(software.title); + await ready(software.title); + expect(axios.get).toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM(software._id)); + expect(axios.put).not.toHaveBeenCalled(); + }, + ); + + it('loads the unique role matching a case-insensitive search or Go submission', async () => { + mount(); + await ready(); + search(' software '); + await ready(software.title); + expect(screen.getByRole('combobox', { name: 'Job role' })).toHaveValue(software.title); + expect(screen.getAllByText(/\d+\.\) Software question/)).toHaveLength(12); + }); + + it.each(['ENGINEER', '[.*]'])( + 'does not guess a role for ambiguous or unmatched search %s', + async query => { + mount(); + await ready(); + const calls = axios.get.mock.calls.length; + search(query); + expect(screen.getByRole('alert')).toHaveTextContent( + query === 'ENGINEER' ? 'Several roles match' : 'No matching job role', + ); + expect(axios.get).toHaveBeenCalledTimes(calls); + expect(screen.getByRole('combobox', { name: 'Job role' })).toHaveValue(electrical.title); + }, + ); + + it('includes newly configured roles that are absent from the static role list', async () => { + const newRole = { ...general, _id: 'new-id', title: 'NEW SPECIALIST ROLE' }; + catalog.push(newRole); + details.set(ENDPOINTS.GET_JOB_FORM(newRole._id), newRole); + mount(); + await ready(); + search('new specialist'); + await ready(newRole.title); + expect(screen.getByRole('combobox', { name: 'Job role' })).toHaveValue(newRole.title); + }); + + it('clears the previous questionnaire and disables all edits while another role loads', async () => { + mount(); + await ready(); + const request = deferred(); + axios.get.mockImplementation(url => + url === ENDPOINTS.GET_JOB_FORM(software._id) + ? request.promise + : Promise.resolve({ data: { form: general } }), + ); + select(software.title); + expect(screen.queryByText(/Electrical question/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Add Field' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Import test template' })).toBeDisabled(); + await act(async () => request.resolve({ data: { form: software } })); + await ready(software.title); + }); + + it.each(['success', 'failure'])( + 'ignores an obsolete role response or error: %s', + async result => { + mount(); + await ready(); + const old = deferred(); + axios.get.mockImplementation(url => + url === ENDPOINTS.GET_JOB_FORM(software._id) + ? old.promise + : Promise.resolve({ data: { form: general } }), + ); + select(software.title); + select(general.title); + await ready(general.title); + await act(async () => + result === 'success' + ? old.resolve({ data: { form: software } }) + : old.reject(new Error('old request failed')), + ); + expect(screen.getByText(`Editing questionnaire for: ${general.title}`)).toBeInTheDocument(); + expect(screen.queryByText(/Software question/)).not.toBeInTheDocument(); + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }, + ); + + it('keeps saving disabled on a failed role load and supports retry', async () => { + mount(); + await ready(); + axios.get.mockRejectedValueOnce(new Error('offline')); + select(software.title); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); + expect(screen.queryByText(/Electrical question/)).not.toBeInTheDocument(); + select(software.title); + await ready(software.title); + }); + + it.each([ + { ...software, _id: general._id }, + { ...software, title: general.title }, + { ...software, questions: null }, + { ...software, isActive: false }, + ])('rejects mismatched or malformed form details %#', async detail => { + details.set(ENDPOINTS.GET_JOB_FORM(software._id), detail); + mount(); + await ready(); + select(software.title); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); + expect(axios.put).not.toHaveBeenCalled(); + }); + + it('does not reuse generic questions when a role has no saved form', async () => { + mount(); + await ready(); + select('MASTER CARPENTER'); + await screen.findByText(/No saved questionnaire is available for MASTER CARPENTER/); + expect(screen.queryByText(/Electrical question/)).not.toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); + expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM('MASTER CARPENTER')); + }); + + it('does not choose silently between duplicate saved role names', async () => { + catalog.push({ ...software, _id: 'duplicate-id' }); + mount(); + await ready(); + select(software.title); + await screen.findByText(/Several saved forms/); + expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); + expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM(software._id)); + }); + + it('supports an explicitly empty saved question set', async () => { + details.set(ENDPOINTS.GET_JOB_FORM(software._id), { ...software, questions: [] }); + mount(); + await ready(); + select(software.title); + await ready(software.title); + expect(screen.queryByText(/Electrical question/)).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); + await waitFor(() => + expect(axios.put).toHaveBeenCalledWith( + ENDPOINTS.UPDATE_JOB_FORM, + expect.objectContaining({ formId: software._id, questions: [] }), + ), + ); + }); + + it('allows retrying a failed catalog load', async () => { + axios.get.mockRejectedValueOnce(new Error('offline')); + mount(); + fireEvent.click(await screen.findByRole('button', { name: 'Retry loading roles' })); + await ready(); + }); + + it('excludes inactive and retired forms from initial loading', async () => { + catalog.unshift( + { ...general, _id: 'inactive-id', isActive: false }, + { ...general, _id: 'retired-id', title: '[RETIRED] OLD FORM' }, + ); + mount(); + await ready(); + expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM('inactive-id')); + expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM('retired-id')); + }); + + it('does not fetch or expose editor controls without management permission', async () => { + mount({ allowed: false }); + await screen.findByText('Access restricted'); + expect(axios.get).not.toHaveBeenCalled(); + expect(screen.queryByRole('button', { name: 'Save Form' })).not.toBeInTheDocument(); + }); + + it.each([[], { forms: 'invalid catalog' }])( + 'keeps the editor disabled for empty or malformed catalogs %#', + async data => { + axios.get.mockResolvedValueOnce({ data }); + mount(); + await screen.findByRole('alert'); + expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); + expect(axios.put).not.toHaveBeenCalled(); + }, + ); +}); + +describe('saving only to the loaded role', () => { + it('asks before replacing the selected questionnaire and sends its exact ID, title and questions', async () => { + mount(); + await ready(); + select(software.title); + await ready(software.title); + window.confirm.mockReturnValueOnce(false); + fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); + expect(axios.put).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); + await waitFor(() => expect(axios.put).toHaveBeenCalledTimes(1)); + expect(window.confirm).toHaveBeenLastCalledWith(expect.stringContaining(software.title)); + expect(axios.put.mock.calls[0][1]).toMatchObject({ + formId: software._id, + title: software.title, + requestor: { requestorId: 'owner-id', role: 'Owner' }, + }); + expect(axios.put.mock.calls[0][1].questions).toHaveLength(12); + expect(axios.put.mock.calls[0][1]).not.toHaveProperty('description'); + }); + + it('keeps draft changes when a role switch is cancelled and clears them on a confirmed switch', async () => { + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Import test template' })); + await screen.findByText(/Imported question/); + window.confirm.mockReturnValueOnce(false); + select(software.title); + expect(screen.getByRole('combobox', { name: 'Job role' })).toHaveValue(electrical.title); + expect(screen.getByText(/Imported question/)).toBeInTheDocument(); + select(software.title); + await ready(software.title); + expect(screen.queryByText(/Imported question/)).not.toBeInTheDocument(); + }); + + it('ignores a delayed template import from the previous role', async () => { + mount(); + await ready(); + const previousImport = templateCallbacks.at(-1); + select(software.title); + await ready(software.title); + act(() => previousImport([{ questionText: 'Old role template', questionType: 'textbox' }])); + expect(screen.queryByText(/Old role template/)).not.toBeInTheDocument(); + expect(screen.getAllByText(/\d+\.\) Software question/)).toHaveLength(12); + }); + + it('blocks role switching and duplicate saves until the current save finishes', async () => { + mount(); + await ready(); + const save = deferred(); + axios.put.mockReturnValueOnce(save.promise); + fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); + expect(screen.getByRole('combobox', { name: 'Job role' })).toBeDisabled(); + select(software.title); + fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); + expect(axios.put).toHaveBeenCalledTimes(1); + expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM(software._id)); + await act(async () => save.resolve({ data: {} })); + await ready(); + }); + + it('preserves the selected role and questions after a failed save', async () => { + mount(); + await ready(); + select(software.title); + await ready(software.title); + axios.put.mockRejectedValueOnce(new Error('offline')); + fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); + await waitFor(() => + expect(window.alert).toHaveBeenCalledWith('Failed to save form. Please try again.'), + ); + await ready(software.title); + expect(screen.getAllByText(/\d+\.\) Software question/)).toHaveLength(12); + }); + + it('sends question edits with PATCH to the currently loaded form', async () => { + mount(); + await ready(); + select(software.title); + await ready(software.title); + fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]); + fireEvent.click(screen.getByRole('button', { name: 'Confirm question edit' })); + await waitFor(() => + expect(axios.patch).toHaveBeenCalledWith( + ENDPOINTS.UPDATE_QUESTION(software._id, 0), + expect.objectContaining({ questionText: 'Edited question' }), + ), + ); + }); + + it.each(['delete', 'reorder', 'add'])( + 'binds the %s question action to the loaded role', + async action => { + mount(); + await ready(); + select(general.title); + await ready(general.title); + if (action === 'delete') { + fireEvent.click(screen.getAllByTitle('Delete question')[0]); + await waitFor(() => + expect(axios.delete).toHaveBeenCalledWith( + ENDPOINTS.DELETE_QUESTION(general._id, 0), + expect.objectContaining({ + data: { requestor: { requestorId: 'owner-id', role: 'Owner' } }, + }), + ), + ); + } else if (action === 'reorder') { + fireEvent.click(screen.getAllByTitle('Move down')[0]); + await waitFor(() => + expect(axios.put).toHaveBeenCalledWith( + ENDPOINTS.REORDER_QUESTIONS(general._id), + expect.objectContaining({ fromIndex: 0, toIndex: 1 }), + ), + ); + } else { + fireEvent.change(screen.getByLabelText('Field Label:'), { + target: { value: 'Added question' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Add Field' })); + await waitFor(() => + expect(axios.post).toHaveBeenCalledWith( + ENDPOINTS.ADD_QUESTION(general._id), + expect.objectContaining({ + question: expect.objectContaining({ questionText: 'Added question' }), + }), + ), + ); + } + await ready(general.title); + }, + ); + + it('retains the unsaved-change warning after editing an imported draft question', async () => { + mount(); + await ready(); + fireEvent.click(screen.getByRole('button', { name: 'Import test template' })); + fireEvent.click(screen.getAllByRole('button', { name: 'Edit' })[0]); + fireEvent.click(screen.getByRole('button', { name: 'Confirm question edit' })); + await waitFor(() => expect(axios.patch).toHaveBeenCalled()); + await ready(); + window.confirm.mockReturnValueOnce(false); + select(software.title); + expect(window.confirm).toHaveBeenLastCalledWith( + 'Discard unsaved changes and switch job roles?', + ); + expect(screen.getByRole('combobox', { name: 'Job role' })).toHaveValue(electrical.title); + }); +}); diff --git a/src/components/Collaboration/useJobFormSelection.js b/src/components/Collaboration/useJobFormSelection.js new file mode 100644 index 00000000000..6146c951f3d --- /dev/null +++ b/src/components/Collaboration/useJobFormSelection.js @@ -0,0 +1,125 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { ENDPOINTS } from '~/utils/URL'; +import { JOB_FORM_POSITION_OPTIONS } from '../JobFormManagement/jobFormPositions'; + +const titleKey = title => + String(title || '') + .trim() + .toLowerCase(); +const formId = form => form?._id || form?.id; + +function activeForms(data) { + const forms = data?.forms ?? data; + if (!Array.isArray(forms)) throw new Error('Invalid form catalog'); + return forms.filter( + form => + formId(form) && + titleKey(form.title) && + form.isActive !== false && + !titleKey(form.title).startsWith('[retired]'), + ); +} + +export default function useJobFormSelection({ enabled, onReset, onLoad }) { + const [forms, setForms] = useState([]); + const [selectedTitle, setSelectedTitle] = useState(''); + const [status, setStatus] = useState('loading'); + const [error, setError] = useState(''); + const [catalogLoading, setCatalogLoading] = useState(true); + const [retry, setRetry] = useState(0); + const requestVersion = useRef(0); + + const loadForm = useCallback( + async (title, catalog) => { + const version = ++requestVersion.current; + setSelectedTitle(title); + setError(''); + setStatus('loading'); + onReset(title); + const matches = catalog.filter(form => titleKey(form.title) === titleKey(title)); + if (matches.length !== 1) { + setStatus('error'); + setError( + matches.length + ? 'Several saved forms have this role name. Resolve the duplicate before editing.' + : `No saved questionnaire is available for ${title}. Choose another role.`, + ); + return; + } + const target = matches[0]; + try { + const response = await axios.get(ENDPOINTS.GET_JOB_FORM(formId(target))); + if (version !== requestVersion.current) return; + const form = response.data?.form; + if ( + !form || + formId(form) !== formId(target) || + titleKey(form.title) !== titleKey(target.title) || + form.isActive === false || + !Array.isArray(form.questions) + ) + throw new Error('The saved questionnaire does not match the selected role'); + onLoad(form); + setSelectedTitle(form.title); + setStatus('ready'); + } catch { + if (version !== requestVersion.current) return; + setStatus('error'); + setError(`Could not load the questionnaire for ${title}. Select the role again to retry.`); + } + }, + [onReset, onLoad], + ); + + useEffect(() => { + let ignore = false; + setCatalogLoading(true); + setStatus('loading'); + setError(''); + onReset(''); + async function loadCatalog() { + try { + const response = await axios.get(ENDPOINTS.GET_ALL_JOB_FORMS); + if (ignore) return; + const catalog = activeForms(response.data); + setForms(catalog); + setCatalogLoading(false); + if (catalog.length) await loadForm(catalog[0].title, catalog); + else { + setStatus('error'); + setError('No active saved questionnaires are available.'); + } + } catch { + if (ignore) return; + setCatalogLoading(false); + setStatus('error'); + setError('Could not load the job roles. Please retry.'); + } + } + if (enabled) loadCatalog(); + else setCatalogLoading(false); + return () => { + ignore = true; + requestVersion.current += 1; + }; + }, [enabled, retry, loadForm, onReset]); + + const options = [ + ...new Map( + [...forms.map(form => form.title), ...JOB_FORM_POSITION_OPTIONS].map(title => [ + titleKey(title), + title, + ]), + ).values(), + ]; + return { + selectedTitle, + status, + error, + catalogLoading, + options, + selectRole: title => loadForm(title, forms), + retryCatalog: () => setRetry(value => value + 1), + }; +} From 68d62bcda1c775fc7c6f38cae1926061b944753d Mon Sep 17 00:00:00 2001 From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com> Date: Mon, 2 Nov 2026 20:04:26 -0800 Subject: [PATCH 2/4] Handle the role catalog initialization promise Attach the existing catalog error handling to the initialization promise so unexpected failures cannot become unhandled rejections. Preserve retry and stale-response protection. Refs #5764; Jobs Bug #68 --- .../Collaboration/useJobFormSelection.js | 31 +++++++++---------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/components/Collaboration/useJobFormSelection.js b/src/components/Collaboration/useJobFormSelection.js index 6146c951f3d..78aa16541cc 100644 --- a/src/components/Collaboration/useJobFormSelection.js +++ b/src/components/Collaboration/useJobFormSelection.js @@ -79,26 +79,25 @@ export default function useJobFormSelection({ enabled, onReset, onLoad }) { setError(''); onReset(''); async function loadCatalog() { - try { - const response = await axios.get(ENDPOINTS.GET_ALL_JOB_FORMS); - if (ignore) return; - const catalog = activeForms(response.data); - setForms(catalog); - setCatalogLoading(false); - if (catalog.length) await loadForm(catalog[0].title, catalog); - else { - setStatus('error'); - setError('No active saved questionnaires are available.'); - } - } catch { + const response = await axios.get(ENDPOINTS.GET_ALL_JOB_FORMS); + if (ignore) return; + const catalog = activeForms(response.data); + setForms(catalog); + setCatalogLoading(false); + if (catalog.length) await loadForm(catalog[0].title, catalog); + else { + setStatus('error'); + setError('No active saved questionnaires are available.'); + } + } + if (enabled) { + loadCatalog().catch(() => { if (ignore) return; setCatalogLoading(false); setStatus('error'); setError('Could not load the job roles. Please retry.'); - } - } - if (enabled) loadCatalog(); - else setCatalogLoading(false); + }); + } else setCatalogLoading(false); return () => { ignore = true; requestVersion.current += 1; From fa6090d0bf04d6c18fca552b6d4dbbeed2af0c5b Mon Sep 17 00:00:00 2001 From: Aditya Varun Dhayapulay <93042524+adit24dhaya@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:23:47 -0800 Subject: [PATCH 3/4] Fix duplicate questionnaire selection and failed-load retry Open an unambiguous role initially and let editors explicitly choose a saved form when role names repeat. Retry the selected form through a visible button instead of relying on a native select change event. Add regression coverage for duplicate defaults, explicit form IDs, retry and the resulting save target. Addresses review feedback on PR #5764, Jobs Bug #68. --- .../Collaboration/JobFormbuilder.jsx | 34 +++++++++++++++- .../JobFormBuilder.roleSelection.test.jsx | 40 ++++++++++++++++++- .../Collaboration/useJobFormSelection.js | 39 ++++++++++++------ 3 files changed, 98 insertions(+), 15 deletions(-) diff --git a/src/components/Collaboration/JobFormbuilder.jsx b/src/components/Collaboration/JobFormbuilder.jsx index 0522f93f664..6ea127b9a06 100644 --- a/src/components/Collaboration/JobFormbuilder.jsx +++ b/src/components/Collaboration/JobFormbuilder.jsx @@ -121,13 +121,13 @@ function JobFormBuilder() { const canEditCurrentRole = () => canEdit && version === editorVersion.current && !mutationInFlight.current; - const selectRole = title => { + 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); + selection.selectRole(title, targetId); }; const searchRole = event => { @@ -508,6 +508,36 @@ function JobFormBuilder() { {selection.error || searchError}

)} + {selection.matchingForms.length > 1 && ( +
+ + +
+ )} + {selection.status === 'error' && selection.selectedFormId && ( + + )} {selection.error && !selection.selectedTitle && (
)} {selection.status === 'error' && selection.selectedFormId && ( - )} {selection.error && !selection.selectedTitle && ( - )} @@ -802,7 +806,12 @@ function JobFormBuilder() {
-
diff --git a/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx b/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx index f9e60546903..cb7be279735 100644 --- a/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx +++ b/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx @@ -306,13 +306,24 @@ describe('job role and questionnaire binding', () => { details.set(ENDPOINTS.GET_JOB_FORM(copy._id), copy); mount(); const picker = await screen.findByRole('combobox', { name: 'Saved questionnaire' }); + expect(screen.getByRole('button', { name: 'Save Form' })).toHaveAttribute('disabled'); + expect( + screen.queryByText('Select a role to load its saved questionnaire.'), + ).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Save Form' })).toBeDisabled(); expect( - screen.getByRole('option', { name: /General.*2026-10-13.*electrical-copy/ }), + screen.getByRole('option', { name: /General.*Oct 13, 2026.*l-copy/ }), ).toBeInTheDocument(); axios.get.mockRejectedValueOnce(new Error('offline')); fireEvent.change(picker, { target: { value: copy._id } }); - fireEvent.click(await screen.findByRole('button', { name: 'Retry questionnaire' })); + const retry = await screen.findByRole('button', { name: 'Retry questionnaire' }); + expect( + screen.queryByText('Select a role to load its saved questionnaire.'), + ).not.toBeInTheDocument(); + expect( + screen.getByText(`Questionnaire unavailable for: ${electrical.title}`), + ).toBeInTheDocument(); + fireEvent.click(retry); await ready(electrical.title); expect(screen.getByText(/Duplicate-specific question/)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Save Form' })); @@ -325,6 +336,24 @@ describe('job role and questionnaire binding', () => { expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM(electrical._id)); }); + it('keeps duplicate choices distinct when their shortened IDs match', async () => { + catalog = [ + { ...electrical, _id: 'first-123456', updatedAt: '2026-11-04T23:49:54Z' }, + { ...electrical, _id: 'second-123456', updatedAt: '2026-11-04T23:49:54Z' }, + ]; + mount(); + await screen.findByRole('combobox', { name: 'Saved questionnaire' }); + expect(screen.getByRole('option', { name: /Nov 4, 2026.*first-123456/ })).toBeInTheDocument(); + expect(screen.getByRole('option', { name: /Nov 4, 2026.*second-123456/ })).toBeInTheDocument(); + }); + + it('keeps the picker usable with missing or invalid update dates', async () => { + catalog = [electrical, { ...electrical, _id: 'copy-123456', updatedAt: 'invalid' }]; + mount(); + await screen.findByRole('combobox', { name: 'Saved questionnaire' }); + expect(screen.getAllByRole('option', { name: /Update date unavailable/ })).toHaveLength(2); + }); + it('supports an explicitly empty saved question set', async () => { details.set(ENDPOINTS.GET_JOB_FORM(software._id), { ...software, questions: [] }); mount(); diff --git a/src/components/Collaboration/jobFormSelectionLabels.js b/src/components/Collaboration/jobFormSelectionLabels.js new file mode 100644 index 00000000000..6f46e12754b --- /dev/null +++ b/src/components/Collaboration/jobFormSelectionLabels.js @@ -0,0 +1,19 @@ +const shortDate = new Intl.DateTimeFormat('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + timeZone: 'UTC', +}); + +export default function questionnaireLabel(form, matchingForms) { + const id = String(form._id || form.id); + const suffix = id.slice(-6); + // Keep the full ID when shortened IDs would make two choices indistinguishable. + const duplicateSuffix = matchingForms.some( + other => String(other._id || other.id) !== id && String(other._id || other.id).endsWith(suffix), + ); + const date = form.updatedAt ? new Date(form.updatedAt) : null; + const updated = + date && Number.isFinite(date.getTime()) ? shortDate.format(date) : 'Update date unavailable'; + return `${form.category || 'No category'} — ${updated} — ${duplicateSuffix ? id : suffix}`; +}