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 (
-
- - -
+
+ {canManageJobForms && ( + <> + {roleStatusText} + {(selection.error || searchError) && ( +

+ {selection.error || searchError} +

+ )} + {selection.matchingForms.length > 1 && ( +
+ + +
+ )} + {selection.status === 'error' && selection.selectedFormId && ( + + )} + {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(); }} @@ -649,7 +806,12 @@ function JobFormBuilder() {
-
@@ -662,7 +824,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 0000000000..cb7be27973 --- /dev/null +++ b/src/components/Collaboration/__tests__/JobFormBuilder.roleSelection.test.jsx @@ -0,0 +1,558 @@ +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(); + fireEvent.click(screen.getByRole('button', { name: 'Retry questionnaire' })); + 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('opens an unambiguous role when the first role has duplicate forms', async () => { + catalog.push({ ...electrical, _id: 'electrical-copy' }); + mount(); + await ready(software.title); + expect(axios.get).not.toHaveBeenCalledWith(ENDPOINTS.GET_JOB_FORM(electrical._id)); + }); + + it('lets users choose and retry a specific duplicate without changing its save target', async () => { + const copy = { + ...electrical, + _id: 'electrical-copy', + category: 'General', + updatedAt: '2026-10-13T12:00:00Z', + questions: [{ questionText: 'Duplicate-specific question', questionType: 'textbox' }], + }; + catalog = [electrical, copy]; + 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.*Oct 13, 2026.*l-copy/ }), + ).toBeInTheDocument(); + axios.get.mockRejectedValueOnce(new Error('offline')); + fireEvent.change(picker, { target: { value: copy._id } }); + 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' })); + await waitFor(() => + expect(axios.put).toHaveBeenCalledWith( + ENDPOINTS.UPDATE_JOB_FORM, + expect.objectContaining({ formId: copy._id }), + ), + ); + 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(); + 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/jobFormSelectionLabels.js b/src/components/Collaboration/jobFormSelectionLabels.js new file mode 100644 index 0000000000..6f46e12754 --- /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}`; +} diff --git a/src/components/Collaboration/useJobFormSelection.js b/src/components/Collaboration/useJobFormSelection.js new file mode 100644 index 0000000000..5f58c8b569 --- /dev/null +++ b/src/components/Collaboration/useJobFormSelection.js @@ -0,0 +1,139 @@ +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 [selectedFormId, setSelectedFormId] = 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, targetId = '') => { + const version = ++requestVersion.current; + setSelectedTitle(title); + setSelectedFormId(targetId); + setError(''); + setStatus('loading'); + onReset(title); + const matches = catalog.filter(form => titleKey(form.title) === titleKey(title)); + if (matches.length > 1 && !targetId) { + setStatus('choosing'); + return; + } + const target = targetId ? matches.find(form => formId(form) === targetId) : matches[0]; + if (!target) { + setStatus('error'); + setError(`No saved questionnaire is available for ${title}. Choose another role.`); + return; + } + setSelectedFormId(formId(target)); + 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}. Use Retry questionnaire to try again.`, + ); + } + }, + [onReset, onLoad], + ); + + useEffect(() => { + let ignore = false; + setCatalogLoading(true); + setStatus('loading'); + setError(''); + setSelectedTitle(''); + setSelectedFormId(''); + onReset(''); + async function loadCatalog() { + 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) { + const initial = catalog.find( + candidate => + catalog.filter(form => titleKey(form.title) === titleKey(candidate.title)).length === 1, + ); + await loadForm((initial || 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.'); + }); + } 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, + selectedFormId, + matchingForms: forms.filter(form => titleKey(form.title) === titleKey(selectedTitle)), + status, + error, + catalogLoading, + options, + selectRole: (title, targetId) => loadForm(title, forms, targetId), + retryForm: () => loadForm(selectedTitle, forms, selectedFormId), + retryCatalog: () => setRetry(value => value + 1), + }; +}