diff --git a/src/components/Collaboration/FormPreviewModal.module.css b/src/components/Collaboration/FormPreviewModal.module.css index a8bd5583ec..b2bc689c3e 100644 --- a/src/components/Collaboration/FormPreviewModal.module.css +++ b/src/components/Collaboration/FormPreviewModal.module.css @@ -276,7 +276,14 @@ .darkMode .previewSelect { background-color: #444; border-color: #555; - color: #999; + color: #e0e0e0; + opacity: 1; +} + +.darkMode .previewInput::placeholder, +.darkMode .previewTextarea::placeholder { + color: #cfd8dc; + opacity: 1; } .darkMode .previewOptionLabel { diff --git a/src/components/Collaboration/JobFormBuilder.module.css b/src/components/Collaboration/JobFormBuilder.module.css index 86bac9669f..8e44017e68 100644 --- a/src/components/Collaboration/JobFormBuilder.module.css +++ b/src/components/Collaboration/JobFormBuilder.module.css @@ -9,6 +9,7 @@ } .formBuilderContainer { + min-width: 0; background-color: var(--jfb-white); width: min(1000px, calc(100% - 32px)); margin: 1% auto 0; @@ -433,8 +434,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; diff --git a/src/components/Collaboration/JobFormbuilder.jsx b/src/components/Collaboration/JobFormbuilder.jsx index 769d6f1ecd..299faca3d6 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, useLayoutEffect, useMemo } from 'react'; import axios from 'axios'; import { useDispatch, useSelector } from 'react-redux'; import { Prompt } from 'react-router-dom'; @@ -75,41 +75,43 @@ function JobFormBuilder() { const [editModalOpen, setEditModalOpen] = useState(false); const [editingQuestion, setEditingQuestion] = useState(null); const [editingIndex, setEditingIndex] = useState(null); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [isLoadingForm, setIsLoadingForm] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [saveError, setSaveError] = useState(''); + const [hasModalChanges, setHasModalChanges] = useState(false); + + const hasUnsavedChanges = hasUnsavedJobFormChanges({ + formFields, + initialFormFields, + newField, + initialNewField, + templateName, + jobTitle, + initialJobTitle, + newOption, + hasModalChanges, + }); const markAsSaved = (fields, savedJobTitle) => { setInitialFormFields(structuredClone(fields)); if (savedJobTitle !== undefined) setInitialJobTitle(savedJobTitle); - setHasUnsavedChanges(false); }; - // Prevent refresh while unsaved changes exist - useEffect(() => { + // Back/Forward can leave the document entirely, bypassing React Router's Prompt. + useLayoutEffect(() => { + if (!hasUnsavedChanges) return undefined; const handler = event => { - if (hasUnsavedChanges) { - event.preventDefault(); - event.returnValue = ''; - } + event.preventDefault(); + // Keep a nonempty value for browsers that still use the legacy signal. + // The browser supplies its own text for the native confirmation dialog. + event.returnValue = 'You have unsaved changes.'; + return event.returnValue; }; - globalThis.addEventListener('beforeunload', handler); - return () => globalThis.removeEventListener('beforeunload', handler); + window.addEventListener('beforeunload', handler); + return () => window.removeEventListener('beforeunload', handler); }, [hasUnsavedChanges]); - // Reset builder after template is saved - const resetBuilderState = () => { - setFormFields([]); - setNewField({ - questionText: '', - questionType: 'textbox', - options: [], - visible: true, - isRequired: false, - required: false, - }); - setNewOption(''); - }; - // Auto-load existing form on component mount useEffect(() => { const loadFirstAvailableForm = async () => { @@ -134,6 +136,9 @@ function JobFormBuilder() { } } catch (error) { console.error('Error auto-loading form:', error); + setSaveError('Unable to load the form. Please refresh before editing.'); + } finally { + setIsLoadingForm(false); } }; @@ -142,68 +147,17 @@ function JobFormBuilder() { }); }, []); - // Detect unsaved changes - useEffect(() => { - const changed = hasUnsavedJobFormChanges({ - formFields, - initialFormFields, - newField, - initialNewField, - templateName, - jobTitle, - initialJobTitle, - }); - - setHasUnsavedChanges(changed); - }, [formFields, initialFormFields, newField, templateName, jobTitle, initialJobTitle]); - - // CRUD Functions with Dynamic Form ID - const moveField = async (index, direction) => { + // Keep question changes local until Save Form succeeds. + const moveField = (index, direction) => { const newIndex = direction === 'up' ? index - 1 : index + 1; - - if ( - (direction === 'up' && index > 0) || - (direction === 'down' && index < formFields.length - 1) - ) { - // Update local state immediately - const newFields = [...formFields]; - [newFields[index], newFields[newIndex]] = [newFields[newIndex], newFields[index]]; - setFormFields(newFields); - - // Sync with backend if form exists - if (currentFormId) { - try { - await axios.put(ENDPOINTS.REORDER_QUESTIONS(currentFormId), { - fromIndex: index, - toIndex: newIndex, - requestor: getRequestor(), - }); - markAsSaved(newFields); - } catch (error) { - console.error('Error reordering questions on server:', error); - } - } - } - }; - - const deleteField = async index => { - // Update local state immediately + if (newIndex < 0 || newIndex >= formFields.length) return; const newFields = [...formFields]; - newFields.splice(index, 1); + [newFields[index], newFields[newIndex]] = [newFields[newIndex], newFields[index]]; setFormFields(newFields); + }; - // Sync with backend if form exists - if (currentFormId) { - try { - await axios.delete(ENDPOINTS.DELETE_QUESTION(currentFormId, index), { - data: { requestor: getRequestor() }, - }); - markAsSaved(newFields); - console.log('Question deleted successfully'); - } catch (error) { - console.error('Error deleting question on server:', error); - } - } + const deleteField = index => { + setFormFields(fields => fields.filter((field, fieldIndex) => fieldIndex !== index)); }; const editField = (field, index) => { @@ -221,7 +175,7 @@ function JobFormBuilder() { setEditModalOpen(true); }; - const handleSaveEditedQuestion = async editedQuestion => { + const handleSaveEditedQuestion = editedQuestion => { const isRequired = Boolean(editedQuestion.required || editedQuestion.isRequired); const updatedField = normalizeQuestionForApi({ ...formFields[editingIndex], @@ -233,25 +187,11 @@ function JobFormBuilder() { placeholder: editedQuestion.placeholder, }); - // Update local state immediately const updatedFields = [...formFields]; updatedFields[editingIndex] = updatedField; setFormFields(updatedFields); - // Sync with backend if form exists - if (currentFormId) { - try { - await axios.put(ENDPOINTS.UPDATE_QUESTION(currentFormId, editingIndex), { - ...updatedField, - requestor: getRequestor(), - }); - markAsSaved(updatedFields); - console.log('Question updated successfully'); - } catch (error) { - console.error('Error updating question on server:', error); - } - } - + setHasModalChanges(false); // Close the modal setEditModalOpen(false); setEditingQuestion(null); @@ -259,6 +199,7 @@ function JobFormBuilder() { }; const handleCancelEdit = () => { + setHasModalChanges(false); setEditModalOpen(false); setEditingQuestion(null); setEditingIndex(null); @@ -281,7 +222,7 @@ function JobFormBuilder() { setNewOption(''); }; - const handleAddField = async () => { + const handleAddField = () => { if (newField.questionText.trim() === '') { alert('Field label is required!'); return; @@ -301,22 +242,7 @@ function JobFormBuilder() { const confirmAdd = window.confirm('You already have a similar question. Add Anyway?'); if (!confirmAdd) return; } - const updatedFields = [...formFields, fieldToAdd]; - setFormFields(updatedFields); - - // Sync with backend if form exists - if (currentFormId) { - try { - await axios.post(ENDPOINTS.ADD_QUESTION(currentFormId), { - question: fieldToAdd, - position: formFields.length, - requestor: getRequestor(), - }); - markAsSaved(updatedFields); - } catch (error) { - console.error('Error adding question to server:', error); - } - } + setFormFields([...formFields, fieldToAdd]); setNewField({ questionText: '', @@ -345,6 +271,8 @@ function JobFormBuilder() { return; } + setIsSaving(true); + setSaveError(''); try { await axios.put(ENDPOINTS.UPDATE_JOB_FORM, { formId: currentFormId, @@ -363,7 +291,10 @@ function JobFormBuilder() { error.response?.data?.message || error.response?.data?.error?.message || 'Failed to save form. Please try again.'; + setSaveError(message); alert(message); + } finally { + setIsSaving(false); } }; @@ -373,7 +304,9 @@ function JobFormBuilder() { when={hasUnsavedChanges} message="You have unsaved changes. Are you sure you want to leave this page?" /> -
+
+ {isLoadingForm && Loading form…} + {saveError &&

{saveError}

} One Community Logo
- + setJobTitle(event.target.value)} + /> @@ -394,6 +332,9 @@ function JobFormBuilder() { className={styles.jobformSelect} > + {jobTitle && + jobTitle !== 'Please Choose an option' && + !jobPositions.includes(jobTitle) && } {jobPositions.map(e => (

Job Question Set Builder

+ {hasUnsavedChanges && ( + Unsaved changes — click Save Form to keep your changes. + )} {canManageJobForms ? (

@@ -413,14 +357,7 @@ function JobFormBuilder() { { - importQuestions(fields); - markAsSaved(fields); - }} - onTemplateSaved={() => { - markAsSaved(formFields); - resetBuilderState(); - }} + onImportQuestions={importQuestions} darkMode={darkMode} templateName={templateName} setTemplateName={setTemplateName} @@ -656,6 +593,7 @@ function JobFormBuilder() { {editModalOpen && editingQuestion && ( -

+
); } diff --git a/src/components/Collaboration/QuestionEditModal.jsx b/src/components/Collaboration/QuestionEditModal.jsx index 1e60762dcb..065aed99cf 100644 --- a/src/components/Collaboration/QuestionEditModal.jsx +++ b/src/components/Collaboration/QuestionEditModal.jsx @@ -20,7 +20,7 @@ const toOptionRows = options => value: value ?? '', })); -function QuestionEditModal({ question, onSave, onCancel, darkMode = false }) { +function QuestionEditModal({ question, onSave, onCancel, darkMode = false, onDirtyChange }) { const [editedQuestion, setEditedQuestion] = useState({ ...question, required: question.required || question.isRequired || false, @@ -42,6 +42,17 @@ function QuestionEditModal({ question, onSave, onCancel, darkMode = false }) { setOptionRows(toOptionRows(question.options)); }, [question]); + useEffect(() => { + onDirtyChange?.( + JSON.stringify(editedQuestion) !== + JSON.stringify({ + ...question, + required: question.required || question.isRequired || false, + options: question.options || [], + }), + ); + }, [editedQuestion, question, onDirtyChange]); + const handleInputChange = e => { const { name, value } = e.target; @@ -235,6 +246,7 @@ QuestionEditModal.propTypes = { onSave: PropTypes.func.isRequired, onCancel: PropTypes.func.isRequired, darkMode: PropTypes.bool, + onDirtyChange: PropTypes.func, }; export default QuestionEditModal; diff --git a/src/components/Collaboration/QuestionEditModal.module.css b/src/components/Collaboration/QuestionEditModal.module.css index 255780632e..008793274d 100644 --- a/src/components/Collaboration/QuestionEditModal.module.css +++ b/src/components/Collaboration/QuestionEditModal.module.css @@ -179,6 +179,13 @@ accent-color: var(--jfb-lime); } +.questionEditModal.darkMode .formGroup input::placeholder, +.questionEditModal.darkMode .formGroup textarea::placeholder, +.questionEditModal.darkMode .optionRow input::placeholder { + color: #b0bec5; + opacity: 1; +} + .questionEditModal.darkMode .optionsGroup { background-color: #333; border-color: #555; diff --git a/src/components/Collaboration/QuestionFieldActions.module.css b/src/components/Collaboration/QuestionFieldActions.module.css index 220fd3cdc3..5ce544b936 100644 --- a/src/components/Collaboration/QuestionFieldActions.module.css +++ b/src/components/Collaboration/QuestionFieldActions.module.css @@ -107,16 +107,19 @@ button:not(:disabled):hover { .darkControls .moveButton { background-color: #5a6a7a; + color: var(--jfb-white); } .darkControls .moveButton:hover:not(:disabled) { - background-color: #6a7a8a; + background-color: #647484; } .darkControls .deleteButton { - background-color: #6a6a6a; + background-color: #4a2020; + color: #ffcdd2; + border-color: #ef9a9a; } .darkControls .deleteButton:hover:not(:disabled) { - background-color: #7a7a7a; + background-color: #622b2b; } diff --git a/src/components/Collaboration/QuestionSetManager.jsx b/src/components/Collaboration/QuestionSetManager.jsx index 9933f5667a..e4454d0bfe 100644 --- a/src/components/Collaboration/QuestionSetManager.jsx +++ b/src/components/Collaboration/QuestionSetManager.jsx @@ -14,7 +14,16 @@ import { findDuplicateQuestions, } from './jobFormQuestionUtils'; -function QuestionSetManager({ formFields, setFormFields, onImportQuestions, darkMode = false }) { +function QuestionSetManager({ + formFields, + setFormFields, + onImportQuestions, + templateName, + setTemplateName, + selectedTemplate, + setSelectedTemplate, + darkMode = false, +}) { const { auth } = useSelector(state => state); const getRequestor = () => buildJobFormRequestor(auth?.user); @@ -27,8 +36,6 @@ function QuestionSetManager({ formFields, setFormFields, onImportQuestions, dark placeholder: field.placeholder || '', }); const [templates, setTemplates] = useState([]); - const [templateName, setTemplateName] = useState(''); - const [selectedTemplate, setSelectedTemplate] = useState(''); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [editModalOpen, setEditModalOpen] = useState(false); @@ -520,6 +527,10 @@ QuestionSetManager.propTypes = { ).isRequired, setFormFields: PropTypes.func.isRequired, onImportQuestions: PropTypes.func.isRequired, + templateName: PropTypes.string.isRequired, + setTemplateName: PropTypes.func.isRequired, + selectedTemplate: PropTypes.string.isRequired, + setSelectedTemplate: PropTypes.func.isRequired, darkMode: PropTypes.bool, }; diff --git a/src/components/Collaboration/__tests__/JobFormBuilder.navigation.test.jsx b/src/components/Collaboration/__tests__/JobFormBuilder.navigation.test.jsx new file mode 100644 index 0000000000..26042b5fc0 --- /dev/null +++ b/src/components/Collaboration/__tests__/JobFormBuilder.navigation.test.jsx @@ -0,0 +1,142 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest'; +import { Router, Route, Switch } from 'react-router-dom'; +import { createBrowserHistory } from 'history'; +import axios from 'axios'; +import JobFormBuilder from '../JobFormbuilder'; +import { ENDPOINTS } from '../../../utils/URL'; + +vi.mock('axios', () => ({ + default: { get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})); +vi.mock('react-redux', () => ({ + useDispatch: () => () => true, + useSelector: selector => selector({ auth: { user: {} }, theme: { darkMode: false } }), +})); +vi.mock('../FormPreviewModal', () => ({ default: () => null })); +vi.mock('reactstrap', () => ({ UncontrolledTooltip: () => null })); + +beforeEach(() => { + vi.clearAllMocks(); + window.history.replaceState({}, '', '/before'); + axios.get.mockImplementation(url => + Promise.resolve({ + data: + url === ENDPOINTS.GET_ALL_JOB_FORMS + ? { + forms: [ + { + _id: 'form-1', + title: 'Developer', + questions: [ + { questionText: 'First question', questionType: 'textbox', visible: true }, + { questionText: 'Second question', questionType: 'textbox', visible: true }, + ], + }, + ], + } + : { templates: [] }, + }), + ); + vi.spyOn(window, 'confirm').mockReturnValue(false); +}); +afterEach(() => vi.restoreAllMocks()); + +async function openBuilder() { + const history = createBrowserHistory(); + history.push('/jobformbuilder'); + history.push('/after'); + await new Promise(resolve => { + const stop = history.listen(() => { + stop(); + resolve(); + }); + history.goBack(); + }); + render( + + + + +

Another page

+
+
+
, + ); + await waitFor(() => expect(screen.getByPlaceholderText('Enter Field Label')).not.toBeDisabled()); + return history; +} + +const edits = { + 'question draft': () => + fireEvent.change(screen.getByPlaceholderText('Enter Field Label'), { + target: { value: 'Draft' }, + }), + 'job title': () => + fireEvent.change(screen.getByPlaceholderText('Enter Job Title'), { + target: { value: 'Changed title' }, + }), + 'template name': () => + fireEvent.change(screen.getByLabelText('Template name'), { + target: { value: 'Draft template' }, + }), + 'added question': () => { + fireEvent.change(screen.getByPlaceholderText('Enter Field Label'), { + target: { value: 'New question' }, + }); + fireEvent.click(screen.getByText('Add Field')); + }, + 'deleted question': () => fireEvent.click(screen.getAllByTitle('Delete question')[0]), + 'reordered question': () => fireEvent.click(screen.getAllByTitle('Move down')[0]), + 'edited question': () => { + fireEvent.click(screen.getAllByText('Edit')[0]); + fireEvent.change(screen.getByLabelText('Question Label:'), { + target: { value: 'Edited label' }, + }); + fireEvent.click(screen.getByText('Save Changes')); + }, +}; + +describe('browser history navigation', () => { + it.each( + Object.entries(edits).flatMap(([name, edit]) => + ['goBack', 'goForward'].map(direction => [name, direction, edit]), + ), + )( + 'warns about %s on %s and stays on the builder when canceled', + async (name, direction, edit) => { + const history = await openBuilder(); + edit(); + expect(axios.post).not.toHaveBeenCalled(); + expect(axios.delete).not.toHaveBeenCalled(); + expect(axios.put).not.toHaveBeenCalled(); + act(() => history[direction]()); + await waitFor(() => + expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining('unsaved changes')), + ); + await waitFor(() => expect(window.location.pathname).toBe('/jobformbuilder')); + expect(screen.getByText('Job Question Set Builder')).toBeInTheDocument(); + }, + ); + + it('leaves without a warning after Save Form succeeds', async () => { + const history = await openBuilder(); + vi.spyOn(window, 'alert').mockImplementation(() => {}); + axios.put.mockResolvedValueOnce({ data: {} }); + edits['added question'](); + fireEvent.click(screen.getByText('Save Form')); + await waitFor(() => expect(screen.getByText('Save Form')).not.toBeDisabled()); + act(() => history.goBack()); + expect(await screen.findByText('Another page')).toBeInTheDocument(); + expect(window.confirm).not.toHaveBeenCalled(); + }); + + it('allows leaving with unsaved changes when the user confirms', async () => { + const history = await openBuilder(); + edits['question draft'](); + window.confirm.mockReturnValue(true); + act(() => history.goForward()); + expect(await screen.findByText('Another page')).toBeInTheDocument(); + expect(window.confirm).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/components/Collaboration/__tests__/JobFormBuilder.unsaved.test.jsx b/src/components/Collaboration/__tests__/JobFormBuilder.unsaved.test.jsx new file mode 100644 index 0000000000..df3ef646e9 --- /dev/null +++ b/src/components/Collaboration/__tests__/JobFormBuilder.unsaved.test.jsx @@ -0,0 +1,196 @@ +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import axios from 'axios'; +import JobFormBuilder from '../JobFormbuilder'; +import { ENDPOINTS } from '../../../utils/URL'; + +vi.mock('axios', () => ({ + default: { get: vi.fn(), put: vi.fn(), post: vi.fn(), delete: vi.fn() }, +})); +vi.mock('react-redux', () => ({ + useDispatch: () => () => true, + useSelector: selector => selector({ auth: { user: {} }, theme: { darkMode: true } }), +})); +vi.mock('react-router-dom', () => ({ + Prompt: ({ when }) => {String(when)}, +})); +vi.mock('../FormPreviewModal', () => ({ default: () => null })); +vi.mock('reactstrap', () => ({ UncontrolledTooltip: () => null })); + +const form = { + forms: [ + { + _id: 'form-1', + title: 'Developer', + questions: [ + { questionText: 'First question', questionType: 'textbox', visible: true }, + { questionText: 'Second question', questionType: 'textbox', visible: true }, + ], + }, + ], +}; +const templates = { templates: [{ _id: 'template-1', name: 'Example', fields: [] }] }; +const dirty = () => expect(screen.getByTestId('navigation-warning')).toHaveTextContent('true'); +const clean = () => expect(screen.getByTestId('navigation-warning')).toHaveTextContent('false'); +async function openBuilder() { + render(); + await waitFor(() => expect(screen.getByPlaceholderText('Enter Field Label')).not.toBeDisabled()); + clean(); +} + +beforeEach(() => { + vi.clearAllMocks(); + localStorage.clear(); + vi.spyOn(window, 'alert').mockImplementation(() => {}); + vi.spyOn(window, 'confirm').mockReturnValue(true); + axios.get + .mockReset() + .mockImplementation(url => + Promise.resolve({ data: url === ENDPOINTS.GET_ALL_JOB_FORMS ? form : templates }), + ); + axios.put.mockResolvedValue({ data: {} }); +}); +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('builder unsaved work protection', () => { + it('keeps controls disabled until the initial form has loaded', async () => { + let resolveForm; + axios.get.mockImplementation(url => + url === ENDPOINTS.GET_ALL_JOB_FORMS + ? new Promise(resolve => { + resolveForm = resolve; + }) + : Promise.resolve({ data: templates }), + ); + render(); + expect(screen.getByPlaceholderText('Enter Field Label')).toBeDisabled(); + await act(async () => resolveForm({ data: form })); + expect(screen.getByPlaceholderText('Enter Field Label')).not.toBeDisabled(); + clean(); + }); + + it('requests a native leave warning after Add Field, including the legacy browser signal', async () => { + await openBuilder(); + fireEvent.change(screen.getByPlaceholderText('Enter Field Label'), { + target: { value: 'New question' }, + }); + fireEvent.click(screen.getByText('Add Field')); + const event = new Event('beforeunload', { cancelable: true }); + // A real BeforeUnloadEvent has a string returnValue, unlike a generic Event. + Object.defineProperty(event, 'returnValue', { value: '', writable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + expect(Object.getOwnPropertyDescriptor(event, 'returnValue').value).toBe( + 'You have unsaved changes.', + ); + }); + + it('removes the native leave warning only after the added field is saved', async () => { + await openBuilder(); + fireEvent.change(screen.getByPlaceholderText('Enter Field Label'), { + target: { value: 'New question' }, + }); + fireEvent.click(screen.getByText('Add Field')); + expect(screen.getByRole('status')).toHaveTextContent('Unsaved changes'); + fireEvent.click(screen.getByText('Save Form')); + await waitFor(clean); + expect(screen.queryByText(/Unsaved changes —/)).not.toBeInTheDocument(); + const event = new Event('beforeunload', { cancelable: true }); + Object.defineProperty(event, 'returnValue', { value: '', writable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + expect(Object.getOwnPropertyDescriptor(event, 'returnValue').value).toBe(''); + }); + + it('tracks template name drafts and clears them when reverted', async () => { + await openBuilder(); + fireEvent.change(screen.getByLabelText('Template name'), { target: { value: 'Draft' } }); + dirty(); + fireEvent.change(screen.getByLabelText('Template name'), { target: { value: '' } }); + clean(); + }); + + it('tracks modal drafts and clears them on cancel', async () => { + await openBuilder(); + fireEvent.click(screen.getAllByText('Edit')[0]); + clean(); + fireEvent.change(screen.getByLabelText('Question Label:'), { target: { value: 'Edited' } }); + dirty(); + const event = new Event('beforeunload', { cancelable: true }); + window.dispatchEvent(event); + expect(event.defaultPrevented).toBe(true); + fireEvent.click(screen.getByText('Cancel')); + clean(); + }); + + it('does not mark imported templates as saved', async () => { + await openBuilder(); + axios.get.mockResolvedValue({ + data: { template: { fields: [{ questionText: 'Imported', questionType: 'textbox' }] } }, + }); + fireEvent.change(screen.getByLabelText('Select a template'), { target: { value: 'Example' } }); + fireEvent.click(screen.getByText('Clone with Template')); + await waitFor(dirty); + fireEvent.click(screen.getByText('Save Form')); + await waitFor(clean); + }); + + it('keeps appended templates dirty until the form is saved', async () => { + await openBuilder(); + axios.get.mockResolvedValue({ + data: { template: { fields: [{ questionText: 'Imported', questionType: 'textbox' }] } }, + }); + fireEvent.change(screen.getByLabelText('Select a template'), { target: { value: 'Example' } }); + fireEvent.click(screen.getByText('Append Template')); + await waitFor(dirty); + }); + + it('keeps an unsubmitted question draft dirty after saving the form', async () => { + await openBuilder(); + fireEvent.change(screen.getByPlaceholderText('Enter Field Label'), { + target: { value: 'Draft' }, + }); + fireEvent.click(screen.getByText('Save Form')); + await waitFor(() => expect(screen.getByText('Save Form')).not.toBeDisabled()); + dirty(); + }); + + it('blocks further edits during Save Form and stays clean after success', async () => { + await openBuilder(); + let resolveSave; + axios.put.mockImplementationOnce( + () => + new Promise(resolve => { + resolveSave = resolve; + }), + ); + fireEvent.click(screen.getAllByTitle('Move down')[0]); + dirty(); + fireEvent.click(screen.getByText('Save Form')); + expect(screen.getByPlaceholderText('Enter Field Label')).toBeDisabled(); + await act(async () => resolveSave({ data: {} })); + expect(screen.getByPlaceholderText('Enter Field Label')).not.toBeDisabled(); + clean(); + }); + + it('preserves unrelated visibility changes after reordering a question', async () => { + await openBuilder(); + fireEvent.click(screen.getAllByRole('checkbox')[0]); + dirty(); + fireEvent.click(screen.getAllByTitle('Move down')[0]); + expect(axios.put).not.toHaveBeenCalled(); + await waitFor(() => expect(screen.getByText('Save Form')).not.toBeDisabled()); + dirty(); + }); + + it('shows failed question saves and retains the navigation warning', async () => { + await openBuilder(); + axios.put.mockRejectedValueOnce(new Error('Offline')); + fireEvent.click(screen.getAllByTitle('Delete question')[0]); + fireEvent.click(screen.getByText('Save Form')); + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Failed to save form')); + dirty(); + }); +}); diff --git a/src/components/Collaboration/__tests__/jobFormUnsavedChanges.test.js b/src/components/Collaboration/__tests__/jobFormUnsavedChanges.test.js index 5f83b53ffb..1d362ebe72 100644 --- a/src/components/Collaboration/__tests__/jobFormUnsavedChanges.test.js +++ b/src/components/Collaboration/__tests__/jobFormUnsavedChanges.test.js @@ -52,3 +52,10 @@ describe('hasUnsavedJobFormChanges', () => { expect(hasUnsavedJobFormChanges(getState({ templateName: 'Engineering form' }))).toBe(true); }); }); + +it('tracks an option draft before it is added', () => { + expect(hasUnsavedJobFormChanges(getState({ newOption: 'Draft option' }))).toBe(true); +}); +it('tracks an unsaved edit modal', () => { + expect(hasUnsavedJobFormChanges(getState({ hasModalChanges: true }))).toBe(true); +}); diff --git a/src/components/Collaboration/jobFormDirtyState.js b/src/components/Collaboration/jobFormDirtyState.js index 5c8a678e49..dc1f73d603 100644 --- a/src/components/Collaboration/jobFormDirtyState.js +++ b/src/components/Collaboration/jobFormDirtyState.js @@ -6,11 +6,15 @@ export function hasUnsavedJobFormChanges({ templateName, jobTitle, initialJobTitle, + newOption = '', + hasModalChanges = false, }) { return ( JSON.stringify(formFields) !== JSON.stringify(initialFormFields) || JSON.stringify(newField) !== JSON.stringify(initialNewField) || templateName !== '' || + newOption !== '' || + hasModalChanges || jobTitle !== initialJobTitle ); }