diff --git a/CHANGELOG.md b/CHANGELOG.md index bdd20fe84..0cc1bdeb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,11 @@ ## Added +- `Custom Question` additions [#929] + - Added new `CustomQuestionEdit` page + - Added new `QuestionCustomize` page + - Added new `CustomQuestionNew` page + - Added new `AddCustomQuestion`, `UpdateCustomQuestion`, `RemoveCustomQuestion`, `AddQuestionCustomization`, `UpdateQuestionCustomization`, `RemoveQuestionCustomization` mutations + - Added `CustomQuestion` and `QuestionCustomizationByVersionedQuestion` mutations + - Added new routes for Custom Queries to `utils/routes.ts` - Added `CustomSectionEdit`, and `CreateCustomSectionPage` for adding and editing Custom Sections [#928] - Added new `/template/customizations/[templateCustomizationOverview]` page [#927] - Added new `CustomizedTemplate` components directory and new `CustomizedQuestionEdit` and `CustomizedSectionEdit` components [#927] @@ -13,6 +20,14 @@ - Added related works project overview page [#700] ## Updated +- `Custom Question` updates [#929] +- Moved `addQuestionMutation` out of `QuestionView` so that `QuestionView` can be shared for both `BASE` and `CUSTOM` questions + - Updated `QuestionTypeSelectPage` to include the `addQuestionMutation` and `getDisplayOrder` and pass those to `QuestionAdd` + - Added use of `Loading` component in `TemplateCreatePage` + - Added `fetchPolicy` of `cache-and-network` to `TemplateCustomiationOverview` query so that we get updated data when user returns from editing section or question + - Updated form components with `isDisabled` prop so that users customizing existing questions can see a disabled version of the form fields + - Updated `QuestionAdd` to accept more props and be more flexible for shared use with Custom Questions + - Updated `QuestionView` to accept more props like `orgGuidance` so it can be shared with Custom Questions - Updated `ReposSelector` and `RepoSelectorForAnswer` components to use the new `Re3RepositoryTypesListDocument`, `Re3SubjectListDocument`, and `Re3byUrIsDocument` queries [#113] - Updated `ResearchOutputAnswerComponent` so that we don't get a duplicate `save` CTAs when on the `SingleResearchOutputComponent` page [#113] - Updated `SectionCustomizePage` component for customizing existing sections [#928] diff --git a/app/[locale]/account/profile/__tests__/page.spec.tsx b/app/[locale]/account/profile/__tests__/page.spec.tsx index f67aa8d78..b56fe3501 100644 --- a/app/[locale]/account/profile/__tests__/page.spec.tsx +++ b/app/[locale]/account/profile/__tests__/page.spec.tsx @@ -67,7 +67,7 @@ const mockUserData = { me: { givenName: 'John', surName: 'Doe', - affiliation: { name: 'Test Institution', uri: 'test-uri' }, + affiliation: { name: 'Test Institution', uri: 'test-uri', displayName: 'Test Institution' }, emails: [{ id: '1', email: 'test@example.com', isPrimary: true, isConfirmed: true }], languageId: 'en', }, @@ -222,7 +222,7 @@ describe('ProfilePage', () => { me: { givenName: 'John', surName: 'Doe', - affiliation: { name: '', uri: '' }, + affiliation: { name: '', uri: '', displayName: '' }, emails: [{ id: '1', email: 'test@example.com', isPrimary: true, isConfirmed: true }], languageId: 'en', }, diff --git a/app/[locale]/loading.tsx b/app/[locale]/loading.tsx new file mode 100644 index 000000000..d423e80e3 --- /dev/null +++ b/app/[locale]/loading.tsx @@ -0,0 +1,6 @@ +import Loading from '@/components/Loading'; + +{/** Next.js only shows this if it takes longer than ~300ms to load */ } +export default function LocaleLoading() { + return ; +} \ No newline at end of file diff --git a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__mocks__/mockMeQuery.json b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__mocks__/mockMeQuery.json index f01fb6aef..2b53e0de9 100644 --- a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__mocks__/mockMeQuery.json +++ b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__mocks__/mockMeQuery.json @@ -24,6 +24,7 @@ "affiliation": { "id": 1, "name": "California Digital Library", + "displayName": "California Digital Library", "searchName": "California Digital Library | cdlib.org | CDL ", "uri": "https://ror.org/03yrm5c26", "__typename": "Affiliation" diff --git a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentList.spec.tsx b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentList.spec.tsx index 416e1f17e..fdbd72e88 100644 --- a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentList.spec.tsx +++ b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentList.spec.tsx @@ -92,7 +92,7 @@ describe("CommentList", () => { role: UserRole.Admin, emails: [], errors: {}, - affiliation: { id: 1, name: "Test Org", searchName: "test-org", uri: "https://test.org" } + affiliation: { id: 1, name: "Test Org", searchName: "test-org", uri: "https://test.org", displayName: "Test Org" } } }, planOwners: [1, 7], // Current user and admin are plan owners @@ -178,7 +178,7 @@ describe("CommentList", () => { role: UserRole.Admin, emails: [], errors: {}, - affiliation: { id: 1, name: "Test Org", searchName: "test-org", uri: "https://test.org" } + affiliation: { id: 1, name: "Test Org", searchName: "test-org", uri: "https://test.org", displayName: "Test Org" } }; const props = { diff --git a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentsDrawer.spec.tsx b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentsDrawer.spec.tsx index 5c667d653..bcc566f96 100644 --- a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentsDrawer.spec.tsx +++ b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/__tests__/CommentsDrawer.spec.tsx @@ -94,6 +94,7 @@ const createMockMeQuery = (overrides = {}) => ({ affiliation: { id: 1, name: "Test Organization", + displayName: "Test Organization", searchName: "test-organization", uri: "https://test.org" }, diff --git a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/hooks/__tests__/useComments.spec.ts b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/hooks/__tests__/useComments.spec.ts index 65d5eb0eb..60200528e 100644 --- a/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/hooks/__tests__/useComments.spec.ts +++ b/app/[locale]/projects/[projectId]/dmp/[dmpid]/s/[sid]/q/[qid]/hooks/__tests__/useComments.spec.ts @@ -63,7 +63,8 @@ describe('useComments', () => { id: 1, name: "Test Organization", searchName: "test-organization", - uri: "https://test.org" + uri: "https://test.org", + displayName: "Test Organization" }, } }; diff --git a/app/[locale]/template/[templateId]/q/[q_slug]/page.tsx b/app/[locale]/template/[templateId]/q/[q_slug]/page.tsx index 3f3b46706..b3a1d4111 100644 --- a/app/[locale]/template/[templateId]/q/[q_slug]/page.tsx +++ b/app/[locale]/template/[templateId]/q/[q_slug]/page.tsx @@ -88,7 +88,7 @@ import { import { isOptionsType, getOverrides, -} from './hooks/useEditQuestion'; +} from '@/app/hooks/useEditQuestion'; import styles from './questionEdit.module.scss'; const QuestionEdit = () => { diff --git a/app/[locale]/template/[templateId]/q/new/__tests__/page.spec.tsx b/app/[locale]/template/[templateId]/q/new/__tests__/page.spec.tsx index 78be293f8..5879b007f 100644 --- a/app/[locale]/template/[templateId]/q/new/__tests__/page.spec.tsx +++ b/app/[locale]/template/[templateId]/q/new/__tests__/page.spec.tsx @@ -5,9 +5,17 @@ import { useParams, useRouter, useSearchParams } from 'next/navigation'; import { useQueryStep } from '@/app/[locale]/template/[templateId]/q/new/utils'; import QuestionTypeSelectPage from "../page"; import { mockScrollIntoView, mockScrollTo } from "@/__mocks__/common"; - +import { useMutation, useQuery } from '@apollo/client/react'; expect.extend(toHaveNoViolations); +jest.mock('@apollo/client/react', () => ({ + useQuery: jest.fn(), + useMutation: jest.fn(), +})); + +const mockUseQuery = jest.mocked(useQuery); +const mockUseMutation = jest.mocked(useMutation); + jest.mock('next/navigation', () => ({ useParams: jest.fn(), useRouter: jest.fn(), @@ -76,6 +84,23 @@ describe("QuestionTypeSelectPage", () => { } as unknown as ReturnType; }); + mockUseMutation.mockImplementation(() => [ + jest.fn().mockResolvedValue({ data: {} }), + { loading: false, error: undefined } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any); + + mockUseQuery.mockImplementation(() => ({ + data: { + questions: [ + { displayOrder: 1 }, + { displayOrder: 2 }, + ] + }, + loading: false, + error: undefined, + /* eslint-disable @typescript-eslint/no-explicit-any */ + }) as any); }); diff --git a/app/[locale]/template/[templateId]/q/new/page.tsx b/app/[locale]/template/[templateId]/q/new/page.tsx index 73e3ca89a..05fb26228 100644 --- a/app/[locale]/template/[templateId]/q/new/page.tsx +++ b/app/[locale]/template/[templateId]/q/new/page.tsx @@ -15,6 +15,15 @@ import { Text } from "react-aria-components"; + +// GraphQL +import { useMutation, useQuery } from '@apollo/client/react'; +import { + AddQuestionDocument, + QuestionsDisplayOrderDocument, +} from '@/generated/graphql'; + + // Components import PageHeader from "@/components/PageHeader"; import { ContentContainer, LayoutContainer, } from '@/components/Container'; @@ -30,6 +39,17 @@ import { QuestionFormatInterface } from '@/app/types'; import styles from './newQuestion.module.scss'; import { getQuestionTypes } from "@/utils/questionTypeHandlers"; +const TemplateQuestionBreadcrumbs = ({ templateId, sectionId, Global }: { templateId: string; sectionId: string; Global: (key: string, values?: Record) => string }) => { + return ( + + {Global('breadcrumbs.home')} + {Global('breadcrumbs.templates')} + {Global('breadcrumbs.editTemplate')} + {Global('breadcrumbs.selectQuestionType')} + {Global('breadcrumbs.question')} + + ) +} const QuestionTypeSelectPage: React.FC = () => { // Get templateId param @@ -58,6 +78,37 @@ const QuestionTypeSelectPage: React.FC = () => { const Global = useTranslations('Global'); const QuestionTypeSelect = useTranslations('QuestionTypeSelectPage'); + // Initialize add and update question mutations + const [addQuestionMutation] = useMutation(AddQuestionDocument); + + // Query request for questions to calculate max displayOrder + const { data: questionDisplayOrders } = useQuery(QuestionsDisplayOrderDocument, { + variables: { + sectionId: Number(sectionId) + }, + skip: !sectionId + }) + + // Calculate the display order of the new question based on the last displayOrder number + const getDisplayOrder = useCallback(() => { + if (!questionDisplayOrders?.questions?.length) { + return 1; + } + + // Filter out null/undefined questions and handle missing displayOrder + const validDisplayOrders = questionDisplayOrders.questions + .map(q => q?.displayOrder) + .filter((order): order is number => typeof order === 'number'); + + if (validDisplayOrders.length === 0) { + return 1; + } + + const maxDisplayOrder = Math.max(...validDisplayOrders); + return maxDisplayOrder + 1; + }, [questionDisplayOrders]); + + // Handle the selection of a question type const handleSelect = ( { @@ -247,6 +298,19 @@ const QuestionTypeSelectPage: React.FC = () => { questionName={selectedQuestionType?.questionName ?? null} questionJSON={selectedQuestionType?.questionJSON ?? ''} sectionId={sectionId ? sectionId : ''} + breadcrumbs={} + backUrl={routePath('template.q.new', { templateId }, { section_id: sectionId, step: 1 })} + successUrl={routePath('template.show', { templateId })} + onSave={async (commonFields) => { + const input = { + templateId: Number(templateId), + sectionId: Number(sectionId), + displayOrder: getDisplayOrder(), + isDirty: true, + ...commonFields, + }; + await addQuestionMutation({ variables: { input } }); + }} /> )} diff --git a/app/[locale]/template/create/__tests__/page.spec.tsx b/app/[locale]/template/create/__tests__/page.spec.tsx index 3d12f2812..6d6f169c2 100644 --- a/app/[locale]/template/create/__tests__/page.spec.tsx +++ b/app/[locale]/template/create/__tests__/page.spec.tsx @@ -39,7 +39,7 @@ describe('TemplateCreatePage', () => { (useQueryStep as jest.Mock).mockReturnValue(null); // No step in query initially render(); - expect(screen.getByText('...messaging.loading')).toBeInTheDocument(); + expect(screen.getByText('messaging.loading')).toBeInTheDocument(); }); it('should render step 1 form when step is 1', () => { diff --git a/app/[locale]/template/create/page.tsx b/app/[locale]/template/create/page.tsx index 73cea847a..f0b57256d 100644 --- a/app/[locale]/template/create/page.tsx +++ b/app/[locale]/template/create/page.tsx @@ -20,6 +20,7 @@ import FormInput from '@/components/Form/FormInput'; import { debounce } from '@/hooks/debounce'; import { useQueryStep } from '@/app/[locale]/template/create/useQueryStep'; +import Loading from '@/components/Loading'; const TemplateCreatePage: React.FC = () => { const router = useRouter(); @@ -68,7 +69,7 @@ const TemplateCreatePage: React.FC = () => { // TODO: Need to implement a shared loading component if (step === null) { - return
...{Global('messaging.loading')}
+ return ; } return ( diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/__tests__/page.spec.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/__tests__/page.spec.tsx new file mode 100644 index 000000000..8aa18bc1e --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/__tests__/page.spec.tsx @@ -0,0 +1,1011 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from '@/utils/test-utils'; +import { useQuery, useMutation } from '@apollo/client/react'; +import { + CustomQuestionDocument, + UpdateCustomQuestionDocument, + RemoveCustomQuestionDocument, +} from '@/generated/graphql'; + +import { axe, toHaveNoViolations } from 'jest-axe'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useToast } from '@/context/ToastContext'; +import logECS from '@/utils/clientLogger'; +import CustomQuestionEdit from '../page'; +import { mockScrollIntoView, mockScrollTo } from "@/__mocks__/common"; + +expect.extend(toHaveNoViolations); + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useParams: jest.fn(), + useSearchParams: jest.fn(), +})); + +// Mock Apollo Client hooks +jest.mock('@apollo/client/react', () => ({ + useQuery: jest.fn(), + useMutation: jest.fn(), +})); + +jest.mock('@/context/ToastContext', () => ({ + useToast: jest.fn(() => ({ + add: jest.fn(), + })), +})); + +// Jest with jsdom doesn't implement structuredClone, which is used in our component code. This causes tests to fail when they try to clone data returned from +// mocked queries/mutations. We can polyfill it in our test environment using JSON.parse/stringify since we don't have any complex data types that require the full capabilities of structuredClone. +if (typeof global.structuredClone !== 'function') { + global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); +} + +const mockUseQuery = jest.mocked(useQuery); +const mockUseMutation = jest.mocked(useMutation); +const mockUseRouter = useRouter as jest.Mock; +const mockUseSearchParams = useSearchParams as jest.Mock; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +const mockRadioButtonQuestion = { + customQuestion: { + __typename: "CustomQuestion", + id: 7, + json: JSON.stringify({ + meta: { schemaVersion: "1.0" }, + type: "radioButtons", + options: [ + { label: "Alpha", value: "Alpha", selected: false }, + { label: "Bravo", value: "Bravo", selected: true }, + ], + attributes: {}, + }), + modified: "2026-03-11 20:01:49", + sampleText: "

This is my sample text

", + requirementText: "

My question requirements

", + questionText: "Custom Radio Button question", + guidanceText: "

My question guidance

