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?"
/>
-
+
);
}
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
);
}