", + errors: { + __typename: "CustomQuestionErrors", + general: null, + guidanceText: null, + json: null, + migrationStatus: null, + pinnedQuestionId: null, + pinnedQuestionType: null, + questionText: null, + required: null, + requirementText: null, + sampleText: null, + sectionId: null, + sectionType: null, + templateCustomizationId: null, + useSampleTextAsDefault: null, + }, + migrationStatus: "OK", + pinnedQuestionId: null, + pinnedQuestionType: null, + required: true, + sectionId: 6203, + sectionType: "BASE", + templateCustomizationId: 8, + useSampleTextAsDefault: false, + }, +}; + +const mockTextAreaQuestion = { + customQuestion: { + ...mockRadioButtonQuestion.customQuestion, + id: 8, + questionText: "Custom Text Area question", + json: JSON.stringify({ + meta: { schemaVersion: "1.0" }, + type: "textArea", + attributes: { asRichText: true, cols: 20, rows: 20, maxLength: 1000, minLength: 0 }, + }), + useSampleTextAsDefault: true, + }, +}; + +const mockTextQuestion = { + customQuestion: { + ...mockRadioButtonQuestion.customQuestion, + id: 9, + questionText: "Custom Text question", + json: JSON.stringify({ + meta: { schemaVersion: "1.0" }, + type: "text", + attributes: { maxLength: 1000, minLength: 0, pattern: "^.+$" }, + }), + }, +}; + +const mockDateRangeQuestion = { + customQuestion: { + ...mockRadioButtonQuestion.customQuestion, + id: 10, + questionText: "Custom Date Range question", + json: JSON.stringify({ + meta: { schemaVersion: "1.0" }, + type: "dateRange", + columns: { + start: { label: "Start Date" }, + end: { label: "End Date" }, + }, + attributes: {}, + }), + }, +}; + +const mockAffiliationSearchQuestion = { + customQuestion: { + ...mockRadioButtonQuestion.customQuestion, + id: 11, + questionText: "Custom Affiliation Search question", + json: JSON.stringify({ + meta: { schemaVersion: "1.0" }, + type: "affiliationSearch", + attributes: { + label: "Institution", + help: "Search for your institution", + }, + graphQL: { + query: "query Affiliations($name: String!){ affiliations(name: $name) { items { id displayName uri } } }", + responseField: "affiliations.items", + variables: [{ name: "name", type: "string", label: "Search", minLength: 3 }], + answerField: "uri", + displayFields: [{ label: "Institution", propertyName: "displayName" }], + }, + }), + }, +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let mockUpdateCustomQuestionFn: jest.Mock; +let mockRemoveCustomQuestionFn: jest.Mock; + +const setupMocks = (questionData = mockRadioButtonQuestion) => { + mockUseQuery.mockImplementation((document) => { + if (document === CustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return { data: questionData, loading: false, error: null } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + mockUpdateCustomQuestionFn = jest.fn().mockResolvedValue({ + data: { updateCustomQuestion: { errors: { general: null, questionText: null } } }, + }); + + mockRemoveCustomQuestionFn = jest.fn().mockResolvedValue({ + data: { removeCustomQuestion: { errors: { general: null } } }, + }); + + mockUseMutation.mockImplementation((document) => { + if (document === UpdateCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockUpdateCustomQuestionFn, { loading: false, error: undefined }] as any; + } + if (document === RemoveCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockRemoveCustomQuestionFn, { loading: false, error: undefined }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [jest.fn(), { loading: false, error: undefined }] as any; + }); +}; + +const setupSearchParams = (questionType = 'radioButtons') => { + mockUseSearchParams.mockReturnValue({ + get: (key: string) => { + const params: Record = { questionType }; + return params[key] || null; + }, + getAll: () => [], + has: () => false, + keys() { }, + values() { }, + entries() { }, + forEach() { }, + toString() { return ''; }, + } as unknown as ReturnType); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("CustomQuestionEdit", () => { + let mockRouter: { push: jest.Mock }; + + beforeEach(() => { + setupMocks(); + setupSearchParams(); + HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + mockScrollTo(); + + (useParams as jest.Mock).mockReturnValue({ + templateCustomizationId: '8', + customQuestionId: '7', + }); + + mockRouter = { push: jest.fn() }; + mockUseRouter.mockReturnValue(mockRouter); + + (useToast as jest.Mock).mockReturnValue({ add: jest.fn() }); + + window.tinymce = { init: jest.fn(), remove: jest.fn() }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + it("should render the page heading", async () => { + render(); + const heading = screen.getByRole('heading', { level: 1 }); + expect(heading).toBeInTheDocument(); + }); + + it("should render core form fields", async () => { + render(); + expect(screen.getByText(/labels.type/i)).toBeInTheDocument(); + expect(screen.getByLabelText(/labels.questionText/i)).toBeInTheDocument(); + expect(screen.getByText(/labels.requirementText/i)).toBeInTheDocument(); + expect(screen.getByText(/labels.guidanceText/i)).toBeInTheDocument(); + }); + + it("should render the save button", async () => { + render(); + expect(screen.getByRole('button', { name: /buttons.saveAndUpdate/i })).toBeInTheDocument(); + }); + + it("should render the sidebar preview section", async () => { + render(); + expect(screen.getByRole('heading', { name: /headings.preview/i })).toBeInTheDocument(); + expect(screen.getByRole('heading', { level: 3 })).toHaveTextContent('headings.bestPractice'); + }); + + it("should render the delete danger zone", async () => { + render(); + expect(screen.getByText(/headings.deleteQuestion/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /buttons.deleteCustomization/i })).toBeInTheDocument(); + }); + + it("should show loading state while query is in flight", async () => { + mockUseQuery.mockReturnValue({ data: undefined, loading: true, error: undefined } as any); + render(); + expect(screen.queryByLabelText(/labels.questionText/i)).not.toBeInTheDocument(); + }); + + it("should populate question text field from query data", async () => { + render(); + await waitFor(() => { + const input = screen.getByLabelText(/labels.questionText/i) as HTMLInputElement; + expect(input.value).toBe('Custom Radio Button question'); + }); + }); + + // ------------------------------------------------------------------------- + // Question-type-specific rendering + // ------------------------------------------------------------------------- + + it("should render sampleText and useSampleTextAsDefault checkbox for textArea question type", async () => { + setupMocks(mockTextAreaQuestion); + setupSearchParams('textArea'); + + render(); + + await waitFor(() => { + expect(screen.getByText(/labels.sampleText/i)).toBeInTheDocument(); + expect(screen.getByText(/descriptions.sampleTextAsDefault/i)).toBeInTheDocument(); + }); + }); + + it("should NOT render sampleText field for radioButtons question type", async () => { + render(); + await waitFor(() => { + expect(screen.queryByText(/labels.sampleText/i)).not.toBeInTheDocument(); + }); + }); + + it("should render radio options editor for radioButtons question type", async () => { + render(); + await waitFor(() => { + // QuestionOptionsComponent renders rows + expect(screen.getAllByLabelText(/text/i).length).toBeGreaterThan(0); + }); + }); + + it("should render required yes/no radio buttons", async () => { + render(); + await waitFor(() => { + expect(screen.getByText('form.yesLabel')).toBeInTheDocument(); + expect(screen.getByText('form.noLabel')).toBeInTheDocument(); + }); + }); + + it("should have yes radio checked when question.required is true", async () => { + render(); + await waitFor(() => { + const yesRadio = screen.getAllByRole('radio', { name: /form.yesLabel/i })[0]; + expect(yesRadio).toBeChecked(); + }); + }); + + // ------------------------------------------------------------------------- + // Change type button + // ------------------------------------------------------------------------- + + it("should redirect to question types page when Change type button is clicked", async () => { + render(); + + const changeTypeButton = screen.getByRole('button', { name: /buttons.changeType/i }); + fireEvent.click(changeTypeButton); + + await waitFor(() => { + expect(mockRouter.push).toHaveBeenCalledWith( + expect.stringContaining('step=1') + ); + }); + }); + + // ------------------------------------------------------------------------- + // Form validation + // ------------------------------------------------------------------------- + + it("should display error when question text is empty on submit", async () => { + render(); + + const input = screen.getByLabelText(/labels.questionText/i); + fireEvent.change(input, { target: { value: '' } }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(screen.getByText(/messages.errors.questionTextRequired/i)).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Save / update + // ------------------------------------------------------------------------- + + it("should call updateCustomQuestionMutation with correct variables on save", async () => { + setupMocks(mockTextQuestion); + setupSearchParams('text'); + + render(); + + const input = screen.getByLabelText(/labels.questionText/i); + await act(async () => { + fireEvent.change(input, { target: { value: 'Updated question text' } }); + }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockUpdateCustomQuestionFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + customQuestionId: 7, + questionText: 'Updated question text', + }), + }), + }) + ); + }); + }); + + it("should redirect to template customize page after successful save", async () => { + setupMocks(mockTextQuestion); + setupSearchParams('text'); + + render(); + + const input = screen.getByLabelText(/labels.questionText/i); + await act(async () => { + fireEvent.change(input, { target: { value: 'Updated question text' } }); + }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockRouter.push).toHaveBeenCalledWith( + expect.stringContaining('/en-US/template/customizations/8') + ); + }); + }); + + it("should show toast on successful save", async () => { + const mockAdd = jest.fn(); + (useToast as jest.Mock).mockReturnValue({ add: mockAdd }); + setupMocks(mockTextQuestion); + setupSearchParams('text'); + + render(); + + const input = screen.getByLabelText(/labels.questionText/i); + await act(async () => { + fireEvent.change(input, { target: { value: 'Updated question text' } }); + }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ type: 'success' }) + ); + }); + }); + + it("should display error when updateCustomQuestion mutation throws", async () => { + // Call setupMocks first to establish base state + setupMocks(mockTextQuestion); + setupSearchParams('text'); + + // Then override the mutation AFTER setupMocks + mockUseMutation.mockImplementation((document) => { + if (document === UpdateCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [jest.fn().mockRejectedValueOnce(new Error("Network error")), { loading: false }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockRemoveCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + // findByLabelText already waits, but wrap render in act to flush effects first + const input = await screen.findByLabelText(/labels.questionText/i); + + await act(async () => { + fireEvent.change(input, { target: { value: 'Some question' } }); + }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(screen.getByText(/messages.errors.questionUpdateError/i)).toBeInTheDocument(); + }); + }); + + it("should display general error returned from server on save", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === UpdateCustomQuestionDocument) { + return [jest.fn().mockResolvedValueOnce({ + data: { + updateCustomQuestion: { + errors: { general: 'Server validation error', questionText: null }, + }, + }, + /* eslint-disable @typescript-eslint/no-explicit-any */ + }), { loading: false }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockRemoveCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + const input = screen.getByLabelText(/labels.questionText/i); + await act(async () => { + fireEvent.change(input, { target: { value: 'Some question' } }); + }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(screen.getByText(/server validation error/i)).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Unsaved changes warning + // ------------------------------------------------------------------------- + + it("should warn user of unsaved changes when trying to navigate away", async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + render(); + + const input = screen.getByLabelText(/labels.questionText/i); + await act(async () => { + fireEvent.change(input, { target: { value: 'Changed question text' } }); + }); + + await waitFor(() => { + const handler = addEventListenerSpy.mock.calls + .filter(([event]) => event === 'beforeunload') + .map(([, fn]) => fn) + .pop(); + + expect(handler).toBeDefined(); + + const event = new Event('beforeunload'); + Object.defineProperty(event, 'returnValue', { writable: true, value: undefined }); + + if (typeof handler === 'function') { + handler(event as unknown as BeforeUnloadEvent); + } else if (handler && typeof (handler as EventListenerObject).handleEvent === 'function') { + (handler as EventListenerObject).handleEvent(event as unknown as BeforeUnloadEvent); + } + + expect((event as BeforeUnloadEvent).returnValue).toBe(''); + }); + + removeEventListenerSpy.mockRestore(); + addEventListenerSpy.mockRestore(); + }); + + // ------------------------------------------------------------------------- + // Query errors + // ------------------------------------------------------------------------- + + it("should call logECS when query returns an error", async () => { + mockUseQuery.mockImplementation((document) => { + if (document === CustomQuestionDocument) { + return { + data: mockTextQuestion, + loading: false, + error: { message: 'GraphQL query error' }, + } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + await act(async () => { + render(); + }); + + + await waitFor(() => { + expect(screen.getByText(/graphql query error/i)).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Delete functionality + // ------------------------------------------------------------------------- + + describe("Delete Custom Question", () => { + it("should open the delete confirmation modal when delete button is clicked", async () => { + await act(async () => { + render(); + }); + + const deleteButton = screen.getByRole('button', { name: /buttons.deleteCustomization/i }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /buttons.cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /buttons.confirm/i })).toBeInTheDocument(); + }); + }); + + it("should close the modal when cancel is clicked", async () => { + render(); + + const deleteButton = screen.getByRole('button', { name: /buttons.deleteCustomization/i }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + const cancelButton = screen.getByRole('button', { name: /buttons.cancel/i }); + fireEvent.click(cancelButton); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it("should call removeCustomQuestionMutation and redirect on successful delete", async () => { + const mockRemove = jest.fn().mockResolvedValueOnce({ + data: { removeCustomQuestion: { errors: { general: null } } }, + }); + + mockUseMutation.mockImplementation((document) => { + if (document === RemoveCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockRemove, { loading: false }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockUpdateCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.confirm/i })); + }); + + await waitFor(() => { + expect(mockRemove).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { customQuestionId: 7 }, + }) + ); + expect(mockRouter.push).toHaveBeenCalledWith( + expect.stringContaining('/en-US/template/customizations/8') + ); + }); + }); + + it("should display error message when deletion throws", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === RemoveCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [jest.fn().mockRejectedValueOnce(new Error("Delete failed")), { loading: false }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockUpdateCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.confirm/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/messages.error.errorDeletingQuestion/i)).toBeInTheDocument(); + }); + }); + + it("should call logECS when deletion throws an error", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === RemoveCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [jest.fn().mockRejectedValueOnce(new Error("Delete failed")), { loading: false }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockUpdateCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.confirm/i })); + }); + + await waitFor(() => { + expect(logECS).toHaveBeenCalledWith( + 'error', + 'deleteCustomQuestion', + expect.objectContaining({ + error: expect.anything(), + url: expect.objectContaining({ path: expect.any(String) }), + }) + ); + }); + }); + + it("should display error when server returns errors on deletion response", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === RemoveCustomQuestionDocument) { + return [jest.fn().mockResolvedValueOnce({ + data: { + removeCustomQuestion: { + errors: { general: 'Server deletion error' }, + }, + }, + /* eslint-disable @typescript-eslint/no-explicit-any */ + }), { loading: false }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockUpdateCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.confirm/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/server deletion error/i)).toBeInTheDocument(); + }); + }); + + it("should disable the delete trigger button while deletion is in progress", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === RemoveCustomQuestionDocument) { + return [ + jest.fn().mockImplementation( + () => new Promise((resolve) => + setTimeout(() => resolve({ data: { removeCustomQuestion: { errors: { general: null } } } }), 200) + ) + ), + { loading: false }, + /* eslint-disable @typescript-eslint/no-explicit-any */ + ] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockUpdateCustomQuestionFn, { loading: false }] as any; + }); + + render(); + + const deleteButton = screen.getByRole('button', { name: /buttons.deleteCustomization/i }); + fireEvent.click(deleteButton); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /buttons.confirm/i })); + + await waitFor(() => { + expect(deleteButton).toBeDisabled(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // handleInputChange - showCommentField radio group + // ------------------------------------------------------------------------- + + it("should update showCommentField when additionalCommentBox radio is changed to 'no'", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/labels.additionalCommentBox/i)).toBeInTheDocument(); + }); + + const noRadios = screen.getAllByRole('radio', { name: /labels.doNotShowCommentField/i }); + await act(async () => { + fireEvent.click(noRadios[0]); + }); + + // Verify the mutation is called with showCommentField: false when saved + const input = screen.getByLabelText(/labels.questionText/i); + await act(async () => { + fireEvent.change(input, { target: { value: 'Updated question' } }); + }); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockUpdateCustomQuestionFn).toHaveBeenCalled(); + }); + }); + + it("should update showCommentField when additionalCommentBox radio is changed to 'yes'", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/labels.additionalCommentBox/i)).toBeInTheDocument(); + }); + + const yesRadios = screen.getAllByRole('radio', { name: /labels.showCommentField/i }); + await act(async () => { + fireEvent.click(yesRadios[0]); + }); + + await waitFor(() => { + expect(yesRadios[0]).toBeChecked(); + }); + }); + + // ------------------------------------------------------------------------- + // handleRadioChange - required field radio group + // ------------------------------------------------------------------------- + + it("should set required to false when 'no' radio is selected", async () => { + // mockRadioButtonQuestion has required: true, so yes is initially checked + render(); + + await waitFor(() => { + const yesRadio = screen.getAllByRole('radio', { name: /form.yesLabel/i })[0]; + expect(yesRadio).toBeChecked(); + }); + + const noRadio = screen.getAllByRole('radio', { name: /form.noLabel/i })[0]; + await act(async () => { + fireEvent.click(noRadio); + }); + + await waitFor(() => { + expect(noRadio).toBeChecked(); + }); + }); + + it("should set required to true when 'yes' radio is selected", async () => { + setupMocks(mockTextQuestion); // required: false in mockTextQuestion... + // Actually mockTextQuestion inherits required: true from mockRadioButtonQuestion + // so use a question with required: false + setupMocks({ + customQuestion: { + ...mockRadioButtonQuestion.customQuestion, + required: false, + } + }); + setupSearchParams('radioButtons'); + + render(); + + await waitFor(() => { + const noRadio = screen.getAllByRole('radio', { name: /form.noLabel/i })[0]; + expect(noRadio).toBeChecked(); + }); + + const yesRadio = screen.getAllByRole('radio', { name: /form.yesLabel/i })[0]; + await act(async () => { + fireEvent.click(yesRadio); + }); + + await waitFor(() => { + expect(yesRadio).toBeChecked(); + }); + }); + + // ------------------------------------------------------------------------- + // updateRows - options questions + // ------------------------------------------------------------------------- + + it("should update rows and question JSON when options change", async () => { + render(); + + // Wait for options to render (radioButtons question has Alpha and Bravo) + await waitFor(() => { + expect(screen.getAllByLabelText(/text/i).length).toBeGreaterThan(0); + }); + + // Change the first option's text + const optionInputs = screen.getAllByLabelText(/text/i); + await act(async () => { + fireEvent.change(optionInputs[0], { target: { value: 'Updated Option' } }); + }); + + // Save and verify mutation is called (proving JSON was updated) + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockUpdateCustomQuestionFn).toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // handleRangeLabelChange - dateRange question type + // ------------------------------------------------------------------------- + + it("should update start label for dateRange question type", async () => { + setupMocks(mockDateRangeQuestion); + setupSearchParams('dateRange'); + + render(); + + const startInput = await screen.findByLabelText(/range start/i); + await act(async () => { + fireEvent.change(startInput, { target: { value: 'New Start Label' } }); + }); + + expect(startInput).toHaveValue('New Start Label'); + }); + + it("should update end label for dateRange question type", async () => { + setupMocks(mockDateRangeQuestion); + setupSearchParams('dateRange'); + + render(); + + const endInput = await screen.findByLabelText(/range end/i); + await act(async () => { + fireEvent.change(endInput, { target: { value: 'New End Label' } }); + }); + + expect(endInput).toHaveValue('New End Label'); + }); + + // ------------------------------------------------------------------------- + // handleTypeAheadSearchLabelChange - affiliationSearch question type + // ------------------------------------------------------------------------- + + it("should update typeahead search label for affiliationSearch question type", async () => { + setupMocks(mockAffiliationSearchQuestion); + setupSearchParams('affiliationSearch'); + + render(); + + const labelInput = await screen.findByPlaceholderText(/enter search label/i); + await act(async () => { + fireEvent.change(labelInput, { target: { value: 'Updated Institution Label' } }); + }); + + expect(labelInput).toHaveValue('Updated Institution Label'); + }); + + // ------------------------------------------------------------------------- + // handleTypeAheadHelpTextChange - affiliationSearch question type + // ------------------------------------------------------------------------- + + it("should update typeahead help text for affiliationSearch question type", async () => { + setupMocks(mockAffiliationSearchQuestion); + setupSearchParams('affiliationSearch'); + + render(); + + const helpTextInput = await screen.findByPlaceholderText(/enter the help text/i); + await act(async () => { + fireEvent.change(helpTextInput, { target: { value: 'Updated help text' } }); + }); + + expect(helpTextInput).toHaveValue('Updated help text'); + }); + + // ------------------------------------------------------------------------- + // Accessibility + // ------------------------------------------------------------------------- + + it("should pass axe accessibility checks", async () => { + const { container } = render(); + + await act(async () => { + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); +}); \ No newline at end of file diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/[questionId]/questionCustomEdit.module.scss b/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/customQuestionEdit.module.scss similarity index 72% rename from app/[locale]/template/customizations/[templateCustomizationId]/q/[questionId]/questionCustomEdit.module.scss rename to app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/customQuestionEdit.module.scss index 79f542e29..c34c483b8 100644 --- a/app/[locale]/template/customizations/[templateCustomizationId]/q/[questionId]/questionCustomEdit.module.scss +++ b/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/customQuestionEdit.module.scss @@ -64,3 +64,15 @@ padding: var(--space-4); margin-bottom: var(--space-4); } + +.questionContainer { + margin-top: 0px; + outline: none; + border: var(--card-border, none); + border-radius: var(--card-border-radius, 0.3125rem); + box-shadow: var(--card-shadow, 0px 4px 6px 0px rgba(0, 0, 0, 0.09)); + padding: 0.75rem; + background-color: var(--card-background, #fff); + margin-bottom: 1rem; + padding: 2rem; +} diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/page.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/page.tsx new file mode 100644 index 000000000..711072cb3 --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/customQuestion/[customQuestionId]/page.tsx @@ -0,0 +1,926 @@ +'use client' + +import { useEffect, useRef, useState } from 'react'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useQuery, useMutation } from '@apollo/client/react'; +import { + Breadcrumb, + Breadcrumbs, + Button, + Checkbox, + Dialog, + DialogTrigger, + Form, + Input, + Label, + Link, + Modal, + ModalOverlay, + Radio, + Text, + TextField +} from "react-aria-components"; + +// GraphQL +import { + UpdateCustomQuestionDocument, + CustomQuestionDocument, + RemoveCustomQuestionDocument +} from '@/generated/graphql'; + + +import { + AnyParsedQuestion, + Question, + QuestionOption, + QuestionOptions, + QuestionFormatInterface, +} from '@/app/types'; + +// Components +import PageHeader from "@/components/PageHeader"; +import QuestionOptionsComponent + from '@/components/Form/QuestionOptionsComponent'; +import QuestionPreview from '@/components/QuestionPreview'; +import { + FormInput, + RadioGroupComponent, + RangeComponent, + TypeAheadSearch, + ResearchOutputComponent +} from '@/components/Form'; +import FormTextArea from '@/components/Form/FormTextArea'; +import ErrorMessages from '@/components/ErrorMessages'; +import QuestionView from '@/components/QuestionView'; +import { getParsedQuestionJSON } from '@/components/hooks/getParsedQuestionJSON'; +import Loading from '@/components/Loading'; +import { + ContentContainer, + LayoutWithPanel, + SidebarPanel +} from '@/components/Container'; + +//Utils and Other +import { useResearchOutputTable } from '@/app/hooks/useResearchOutputTable'; +import { useToast } from '@/context/ToastContext'; +import { routePath } from '@/utils/routes'; +import { stripHtmlTags } from '@/utils/general'; +import logECS from '@/utils/clientLogger'; +import { + getQuestionFormatInfo, + questionTypeHandlers +} from '@/utils/questionTypeHandlers'; + +import { + RANGE_QUESTION_TYPE, + TYPEAHEAD_QUESTION_TYPE, + DATE_RANGE_QUESTION_TYPE, + NUMBER_RANGE_QUESTION_TYPE, + TEXT_AREA_QUESTION_TYPE, + RESEARCH_OUTPUT_QUESTION_TYPE, + QUESTION_TYPES_EXCLUDED_FROM_COMMENT_FIELD, +} from '@/lib/constants'; +import { + isOptionsType, + getOverrides, +} from '@/app/hooks/useEditQuestion'; +import styles from './customQuestionEdit.module.scss'; + +const CustomQuestionEdit = () => { + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const toastState = useToast(); // Access the toast state from context + const templateCustomizationId = String(params.templateCustomizationId); + const customQuestionId = String(params.customQuestionId); + const questionTypeIdQueryParam = searchParams.get('questionType') || null; + + //For scrolling to error in page + const errorRef = useRef(null); + // Ref to track whether customization is being deleted to prevent refetching deleted customization + const isBeingDeletedRef = useRef(false); + + const hasHydrated = useRef(false); + // Track whether there are unsaved changes + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + // Form state + const [isSubmitting, setIsSubmitting] = useState(false); + /*To be able to show a loading state when redirecting after successful update because otherwise there is a + bit of a stutter where the page reloads before redirecting*/ + const [isRedirecting, setIsRedirecting] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + + // State for managing form inputs + const [question, setQuestion] = useState(); + const [rows, setRows] = useState([{ id: 0, text: "", isSelected: false }]); + const [questionType, setQuestionType] = useState(''); + const [questionTypeName, setQuestionTypeName] = useState(''); // Added to store friendly question name + const [formSubmitted, setFormSubmitted] = useState(false); + const [hasOptions, setHasOptions] = useState(false); + const [errors, setErrors] = useState([]); + const [dateRangeLabels, setDateRangeLabels] = useState<{ start: string; end: string }>({ start: '', end: '' }); + const [typeaheadHelpText, setTypeAheadHelpText] = useState(''); + const [typeaheadSearchLabel, setTypeaheadSearchLabel] = useState(''); + const [parsedQuestionJSON, setParsedQuestionJSON] = useState(); + const [isConfirmOpen, setConfirmOpen] = useState(false); + + // Add state for live region announcements + const [announcement, setAnnouncement] = useState(''); + + // localization keys + const Global = useTranslations('Global'); + const t = useTranslations('QuestionEdit'); + const QuestionAdd = useTranslations('QuestionAdd'); + const QuestionEdit = useTranslations("QuestionEdit"); + + // Set URLs + const TEMPLATE_URL = routePath('template.customize', { templateCustomizationId }); + + // Helper function to make announcements + const announce = (message: string) => { + setAnnouncement(message); + // Clear after announcement is made + setTimeout(() => setAnnouncement(''), 100); + }; + + // Research Output Table Hooks + const { + buildResearchOutputFormState, + hydrateFromJSON, + licensesData, + defaultResearchOutputTypesData, + expandedFields, + nonCustomizableFieldIds, + standardFields, + additionalFields, + newOutputType, + setNewOutputType, + newLicenseType, + setNewLicenseType, + handleRepositoriesChange, + handleMetaDataStandardsChange, + handleStandardFieldChange, + handleCustomizeField, + handleToggleMetaDataStandards, + handleTogglePreferredRepositories, + handleLicenseModeChange, + handleAddCustomLicenseType, + handleRemoveCustomLicenseType, + handleOutputTypeModeChange, + handleAddCustomOutputType, + handleRemoveCustomOutputType, + addAdditionalField, + handleDeleteAdditionalField, + handleUpdateAdditionalField, + updateStandardFieldProperty + } = useResearchOutputTable({ setHasUnsavedChanges, announce }); + + + // Initialize user updateCustomQuestion mutation + const [updateCustomQuestionMutation] = useMutation(UpdateCustomQuestionDocument); + + // Initialize removeCustomQuestion mutation + const [removeCustomQuestionMutation] = useMutation(RemoveCustomQuestionDocument); + + // Run selected question query + const { + data: selectedQuestion, + loading, + error: selectedQuestionQueryError + } = useQuery(CustomQuestionDocument, { + variables: { customQuestionId: Number(customQuestionId) }, + skip: isBeingDeletedRef.current, + }); + + // Update rows state and question.json when options change + const updateRows = (newRows: QuestionOptions[]) => { + setRows(newRows); + + if (hasOptions && questionType && question?.json) { + const updatedJSON = buildUpdatedJSON(question, newRows); + + if (updatedJSON) { + setQuestion((prev) => ({ + ...prev, + json: JSON.stringify(updatedJSON.data), + })); + setHasUnsavedChanges(true); + } + } + }; + + // Return user back to the page to select a question type + const redirectToQuestionTypes = () => { + const sectionId = selectedQuestion?.customQuestion?.sectionId; + // questionId as query param included to let page know that user is updating an existing question + router.push(routePath('template.customize.question.create', { templateCustomizationId }, { section_id: sectionId, step: 1, customQuestionId })) + } + + //Handle change to Question Text + const handleQuestionTextChange = (value: string) => { + setQuestion(prev => ({ + ...prev, + questionText: value + })); + setHasUnsavedChanges(true); + }; + + // Update common input fields when any of them change + const handleInputChange = (field: keyof Question, value: string | boolean | undefined) => { + setQuestion((prev) => ({ + ...prev, + [field]: value === undefined ? '' : value, // Default to empty string if value is undefined + })); + setHasUnsavedChanges(true); + }; + + + // Handle changes from RadioGroup + const handleRadioChange = (value: string) => { + if (value) { + const isRequired = value === 'yes' ? true : false; + setQuestion(prev => ({ + ...prev, + required: isRequired + })); + setHasUnsavedChanges(true); + } + }; + + // Handler for date range label changes + const handleRangeLabelChange = (field: 'start' | 'end', value: string) => { + setDateRangeLabels(prev => ({ ...prev, [field]: value })); + + if (parsedQuestionJSON && (parsedQuestionJSON?.type === "dateRange" || parsedQuestionJSON?.type === "numberRange")) { + if (parsedQuestionJSON?.columns?.[field]) { + const updatedParsed = structuredClone(parsedQuestionJSON); // To avoid mutating state directly + updatedParsed.columns[field].label = value; + setQuestion(prev => ({ + ...prev, + json: JSON.stringify(updatedParsed), + })); + setHasUnsavedChanges(true); + } + } + }; + + // Handler for typeahead search label changes + const handleTypeAheadSearchLabelChange = (value: string) => { + setTypeaheadSearchLabel(value); + + if (parsedQuestionJSON && parsedQuestionJSON?.type === "affiliationSearch") { + const updatedParsed = structuredClone(parsedQuestionJSON); // To avoid mutating state directly + updatedParsed.attributes.label = value; + setQuestion(prev => ({ + ...prev, + json: JSON.stringify(updatedParsed), + })); + setHasUnsavedChanges(true); + } + }; + + // Handler for typeahead help text changes + const handleTypeAheadHelpTextChange = (value: string) => { + setTypeAheadHelpText(value); + + if (parsedQuestionJSON && parsedQuestionJSON?.type === "affiliationSearch") { + if (parsedQuestionJSON?.attributes?.help) { + const updatedParsed = structuredClone(parsedQuestionJSON); // To avoid mutating state directly + updatedParsed.attributes.help = value; + setQuestion(prev => ({ + ...prev, + json: JSON.stringify(updatedParsed), + })); + setHasUnsavedChanges(true); + } + } + }; + + // Prepare input for the questionTypeHandler. For options questions, we update the + // values with rows state. For non-options questions, we use the parsed JSON + const getFormState = (question: Question, rowsOverride?: QuestionOptions[]) => { + if (hasOptions) { + const useRows = rowsOverride ?? rows; + return { + options: useRows.map(row => ({ + label: row.text, + value: row.text, + selected: row.isSelected, + })), + }; + } + + const { parsed, error } = getParsedQuestionJSON(question, routePath('template.customize', { templateCustomizationId }), Global); + + if (questionType === RESEARCH_OUTPUT_QUESTION_TYPE) { + return buildResearchOutputFormState(); + } + + if (!parsed) { + if (error) { + setErrors([error]); + } + return; + } + return { + ...parsed, + attributes: { + ...('attributes' in parsed ? parsed.attributes : {}), + ...getOverrides(questionType), + }, + }; + }; + + // Pass the merged userInput to questionTypeHandlers to generate json and do type and schema validation + const buildUpdatedJSON = (question: Question, rowsOverride?: QuestionOptions[]) => { + const userInput = getFormState(question, rowsOverride); + const { parsed, error } = getParsedQuestionJSON(question, routePath('template.customize', { templateCustomizationId }), Global); + + if (!parsed) { + if (error) { + setErrors([error]); + } + return; + } + return questionTypeHandlers[questionType as keyof typeof questionTypeHandlers]( + parsed, + userInput + ); + }; + + // Make GraphQL mutation request to update the custom question + const handleUpdateCustomQuestion = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; // Prevent double submissions + setIsSubmitting(true); + setFormSubmitted(true); + + if (!question) { + setIsSubmitting(false); + return; + } + + const updatedJSON = buildUpdatedJSON(question); + const { success, error } = updatedJSON ?? {}; + + if (!success || error) { + const errorMessage = error ?? t('messages.errors.questionUpdateError'); + setErrors([errorMessage]); + announce(QuestionAdd('researchOutput.announcements.errorOccurred') || 'An error occurred.'); + setIsSubmitting(false); + return; + } + + const cleanedQuestionText = stripHtmlTags(question.questionText ?? ''); + + try { + const response = await updateCustomQuestionMutation({ + variables: { + input: { + customQuestionId: Number(customQuestionId), + questionText: cleanedQuestionText, + json: JSON.stringify(updatedJSON?.data ?? {}), + requirementText: question.requirementText ?? null, + guidanceText: question.guidanceText ?? null, + sampleText: question.sampleText ?? null, + useSampleTextAsDefault: question.useSampleTextAsDefault ?? false, + required: question.required ?? false, + } + } + }); + + const responseErrors = response.data?.updateCustomQuestion?.errors; + if (responseErrors && Object.values(responseErrors).some(err => err && err !== 'CustomQuestionErrors')) { + setErrors([responseErrors.general ?? t('messages.errors.questionUpdateError')]); + setIsSubmitting(false); + return; + } + + setHasUnsavedChanges(false); + setIsRedirecting(true); + toastState.add(QuestionAdd('messages.success.questionUpdated'), { type: 'success' }); + router.push(TEMPLATE_URL); + } catch (error) { + setIsSubmitting(false); + logECS('error', 'updateCustomQuestion', { + error, + url: { path: TEMPLATE_URL } + }); + setErrors([t('messages.errors.questionUpdateError')]); + } + }; + + const handleDeleteCustomQuestion = async () => { + if (isDeleting) return; // Prevent double-clicks + isBeingDeletedRef.current = true; + setIsDeleting(true); + try { + const response = await removeCustomQuestionMutation({ + variables: { customQuestionId: Number(customQuestionId) }, + }); + + const responseErrors = response.data?.removeCustomQuestion?.errors; + if (responseErrors && Object.values(responseErrors).some(err => err && err !== 'CustomQuestionErrors')) { + setErrors([responseErrors.general ?? QuestionEdit('messages.error.errorDeletingQuestion')]); + return; + } + + toastState.add(QuestionEdit('messages.success.successDeletingQuestion'), { type: 'success' }); + router.push(TEMPLATE_URL); + } catch (error) { + logECS('error', 'deleteCustomQuestion', { + error, + url: { path: routePath('template.customQuestion', { templateCustomizationId, customQuestionId }) } + }); + setErrors([QuestionEdit('messages.error.errorDeletingQuestion')]); + } finally { + setIsDeleting(false); + setConfirmOpen(false); + } + }; + + // Set question details in state when data is loaded + useEffect(() => { + if (selectedQuestion?.customQuestion) { + const q = { + ...selectedQuestion.customQuestion, + required: selectedQuestion.customQuestion.required ?? false // convert null to false + }; + try { + const { parsed, error } = getParsedQuestionJSON(q, routePath('template.customize', { templateCustomizationId }), Global); + if (!parsed?.type) { + if (error) { + logECS('error', 'Parsing error', { + error: 'Invalid question type in parsed JSON', + url: { path: routePath('template.customize', { templateCustomizationId }) } + }); + + setErrors([error]) + } + return; + } + + const questionType = parsed.type; + const translationKey = `questionTypes.${questionType}`; + const questionTypeFriendlyName = Global(translationKey); + + setQuestionType(questionType); + setQuestionTypeName(questionTypeFriendlyName); + setParsedQuestionJSON(parsed); + + const isOptionsQuestion = isOptionsType(questionType); + setQuestion({ + ...q, + showCommentField: 'showCommentField' in parsed ? parsed.showCommentField : false // Default to false if not present + }); + + setHasOptions(isOptionsQuestion); + + if (questionType === TYPEAHEAD_QUESTION_TYPE) { + setTypeaheadSearchLabel(parsed?.attributes?.label ?? ''); + setTypeAheadHelpText(parsed?.attributes?.help ?? ''); + } + + // Set options info with proper type checking + if (isOptionsQuestion && 'options' in parsed && parsed.options && Array.isArray(parsed.options)) { + const optionRows: QuestionOptions[] = parsed.options + .map((option: QuestionOption, index: number) => ({ + id: index, + text: option?.label || '', + isSelected: option?.selected || option?.checked || false, + })); + setRows(optionRows); + } + } catch (error) { + logECS('error', 'Parsing error', { + error, + url: { path: routePath('template.customize', { templateCustomizationId }) } + }); + setErrors([Global('messaging.errors.errorParsingData')]); + } + } + }, [selectedQuestion]); + + useEffect(() => { + if (questionType) { + // To determine if we have an options question type + const isOptionQuestion = isOptionsType(questionType); + + setHasOptions(isOptionQuestion); + } + + }, [questionType]) + + // Set labels for dateRange and numberRange + useEffect(() => { + if ((parsedQuestionJSON?.type === DATE_RANGE_QUESTION_TYPE || parsedQuestionJSON?.type === NUMBER_RANGE_QUESTION_TYPE)) { + try { + setDateRangeLabels({ + start: parsedQuestionJSON?.columns?.start?.label ?? '', + end: parsedQuestionJSON?.columns?.end?.label ?? '', + }); + } catch { + setDateRangeLabels({ start: '', end: '' }); + } + } + }, [parsedQuestionJSON]) + + // Research Output Question - Hydrate state from JSON + useEffect(() => { + if (!hasHydrated.current && + parsedQuestionJSON?.type === RESEARCH_OUTPUT_QUESTION_TYPE && + Array.isArray(parsedQuestionJSON.columns)) { + try { + hydrateFromJSON(parsedQuestionJSON); + hasHydrated.current = true; + } catch (error) { + console.error('Error hydrating research output fields from JSON', error); + } + } + }, [parsedQuestionJSON, hydrateFromJSON]); + + + // If a user passes in a questionType query param we will find the matching questionTypes + // json schema and update the question with it + useEffect(() => { + if (questionType && questionTypeIdQueryParam && question) { + // Find the matching question type + const qInfo: QuestionFormatInterface | null = getQuestionFormatInfo(questionTypeIdQueryParam); + + if (qInfo?.defaultJSON) { + // Update the question object with the new JSON + setQuestion(prev => ({ + ...prev, + json: JSON.stringify(qInfo.defaultJSON) + })); + + setHasUnsavedChanges(true); + + setQuestionType(questionTypeIdQueryParam) + + // Update the questionTypeName + const questionTypeFriendlyName = Global(`questionTypes.${questionTypeIdQueryParam}`); + setQuestionTypeName(questionTypeFriendlyName); + + const isOptionsQuestion = isOptionsType(questionTypeIdQueryParam) + setHasOptions(isOptionsQuestion); + + } + } + }, [questionType, questionTypeIdQueryParam]); + + // Set parsed question JSON whenever question state changes + useEffect(() => { + if (question) { + const { parsed, error } = getParsedQuestionJSON(question, routePath('template.customize', { templateCustomizationId }), Global); + if (!parsed) { + if (error) { + setErrors([error]) + } + return; + } + setParsedQuestionJSON(parsed); + } + }, [question]) + + // Warn user of unsaved changes if they try to leave the page + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; // Required for Chrome/Firefox to show the confirm dialog + } + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, [hasUnsavedChanges]); + + if (loading || isRedirecting) { + return ; + } + + if (selectedQuestionQueryError) { + return ; + } + + return ( + <> + + {Global('breadcrumbs.home')} + {Global('breadcrumbs.templateCustomizations')} + {Global('breadcrumbs.template')} + {Global('breadcrumbs.question')} + + } + actions={null} + className="" + /> + + {/* Live region for announcements - visually hidden but read by screen readers */} +
+ {announcement} +
+ + + +
+
+ + + + + + {t('helpText.textField')} + + + + handleQuestionTextChange(e.target.value)} + helpMessage={t('helpText.questionText')} + isInvalid={!question?.questionText} + errorMessage={t('messages.errors.questionTextRequired')} + /> + + {/**Question type fields here */} + {hasOptions && ( +
+

{t('helpText.questionOptions', { questionType })}

+ +
+ )} + + {/**Date and Number range question types */} + {questionType && RANGE_QUESTION_TYPE.includes(questionType) && ( + + )} + + {/**Typeahead search question type */} + {questionType && (questionType === TYPEAHEAD_QUESTION_TYPE) && ( + + )} + + {!QUESTION_TYPES_EXCLUDED_FROM_COMMENT_FIELD.includes(questionType ?? '') && ( + handleInputChange('showCommentField', value === 'yes')} + > +
+ {QuestionAdd('labels.showCommentField')} +
+ +
+ {QuestionAdd('labels.doNotShowCommentField')} +
+
+ )} + + { + setQuestion(prev => ({ + ...prev, + requirementText: newValue + })); + setHasUnsavedChanges(true); + }} + /> + + + { + setQuestion(prev => ({ + ...prev, + guidanceText: newValue + })); + setHasUnsavedChanges(true); + }} + helpMessage={t('helpText.guidanceText')} + /> + + {questionType === TEXT_AREA_QUESTION_TYPE && ( + { + setQuestion(prev => ({ + ...prev, + sampleText: newValue + })); + setHasUnsavedChanges(true); + }} + /> + )} + + {questionType === TEXT_AREA_QUESTION_TYPE && ( + { + setQuestion({ + ...question, + useSampleTextAsDefault: !question?.useSampleTextAsDefault + }); + setHasUnsavedChanges(true); + }} + isSelected={question?.useSampleTextAsDefault || false} + > +
+ +
+ {t('descriptions.sampleTextAsDefault')} + +
+ )} + + {questionType === RESEARCH_OUTPUT_QUESTION_TYPE && ( + + )} + + +
+ {Global('form.yesLabel')} +
+ +
+ {Global('form.noLabel')} +
+
+ + + + + + + + +
+

{t('headings.deleteQuestion')}

+

{t('descriptions.deleteWarning')}

+ + + + + + {({ close }) => ( + <> +

{t('headings.confirmDelete')}

+

{t('descriptions.deleteWarning')}

+
+ + +
+ + )} +
+
+
+
+
+
+
+ + <> +

{Global('headings.preview')}

+

{QuestionEdit('descriptions.previewText')}

+ + + + +

{QuestionEdit('headings.bestPractice')}

+

{QuestionEdit('descriptions.bestPracticePara1')}

+

{QuestionEdit('descriptions.bestPracticePara2')}

+

{QuestionEdit('descriptions.bestPracticePara3')}

+ +
+
+ + + ); +} + +export default CustomQuestionEdit; diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/page.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/page.tsx index 18047210b..987cf2ace 100644 --- a/app/[locale]/template/customizations/[templateCustomizationId]/page.tsx +++ b/app/[locale]/template/customizations/[templateCustomizationId]/page.tsx @@ -91,6 +91,7 @@ const TemplateCustomizationOverview: React.FC = () => { refetch } = useQuery(TemplateCustomizationOverviewDocument, { variables: { templateCustomizationId: Number(templateCustomizationId) }, + fetchPolicy: 'cache-and-network', // User sees cached data imediately while a fresh fetch runs to get latest updates from editing }); // Mutations @@ -356,7 +357,6 @@ const TemplateCustomizationOverview: React.FC = () => { } }, [data]); - if (loading) { return ; } diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/[questionId]/page.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/q/[questionId]/page.tsx deleted file mode 100644 index 2ef1b5e38..000000000 --- a/app/[locale]/template/customizations/[templateCustomizationId]/q/[questionId]/page.tsx +++ /dev/null @@ -1,865 +0,0 @@ -// 'use client' - -// import { useEffect, useRef, useState } from 'react'; -// import { useParams, useRouter, useSearchParams } from 'next/navigation'; -// import { useTranslations } from 'next-intl'; -// import { useQuery } from '@apollo/client/react'; -// import { -// Breadcrumb, -// Breadcrumbs, -// Button, -// Dialog, -// DialogTrigger, -// Form, -// Label, -// Link, -// Modal, -// ModalOverlay, -// Tab, -// TabList, -// TabPanel, -// Tabs, -// Text, -// } from "react-aria-components"; - -// // GraphQL -// import { -// QuestionDocument, -// } from '@/generated/graphql'; - -// import { -// removeQuestionAction, -// updateQuestionAction -// } from './actions'; - -// import { -// AnyParsedQuestion, -// Question, -// QuestionOption, -// QuestionOptions, -// QuestionFormatInterface, -// RemoveQuestionErrors, -// UpdateQuestionErrors, -// } from '@/app/types'; - -// // Components -// import PageHeader from "@/components/PageHeader"; -// import QuestionOptionsComponent -// from '@/components/Form/QuestionOptionsComponent'; -// import QuestionPreview from '@/components/QuestionPreview'; -// import { -// FormInput, -// RadioGroupComponent, -// RangeComponent, -// TypeAheadSearch, -// ResearchOutputComponent -// } from '@/components/Form'; -// import FormTextArea from '@/components/Form/FormTextArea'; -// import ErrorMessages from '@/components/ErrorMessages'; -// import TinyMCEEditor from "@/components/TinyMCEEditor"; -// import QuestionView from '@/components/QuestionView'; -// import { getParsedQuestionJSON } from '@/components/hooks/getParsedQuestionJSON'; - -// //Utils and Other -// import { useResearchOutputTable } from '@/app/hooks/useResearchOutputTable'; -// import { useToast } from '@/context/ToastContext'; -// import { routePath } from '@/utils/routes'; -// import { stripHtmlTags } from '@/utils/general'; -// import logECS from '@/utils/clientLogger'; -// import { extractErrors } from "@/utils/errorHandler"; -// import { -// getQuestionFormatInfo, -// getQuestionTypes, -// questionTypeHandlers -// } from '@/utils/questionTypeHandlers'; - -// import { -// RANGE_QUESTION_TYPE, -// TYPEAHEAD_QUESTION_TYPE, -// DATE_RANGE_QUESTION_TYPE, -// NUMBER_RANGE_QUESTION_TYPE, -// TEXT_AREA_QUESTION_TYPE, -// RESEARCH_OUTPUT_QUESTION_TYPE, -// QUESTION_TYPES_EXCLUDED_FROM_COMMENT_FIELD, -// } from '@/lib/constants'; -// import { -// isOptionsType, -// getOverrides, -// } from './hooks/useEditQuestion'; -// import styles from './questionCustomEdit.module.scss'; -// import ResearchOutputDisplay from '@/components/Form/ResearchOutputDisplay'; - -// const QuestionCustomizePage = () => { -// const params = useParams(); -// const router = useRouter(); -// const searchParams = useSearchParams(); -// const toastState = useToast(); // Access the toast state from context -// const templateId = String(params.templateId); -// const questionId = String(params.questionId); //question id -// const questionTypeIdQueryParam = searchParams.get('questionType') || null; - -// //For scrolling to error in page -// const errorRef = useRef(null); - -// const hasHydrated = useRef(false); -// // Track whether there are unsaved changes -// const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); -// // Form state -// const [isSubmitting, setIsSubmitting] = useState(false); - -// // State for managing form inputs -// const [question, setQuestion] = useState(); -// const [rows, setRows] = useState([{ id: 0, text: "", isSelected: false }]); -// const [questionType, setQuestionType] = useState(''); -// const [questionTypeName, setQuestionTypeName] = useState(''); // Added to store friendly question name -// const [formSubmitted, setFormSubmitted] = useState(false); -// const [hasOptions, setHasOptions] = useState(false); -// const [errors, setErrors] = useState([]); -// const [dateRangeLabels, setDateRangeLabels] = useState<{ start: string; end: string }>({ start: '', end: '' }); -// const [typeaheadHelpText, setTypeAheadHelpText] = useState(''); -// const [typeaheadSearchLabel, setTypeaheadSearchLabel] = useState(''); -// const [parsedQuestionJSON, setParsedQuestionJSON] = useState(); -// const [isConfirmOpen, setConfirmOpen] = useState(false); - -// // Add state for live region announcements -// const [announcement, setAnnouncement] = useState(''); - -// // localization keys -// const Global = useTranslations('Global'); -// const t = useTranslations('QuestionEdit'); -// const QuestionAdd = useTranslations('QuestionAdd'); - -// // Set URLs -// const TEMPLATE_URL = routePath('template.show', { templateId }); - -// // Helper function to make announcements -// const announce = (message: string) => { -// setAnnouncement(message); -// // Clear after announcement is made -// setTimeout(() => setAnnouncement(''), 100); -// }; - -// // Research Output Table Hooks -// const { -// buildResearchOutputFormState, -// hydrateFromJSON, -// licensesData, -// defaultResearchOutputTypesData, -// expandedFields, -// nonCustomizableFieldIds, -// standardFields, -// additionalFields, -// newOutputType, -// setNewOutputType, -// newLicenseType, -// setNewLicenseType, -// handleRepositoriesChange, -// handleMetaDataStandardsChange, -// handleStandardFieldChange, -// handleCustomizeField, -// handleToggleMetaDataStandards, -// handleTogglePreferredRepositories, -// handleLicenseModeChange, -// handleAddCustomLicenseType, -// handleRemoveCustomLicenseType, -// handleOutputTypeModeChange, -// handleAddCustomOutputType, -// handleRemoveCustomOutputType, -// addAdditionalField, -// handleDeleteAdditionalField, -// handleUpdateAdditionalField, -// updateStandardFieldProperty -// } = useResearchOutputTable({ setHasUnsavedChanges, announce }); - -// // Run selected question query -// const { -// data: selectedQuestion, -// loading, -// error: selectedQuestionQueryError -// } = useQuery(QuestionDocument, { -// variables: { -// questionId: Number(questionId) -// } -// }); - -// const RichTextDisplay: React.FC<{ label: string; content: string; helpText?: string }> = ({ -// label, -// content, -// helpText -// }) => ( -//
-// -// {helpText && {helpText}} -//
-//
-// ); - - -// // Update rows state and question.json when options change -// const updateRows = (newRows: QuestionOptions[]) => { -// setRows(newRows); - -// if (hasOptions && questionType && question?.json) { -// const updatedJSON = buildUpdatedJSON(question, newRows); - -// if (updatedJSON) { -// setQuestion((prev) => ({ -// ...prev, -// json: JSON.stringify(updatedJSON.data), -// })); -// setHasUnsavedChanges(true); -// } -// } -// }; - -// // Return user back to the page to select a question type -// const redirectToQuestionTypes = () => { -// const sectionId = selectedQuestion?.question?.sectionId; -// // questionId as query param included to let page know that user is updating an existing question -// router.push(routePath('template.q.new', { templateId }, { section_id: sectionId, step: 1, questionId })) -// } - -// //Handle change to Question Text -// const handleQuestionTextChange = (value: string) => { -// setQuestion(prev => ({ -// ...prev, -// questionText: value -// })); -// setHasUnsavedChanges(true); -// }; - -// // Update common input fields when any of them change -// const handleInputChange = (field: keyof Question, value: string | boolean | undefined) => { -// setQuestion((prev) => ({ -// ...prev, -// [field]: value === undefined ? '' : value, // Default to empty string if value is undefined -// })); -// setHasUnsavedChanges(true); -// }; - - -// // Handle changes from RadioGroup -// const handleRadioChange = (value: string) => { -// if (value) { -// const isRequired = value === 'yes' ? true : false; -// setQuestion(prev => ({ -// ...prev, -// required: isRequired -// })); -// setHasUnsavedChanges(true); -// } -// }; - -// // Handler for date range label changes -// const handleRangeLabelChange = (field: 'start' | 'end', value: string) => { -// setDateRangeLabels(prev => ({ ...prev, [field]: value })); - -// if (parsedQuestionJSON && (parsedQuestionJSON?.type === "dateRange" || parsedQuestionJSON?.type === "numberRange")) { -// if (parsedQuestionJSON?.columns?.[field]) { -// const updatedParsed = structuredClone(parsedQuestionJSON); // To avoid mutating state directly -// updatedParsed.columns[field].label = value; -// setQuestion(prev => ({ -// ...prev, -// json: JSON.stringify(updatedParsed), -// })); -// setHasUnsavedChanges(true); -// } -// } -// }; - -// // Handler for typeahead search label changes -// const handleTypeAheadSearchLabelChange = (value: string) => { -// setTypeaheadSearchLabel(value); - -// if (parsedQuestionJSON && parsedQuestionJSON?.type === "affiliationSearch") { -// const updatedParsed = structuredClone(parsedQuestionJSON); // To avoid mutating state directly -// updatedParsed.attributes.label = value; -// setQuestion(prev => ({ -// ...prev, -// json: JSON.stringify(updatedParsed), -// })); -// setHasUnsavedChanges(true); -// } -// }; - -// // Handler for typeahead help text changes -// const handleTypeAheadHelpTextChange = (value: string) => { -// setTypeAheadHelpText(value); - -// if (parsedQuestionJSON && parsedQuestionJSON?.type === "affiliationSearch") { -// if (parsedQuestionJSON?.attributes?.help) { -// const updatedParsed = structuredClone(parsedQuestionJSON); // To avoid mutating state directly -// updatedParsed.attributes.help = value; -// setQuestion(prev => ({ -// ...prev, -// json: JSON.stringify(updatedParsed), -// })); -// setHasUnsavedChanges(true); -// } -// } -// }; - -// // Prepare input for the questionTypeHandler. For options questions, we update the -// // values with rows state. For non-options questions, we use the parsed JSON -// const getFormState = (question: Question, rowsOverride?: QuestionOptions[]) => { -// if (hasOptions) { -// const useRows = rowsOverride ?? rows; -// return { -// options: useRows.map(row => ({ -// label: row.text, -// value: row.text, -// selected: row.isSelected, -// })), -// }; -// } - -// const { parsed, error } = getParsedQuestionJSON(question, routePath('template.q.slug', { templateId, q_slug: questionId }), Global); - -// if (questionType === RESEARCH_OUTPUT_QUESTION_TYPE) { -// return buildResearchOutputFormState(); -// } - -// if (!parsed) { -// if (error) { -// setErrors(prev => [...prev, error]) -// } -// return; -// } -// return { -// ...parsed, -// attributes: { -// ...('attributes' in parsed ? parsed.attributes : {}), -// ...getOverrides(questionType), -// }, -// }; -// }; - -// // Pass the merged userInput to questionTypeHandlers to generate json and do type and schema validation -// const buildUpdatedJSON = (question: Question, rowsOverride?: QuestionOptions[]) => { -// const userInput = getFormState(question, rowsOverride); -// const { parsed, error } = getParsedQuestionJSON(question, routePath('template.q.slug', { templateId, q_slug: questionId }), Global); - -// if (!parsed) { -// if (error) { -// setErrors(prev => [...prev, error]) -// } -// return; -// } -// return questionTypeHandlers[questionType as keyof typeof questionTypeHandlers]( -// parsed, -// userInput -// ); -// }; - -// // Handle form submission to update the question -// const handleUpdate = async (e: React.FormEvent) => { -// e.preventDefault(); -// // Prevent double submission -// if (isSubmitting) return; -// setIsSubmitting(true); - -// // Set formSubmitted to true to indicate the form has been submitted -// setFormSubmitted(true); - -// if (question) { -// const updatedJSON = buildUpdatedJSON(question); -// const { success, error } = updatedJSON ?? {}; - -// if (success && !error) { -// // Strip all tags from questionText before sending to backend -// const cleanedQuestionText = stripHtmlTags(question.questionText ?? ''); - -// // Add mutation for question -// const response = await updateQuestionAction({ -// questionId: Number(questionId), -// displayOrder: Number(question.displayOrder), -// json: JSON.stringify(updatedJSON ? updatedJSON.data : ''), -// questionText: cleanedQuestionText, -// requirementText: String(question.requirementText), -// guidanceText: String(question.guidanceText), -// sampleText: String(question.sampleText), -// useSampleTextAsDefault: question?.useSampleTextAsDefault || false, -// required: Boolean(question.required) -// }); - -// if (response.redirect) { -// router.push(response.redirect); -// } - -// if (!response.success) { -// const errors = response.errors; -// // Announcement for screen readers -// announce(QuestionAdd('researchOutput.announcements.errorOccurred') || 'An error occurred. Please check the form.'); - -// //Check if errors is an array or an object -// if (Array.isArray(errors)) { -// //Handle errors as an array -// setErrors(errors); -// } -// } else { -// if (response?.data?.errors) { -// const errs = extractErrors(response?.data?.errors, ["general", "questionText"]); -// if (errs.length > 0) { -// setErrors(errs); -// } -// } -// setIsSubmitting(false); -// setHasUnsavedChanges(false); -// toastState.add(QuestionAdd('messages.success.questionUpdated'), { type: 'success' }); - -// // Redirect user to the Edit Question view with their new question id after successfully adding the new question -// router.push(TEMPLATE_URL); -// } -// } -// } -// }; - -// // Handle form submission to delete the question -// const handleDelete = async () => { -// const response = await removeQuestionAction({ -// questionId: Number(questionId), -// }); - -// if (response.redirect) { -// router.push(response.redirect); -// return; -// } - -// if (!response.success) { -// const errors = response.errors; - -// //Check if errors is an array or an object -// if (Array.isArray(errors)) { -// //Handle errors as an array -// setErrors(errors); -// } -// } else { -// if (response?.data?.errors) { -// const errs = extractErrors(response?.data?.errors, ["general", "guidanceText", "questionText", "requirementText", "sampleText"]); -// if (errs.length > 0) { -// setErrors(errs); -// } else { -// // Show success message and redirect to Edit Template page -// toastState.add(t('messages.success.questionRemoved'), { type: 'success' }); -// router.push(TEMPLATE_URL); -// } -// } -// } -// }; - -// // Saves any query errors to errors state -// useEffect(() => { -// const allErrors = []; - -// if (selectedQuestionQueryError) { -// allErrors.push(selectedQuestionQueryError.message); -// } - -// setErrors(allErrors); -// }, [selectedQuestionQueryError]); - -// // Set question details in state when data is loaded -// useEffect(() => { -// if (selectedQuestion?.question) { -// const q = { -// ...selectedQuestion.question, -// required: selectedQuestion.question.required ?? false // convert null to false -// }; -// try { -// const { parsed, error } = getParsedQuestionJSON(q, routePath('template.show', { templateId }), Global); -// if (!parsed?.type) { -// if (error) { -// logECS('error', 'Parsing error', { -// error: 'Invalid question type in parsed JSON', -// url: { path: routePath('template.q.slug', { templateId, q_slug: questionId }) } -// }); - -// setErrors(prev => [...prev, error]) -// } -// return; -// } - -// const questionType = parsed.type; -// const translationKey = `questionTypes.${questionType}`; -// const questionTypeFriendlyName = Global(translationKey); - -// setQuestionType(questionType); -// setQuestionTypeName(questionTypeFriendlyName); -// setParsedQuestionJSON(parsed); - -// const isOptionsQuestion = isOptionsType(questionType); -// setQuestion({ -// ...q, -// showCommentField: 'showCommentField' in parsed ? parsed.showCommentField : false // Default to false if not present -// }); - -// setHasOptions(isOptionsQuestion); - -// if (questionType === TYPEAHEAD_QUESTION_TYPE) { -// setTypeaheadSearchLabel(parsed?.attributes?.label ?? ''); -// setTypeAheadHelpText(parsed?.attributes?.help ?? ''); -// } - -// // Set options info with proper type checking -// if (isOptionsQuestion && 'options' in parsed && parsed.options && Array.isArray(parsed.options)) { -// const optionRows: QuestionOptions[] = parsed.options -// .map((option: QuestionOption, index: number) => ({ -// id: index, -// text: option?.label || '', -// isSelected: option?.selected || option?.checked || false, -// })); -// setRows(optionRows); -// } -// } catch (error) { -// logECS('error', 'Parsing error', { -// error, -// url: { path: routePath('template.q.slug', { templateId, q_slug: questionId }) } -// }); -// setErrors(prev => [...prev, 'Error parsing question data']); -// } -// } -// }, [selectedQuestion]); - -// useEffect(() => { -// if (questionType) { -// // To determine if we have an options question type -// const isOptionQuestion = isOptionsType(questionType); - -// setHasOptions(isOptionQuestion); -// } - -// }, [questionType]) - -// // Set labels for dateRange and numberRange -// useEffect(() => { -// if ((parsedQuestionJSON?.type === DATE_RANGE_QUESTION_TYPE || parsedQuestionJSON?.type === NUMBER_RANGE_QUESTION_TYPE)) { -// try { -// setDateRangeLabels({ -// start: parsedQuestionJSON?.columns?.start?.label ?? '', -// end: parsedQuestionJSON?.columns?.end?.label ?? '', -// }); -// } catch { -// setDateRangeLabels({ start: '', end: '' }); -// } -// } -// }, [parsedQuestionJSON]) - -// // Research Output Question - Hydrate state from JSON -// useEffect(() => { -// if (!hasHydrated.current && -// parsedQuestionJSON?.type === RESEARCH_OUTPUT_QUESTION_TYPE && -// Array.isArray(parsedQuestionJSON.columns)) { -// try { -// hydrateFromJSON(parsedQuestionJSON); -// hasHydrated.current = true; -// } catch (error) { -// console.error('Error hydrating research output fields from JSON', error); -// } -// } -// }, [parsedQuestionJSON, hydrateFromJSON]); - -// // If a user changes their question type, then we need to fetch the question types to set the new json schema -// useEffect(() => { -// // Only fetch question types if we have a questionType query param present -// if (questionTypeIdQueryParam) { -// getQuestionTypes(); -// } -// }, [questionTypeIdQueryParam]); - - -// // If a user passes in a questionType query param we will find the matching questionTypes -// // json schema and update the question with it -// useEffect(() => { -// if (questionType && questionTypeIdQueryParam && question) { -// // Find the matching question type -// const qInfo: QuestionFormatInterface | null = getQuestionFormatInfo(questionTypeIdQueryParam); - -// if (qInfo?.defaultJSON) { -// // Update the question object with the new JSON -// setQuestion(prev => ({ -// ...prev, -// json: JSON.stringify(qInfo.defaultJSON) -// })); - -// setHasUnsavedChanges(true); - -// setQuestionType(questionTypeIdQueryParam) - -// // Update the questionTypeName -// const questionTypeFriendlyName = Global(`questionTypes.${questionTypeIdQueryParam}`); -// setQuestionTypeName(questionTypeFriendlyName); - -// const isOptionsQuestion = isOptionsType(questionTypeIdQueryParam) -// setHasOptions(isOptionsQuestion); - -// } -// } -// }, [questionType, questionTypeIdQueryParam]); - -// // Set parsed question JSON whenever question state changes -// useEffect(() => { -// if (question) { -// const { parsed, error } = getParsedQuestionJSON(question, routePath('template.show', { templateId }), Global); -// if (!parsed) { -// if (error) { -// setErrors(prev => [...prev, error]) -// } -// return; -// } -// setParsedQuestionJSON(parsed); -// } -// }, [question]) - -// // Warn user of unsaved changes if they try to leave the page -// useEffect(() => { -// const handleBeforeUnload = (e: BeforeUnloadEvent) => { -// if (hasUnsavedChanges) { -// e.preventDefault(); -// e.returnValue = ''; // Required for Chrome/Firefox to show the confirm dialog -// } -// }; - -// window.addEventListener('beforeunload', handleBeforeUnload); -// return () => { -// window.removeEventListener('beforeunload', handleBeforeUnload); -// }; -// }, [hasUnsavedChanges]); - -// if (loading) { -// return
{Global('messaging.loading')}...
; -// } - -// return ( -// <> -// -// {Global('breadcrumbs.home')} -// {Global('breadcrumbs.templates')} -// {Global('breadcrumbs.editTemplate')} -// {Global('breadcrumbs.question')} -// -// } -// actions={null} -// className="" -// /> - -// {/* Live region for announcements - visually hidden but read by screen readers */} -//
-// {announcement} -//
-// - -//
-//
-// -// -// {Global('tabs.editQuestion')} -// {Global('tabs.options')} -// {Global('tabs.logic')} -// - -// -//
-//
-// -//

{questionTypeName}

-//
- -// {/**Question type fields here */} -// {hasOptions && rows.length > 0 && ( -//
-// -//
    -// {rows.map(row => ( -//
  • -// {row.isSelected && Selected} -// {row.text} -//
  • -// ))} -//
-//
-// )} - -// {/**Date and Number range question types */} -// {questionType && RANGE_QUESTION_TYPE.includes(questionType) && ( -//
-// -//

Start: {dateRangeLabels.start}

-//

End: {dateRangeLabels.end}

-//
-// )} - - -// {/**Typeahead search question type */} -// {questionType === TYPEAHEAD_QUESTION_TYPE && ( -// <> -//
-// -//

{typeaheadSearchLabel}

-//
-//
-// -//

{typeaheadHelpText}

-//
-// -// )} - -// {!QUESTION_TYPES_EXCLUDED_FROM_COMMENT_FIELD.includes(questionType ?? '') && ( -//
-// -//

{question?.showCommentField ? 'Yes' : 'No'}

-//
-// )} - -// {question?.requirementText && ( -// -// )} - -// -// console.log(value)} -// id="customQuestionRequirements" -// labelId="customQuestionRequirementsLabel" -// helpText="Add additional requirements that will appear on the Section Overview page" -// /> - -// {/* Guidance Text */} -// {question?.guidanceText && ( -// -// )} - -// -// console.log(value)} -// id="customQuestionGuidance" -// labelId="customQuestionGuidanceLabel" -// helpText="Add additional guidance that will appear on the Question page" -// /> - -// {/* Sample Text */} -// {questionType === TEXT_AREA_QUESTION_TYPE && question?.sampleText && ( -// <> -// -// -// )} - -// {questionType === RESEARCH_OUTPUT_QUESTION_TYPE && ( -// -// )} - -//
-// -//

{question?.required ? Global('form.yesLabel') : Global('form.noLabel')}

-//
- -// -// - - -//
-// -//

{Global('tabs.options')}

-//
-// -//

{Global('tabs.logic')}

-//
-//
- -//
-//

Delete customization

-//

Your customizations to this Question will be removed from the template. This is not reversible.

-// -// -// -// -// -// {({ close }) => ( -// <> -//

{t('headings.confirmDelete')}

-//

{t('descriptions.deleteWarning')}

-//
-// -// -//
-// -// )} -//
-//
-//
-//
-//
- -//
- - - -//
-//

{Global('headings.preview')}

-//

{t('descriptions.previewText')}

-// -// -// - -//

{t('headings.bestPractice')}

-//

{t('descriptions.bestPracticePara1')}

-//

{t('descriptions.bestPracticePara2')}

-//

{t('descriptions.bestPracticePara3')}

-//
-//
-// - -// ); -// } - -// export default QuestionCustomizePage; - -const QuestionCustomizePage = () => { - return

TBD

; -}; - -export default QuestionCustomizePage; \ No newline at end of file diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/__tests__/page.spec.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/__tests__/page.spec.tsx new file mode 100644 index 000000000..cc3c81e32 --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/__tests__/page.spec.tsx @@ -0,0 +1,831 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from '@/utils/test-utils'; +import { useQuery, useMutation } from '@apollo/client/react'; +import { + AddQuestionCustomizationDocument, + UpdateQuestionCustomizationDocument, + RemoveQuestionCustomizationDocument, + QuestionCustomizationByVersionedQuestionDocument, + PublishedQuestionDocument, + MeDocument, +} from '@/generated/graphql'; + +import { axe, toHaveNoViolations } from 'jest-axe'; +import { useParams, useRouter } from 'next/navigation'; +import { useToast } from '@/context/ToastContext'; +import logECS from '@/utils/clientLogger'; +import QuestionCustomizePage from '../page'; +import { mockScrollIntoView, mockScrollTo } from "@/__mocks__/common"; + +expect.extend(toHaveNoViolations); + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useParams: jest.fn(), +})); + +jest.mock('@apollo/client/react', () => ({ + useQuery: jest.fn(), + useMutation: jest.fn(), +})); + +jest.mock('@/context/ToastContext', () => ({ + useToast: jest.fn(() => ({ add: jest.fn() })), +})); + +const mockUseQuery = jest.mocked(useQuery); +const mockUseMutation = jest.mocked(useMutation); +const mockUseRouter = useRouter as jest.Mock; + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +const mockPublishedQuestion = { + publishedQuestion: { + __typename: "PublishedQuestion", + id: 101, + displayOrder: 1, + questionText: "

What data will you collect?

", + requirementText: "

This is a requirement.

", + guidanceText: "

Some base guidance here.

", + sampleText: "

Sample answer text here.

", + useSampleTextAsDefault: false, + required: true, + json: null, + versionedTemplateId: 945, + ownerAffiliation: { + __typename: "Affiliation", + acronyms: ["NIH"], + displayName: "National Institutes of Health", + uri: "https://ror.org/01cwqze88", + name: "National Institutes of Health", + }, + }, +}; + +const mockPublishedQuestionNoOptionalFields = { + publishedQuestion: { + ...mockPublishedQuestion.publishedQuestion, + requirementText: null, + guidanceText: null, + sampleText: null, + }, +}; + +const mockMeData = { + me: { + __typename: "User", + affiliation: { + __typename: "Affiliation", + displayName: "Test University", + name: "Test University", + }, + }, +}; + +const mockExistingCustomization = { + questionCustomizationByVersionedQuestion: { + __typename: "QuestionCustomization", + id: 55, + guidanceText: "

Existing guidance

", + sampleText: "

Existing sample text

", + }, +}; + +const mockNoExistingCustomization = { + questionCustomizationByVersionedQuestion: null +}; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let mockAddCustomizationFn: jest.Mock; +let mockUpdateCustomizationFn: jest.Mock; +let mockRemoveCustomizationFn: jest.Mock; + +const setupMocks = ( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + customizationData: { questionCustomizationByVersionedQuestion: any } = mockExistingCustomization, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + publishedQuestionData: { publishedQuestion: any } = mockPublishedQuestion +) => { + mockUseQuery.mockImplementation((document) => { + if (document === PublishedQuestionDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { data: publishedQuestionData, loading: false, error: undefined } as any; + } + if (document === MeDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { data: mockMeData, loading: false, error: undefined } as any; + } + if (document === QuestionCustomizationByVersionedQuestionDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { data: customizationData, loading: false, error: undefined } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + mockAddCustomizationFn = jest.fn().mockResolvedValue({ + data: { addQuestionCustomization: { id: 99, errors: null } }, + }); + + mockUpdateCustomizationFn = jest.fn().mockResolvedValue({ + data: { updateQuestionCustomization: { errors: { general: null, guidanceText: null, sampleText: null } } }, + }); + + mockRemoveCustomizationFn = jest.fn().mockResolvedValue({ + data: { removeQuestionCustomization: { errors: {} } }, + }); + + mockUseMutation.mockImplementation((document) => { + if (document === AddQuestionCustomizationDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockAddCustomizationFn, { loading: false }] as any; + } + if (document === UpdateQuestionCustomizationDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockUpdateCustomizationFn, { loading: false }] as any; + } + if (document === RemoveQuestionCustomizationDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockRemoveCustomizationFn, { loading: false }] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [jest.fn(), { loading: false }] as any; + }); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("QuestionCustomizePage", () => { + let mockRouter: { push: jest.Mock }; + + beforeEach(() => { + setupMocks(); + HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + mockScrollTo(); + + (useParams as jest.Mock).mockReturnValue({ + templateCustomizationId: '8', + versionedQuestionId: '101', + }); + + mockRouter = { push: jest.fn() }; + mockUseRouter.mockReturnValue(mockRouter); + + (useToast as jest.Mock).mockReturnValue({ add: jest.fn() }); + + window.tinymce = { init: jest.fn(), remove: jest.fn() }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Rendering + // ------------------------------------------------------------------------- + + describe("Rendering", () => { + it("should render the page heading", () => { + render(); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + it("should render the save button", () => { + render(); + expect(screen.getByRole('button', { name: /buttons.saveAndUpdate/i })).toBeInTheDocument(); + }); + + it("should render the delete customization danger zone", () => { + render(); + expect(screen.getByRole('button', { name: /buttons.deleteCustomization/i })).toBeInTheDocument(); + }); + + it("should render the sidebar preview section", () => { + render(); + expect(screen.getByRole('heading', { name: /headings.preview/i })).toBeInTheDocument(); + }); + + it("should show loading spinner while publishedQuestion query is in flight", async () => { + mockUseQuery.mockImplementation((document) => { + if (document === PublishedQuestionDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return { data: undefined, loading: true, error: undefined } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + render(); + + await waitFor(() => { + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); + }) + }); + + it("should render base question requirements when present", async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/labels.requirements/i)).toBeInTheDocument(); + }); + }); + + it("should render base question guidance when present", async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/labels.guidance/i)).toBeInTheDocument(); + }); + }); + + it("should render base question sample text section when present", async () => { + render(); + await waitFor(() => { + expect(screen.getByText(/labels.sampleText/i)).toBeInTheDocument(); + }); + }); + + it("should NOT render requirements section when requirementText is null", async () => { + setupMocks(mockExistingCustomization, mockPublishedQuestionNoOptionalFields); + render(); + await waitFor(() => { + expect(screen.queryByText(/labels.requirements/i)).not.toBeInTheDocument(); + }); + }); + + it("should NOT render guidance section when guidanceText is null", async () => { + setupMocks(mockExistingCustomization, mockPublishedQuestionNoOptionalFields); + render(); + await waitFor(() => { + expect(screen.queryByText(/labels.guidance/i)).not.toBeInTheDocument(); + }); + }); + + it("should NOT render sample text section when sampleText is null", async () => { + setupMocks(mockExistingCustomization, mockPublishedQuestionNoOptionalFields); + render(); + await waitFor(() => { + expect(screen.queryByText(/labels.sampleText/i)).not.toBeInTheDocument(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Initialization — addQuestionCustomization vs loading existing + // ------------------------------------------------------------------------- + + describe("Initialization", () => { + it("should call addQuestionCustomization when no existing customization is found", async () => { + setupMocks(mockNoExistingCustomization); + render(); + + await waitFor(() => { + expect(mockAddCustomizationFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + templateCustomizationId: 8, + versionedQuestionId: 101, + }), + }), + }) + ); + }); + }); + + it("should NOT call addQuestionCustomization when an existing customization is found", async () => { + render(); + + await waitFor(() => { + expect(mockAddCustomizationFn).not.toHaveBeenCalled(); + }); + }); + + it("should populate guidance text field with existing customization data", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText(/labels.additionalGuidanceText/i)).toBeInTheDocument(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Form submission + // ------------------------------------------------------------------------- + + describe("Form submission", () => { + it("should call updateQuestionCustomization with correct variables on save", async () => { + render(); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockUpdateCustomizationFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + questionCustomizationId: 55, + }), + }), + }) + ); + }); + }); + + it("should redirect to template customization page after successful save", async () => { + render(); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockRouter.push).toHaveBeenCalledWith( + expect.stringContaining('/en-US/template/customizations/8') + ); + }); + }); + + it("should show a success toast after saving", async () => { + const mockAdd = jest.fn(); + (useToast as jest.Mock).mockReturnValue({ add: mockAdd }); + + render(); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ type: 'success' }) + ); + }); + }); + + it("should show saving label while submitting", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === UpdateQuestionCustomizationDocument) { + return [ + jest.fn().mockImplementation( + () => new Promise(resolve => + setTimeout(() => resolve({ + data: { updateQuestionCustomization: { errors: { general: null } } } + }), 200) + ) + ), + { loading: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [jest.fn(), { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.saveAndUpdate/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /buttons.saving/i })).toBeInTheDocument(); + }); + }); + + it("should prevent double submission", async () => { + mockUseMutation.mockImplementation((document) => { + if (document === UpdateQuestionCustomizationDocument) { + return [ + jest.fn().mockImplementation( + () => new Promise(resolve => + setTimeout(() => resolve({ + data: { updateQuestionCustomization: { errors: { general: null } } } + }), 200) + ) + ), + { loading: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [jest.fn(), { loading: false }] as any; + }); + + render(); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(saveButton).toBeDisabled(); + }); + }); + + it("should display error when updateQuestionCustomization mutation throws", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === UpdateQuestionCustomizationDocument) { + return [ + jest.fn().mockRejectedValueOnce(new Error("Network error")), + { loading: false } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [jest.fn(), { loading: false }] as any; + }); + + render(); + + const saveButton = screen.getByRole('button', { name: /buttons.saveAndUpdate/i }); + await act(async () => { + fireEvent.click(saveButton); + }); + + await waitFor(() => { + expect(screen.getByText(/messages.error.errorUpdatingCustomization/i)).toBeInTheDocument(); + }); + }); + + it("should call logECS when updateQuestionCustomization mutation throws", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === UpdateQuestionCustomizationDocument) { + return [ + jest.fn().mockRejectedValueOnce(new Error("Network error")), + { loading: false } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [jest.fn(), { loading: false }] as any; + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.saveAndUpdate/i })); + }); + + await waitFor(() => { + expect(logECS).toHaveBeenCalledWith( + 'error', + 'updateQuestionCustomization', + expect.objectContaining({ + error: expect.anything(), + url: expect.objectContaining({ path: expect.any(String) }), + }) + ); + }); + }); + + it("should display general error returned from server on save", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === UpdateQuestionCustomizationDocument) { + return [ + jest.fn().mockResolvedValueOnce({ + data: { + updateQuestionCustomization: { + errors: { general: 'Server validation failed', guidanceText: null, sampleText: null }, + }, + }, + }), + { loading: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [jest.fn(), { loading: false }] as any; + }); + + render(); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.saveAndUpdate/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/server validation failed/i)).toBeInTheDocument(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Delete customization + // ------------------------------------------------------------------------- + + describe("Delete customization", () => { + it("should open the delete confirmation modal when delete button is clicked", async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /buttons.cancel/i })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /buttons.delete/i })).toBeInTheDocument(); + }); + }); + + it("should close the modal when cancel is clicked", async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole('button', { name: /buttons.cancel/i })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it("should call removeQuestionCustomization and redirect on successful delete", async () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + + await waitFor(() => { + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + }); + + await waitFor(() => { + expect(mockRemoveCustomizationFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: { questionCustomizationId: 55 }, + }) + ); + expect(mockRouter.push).toHaveBeenCalledWith( + expect.stringContaining('/en-US/template/customizations/8') + ); + }); + }); + + it("should show success toast after successful delete", async () => { + const mockAdd = jest.fn(); + (useToast as jest.Mock).mockReturnValue({ add: mockAdd }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + }); + + await waitFor(() => { + expect(mockAdd).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ type: 'success' }) + ); + }); + }); + + it("should display error message when deletion throws", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === RemoveQuestionCustomizationDocument) { + return [ + jest.fn().mockRejectedValueOnce(new Error("Delete failed")), + { loading: false } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockUpdateCustomizationFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/messages.error.errorDeletingCustomization/i)).toBeInTheDocument(); + }); + }); + + it("should call logECS when deletion throws an error", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === RemoveQuestionCustomizationDocument) { + return [ + jest.fn().mockRejectedValueOnce(new Error("Delete failed")), + { loading: false } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockUpdateCustomizationFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + }); + + await waitFor(() => { + expect(logECS).toHaveBeenCalledWith( + 'error', + 'deleteQuestionCustomization', + expect.objectContaining({ + error: expect.anything(), + url: expect.objectContaining({ path: expect.any(String) }), + }) + ); + }); + }); + + it("should display error when server returns errors on deletion response", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === RemoveQuestionCustomizationDocument) { + return [ + jest.fn().mockResolvedValueOnce({ + data: { + removeQuestionCustomization: { + errors: { general: 'Server deletion error' }, + }, + }, + }), + { loading: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockUpdateCustomizationFn, { loading: false }] as any; + }); + + render(); + + fireEvent.click(screen.getByRole('button', { name: /buttons.deleteCustomization/i })); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + }); + + await waitFor(() => { + expect(screen.getByText(/server deletion error/i)).toBeInTheDocument(); + }); + }); + + it("should disable the delete trigger button while deletion is in progress", async () => { + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === RemoveQuestionCustomizationDocument) { + return [ + jest.fn().mockImplementation( + () => new Promise(resolve => + setTimeout(() => resolve({ + data: { removeQuestionCustomization: { errors: {} } } + }), 200) + ) + ), + { loading: false }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockUpdateCustomizationFn, { loading: false }] as any; + }); + + render(); + + const deleteButton = screen.getByRole('button', { name: /buttons.deleteCustomization/i }); + fireEvent.click(deleteButton); + + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + + await waitFor(() => { + expect(deleteButton).toBeDisabled(); + }); + }); + + it("should not call removeQuestionCustomization again if deletion already in progress", async () => { + const mockRemove = jest.fn().mockImplementation( + () => new Promise(resolve => + setTimeout(() => resolve({ data: { removeQuestionCustomization: { errors: {} } } }), 200) + ) + ); + + setupMocks(); + mockUseMutation.mockImplementation((document) => { + if (document === RemoveQuestionCustomizationDocument) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockRemove, { loading: false }] as any; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return [mockUpdateCustomizationFn, { loading: false }] as any; + }); + + render(); + + const triggerButton = screen.getByRole('button', { name: /buttons.deleteCustomization/i }); + + // First deletion attempt + fireEvent.click(triggerButton); + await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument()); + fireEvent.click(screen.getByRole('button', { name: /buttons.delete/i })); + + // Trigger button is now disabled — modal cannot be reopened for a second attempt + await waitFor(() => { + expect(triggerButton).toBeDisabled(); + }); + + // Confirm only one deletion call was made + expect(mockRemove).toHaveBeenCalledTimes(1); + }); + }); + + // ------------------------------------------------------------------------- + // Unsaved changes warning + // ------------------------------------------------------------------------- + + describe("Unsaved changes warning", () => { + it("should warn user when trying to navigate away with unsaved changes", async () => { + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); + + render(); + + // Trigger a change to set hasUnsavedChanges + await waitFor(() => { + expect(screen.getByText(/labels.additionalGuidanceText/i)).toBeInTheDocument(); + }); + + // Find the guidance textarea and change it - this calls updateCustomQuestionContent + // which sets hasUnsavedChanges: true + const guidanceTextarea = screen.getByLabelText(/labels.additionalGuidanceText/i); + await act(async () => { + fireEvent.change(guidanceTextarea, { target: { value: 'New guidance text' } }); + }); + + await waitFor(() => { + const handler = addEventListenerSpy.mock.calls + .filter(([event]) => event === 'beforeunload') + .map(([, fn]) => fn) + .pop(); + + expect(handler).toBeDefined(); + + const event = new Event('beforeunload'); + Object.defineProperty(event, 'returnValue', { writable: true, value: undefined }); + + if (typeof handler === 'function') { + handler(event as unknown as BeforeUnloadEvent); + } else if (handler && typeof (handler as EventListenerObject).handleEvent === 'function') { + (handler as EventListenerObject).handleEvent(event as unknown as BeforeUnloadEvent); + } + + expect((event as BeforeUnloadEvent).returnValue).toBe(undefined); + }); + + removeEventListenerSpy.mockRestore(); + addEventListenerSpy.mockRestore(); + }); + }); + + // ------------------------------------------------------------------------- + // Accessibility + // ------------------------------------------------------------------------- + + describe("Accessibility", () => { + it("should pass axe accessibility checks", async () => { + const { container } = render(); + + await act(async () => { + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/page.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/page.tsx new file mode 100644 index 000000000..51dabdc24 --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/page.tsx @@ -0,0 +1,454 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useTranslations } from 'next-intl'; +import { useQuery, useMutation } from '@apollo/client/react'; +import { + Breadcrumb, + Breadcrumbs, + Button, + Dialog, + DialogTrigger, + Form, + Link, + Modal, + ModalOverlay, +} from "react-aria-components"; + +// GraphQL +import { + AddQuestionCustomizationDocument, + UpdateQuestionCustomizationDocument, + RemoveQuestionCustomizationDocument, + QuestionCustomizationByVersionedQuestionDocument, + PublishedQuestionDocument, + QuestionCustomizationErrors, + MeDocument, +} from '@/generated/graphql'; + +import { Question } from '@/app/types'; + +// Components +import PageHeader from "@/components/PageHeader"; +import { + ContentContainer, + LayoutWithPanel, + SidebarPanel, +} from '@/components/Container'; +import FormTextArea from '@/components/Form/FormTextArea'; +import ErrorMessages from '@/components/ErrorMessages'; +import Loading from '@/components/Loading'; +import { DmpIcon } from "@/components/Icons"; +import QuestionView from '@/components/QuestionView'; +import QuestionPreview from '@/components/QuestionPreview'; + +// Utils +import { SanitizeHTML } from '@/utils/sanitize'; +import logECS from '@/utils/clientLogger'; +import { useToast } from '@/context/ToastContext'; +import { scrollToTop } from '@/utils/general'; +import { routePath } from '@/utils/routes'; +import { extractErrors } from '@/utils/errorHandler'; +import { stripHtmlTags } from '@/utils/general'; +import styles from './questionCustomEdit.module.scss'; + +const QuestionCustomizePage: React.FC = () => { + const toastState = useToast(); + const params = useParams(); + const router = useRouter(); + + const templateCustomizationId = String(params.templateCustomizationId); + const versionedQuestionId = String(params.versionedQuestionId); + + const hasInitialized = useRef(false); + const errorRef = useRef(null); + + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const [isRedirecting, setIsRedirecting] = useState(false); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const [questionCustomizationId, setQuestionCustomizationId] = useState(null); + const [errorMessages, setErrorMessages] = useState([]); + + // Base question data from the published question - used for preview and as reference for customization + const [baseQuestion, setBaseQuestion] = useState(undefined); + const [isPreviewOpen, setIsPreviewOpen] = useState(false); + + // Customizable fields + const [customQuestionData, setCustomQuestionData] = useState({ + guidanceText: '', + sampleText: '', + }); + + // Localization + const Global = useTranslations('Global'); + const QuestionCustomize = useTranslations('QuestionCustomize'); + const QuestionEdit = useTranslations('QuestionEdit'); + + // URLs + const TEMPLATE_URL = routePath('template.customize', { templateCustomizationId }); + const UPDATE_QUESTION_URL = routePath('template.customize.question', { templateCustomizationId, versionedQuestionId }); + + // Mutations + const [addQuestionCustomization] = useMutation(AddQuestionCustomizationDocument); + const [updateQuestionCustomization] = useMutation(UpdateQuestionCustomizationDocument); + const [removeQuestionCustomization] = useMutation(RemoveQuestionCustomizationDocument); + + // Queries + const { data: publishedQuestion, loading: publishedQuestionLoading } = useQuery(PublishedQuestionDocument, { + variables: { versionedQuestionId: Number(versionedQuestionId) }, + }); + + const { data: meData, loading: meLoading } = useQuery(MeDocument); + + const { data: questionCustomization, loading: questionCustomizationLoading } = useQuery( + QuestionCustomizationByVersionedQuestionDocument, + { + variables: { + templateCustomizationId: Number(templateCustomizationId), + versionedQuestionId: Number(versionedQuestionId), + }, + } + ); + + const updateCustomQuestionContent = (key: string, value: string | boolean) => { + setCustomQuestionData(prev => ({ ...prev, [key]: value })); + setHasUnsavedChanges(true); + }; + + const handleSave = async (): Promise<[Record, boolean]> => { + try { + const response = await updateQuestionCustomization({ + variables: { + input: { + questionCustomizationId: Number(questionCustomizationId), + guidanceText: customQuestionData.guidanceText, + sampleText: customQuestionData.sampleText, + }, + }, + refetchQueries: [QuestionCustomizationByVersionedQuestionDocument] + }); + + const responseErrors = response.data?.updateQuestionCustomization?.errors; + if (responseErrors && Object.values(responseErrors).some(err => err && err !== 'QuestionCustomizationErrors')) { + return [responseErrors, false]; + } + + return [{}, true]; + } catch (error) { + logECS('error', 'updateQuestionCustomization', { + error, + url: { path: UPDATE_QUESTION_URL }, + }); + setErrorMessages([QuestionCustomize('messages.error.errorUpdatingCustomization')]); + return [{}, false]; + } + }; + + const handleDeleteQuestionCustomization = async () => { + if (isDeleting) return; + setIsDeleting(true); + setErrorMessages([]); + + try { + const response = await removeQuestionCustomization({ + variables: { questionCustomizationId: Number(questionCustomizationId) }, + refetchQueries: [QuestionCustomizationByVersionedQuestionDocument], + }); + + const responseErrors = response.data?.removeQuestionCustomization?.errors; + if (responseErrors && Object.keys(responseErrors).length > 0) { + const errs = extractErrors(responseErrors, ['general', 'guidanceText', 'sampleText']); + if (errs.length > 0) { + setErrorMessages(errs); + return; + } + } + + toastState.add(QuestionCustomize('messages.success.successfullyDeletedCustomization'), { type: 'success' }); + setIsRedirecting(true); + router.push(TEMPLATE_URL); + } catch (error) { + logECS('error', 'deleteQuestionCustomization', { + error, + url: { path: UPDATE_QUESTION_URL }, + }); + setErrorMessages([QuestionCustomize('messages.error.errorDeletingCustomization')]); + } finally { + setIsDeleting(false); + setIsDeleteModalOpen(false); + } + }; + + const handleFormSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + setIsSubmitting(true); + setErrorMessages([]); + + const [errors, success] = await handleSave(); + + if (!success) { + setErrorMessages([errors?.general ?? QuestionCustomize('messages.error.errorUpdatingCustomization')]); + setIsSubmitting(false); + } else { + setHasUnsavedChanges(false); + toastState.add(QuestionCustomize('messages.success.successfullyUpdatedCustomization'), { type: 'success' }); + setIsRedirecting(true); + router.push(TEMPLATE_URL); + } + }; + + // Initialize or load existing customization + useEffect(() => { + const initializeCustomization = async () => { + if (hasInitialized.current || questionCustomizationLoading) return; + hasInitialized.current = true; + + if (!questionCustomization?.questionCustomizationByVersionedQuestion) { + // No existing customization — create one + const response = await addQuestionCustomization({ + variables: { + input: { + templateCustomizationId: Number(templateCustomizationId), + versionedQuestionId: Number(versionedQuestionId), + }, + }, + }); + setQuestionCustomizationId(response.data?.addQuestionCustomization?.id ?? null); + } else { + const existing = questionCustomization.questionCustomizationByVersionedQuestion; + setQuestionCustomizationId(existing.id ?? null); + setCustomQuestionData({ + guidanceText: existing.guidanceText ?? '', + sampleText: existing.sampleText ?? '', + }); + } + }; + + initializeCustomization(); + }, [questionCustomizationLoading, questionCustomization]); + + // Warn on unsaved changes + useEffect(() => { + const handleBeforeUnload = (e: BeforeUnloadEvent) => { + if (hasUnsavedChanges) { + e.preventDefault(); + e.returnValue = ''; + } + }; + window.addEventListener('beforeunload', handleBeforeUnload); + return () => window.removeEventListener('beforeunload', handleBeforeUnload); + }, [hasUnsavedChanges]); + + // Scroll errors into view + useEffect(() => { + if (errorMessages.length > 0) scrollToTop(errorRef); + }, [errorMessages]); + + useEffect(() => { + if (publishedQuestion?.publishedQuestion) { + const q = publishedQuestion.publishedQuestion; + setBaseQuestion({ + id: q.id, + displayOrder: q.displayOrder, + questionText: stripHtmlTags(q.questionText) ?? null, + requirementText: q.requirementText ?? null, + guidanceText: q.guidanceText ?? null, + sampleText: q.sampleText ?? null, + useSampleTextAsDefault: q.useSampleTextAsDefault ?? false, + required: q.required ?? false, + json: q.json ?? null, + templateId: q.versionedTemplateId ?? null, + ownerAffiliation: q.ownerAffiliation ? { + acronyms: q.ownerAffiliation.acronyms ?? null, + displayName: q.ownerAffiliation.displayName, + uri: q.ownerAffiliation.uri, + name: q.ownerAffiliation.name, + } : null, + }); + } + }, [publishedQuestion]); + + + if (publishedQuestionLoading || meLoading || questionCustomizationLoading) return ; + if (isRedirecting) return ; + + return ( + <> + + {Global('breadcrumbs.home')} + {Global('breadcrumbs.templateCustomizations')} + {Global('breadcrumbs.editTemplate')} + {QuestionCustomize('title')} + + } + actions={null} + className="" + /> + + + + +
+ + +
+ {baseQuestion?.requirementText && ( +
+

{QuestionCustomize('labels.requirements')}

+ +
+ )} + + {baseQuestion && ( +
+ {/**Key the inert div so it remounts when preview closes */} +
+ +
+
+ )} + + + {baseQuestion?.guidanceText && ( +
+

{QuestionCustomize('labels.guidance')}

+ +
+ )} + + {/* Editable customization fields */} +
+ updateCustomQuestionContent('guidanceText', value)} + /> +
+ + {baseQuestion?.sampleText && ( + <> +
+

{QuestionCustomize('labels.sampleText')}

+ +
+ +
+ updateCustomQuestionContent('sampleText', value)} + /> +
+ + )} + + +
+ + {/* Delete customization */} +
+

{QuestionCustomize('buttons.deleteCustomization')}

+

{QuestionCustomize.rich("descriptions.deleteCustomization", { + strong: (chunks) => {chunks} + })}

+ + + + + + {({ close }) => ( + <> +

{QuestionCustomize('heading.deleteCustomization')}

+

{QuestionCustomize.rich("descriptions.deleteCustomization", { + strong: (chunks) => {chunks} + })}

+
+ + +
+ + )} +
+
+
+
+
+
+
+ + <> +

{Global('headings.preview')}

+

{QuestionEdit('descriptions.previewText')}

+ {/** When modal closes, isPreviewOpen flips from true -> false, the key changes and React unmounts and remounts the inert QuestionView + * with a fresh TinyMCEEditor instance. This was needed because sometimes the TinyMCEEditor did not rerender after coming back from Preview*/} + + + + +

{QuestionEdit('headings.bestPractice')}

+

{QuestionEdit('descriptions.bestPracticePara1')}

+

{QuestionEdit('descriptions.bestPracticePara2')}

+

{QuestionEdit('descriptions.bestPracticePara3')}

+ +
+
+ + ); +}; + +export default QuestionCustomizePage; \ No newline at end of file diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/questionCustomEdit.module.scss b/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/questionCustomEdit.module.scss new file mode 100644 index 000000000..2a959a2c4 --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/q/[versionedQuestionId]/questionCustomEdit.module.scss @@ -0,0 +1,84 @@ +.optionsDescription { + font-size: var(--fs-sm); + font-weight:600; +} + +.searchField { + display: grid; + grid-template-areas: + "label label" + "input button" + "help help"; + grid-template-columns: 1fr auto; + max-width: 500px; +} + +.searchLabel { + grid-area: label; +} + +.searchInput { + grid-area: input; + +} + +.searchButton { + grid-area: button; + margin-left: 10px; + width: fit-content; +} + +.searchHelpText { + grid-area: help; + width: fit-content; +} + +.optionsWrapper { + padding: var(--space-0); + margin-bottom: var(--space-6); +} + +.questionFormField { + height: 100px +} + +.deleteZone { + margin-top: 2rem; + padding-top: 1.5rem; + margin-bottom: 2rem; +} + +.deleteConfirmButtons { + display: flex; + justify-content: flex-end; + gap: 1rem; + margin-top: 1.5rem; +} + +.commentCheckbox { + margin-bottom: var(--space-6); +} + +.optionsWrapper { + border: 1px solid var(--border-color); + padding: var(--space-4); + margin-bottom: var(--space-4); +} + +.deleteQuestionCustomizationContainer { + margin-top: 2rem; + padding-top: 1.5rem; + margin-bottom: 2rem; +} + +.questionContainer { + margin-top: 0px; + outline: none; + border: var(--card-border, none); + border-radius: var(--card-border-radius, 0.3125rem); + box-shadow: var(--card-shadow, 0px 4px 6px 0px rgba(0, 0, 0, 0.09)); + padding: 0.75rem; + background-color: var(--card-background, #fff); + margin-bottom: 1rem; + padding: 2rem; +} \ No newline at end of file diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/new/__tests__/page.spec.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/q/new/__tests__/page.spec.tsx new file mode 100644 index 000000000..f23099eb1 --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/q/new/__tests__/page.spec.tsx @@ -0,0 +1,570 @@ +import React from "react"; +import { act, fireEvent, render, screen, waitFor } from '@/utils/test-utils'; +import { useQuery, useMutation } from '@apollo/client/react'; +import { + AddCustomQuestionDocument, + QuestionsDisplayOrderDocument, + TemplateCustomizationOverviewDocument, +} from '@/generated/graphql'; + +import { axe, toHaveNoViolations } from 'jest-axe'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import CustomQuestionNew from '../page'; +import { mockScrollIntoView, mockScrollTo } from "@/__mocks__/common"; + +expect.extend(toHaveNoViolations); + +jest.mock('next/navigation', () => ({ + useRouter: jest.fn(), + useParams: jest.fn(), + useSearchParams: jest.fn(), +})); + +jest.mock('@apollo/client/react', () => ({ + useQuery: jest.fn(), + useMutation: jest.fn(), +})); + +jest.mock('@/app/[locale]/template/[templateId]/q/new/utils', () => ({ + useQueryStep: jest.fn(), +})); + +jest.mock('@/utils/questionTypeHandlers', () => ({ + getQuestionTypes: jest.fn(), +})); + +// Simplify child components to keep tests focused on page-level behaviour +jest.mock('@/components/QuestionTypeCard', () => ({ + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + default: ({ questionType, handleSelect }: { questionType: any; handleSelect: Function }) => ( + + ), +})); + +jest.mock('@/components/QuestionAdd', () => ({ + __esModule: true, + default: ({ questionType, onSave }: { questionType: string | null; onSave: Function }) => ( +
+ {questionType} + +
+ ), +})); + +const mockUseQuery = jest.mocked(useQuery); +const mockUseMutation = jest.mocked(useMutation); +const mockUseRouter = useRouter as jest.Mock; +const mockUseSearchParams = useSearchParams as jest.Mock; + +import { useQueryStep } from '@/app/[locale]/template/[templateId]/q/new/utils'; +import { getQuestionTypes } from '@/utils/questionTypeHandlers'; +const mockUseQueryStep = jest.mocked(useQueryStep); +const mockGetQuestionTypes = jest.mocked(getQuestionTypes); + +// --------------------------------------------------------------------------- +// Mock data +// --------------------------------------------------------------------------- + +const mockTemplateOverviewData = { + templateCustomizationOverview: { + __typename: "TemplateCustomizationOverview", + customizationId: 8, + sections: [ + { + __typename: "SectionCustomizationOverview", + id: 6198, + displayOrder: 0, + name: "Element 1: Data Type", + sectionType: "BASE", + questions: [ + { __typename: "QuestionCustomizationOverview", id: 12611, displayOrder: 0, questionText: "Question 1A", questionType: "BASE" }, + { __typename: "QuestionCustomizationOverview", id: 12612, displayOrder: 1, questionText: "Question 1B", questionType: "BASE" }, + { __typename: "QuestionCustomizationOverview", id: 12613, displayOrder: 2, questionText: "Question 1C", questionType: "BASE" }, + ], + }, + { + __typename: "SectionCustomizationOverview", + id: 6203, + displayOrder: 5, + name: "Element 6: Oversight of Data Management and Sharing", + sectionType: "BASE", + questions: [ + { __typename: "QuestionCustomizationOverview", id: 7, displayOrder: 0, questionText: "Custom Text Area question123", questionType: "CUSTOM" }, + { __typename: "QuestionCustomizationOverview", id: 12622, displayOrder: 1, questionText: "Base question", questionType: "BASE" }, + ], + }, + ], + }, +}; + +const mockQuestionTypes = [ + { type: 'text', title: 'Short Text', usageDescription: 'For short text answers' }, + { type: 'textArea', title: 'Long Text', usageDescription: 'For long text answers' }, + { type: 'radioButtons', title: 'Radio Buttons', usageDescription: 'For multiple choice' }, +]; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +let mockAddCustomQuestionFn: jest.Mock; + +const setupMocks = () => { + mockUseQuery.mockImplementation((document) => { + if (document === TemplateCustomizationOverviewDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return { data: mockTemplateOverviewData, loading: false, error: null } as any; + } + if (document === QuestionsDisplayOrderDocument) { + return { + data: { questions: [{ displayOrder: 1 }, { displayOrder: 2 }, { displayOrder: 3 }] }, + loading: false, + error: null, + /* eslint-disable @typescript-eslint/no-explicit-any */ + } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + mockAddCustomQuestionFn = jest.fn().mockResolvedValue({ + data: { addCustomQuestion: { errors: { general: null } } }, + }); + + mockUseMutation.mockImplementation((document) => { + if (document === AddCustomQuestionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [mockAddCustomQuestionFn, { loading: false, error: undefined }] as any; + } + /* eslint-disable @typescript-eslint/no-explicit-any */ + return [jest.fn(), { loading: false, error: undefined }] as any; + }); + + /* eslint-disable @typescript-eslint/no-explicit-any */ + mockGetQuestionTypes.mockReturnValue(mockQuestionTypes as any); + mockUseQueryStep.mockReturnValue(1); +}; + +const setupSearchParams = (params: Record = {}) => { + mockUseSearchParams.mockReturnValue({ + get: (key: string) => params[key] ?? null, + getAll: () => [], + has: () => false, + keys() { }, + values() { }, + entries() { }, + forEach() { }, + toString() { return ''; }, + } as unknown as ReturnType); +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("CustomQuestionNew", () => { + let mockRouter: { push: jest.Mock; replace: jest.Mock }; + + beforeEach(() => { + setupMocks(); + setupSearchParams({ section_id: '6198' }); + HTMLElement.prototype.scrollIntoView = mockScrollIntoView; + mockScrollTo(); + + (useParams as jest.Mock).mockReturnValue({ templateCustomizationId: '8' }); + + mockRouter = { push: jest.fn(), replace: jest.fn() }; + mockUseRouter.mockReturnValue(mockRouter); + + window.tinymce = { init: jest.fn(), remove: jest.fn() }; + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // Step 1 — Question Type Selection + // ------------------------------------------------------------------------- + + describe("Step 1 — Question Type Selection", () => { + it("should render the page heading", () => { + render(); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + it("should render the search field", () => { + render(); + expect(screen.getByRole('search')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: "Clear search" })).toBeInTheDocument(); + }); + + it("should render all question type cards on initial load", () => { + render(); + expect(screen.getByTestId('question-type-card-text')).toBeInTheDocument(); + expect(screen.getByTestId('question-type-card-textArea')).toBeInTheDocument(); + expect(screen.getByTestId('question-type-card-radioButtons')).toBeInTheDocument(); + }); + + it("should show loading state while template query is in flight", () => { + mockUseQuery.mockReturnValue({ data: undefined, loading: true, error: undefined } as any); + render(); + expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Error handling + // ------------------------------------------------------------------------- + + describe("Error Handling", () => { + it("should render error message when template query fails", () => { + mockUseQuery.mockImplementation((document) => { + if (document === TemplateCustomizationOverviewDocument) { + return { data: undefined, loading: false, error: { message: 'Template query failed' } } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + render(); + expect(screen.getByText(/template query failed/i)).toBeInTheDocument(); + }); + + it("should call logECS when template query fails", () => { + const queryError = new Error('Network error'); + mockUseQuery.mockImplementation((document) => { + if (document === TemplateCustomizationOverviewDocument) { + return { data: undefined, loading: false, error: queryError } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + render(); + + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + }); + + // ------------------------------------------------------------------------- + // Search functionality + // ------------------------------------------------------------------------- + + describe("Search", () => { + it("should filter question type cards when search button is clicked", async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'radio' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: "Clear search" })); + }); + + await waitFor(() => { + expect(screen.getByTestId('question-type-card-radioButtons')).toBeInTheDocument(); + expect(screen.queryByTestId('question-type-card-text')).not.toBeInTheDocument(); + expect(screen.queryByTestId('question-type-card-textArea')).not.toBeInTheDocument(); + }); + }); + + it("should show 'no items found' message when search returns no results", async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'zzzznotatype' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: "Clear search" })); + }); + + await waitFor(() => { + expect(screen.getByText(/messaging.noItemsFound/i)).toBeInTheDocument(); + }); + }); + + it("should show results count and clear filter button after a successful search", async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'text' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: "Clear search" })); + }); + + await waitFor(() => { + expect(screen.getAllByRole('button', { name: /links.clearFilter/i }).length).toBeGreaterThan(0); + expect(screen.getAllByText(/messaging.resultsText/i)).toHaveLength(2); // "1 result" in heading and "1 result found" in clear filter button + }); + }); + + it("should restore the full list when clear filter is clicked", async () => { + render(); + + await act(async () => { + fireEvent.change(screen.getByRole('searchbox'), { target: { value: 'radio' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: "Clear search" })); + }); + + await waitFor(() => { + expect(screen.queryByTestId('question-type-card-text')).not.toBeInTheDocument(); + }); + + const clearButton = screen.getAllByRole('button', { name: /links.clearFilter/i })[0]; + await act(async () => { + fireEvent.click(clearButton); + }); + + await waitFor(() => { + expect(screen.getByTestId('question-type-card-text')).toBeInTheDocument(); + expect(screen.getByTestId('question-type-card-textArea')).toBeInTheDocument(); + expect(screen.getByTestId('question-type-card-radioButtons')).toBeInTheDocument(); + }); + }); + + it("should restore full list and hide results count when search term is cleared", async () => { + render(); + + const searchInput = screen.getByRole('searchbox'); + await act(async () => { + fireEvent.change(searchInput, { target: { value: 'radio' } }); + }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Clear search' })); + }); + await act(async () => { + fireEvent.change(searchInput, { target: { value: '' } }); + }); + + await waitFor(() => { + expect(screen.queryByText(/links.clearFilter/i)).not.toBeInTheDocument(); + expect(screen.getByTestId('question-type-card-text')).toBeInTheDocument(); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Question type selection — handleSelect + // ------------------------------------------------------------------------- + + describe("Question type selection", () => { + it("should advance to step 2 and call router.replace when a type is selected (no customQuestionId)", async () => { + render(); + + await act(async () => { + fireEvent.click(screen.getByTestId('question-type-card-text')); + }); + + await waitFor(() => { + expect(mockRouter.replace).toHaveBeenCalledWith( + expect.stringContaining('step=2') + ); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Step 2 — QuestionAdd form + // ------------------------------------------------------------------------- + + describe("Step 2 — QuestionAdd form", () => { + it("should render the QuestionAdd form when step is 2", () => { + mockUseQueryStep.mockReturnValue(2); + render(); + expect(screen.getByTestId('question-add')).toBeInTheDocument(); + }); + + it("should NOT render the question type search/list when step is 2", () => { + mockUseQueryStep.mockReturnValue(2); + render(); + expect(screen.queryByRole('search')).not.toBeInTheDocument(); + expect(screen.queryByTestId('question-type-card-text')).not.toBeInTheDocument(); + }); + + it("should call addCustomQuestionMutation with correct variables when saving", async () => { + mockUseQueryStep.mockReturnValue(2); + render(); + + await waitFor(() => expect(screen.getByTestId('question-add')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save question/i })); + }); + + await waitFor(() => { + expect(mockAddCustomQuestionFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + templateCustomizationId: 8, + sectionId: 6198, + }), + }), + }) + ); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Last question pinning logic (useEffect on data + sectionId) + // ------------------------------------------------------------------------- + + describe("Last question pinning", () => { + it("should pin after the question with the highest displayOrder in the section", async () => { + // Section 6198 has questions with displayOrders 0, 1, 2 — highest is id:12613 (BASE) + mockUseQueryStep.mockReturnValue(2); + setupSearchParams({ section_id: '6198' }); + + render(); + + await waitFor(() => expect(screen.getByTestId('question-add')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save question/i })); + }); + + await waitFor(() => { + expect(mockAddCustomQuestionFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + pinnedQuestionId: 12613, + pinnedQuestionType: 'BASE', + }), + }), + }) + ); + }); + }); + + it("should pin after the last CUSTOM question when section has mixed types", async () => { + // Section 6203: id:7 (CUSTOM, displayOrder:0), id:12622 (BASE, displayOrder:1) → last is 12622 + mockUseQueryStep.mockReturnValue(2); + setupSearchParams({ section_id: '6203' }); + + render(); + + await waitFor(() => expect(screen.getByTestId('question-add')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save question/i })); + }); + + await waitFor(() => { + expect(mockAddCustomQuestionFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + pinnedQuestionId: 12622, + pinnedQuestionType: 'BASE', + }), + }), + }) + ); + }); + }); + + it("should pass pinnedQuestionId: null when the section has no questions", async () => { + mockUseQuery.mockImplementation((document) => { + if (document === TemplateCustomizationOverviewDocument) { + return { + data: { + templateCustomizationOverview: { + sections: [{ id: 6198, displayOrder: 0, questions: [] }], + }, + }, + loading: false, + error: null, + } as any; + } + return { data: null, loading: false, error: undefined }; + }); + + mockUseQueryStep.mockReturnValue(2); + + render(); + + await waitFor(() => expect(screen.getByTestId('question-add')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save question/i })); + }); + + await waitFor(() => { + expect(mockAddCustomQuestionFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + pinnedQuestionId: null, + }), + }), + }) + ); + }); + }); + + it("should pass pinnedQuestionId: null when no section_id param is provided", async () => { + setupSearchParams({}); // no section_id + mockUseQueryStep.mockReturnValue(2); + + render(); + + await waitFor(() => expect(screen.getByTestId('question-add')).toBeInTheDocument()); + + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /save question/i })); + }); + + await waitFor(() => { + expect(mockAddCustomQuestionFn).toHaveBeenCalledWith( + expect.objectContaining({ + variables: expect.objectContaining({ + input: expect.objectContaining({ + pinnedQuestionId: null, + }), + }), + }) + ); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Accessibility + // ------------------------------------------------------------------------- + + describe("Accessibility", () => { + it("should pass axe accessibility checks on step 1", async () => { + const { container } = render(); + await act(async () => { + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + + it("should pass axe accessibility checks on step 2", async () => { + mockUseQueryStep.mockReturnValue(2); + const { container } = render(); + await act(async () => { + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + }); + }); +}); \ No newline at end of file diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/new/newCustomQuestion.module.scss b/app/[locale]/template/customizations/[templateCustomizationId]/q/new/newCustomQuestion.module.scss new file mode 100644 index 000000000..8c8a1a0a8 --- /dev/null +++ b/app/[locale]/template/customizations/[templateCustomizationId]/q/new/newCustomQuestion.module.scss @@ -0,0 +1,12 @@ +.clearFilter { + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; +} + +.searchMatchText { + font-size: var(--fs-small); + margin-top: var(--space-2); + margin-bottom: var(--space-2); +} diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/q/new/page.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/q/new/page.tsx index 68854cc0d..97f620f6d 100644 --- a/app/[locale]/template/customizations/[templateCustomizationId]/q/new/page.tsx +++ b/app/[locale]/template/customizations/[templateCustomizationId]/q/new/page.tsx @@ -1,263 +1,335 @@ -// 'use client' - -// import { useCallback, useEffect, useRef, useState } from 'react'; -// import { useTranslations } from 'next-intl'; -// import { useParams, useRouter, useSearchParams } from 'next/navigation'; -// import { -// Breadcrumb, -// Breadcrumbs, -// Button, -// FieldError, -// Input, -// Label, -// Link, -// SearchField, -// Text -// } from "react-aria-components"; - -// // Components -// import PageHeader from "@/components/PageHeader"; -// import { ContentContainer, LayoutContainer, } from '@/components/Container'; -// import QuestionAdd from '@/components/QuestionAdd'; -// import QuestionTypeCard from '@/components/QuestionTypeCard'; -// import ErrorMessages from '@/components/ErrorMessages'; - -// //Other -// import { scrollToTop } from '@/utils/general'; -// import { routePath } from '@/utils/routes'; -// import { useQueryStep } from '@/app/[locale]/template/[templateId]/q/new/utils'; -// import { QuestionFormatInterface } from '@/app/types'; -// import styles from './newQuestion.module.scss'; -// import { getQuestionTypes } from "@/utils/questionTypeHandlers"; - - -// const QuestionCustomizeNew: React.FC = () => { -// // Get templateId param -// const params = useParams(); -// const router = useRouter(); -// const searchParams = useSearchParams(); -// const topRef = useRef(null); -// //For scrolling to error in page -// const errorRef = useRef(null); -// const templateId = String(params.templateId); // From route /template/:templateId -// const sectionId = searchParams.get('section_id') ?? ''; -// const questionId = searchParams.get('questionId');// if user is switching their question type while editing an existing question - -// // State management -// const [step, setStep] = useState(null); -// const [questionTypes, setQuestionTypes] = useState([]); -// const [searchTerm, setSearchTerm] = useState(''); -// const [filteredQuestionTypes, setFilteredQuestionTypes] = useState([]); -// const [searchButtonClicked, setSearchButtonClicked] = useState(false); -// const [selectedQuestionType, setSelectedQuestionType] = useState<{ questionType: string, questionName: string, questionJSON: string }>(); -// const [errors, setErrors] = useState([]); - -// const stepQueryValue = useQueryStep(); - -// //Localization keys -// const Global = useTranslations('Global'); -// const QuestionTypeSelect = useTranslations('QuestionTypeSelectPage'); - -// // Handle the selection of a question type -// const handleSelect = ( -// { -// questionJSON, -// questionType, -// questionTypeName -// }: { -// questionJSON: string; -// questionType: string; -// questionTypeName: string; -// }) => { - -// if (questionId) { -// //If the user came from editing an existing question, we want to return them to that page with the new questionTypeId -// // We need to use a full page reload to ensure all state is reset so that 'beforeunload' events are properly handled in the next page -// // to display unsaved changes warning if needed -// window.location.href = routePath('template.q.slug', { templateId, q_slug: questionId }, { questionType }); - -// } else { -// // redirect to the Question Edit page if a user is adding a new question -// if (questionType) { -// setSelectedQuestionType({ questionType, questionName: questionTypeName, questionJSON }); -// setStep(2); -// // Use router.replace with restore=true so that 'beforeunload' events are properly detected in the next page. This will cause users to go back to the -// // Template Overview page rather than the question type selection page when they click "Back" in their browser -// router.replace(routePath('template.q.new', { templateId }, { section_id: sectionId, step: 2, restore: true })); - -// } -// } -// } - -// // Clear search term and filters -// const resetSearch = useCallback(() => { -// setSearchTerm(''); -// setFilteredQuestionTypes(null); -// scrollToTop(topRef); -// }, [scrollToTop]); - - -// // Filter through questionTypes and find the question type whose info includes the search term -// const filterQuestionTypes = ( -// questionTypes: QuestionFormatInterface[], -// term: string -// ): QuestionFormatInterface[] => -// questionTypes.filter(qt => { -// const lowerTerm = term.toLowerCase(); -// const nameMatch = qt.title?.toLowerCase().includes(lowerTerm); -// const usageDescriptionMatch = qt.usageDescription?.toLowerCase().includes(lowerTerm); -// const jsonMatch = qt.usageDescription?.toLowerCase().includes(lowerTerm); - -// return nameMatch || jsonMatch || usageDescriptionMatch; -// }); - -// // Filter results when a user enters a search term and clicks "Search" button -// const handleFiltering = (term: string) => { -// setSearchButtonClicked(true); -// setErrors([]); - -// // Search title, funder and description fields for terms -// const filteredQuestionTypes = filterQuestionTypes(questionTypes, term); - -// if (filteredQuestionTypes.length > 0) { -// setFilteredQuestionTypes(filteredQuestionTypes.length > 0 ? filteredQuestionTypes : null); -// } -// } - -// useEffect(() => { -// setQuestionTypes(getQuestionTypes()); -// }, []); - -// useEffect(() => { -// // Need this to set list of templates back to original, full list after filtering -// if (searchTerm === '') { -// resetSearch(); -// setSearchButtonClicked(false); -// } -// }, [searchTerm, resetSearch]) - -// useEffect(() => { -// // If a step was specified in a query param, then set that step -// if (step !== stepQueryValue) { -// setStep(stepQueryValue); -// } -// }, [stepQueryValue]) - -// return ( -// <> -// {step === 1 && ( -// <> -// -// {Global('breadcrumbs.home')} -// {Global('breadcrumbs.templates')} -// {Global('breadcrumbs.editTemplate')} -// {Global('breadcrumbs.selectQuestionType')} -// -// } -// actions={null} -// className="" -// /> - -// -// -// -//
-// -// -// setSearchTerm(e.target.value)} /> -// -// -// -// {QuestionTypeSelect('searchHelpText')} -// -// -//
-//
-// {/*Show # of results with clear filter link*/} -// {(searchTerm.length > 0 && searchButtonClicked) && ( -//
{Global('messaging.resultsText', { name: filteredQuestionTypes?.length || 0 })} -
-// )} -//
-// {filteredQuestionTypes && filteredQuestionTypes.length > 0 ? ( -// <> -// {filteredQuestionTypes.map((questionType) => ( -// -// ))} -// -// ) : ( -// <> -// {/**If the user is searching, and there were no results from the search -// * then display the message 'no results found -// */} -// {(searchTerm.length > 0 && searchButtonClicked) ? ( -// <> -// {Global('messaging.noItemsFound')} -// -// ) : ( -// <> -// {questionTypes.map((questionType) => ( -// -// ))} -// -// ) -// } -// -// ) - -// } -//
-// {/*Show # of results with clear filter link*/} -// {((filteredQuestionTypes && filteredQuestionTypes.length > 0) && searchButtonClicked) && ( -//
-//
{Global('messaging.resultsText', { name: filteredQuestionTypes?.length || 0 })} -
-//
-// )} -//
-//
-//
-// -// )} -// {step === 2 && ( -// <> -// {/*Show Edit Question form*/} -// -// -// )} -// -// ); -// } - -// export default QuestionCustomizeNew; - -const QuestionCustomizeNew = () => { - return

TBD

; -}; - -export default QuestionCustomizeNew; +'use client' + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { useParams, useRouter, useSearchParams } from 'next/navigation'; +import { + Breadcrumb, + Breadcrumbs, + Button, + FieldError, + Input, + Label, + Link, + SearchField, + Text +} from "react-aria-components"; + +// GraphQL +import { useMutation, useQuery } from '@apollo/client/react'; +import { + AddCustomQuestionDocument, + CustomizableObjectOwnership, + TemplateCustomizationOverviewDocument +} from '@/generated/graphql'; + + +// Components +import PageHeader from "@/components/PageHeader"; +import { ContentContainer, LayoutContainer, } from '@/components/Container'; +import QuestionAdd from '@/components/QuestionAdd'; +import QuestionTypeCard from '@/components/QuestionTypeCard'; +import ErrorMessages from '@/components/ErrorMessages'; +import Loading from '@/components/Loading'; + +//Other +import { scrollToTop } from '@/utils/general'; +import { routePath } from '@/utils/routes'; +import { useQueryStep } from '@/app/[locale]/template/[templateId]/q/new/utils'; +import { QuestionFormatInterface } from '@/app/types'; +import styles from './newCustomQuestion.module.scss'; +import { getQuestionTypes } from "@/utils/questionTypeHandlers"; + +const CustomQuestionBreadcrumbs = ({ templateCustomizationId, Global }: { templateCustomizationId: string; Global: (key: string, values?: Record) => string }) => { + return ( + + {Global('breadcrumbs.home')} + {Global('breadcrumbs.templateCustomizations')} + {Global('breadcrumbs.template')} + {Global('breadcrumbs.selectQuestionType')} + + ) +} + +const CustomQuestionNew: React.FC = () => { + // Get templateId param + const params = useParams(); + const router = useRouter(); + const searchParams = useSearchParams(); + const topRef = useRef(null); + //For scrolling to error in page + const errorRef = useRef(null); + const templateCustomizationId = String(params.templateCustomizationId); // From route /template/customizations/:templateCustomizationId + const sectionId = searchParams.get('section_id') ?? ''; + const customQuestionId = searchParams.get('customQuestionId');// if user is switching their question type while editing an existing question + + // Track the last question in the current section to pin the new question after + const [lastQuestionId, setLastQuestionId] = useState(null); + const [lastQuestionType, setLastQuestionType] = useState(null); + + // State management + const [step, setStep] = useState(null); + const [questionTypes, setQuestionTypes] = useState([]); + const [searchTerm, setSearchTerm] = useState(''); + const [filteredQuestionTypes, setFilteredQuestionTypes] = useState([]); + const [searchButtonClicked, setSearchButtonClicked] = useState(false); + const [selectedQuestionType, setSelectedQuestionType] = useState<{ questionType: string, questionName: string, questionJSON: string }>(); + const [errors, setErrors] = useState([]); + + const stepQueryValue = useQueryStep(); + + //Localization keys + const Global = useTranslations('Global'); + const QuestionTypeSelect = useTranslations('QuestionTypeSelectPage'); + + // Initialize add and update question mutations + const [addCustomQuestionMutation] = useMutation(AddCustomQuestionDocument, { + refetchQueries: [TemplateCustomizationOverviewDocument], + }); + + // Run template query to get all sections and questions under the given templateCustomizationId + const { + data, + loading, + error: templateQueryErrors, + } = useQuery(TemplateCustomizationOverviewDocument, { + variables: { templateCustomizationId: Number(templateCustomizationId) }, + }); + + // Handle the selection of a question type + const handleSelect = ( + { + questionJSON, + questionType, + questionTypeName + }: { + questionJSON: string; + questionType: string; + questionTypeName: string; + }) => { + + if (customQuestionId) { + //If the user came from editing an existing question, we want to return them to that page with the new questionTypeId + // We need to use a full page reload to ensure all state is reset so that 'beforeunload' events are properly handled in the next page + // to display unsaved changes warning if needed + window.location.href = routePath('template.customQuestion', { templateCustomizationId, customQuestionId }, { section_id: sectionId, step: 1, questionType, questionName: questionTypeName, questionJSON }); + + } else { + // redirect to the Question Edit page if a user is adding a new question + if (questionType) { + setSelectedQuestionType({ questionType, questionName: questionTypeName, questionJSON }); + setStep(2); + // Use router.replace with restore=true so that 'beforeunload' events are properly detected in the next page. This will cause users to go back to the + // Template Overview page rather than the question type selection page when they click "Back" in their browser + router.replace(routePath('template.customize.question.create', { templateCustomizationId }, { section_id: sectionId, step: 2, restore: true })); + + } + } + } + + // Clear search term and filters + const resetSearch = useCallback(() => { + setSearchTerm(''); + setFilteredQuestionTypes(null); + scrollToTop(topRef); + }, [scrollToTop]); + + + // Filter through questionTypes and find the question type whose info includes the search term + const filterQuestionTypes = ( + questionTypes: QuestionFormatInterface[], + term: string + ): QuestionFormatInterface[] => + questionTypes.filter(qt => { + const lowerTerm = term.toLowerCase(); + const nameMatch = qt.title?.toLowerCase().includes(lowerTerm); + const usageDescriptionMatch = qt.usageDescription?.toLowerCase().includes(lowerTerm); + + return nameMatch || usageDescriptionMatch; + }); + + // Filter results when a user enters a search term and clicks "Search" button + const handleFiltering = (term: string) => { + setSearchButtonClicked(true); + setErrors([]); + + // Search title, funder and description fields for terms + const filteredQuestionTypes = filterQuestionTypes(questionTypes, term); + + if (filteredQuestionTypes.length > 0) { + setFilteredQuestionTypes(filteredQuestionTypes.length > 0 ? filteredQuestionTypes : []); + } + } + + useEffect(() => { + setQuestionTypes(getQuestionTypes()); + }, []); + + useEffect(() => { + // Need this to set list of templates back to original, full list after filtering + if (searchTerm === '') { + resetSearch(); + setSearchButtonClicked(false); + } + }, [searchTerm, resetSearch]) + + useEffect(() => { + // If a step was specified in a query param, then set that step + if (step !== stepQueryValue) { + setStep(stepQueryValue); + } + }, [stepQueryValue]) + + // Calculate the last question in the current section to determine where to pin the new question. + // This runs whenever the template overview query returns new data or the sectionId changes (i.e. user goes to a different section) + useEffect(() => { + if (!data?.templateCustomizationOverview?.sections || !sectionId) return; + + const section = data.templateCustomizationOverview.sections.find( + s => s?.id === Number(sectionId) + ); + + if (!section?.questions?.length) { + setLastQuestionId(null); + setLastQuestionType(null); + return; + } + + // Questions are ordered by displayOrder — find the one with the highest value + const lastQuestion = [...section.questions] + .filter(q => q != null) + .sort((a, b) => (a?.displayOrder ?? 0) - (b?.displayOrder ?? 0)) + .at(-1); + + setLastQuestionId(lastQuestion?.id ?? null); + // questionType in the data is "BASE" | "CUSTOM" which maps directly to CustomizableObjectOwnership + setLastQuestionType(lastQuestion?.questionType as CustomizableObjectOwnership ?? null); + + }, [data, sectionId]); + + if (loading) { + return ; + } + + if (templateQueryErrors) { + return ; + } + return ( + <> + {step === 1 && ( + <> + } + actions={null} + className="" + /> + + + + +
+ + + setSearchTerm(e.target.value)} /> + + + + {QuestionTypeSelect('searchHelpText')} + + +
+
+ {/*Show # of results with clear filter link*/} + {(searchTerm.length > 0 && searchButtonClicked) && ( +
{Global('messaging.resultsText', { name: filteredQuestionTypes?.length || 0 })} -
+ )} +
+ {filteredQuestionTypes && filteredQuestionTypes.length > 0 ? ( + <> + {filteredQuestionTypes.map((questionType) => ( + + ))} + + ) : ( + <> + {/**If the user is searching, and there were no results from the search + * then display the message 'no results found + */} + {(searchTerm.length > 0 && searchButtonClicked) ? ( + <> + {Global('messaging.noItemsFound')} + + ) : ( + <> + {questionTypes.map((questionType) => ( + + ))} + + ) + } + + ) + + } +
+ {/*Show # of results with clear filter link*/} + {((filteredQuestionTypes && filteredQuestionTypes.length > 0) && searchButtonClicked) && ( +
+
{Global('messaging.resultsText', { name: filteredQuestionTypes?.length || 0 })} -
+
+ )} +
+
+
+ + )} + {step === 2 && ( + <> + {/*Show Edit Question form*/} + } + backUrl={routePath('template.customize.question.create', { templateCustomizationId }, { section_id: sectionId, step: 1 })} + successUrl={routePath('template.customize', { templateCustomizationId })} + onSave={async (commonFields) => { + const input = { + templateCustomizationId: Number(templateCustomizationId), + sectionId: Number(sectionId), + sectionType: CustomizableObjectOwnership.Custom, + pinnedQuestionId: lastQuestionId, + pinnedQuestionType: lastQuestionType ?? null, + ...commonFields, + }; + await addCustomQuestionMutation({ variables: { input } }); + }} + /> + + )} + + ); +} + +export default CustomQuestionNew; diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/section/[versionedSectionId]/__tests__/page.spec.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/section/[versionedSectionId]/__tests__/page.spec.tsx index 03a1c3e39..a0a4982e3 100644 --- a/app/[locale]/template/customizations/[templateCustomizationId]/section/[versionedSectionId]/__tests__/page.spec.tsx +++ b/app/[locale]/template/customizations/[templateCustomizationId]/section/[versionedSectionId]/__tests__/page.spec.tsx @@ -163,6 +163,7 @@ describe('SectionCustomizePage', () => { it('should show a loading state while published section data is loading', async () => { mockUseQuery.mockImplementation((document) => { if (document === PublishedSectionDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return { data: undefined, loading: true, error: null } as any; } return { data: null, loading: false, error: undefined }; @@ -282,11 +283,14 @@ describe('SectionCustomizePage', () => { ] as any; } if (document === AddSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockAddSectionCustomizationFn, { loading: false, error: undefined }] as any; } if (document === RemoveSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockRemoveSectionCustomizationFn, { loading: false, error: undefined }] as any; } + /* eslint-disable @typescript-eslint/no-explicit-any */ return [jest.fn(), { loading: false, error: undefined }] as any; }); @@ -320,14 +324,18 @@ describe('SectionCustomizePage', () => { }, }), { loading: false, error: undefined }, + /* eslint-disable @typescript-eslint/no-explicit-any */ ] as any; } if (document === AddSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockAddSectionCustomizationFn, { loading: false, error: undefined }] as any; } if (document === RemoveSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockRemoveSectionCustomizationFn, { loading: false, error: undefined }] as any; } + /* eslint-disable @typescript-eslint/no-explicit-any */ return [jest.fn(), { loading: false, error: undefined }] as any; }); @@ -456,14 +464,18 @@ describe('SectionCustomizePage', () => { return [ jest.fn().mockRejectedValueOnce(new Error('Delete failed')), { loading: false, error: undefined }, + /* eslint-disable @typescript-eslint/no-explicit-any */ ] as any; } if (document === AddSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockAddSectionCustomizationFn, { loading: false, error: undefined }] as any; } if (document === UpdateSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockUpdateSectionCustomizationFn, { loading: false, error: undefined }] as any; } + /* eslint-disable @typescript-eslint/no-explicit-any */ return [jest.fn(), { loading: false, error: undefined }] as any; }); @@ -500,14 +512,18 @@ describe('SectionCustomizePage', () => { () => new Promise(resolve => setTimeout(() => resolve({ data: { removeSectionCustomization: { errors: {} } } }), 200)) ), { loading: false, error: undefined }, + /* eslint-disable @typescript-eslint/no-explicit-any */ ] as any; } if (document === AddSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockAddSectionCustomizationFn, { loading: false, error: undefined }] as any; } if (document === UpdateSectionCustomizationDocument) { + /* eslint-disable @typescript-eslint/no-explicit-any */ return [mockUpdateSectionCustomizationFn, { loading: false, error: undefined }] as any; } + /* eslint-disable @typescript-eslint/no-explicit-any */ return [jest.fn(), { loading: false, error: undefined }] as any; }); diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/section/create/__tests__/page.spec.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/section/create/__tests__/page.spec.tsx index dfe06a502..06c7d46a2 100644 --- a/app/[locale]/template/customizations/[templateCustomizationId]/section/create/__tests__/page.spec.tsx +++ b/app/[locale]/template/customizations/[templateCustomizationId]/section/create/__tests__/page.spec.tsx @@ -269,7 +269,7 @@ describe("CreateCustomSectionPage", () => { }); await waitFor(() => { - expect(mockPush).toHaveBeenCalledWith('/template/customizations/16'); + expect(mockPush).toHaveBeenCalledWith('/en-US/template/customizations/16'); }); }); diff --git a/app/[locale]/template/customizations/[templateCustomizationId]/section/create/page.tsx b/app/[locale]/template/customizations/[templateCustomizationId]/section/create/page.tsx index fbfd266e9..e61377816 100644 --- a/app/[locale]/template/customizations/[templateCustomizationId]/section/create/page.tsx +++ b/app/[locale]/template/customizations/[templateCustomizationId]/section/create/page.tsx @@ -58,7 +58,7 @@ const CreateCustomSectionPage: React.FC = () => { // Get templateCustomizationId param const params = useParams(); const router = useRouter(); - const { templateCustomizationId } = params; // From route /template/customizations/:templateCustomizationId/section/create + const templateCustomizationId = String(params.templateCustomizationId); //For scrolling to error in page const errorRef = useRef(null); @@ -108,8 +108,6 @@ const CreateCustomSectionPage: React.FC = () => { } = useQuery(TemplateCustomizationOverviewDocument, { variables: { templateCustomizationId: Number(templateCustomizationId) }, }); - - // Update form fields in state when fields are edited const updateSectionContent = (key: string, value: string) => { clearAllFieldErrors(); @@ -141,38 +139,33 @@ const CreateCustomSectionPage: React.FC = () => { ...prevErrors, [name]: error })); - if (error.length > 1) { - setErrors(prev => [...prev, error]); - } return error; } // Check whether form is valid before submitting const isFormValid = (): boolean => { - // Initialize a flag for form validity + const newErrors: string[] = []; + const newFieldErrors = { ...fieldErrors }; let isValid = true; - const errors: SectionFormInterface = { - sectionName: '', - sectionIntroduction: '', - sectionRequirements: '', - sectionGuidance: '', - }; - // Iterate over formData to validate each field Object.keys(formData).forEach((key) => { const name = key as keyof SectionFormErrorsInterface; const value = formData[name]; - // Call validateField to update errors for each field const error = validateField(name, value); if (error) { isValid = false; - errors[name] = error; + newFieldErrors[name] = error; + newErrors.push(error); } }); + + setFieldErrors(newFieldErrors); + setErrors(newErrors); return isValid; }; + const clearAllFieldErrors = () => { //Remove all field errors setFieldErrors({ @@ -266,11 +259,12 @@ const CreateCustomSectionPage: React.FC = () => { setIsSubmitting(false); } else { + isSubmittingRef.current = false; setIsSubmitting(false); setHasUnsavedChanges(false); showSuccessToast(); // Redirect to the template customization page - router.push(`/template/customizations/${templateCustomizationId}`) + router.push(routePath('template.customize', { templateCustomizationId })) } scrollToTop(topRef); @@ -340,7 +334,7 @@ const CreateCustomSectionPage: React.FC = () => { {Global('breadcrumbs.home')} {Global('breadcrumbs.templateCustomizations')} - {Global('breadcrumbs.template')} + {Global('breadcrumbs.template')} {CreateSectionPage('title')} } @@ -409,6 +403,7 @@ const CreateCustomSectionPage: React.FC = () => { diff --git a/app/[locale]/template/page.tsx b/app/[locale]/template/page.tsx index 0df75a5f0..e1416bbd0 100644 --- a/app/[locale]/template/page.tsx +++ b/app/[locale]/template/page.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { useEffect, useRef, useState } from 'react'; +import Link from 'next/link'; import { Breadcrumb, Breadcrumbs, @@ -8,7 +9,6 @@ import { FieldError, Input, Label, - Link, SearchField, Text } from 'react-aria-components'; @@ -299,8 +299,10 @@ const TemplateListPage: React.FC = () => { } actions={ <> - {t('actionCreate')} + {t('actionCreate')} + } className="page-template-list" diff --git a/app/hooks/useEditQuestion.ts b/app/hooks/useEditQuestion.ts new file mode 100644 index 000000000..2076882e6 --- /dev/null +++ b/app/hooks/useEditQuestion.ts @@ -0,0 +1,24 @@ +import { OPTIONS_QUESTION_TYPES } from '@/lib/constants'; + +// Configure what overrides you want to apply to the question type json objects +export const getOverrides = (questionType: string | null | undefined) => { + switch (questionType) { + case "text": + return { maxLength: null }; + case "textArea": + return { maxLength: null, rows: 20 }; + case "number": + return { min: 0, max: 10000000, step: 1 }; + case "currency": + return { min: 0, max: 10000000, step: 0.01 }; + case "url": + return { maxLength: 2048, minLength: 2, pattern: "https?://.+" }; + default: + return {}; + } +}; + +// Check if question is an options type +export const isOptionsType = (questionType: string) => { + return Boolean(questionType && OPTIONS_QUESTION_TYPES.includes(questionType)); +} \ No newline at end of file diff --git a/app/types/index.ts b/app/types/index.ts index 2024ecb44..a0317cae0 100644 --- a/app/types/index.ts +++ b/app/types/index.ts @@ -354,6 +354,7 @@ export interface RadioGroupProps { onChange?: (value: string) => void; isRequired?: boolean; isRequiredVisualOnly?: boolean; + isDisabled?: boolean; children?: ReactNode; // allow any Radio buttons or JSX } @@ -373,6 +374,7 @@ export interface CheckboxGroupProps { onChange?: ((value: string[]) => void), isRequired?: boolean; isRequiredVisualOnly?: boolean; + isDisabled?: boolean; children?: ReactNode; // allow any Checkboxes or JSX } @@ -573,6 +575,7 @@ export interface AffiliationSearchQuestionProps { affiliationData: { affiliationName: string, affiliationId: string }; otherAffiliationName?: string; otherField?: boolean; + isDisabled?: boolean; setOtherField: (value: boolean) => void; handleAffiliationChange: (id: string, value: string) => Promise handleOtherAffiliationChange: (e: React.ChangeEvent) => void; diff --git a/components/AddQuestionButton/index.tsx b/components/AddQuestionButton/index.tsx index c73a6be9e..c77f4edcf 100644 --- a/components/AddQuestionButton/index.tsx +++ b/components/AddQuestionButton/index.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import Link from 'next/link'; import { useTranslations } from 'next-intl'; import styles from './AddQuestionButton.module.scss'; @@ -22,7 +23,7 @@ const AddQuestionButton: React.FC = ({ return ( ); }; diff --git a/components/CustomizedTemplate/CustomizedQuestionEdit/index.tsx b/components/CustomizedTemplate/CustomizedQuestionEdit/index.tsx index b2d62122b..58e159669 100644 --- a/components/CustomizedTemplate/CustomizedQuestionEdit/index.tsx +++ b/components/CustomizedTemplate/CustomizedQuestionEdit/index.tsx @@ -31,7 +31,6 @@ const CustomizedQuestionEdit: React.FC = ({ hasCustomSampleAnswer, }) => { - const questionText = stripHtml(text); const questionId = Number(id); const questionDisplayOrder = Number(displayOrder); diff --git a/components/CustomizedTemplate/CustomizedSectionEdit/index.tsx b/components/CustomizedTemplate/CustomizedSectionEdit/index.tsx index 70424a2ee..d3b1cdb83 100644 --- a/components/CustomizedTemplate/CustomizedSectionEdit/index.tsx +++ b/components/CustomizedTemplate/CustomizedSectionEdit/index.tsx @@ -34,6 +34,7 @@ const CustomizedSectionEdit: React.FC = ({ onMoveUp, onMoveDown, }) => { + const toastState = useToast(); const t = useTranslations('Sections'); @@ -49,7 +50,6 @@ const CustomizedSectionEdit: React.FC = ({ const [moveCustomQuestionMutation] = useMutation(MoveCustomQuestionDocument); - // Memoize the sorted questions to prevent unnecessary re-renders const sortedQuestionsFromData = useMemo(() => { if (!section?.questions) return []; @@ -182,7 +182,7 @@ const CustomizedSectionEdit: React.FC = ({ // Construct the edit URL based on section type const editUrl = section.sectionType === "CUSTOM" ? `/template/customizations/${templateCustomizationId}/customSection/${section.id}`// Custom Section - : `/template/customizations/${templateCustomizationId}/section/${section.id}`; // Section Customization + : `/template/customizations/${templateCustomizationId}/section/${section.id}`; // Section Customization return ( <> @@ -204,7 +204,11 @@ const CustomizedSectionEdit: React.FC = ({ = ({ onChange, isRequired = false, isRequiredVisualOnly = false, + isDisabled = false, children, }) => { const showRequired = isRequired || isRequiredVisualOnly; @@ -35,6 +36,7 @@ const CheckboxGroupComponent: React.FC = ({ isRequired={isRequired} isInvalid={isInvalid} aria-required={isRequired} + isDisabled={isDisabled} >
diff --git a/components/Form/MultiSelect/index.tsx b/components/Form/MultiSelect/index.tsx index 538ed5413..60b84f3d0 100644 --- a/components/Form/MultiSelect/index.tsx +++ b/components/Form/MultiSelect/index.tsx @@ -12,16 +12,18 @@ interface MultiSelectProps { options: Option[]; defaultSelected?: string[]; selectedKeys?: Set; - onSelectionChange?: (selected: Set) => void; label?: string; + isDisabled?: boolean; + onSelectionChange?: (selected: Set) => void; } function MultiSelect({ options, defaultSelected = [], selectedKeys, - onSelectionChange, label = "Select Items (Multiple)", + isDisabled = false, + onSelectionChange, }: MultiSelectProps) { const isControlled = selectedKeys !== undefined; const [internalSelected, setInternalSelected] = React.useState>(new Set(defaultSelected)); @@ -55,6 +57,7 @@ function MultiSelect({ id={option.name} textValue={option.name} className={styles.multiselectItem} + isDisabled={isDisabled} > {({ isSelected }) => ( <> diff --git a/components/Form/MultiSelect/multiSelect.module.scss b/components/Form/MultiSelect/multiSelect.module.scss index 13cef9eff..7ca4306d9 100644 --- a/components/Form/MultiSelect/multiSelect.module.scss +++ b/components/Form/MultiSelect/multiSelect.module.scss @@ -55,6 +55,11 @@ font-weight: 400; color: #374151; background-color: transparent; + + &[data-disabled][data-selected] { + background-color: rgb(from var(--highlight-background) r g b / 0.5); + } + } /* Hovered but not selected */ diff --git a/components/Form/QuestionComponents/AffiliationSearchQuestionComponent/index.tsx b/components/Form/QuestionComponents/AffiliationSearchQuestionComponent/index.tsx index 2f81f928d..cfe49a1b1 100644 --- a/components/Form/QuestionComponents/AffiliationSearchQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/AffiliationSearchQuestionComponent/index.tsx @@ -10,6 +10,7 @@ const AffiliationSearchQuestionComponent: React.FC {otherField && (
diff --git a/components/Form/QuestionComponents/BooleanQuestionComponent/index.tsx b/components/Form/QuestionComponents/BooleanQuestionComponent/index.tsx index b57e0e856..c1ec5c76c 100644 --- a/components/Form/QuestionComponents/BooleanQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/BooleanQuestionComponent/index.tsx @@ -7,12 +7,14 @@ import { Radio } from 'react-aria-components'; interface BooleanQuestionProps { parsedQuestion: BooleanQuestionType; selectedValue?: string; + isDisabled?: boolean; handleRadioChange: (value: string) => void; } const BooleanQuestionComponent: React.FC = ({ parsedQuestion, selectedValue, + isDisabled = false, handleRadioChange }) => { // Localization keys @@ -30,6 +32,7 @@ const BooleanQuestionComponent: React.FC = ({ value={value} radioGroupLabel="" onChange={handleRadioChange} + isDisabled={isDisabled} >
{Global('form.yesLabel')} diff --git a/components/Form/QuestionComponents/CheckboxesQuestionComponent/index.tsx b/components/Form/QuestionComponents/CheckboxesQuestionComponent/index.tsx index 70e78f1c2..35ef19521 100644 --- a/components/Form/QuestionComponents/CheckboxesQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/CheckboxesQuestionComponent/index.tsx @@ -6,12 +6,14 @@ import { Checkbox } from "react-aria-components"; interface CheckboxesQuestionProps { parsedQuestion: CheckboxesQuestionType; selectedCheckboxValues: string[]; + isDisabled?: boolean; handleCheckboxGroupChange: (values: string[]) => void; } const CheckboxesQuestionComponent: React.FC = ({ parsedQuestion, selectedCheckboxValues, + isDisabled = false, handleCheckboxGroupChange }) => { const checkboxData = parsedQuestion.options?.map((opt: CheckboxesQuestionType['options'][number]) => ({ @@ -30,6 +32,7 @@ const CheckboxesQuestionComponent: React.FC = ({ onChange={handleCheckboxGroupChange} checkboxGroupLabel="" checkboxGroupDescription={""} + isDisabled={isDisabled} > {checkboxData.map((checkbox, index) => (
diff --git a/components/Form/QuestionComponents/CurrencyQuestionComponent/index.tsx b/components/Form/QuestionComponents/CurrencyQuestionComponent/index.tsx index 519363d71..8ee2e9eb4 100644 --- a/components/Form/QuestionComponents/CurrencyQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/CurrencyQuestionComponent/index.tsx @@ -6,6 +6,7 @@ interface CurrencyQuestionProps { inputCurrencyValue: number | null; currencyLabel?: string; placeholder?: string; + isDisabled?: boolean; handleCurrencyChange: (value: number | null) => void; } @@ -14,6 +15,7 @@ const CurrencyQuestionComponent: React.FC = ({ inputCurrencyValue, currencyLabel, placeholder, + isDisabled = false, handleCurrencyChange, }) => { const minValue = (parsedQuestion?.attributes as { min?: number }).min; @@ -33,6 +35,7 @@ const CurrencyQuestionComponent: React.FC = ({ minimumFractionDigits: 2, maximumFractionDigits: 2, }} + disabled={isDisabled} /> ); }; diff --git a/components/Form/QuestionComponents/DateRangeQuestionComponent/index.tsx b/components/Form/QuestionComponents/DateRangeQuestionComponent/index.tsx index d3de090be..cbf7397a2 100644 --- a/components/Form/QuestionComponents/DateRangeQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/DateRangeQuestionComponent/index.tsx @@ -10,6 +10,7 @@ interface DateRangeQuestionProps { startDate: string | DateValue | CalendarDate | null; endDate: string | DateValue | CalendarDate | null; }; + isDisabled?: boolean; handleDateChange: ( key: string, value: string | DateValue | CalendarDate | null @@ -19,6 +20,7 @@ interface DateRangeQuestionProps { const DateRangeQuestionComponent: React.FC = ({ parsedQuestion, dateRange, + isDisabled = false, handleDateChange, }) => { // Extract labels from JSON if available @@ -34,12 +36,14 @@ const DateRangeQuestionComponent: React.FC = ({ value={getCalendarDateValue(dateRange.startDate)} onChange={newDate => handleDateChange('startDate', newDate)} label={startLabel} + isDisabled={isDisabled} /> handleDateChange('endDate', newDate)} label={endLabel} + isDisabled={isDisabled} />
) diff --git a/components/Form/QuestionComponents/MultiSelectQuestionComponent/index.tsx b/components/Form/QuestionComponents/MultiSelectQuestionComponent/index.tsx index 03f0d5ce7..5329735ec 100644 --- a/components/Form/QuestionComponents/MultiSelectQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/MultiSelectQuestionComponent/index.tsx @@ -6,6 +6,7 @@ interface MultiselectboxQuestionProps { parsedQuestion: MultiselectBoxQuestionType; selectedMultiSelectValues?: Set; selectBoxLabel?: string; + isDisabled?: boolean; handleMultiSelectChange: (values: Set) => void; } @@ -13,6 +14,7 @@ const MultiSelectQuestionComponent: React.FC = ({ parsedQuestion, selectedMultiSelectValues, selectBoxLabel, + isDisabled = false, handleMultiSelectChange }) => { // Transform options to items for FormSelect/MultiSelect @@ -45,6 +47,7 @@ const MultiSelectQuestionComponent: React.FC = ({ label={selectBoxLabel} aria-label={selectBoxLabel} defaultSelected={defaultSelected} + isDisabled={isDisabled} /> ); }; diff --git a/components/Form/QuestionComponents/NumberRangeQuestionComponent/index.tsx b/components/Form/QuestionComponents/NumberRangeQuestionComponent/index.tsx index e1abeaf70..3e2615649 100644 --- a/components/Form/QuestionComponents/NumberRangeQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/NumberRangeQuestionComponent/index.tsx @@ -8,6 +8,7 @@ interface NumberRangeQuestionProps { startNumber: number | null; endNumber: number | null; }; + isDisabled?: boolean; handleNumberChange: ( key: string, value: number | null @@ -19,6 +20,7 @@ interface NumberRangeQuestionProps { const NumberRangeQuestionComponent: React.FC = ({ parsedQuestion, numberRange, + isDisabled = false, handleNumberChange, startPlaceholder, endPlaceholder, @@ -41,6 +43,7 @@ const NumberRangeQuestionComponent: React.FC = ({ placeholder={startPlaceholder} minValue={startNumberMin} maxValue={startNumberMax ?? undefined} + disabled={isDisabled} /> = ({ placeholder={endPlaceholder} minValue={endNumberMin} maxValue={endNumberMax ?? undefined} + disabled={isDisabled} />
) diff --git a/components/Form/QuestionComponents/RadioButtonsQuestionComponent/index.tsx b/components/Form/QuestionComponents/RadioButtonsQuestionComponent/index.tsx index 02d0dbcfc..0d5dd843f 100644 --- a/components/Form/QuestionComponents/RadioButtonsQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/RadioButtonsQuestionComponent/index.tsx @@ -7,6 +7,7 @@ interface RadioButtonQuestionTypeProps { selectedRadioValue: string | undefined; name?: string; radioGroupLabel?: string; + isDisabled?: boolean; handleRadioChange: (value: string) => void; } @@ -15,6 +16,7 @@ const RadioButtonsQuestionComponent: React.FC = ({ selectedRadioValue, name = 'radio-buttons-question', radioGroupLabel = '', + isDisabled = false, handleRadioChange }) => { const radioButtonData = parsedQuestion.options?.map((opt: RadioButtonsQuestionType['options'][number]) => ({ @@ -32,6 +34,7 @@ const RadioButtonsQuestionComponent: React.FC = ({ value={value ?? ''} radioGroupLabel={radioGroupLabel} onChange={handleRadioChange} + isDisabled={isDisabled} > {radioButtonData.map((radioButton, index) => (
diff --git a/components/Form/QuestionComponents/SelectboxQuestionComponent/index.tsx b/components/Form/QuestionComponents/SelectboxQuestionComponent/index.tsx index 9d1f8059d..aecf402c8 100644 --- a/components/Form/QuestionComponents/SelectboxQuestionComponent/index.tsx +++ b/components/Form/QuestionComponents/SelectboxQuestionComponent/index.tsx @@ -12,6 +12,7 @@ interface SelectboxQuestionProps { selectName?: string; errorMessage?: string; helpMessage?: string; + isDisabled?: boolean; handleSelectChange?: (value: string) => void; } @@ -22,8 +23,10 @@ const SelectboxQuestionComponent: React.FC = ({ selectName = 'select', errorMessage = '', helpMessage = '', + isDisabled = false, handleSelectChange }) => { + // Transform options to items for FormSelect const items = parsedQuestion.options?.map((opt: SelectBoxQuestionType['options'][number]) => ({ id: opt.value, @@ -37,7 +40,6 @@ const SelectboxQuestionComponent: React.FC = ({ const value = selectedSelectValue !== undefined ? selectedSelectValue : initialValue; return ( - = ({ errorMessage={errorMessage} helpMessage={helpMessage} onChange={handleSelectChange} + isDisabled={isDisabled} > {items.map((item: { id: string; name: string }) => ( {item.name} ))} - - ); }; diff --git a/components/Form/RadioGroup/index.tsx b/components/Form/RadioGroup/index.tsx index e29cc67c6..ffe4a4ad5 100644 --- a/components/Form/RadioGroup/index.tsx +++ b/components/Form/RadioGroup/index.tsx @@ -20,11 +20,11 @@ const RadioGroupComponent: React.FC = ({ onChange, isRequired = false, isRequiredVisualOnly = false, + isDisabled = false, children, }) => { const showRequired = isRequired || isRequiredVisualOnly; const t = useTranslations('Global.labels'); - return ( <> = ({ isRequired={isRequired} isInvalid={isInvalid} aria-required={isRequired} + isDisabled={isDisabled} >
); }, - FormSelect: ({ label, items, selectedKey, onChange, isInvalid, errorMessage, selectClasses, ariaLabel, isRequired, helpMessage, ...props }: any) => ( + FormSelect: ({ label, items, selectedKey, onChange, isInvalid, errorMessage, selectClasses, ariaLabel, isRequired, helpMessage, isDisabled, disabled, ...props }: any) => (