diff --git a/designer/README.md b/designer/README.md index 26c5604dde..fb4907a729 100644 --- a/designer/README.md +++ b/designer/README.md @@ -107,6 +107,85 @@ npm run test:since npm run test:uncommitted ``` +## Adding a new question type (component type) + +To add a new question type, you will need to add the new type to a series of files. As an example, if we were to add a new `UnicornField` question type and wanted it to be selectable as a new question from the 'question ype' radio list, (assuming we are happy to use a default 'preview' implementation for the time being), you have to add: + +model/src/components/enums.ts: + +``` + UnicornField = ‘UnicornField’ +``` + +model/src/components/types.ts: + +``` + export interface UnicornFieldComponent extends FormFieldBase { + type: ComponentType.UnicornField + options: FormFieldBase['options'] & { + condition?: string + } + } + + . . . + + export type InputFieldsComponentsDef = + | TextFieldComponent + . . . + | UnicornFieldComponent +``` + +model/src/components/component-types.ts: + +``` +{ + name: 'UnicornField', + title: 'Unicorn field', + type: ComponentType.UnicornField, + hint: '', + options: {}, + schema: {} +} + +``` + +client/src/i18n/translations/en.translation.json: + +``` + fieldTypeToName: { + . . . + "UnicornField": "Unicorn field", + "UnicornField_info": "For internal processing" +``` + +model/src/form/form-editor/index.ts: + +``` +export const questionTypeSchema = Joi.string() +. . . +ComponentType.UnicornField +) +. . . +``` + +designer/server/src/models/forms/editor-v2/question-type.js: + +``` +const questionTypeRadioItems = /** @type {FormEditorCheckbox[]} */ ([ +. . . +{ + text: 'Unicorn', + hint: { + text: 'Rainbows and stuff' + }, + value: ComponentType.UnicornField + }, +. . . +``` + +Further work would be required to define advanced field settings (if appropriate), and potentially to validate/handle the payload being saved from the 'question details' screen. +Later, work would be required to properly implement the preview (this would involve changes to forms-runner to handle the new component class generation so error messages can be retrieved). + ## License THIS INFORMATION IS LICENSED UNDER THE CONDITIONS OF THE OPEN GOVERNMENT LICENCE found at: diff --git a/designer/client/src/javascripts/preview.js b/designer/client/src/javascripts/preview.js index 8f47bcb9dc..5314a3f817 100644 --- a/designer/client/src/javascripts/preview.js +++ b/designer/client/src/javascripts/preview.js @@ -39,12 +39,7 @@ export function showHideForJs() { * @returns {PreviewQuestion} */ export function setupPreview(componentType) { - const PreviewConstructor = - /** @type {() => PreviewQuestion} */ - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - (SetupPreview[componentType] ?? SetupPreview.Question) - - const preview = PreviewConstructor() + const preview = SetupPreview(componentType) showHideForJs() diff --git a/designer/client/src/javascripts/preview/date-input.test.js b/designer/client/src/javascripts/preview/date-input.test.js index bcce06209b..0bd988953d 100644 --- a/designer/client/src/javascripts/preview/date-input.test.js +++ b/designer/client/src/javascripts/preview/date-input.test.js @@ -1,3 +1,5 @@ +import { ComponentType } from '@defra/forms-model' + import { questionDetailsLeftPanelHTML, questionDetailsPreviewHTML @@ -10,7 +12,7 @@ describe('date-input', () => { it('should create class', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.DatePartsField() + const res = SetupPreview(ComponentType.DatePartsField) expect(res.renderInput).toEqual({ id: 'dateInput', name: 'dateInput', diff --git a/designer/client/src/javascripts/preview/declaration.test.js b/designer/client/src/javascripts/preview/declaration.test.js index 49efd4eead..1743cf9f1f 100644 --- a/designer/client/src/javascripts/preview/declaration.test.js +++ b/designer/client/src/javascripts/preview/declaration.test.js @@ -1,4 +1,4 @@ -import { DeclarationQuestion } from '@defra/forms-model' +import { ComponentType, DeclarationQuestion } from '@defra/forms-model' import { questionDetailsLeftPanelHTML, @@ -122,7 +122,7 @@ describe('declaration', () => { questionDetailsPreviewHTML + declarationHTML const question = /** @type {DeclarationQuestion} */ ( - SetupPreview.DeclarationField() + SetupPreview(ComponentType.DeclarationField) ) const declarationTextEl = /** @type {HTMLTextAreaElement | null} */ ( document.getElementById('declarationText') @@ -146,7 +146,7 @@ describe('declaration', () => { questionDetailsPreviewHTML + declarationHTML const question = /** @type {DeclarationQuestion} */ ( - SetupPreview.DeclarationField() + SetupPreview(ComponentType.DeclarationField) ) const declarationTextEl = /** @type {HTMLTextAreaElement | null} */ ( document.getElementById('declarationText') @@ -169,7 +169,7 @@ describe('declaration', () => { questionDetailsPreviewHTML + declarationHTML const question = /** @type {DeclarationQuestion} */ ( - SetupPreview.DeclarationField() + SetupPreview(ComponentType.DeclarationField) ) const declarationTextEl = /** @type {HTMLTextAreaElement | null} */ ( document.getElementById('declarationText') @@ -200,7 +200,7 @@ describe('declaration', () => { questionDetailsPreviewHTML + declarationHTML const res = /** @type {DeclarationQuestion} */ ( - SetupPreview.DeclarationField() + SetupPreview(ComponentType.DeclarationField) ) expect(res).toBeInstanceOf(DeclarationQuestion) expect(res).toBeDefined() @@ -213,7 +213,7 @@ describe('declaration', () => { questionDetailsPreviewHTML + declarationHTML const res = /** @type {DeclarationQuestion} */ ( - SetupPreview.DeclarationField() + SetupPreview(ComponentType.DeclarationField) ) expect(res.question).toBe('Which quest would you like to pick?') expect(res.hintText).toBe('Choose one adventure that best suits you.') @@ -231,7 +231,7 @@ describe('declaration', () => { questionDetailsPreviewHTML + emptyDeclarationHTML const res = /** @type {DeclarationQuestion} */ ( - SetupPreview.DeclarationField() + SetupPreview(ComponentType.DeclarationField) ) expect(res.declarationText).toBe('') }) diff --git a/designer/client/src/javascripts/preview/email-address.test.js b/designer/client/src/javascripts/preview/email-address.test.js index 48202a2de2..bd76c9def3 100644 --- a/designer/client/src/javascripts/preview/email-address.test.js +++ b/designer/client/src/javascripts/preview/email-address.test.js @@ -1,4 +1,4 @@ -import { EmailAddressQuestion } from '@defra/forms-model' +import { ComponentType, EmailAddressQuestion } from '@defra/forms-model' import { questionDetailsLeftPanelHTML, @@ -13,7 +13,7 @@ describe('email', () => { it('should create class', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.EmailAddressField() + const res = SetupPreview(ComponentType.EmailAddressField) expect(res).toBeInstanceOf(EmailAddressQuestion) expect(res).toBeDefined() expect(res.renderInput).toEqual({ diff --git a/designer/client/src/javascripts/preview/list-sortable.test.js b/designer/client/src/javascripts/preview/list-sortable.test.js index 808123b0f6..730e74df48 100644 --- a/designer/client/src/javascripts/preview/list-sortable.test.js +++ b/designer/client/src/javascripts/preview/list-sortable.test.js @@ -1,4 +1,8 @@ -import { ListSortableQuestion } from '@defra/forms-model' +import { + ComponentType, + ListSortableQuestion, + PreviewTypeEnum +} from '@defra/forms-model' import { list1HTML, @@ -82,7 +86,7 @@ describe('list-sortable', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewTabsHTML const preview = /** @type {ListSortableQuestion} */ ( - SetupPreview.RadiosField() + SetupPreview(ComponentType.RadiosField) ) expect(preview.renderInput.fieldset?.legend.text).toBe( 'Which quest would you like to pick?' @@ -497,7 +501,7 @@ describe('list-sortable', () => { describe('editPanelListeners', () => { it('should update the List class when listeners are called', () => { const preview = /** @type {ListSortableQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) const listEventListeners = new ListSortableEventListeners( preview, @@ -586,7 +590,7 @@ describe('list-sortable', () => { '' + '' + list1HTML - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) const reorderButton = /** @type {HTMLElement} */ ( document.getElementById('edit-options-button') ) @@ -602,7 +606,7 @@ describe('list-sortable', () => { '' + '' + list1HTML - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) const reorderButton = /** @type {HTMLElement} */ ( document.getElementById('edit-options-button') ) @@ -633,7 +637,7 @@ describe('list-sortable', () => { x.textContent = 'Not up or down' }) - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) const reorderButton = /** @type {HTMLElement} */ ( document.getElementById('edit-options-button') ) @@ -649,7 +653,7 @@ describe('list-sortable', () => { '' + '' + list1HTML - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) const reorderButton = /** @type {HTMLElement} */ ( document.getElementById('edit-options-button') ) @@ -690,7 +694,7 @@ describe('list-sortable', () => { '' + '' + list1HTML - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) const reorderButton = /** @type {HTMLElement} */ ( document.getElementById('edit-options-button') ) @@ -911,7 +915,7 @@ describe('list-sortable', () => { '' + listEmptyHTML const preview = /** @type {ListSortableQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) expect(preview.listElementObjects).toHaveLength(0) document.body.innerHTML = @@ -919,7 +923,7 @@ describe('list-sortable', () => { '' + list1HTML const preview2 = /** @type {ListSortableQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) preview.resyncPreviewAfterReorder() expect(preview2.listElementObjects).toHaveLength(4) diff --git a/designer/client/src/javascripts/preview/list.test.js b/designer/client/src/javascripts/preview/list.test.js index 30fc29a989..3441a24e9c 100644 --- a/designer/client/src/javascripts/preview/list.test.js +++ b/designer/client/src/javascripts/preview/list.test.js @@ -1,4 +1,4 @@ -import { ListQuestion } from '@defra/forms-model' +import { ListQuestion, PreviewTypeEnum } from '@defra/forms-model' import { list1HTML } from '~/src/javascripts/preview/__stubs__/list' import { questionDetailsStubPanels } from '~/src/javascripts/preview/__stubs__/question.js' @@ -141,7 +141,7 @@ describe('list', () => { it('should setup', () => { document.body.innerHTML = list1HTML const preview = /** @type {ListSortableQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) expect(preview.renderInput.fieldset?.legend.text).toBe('Question') }) @@ -192,7 +192,7 @@ describe('list', () => { describe('editPanelListeners', () => { it('should update the List class when listeners are called', () => { const preview = /** @type {ListQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) const listEventListeners = new ListEventListeners( preview, @@ -331,7 +331,7 @@ describe('list', () => { it('should highlight', () => { const preview = /** @type {ListSortableQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) preview.highlight = `${baronListItemId}-hint` expect(preview.list[3]).toMatchObject({ @@ -341,7 +341,7 @@ describe('list', () => { it('should handle edge cases', () => { const preview = /** @type {ListSortableQuestion} */ ( - SetupPreview.ListSortable() + SetupPreview(PreviewTypeEnum.ListSortable) ) expect(preview.list).toEqual(expectedList) preview.updateValue(undefined, 'new-value') @@ -359,7 +359,9 @@ describe('list', () => { describe('editFieldHasFocus', () => { it('should return true for list text field', () => { - const preview = /** @type {ListQuestion} */ (SetupPreview.ListSortable()) + const preview = /** @type {ListQuestion} */ ( + SetupPreview(PreviewTypeEnum.ListSortable) + ) const listEventListeners = new ListEventListeners( preview, questionElements, diff --git a/designer/client/src/javascripts/preview/phone-number.test.js b/designer/client/src/javascripts/preview/phone-number.test.js index e074477e28..c4583f8efd 100644 --- a/designer/client/src/javascripts/preview/phone-number.test.js +++ b/designer/client/src/javascripts/preview/phone-number.test.js @@ -1,4 +1,4 @@ -import { PhoneNumberQuestion } from '@defra/forms-model' +import { ComponentType, PhoneNumberQuestion } from '@defra/forms-model' import { questionDetailsLeftPanelHTML, @@ -13,7 +13,7 @@ describe('phone number', () => { it('should create class', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.TelephoneNumberField() + const res = SetupPreview(ComponentType.TelephoneNumberField) expect(res).toBeInstanceOf(PhoneNumberQuestion) expect(res).toBeDefined() expect(res.renderInput).toEqual({ diff --git a/designer/client/src/javascripts/preview/question.test.js b/designer/client/src/javascripts/preview/question.test.js index a91b1ea703..43a8d26893 100644 --- a/designer/client/src/javascripts/preview/question.test.js +++ b/designer/client/src/javascripts/preview/question.test.js @@ -1,3 +1,5 @@ +import { PreviewTypeEnum } from '@defra/forms-model' + import { questionDetailsLeftPanelHTML, questionDetailsPreviewHTML @@ -74,7 +76,7 @@ describe('question', () => { it('should create class', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.Question() + const res = SetupPreview(PreviewTypeEnum.Question) expect(res).toBeDefined() expect(res.renderInput).toEqual({ id: expect.stringContaining('inputField'), @@ -101,7 +103,7 @@ describe('question', () => { it('should handle changed values', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.Question() + const res = SetupPreview(PreviewTypeEnum.Question) expect(res.titleText).toBe('Which quest would you like to pick?') expect(res.question).toBe('Which quest would you like to pick?') expect(res.hintText).toBe('Choose one adventure that best suits you.') @@ -118,7 +120,7 @@ describe('question', () => { it('should handle missing values', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.Question() + const res = SetupPreview(PreviewTypeEnum.Question) res.question = '' expect(res.titleText).toBe('Question') res.hintText = '' @@ -141,7 +143,7 @@ describe('question', () => { }) it('should highlight', () => { - const preview = SetupPreview.Question() + const preview = SetupPreview(PreviewTypeEnum.Question) preview.highlight = `hintText` expect(preview).toMatchObject({ hint: { text: 'Hint text' } diff --git a/designer/client/src/javascripts/preview/short-answer.test.js b/designer/client/src/javascripts/preview/short-answer.test.js index 025b4cafc9..b3b0337f48 100644 --- a/designer/client/src/javascripts/preview/short-answer.test.js +++ b/designer/client/src/javascripts/preview/short-answer.test.js @@ -1,3 +1,5 @@ +import { ComponentType } from '@defra/forms-model' + import { questionDetailsLeftPanelHTML, questionDetailsPreviewHTML @@ -10,7 +12,7 @@ describe('ShortAnswer', () => { it('should create class', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.TextField() + const res = SetupPreview(ComponentType.TextField) expect(res).toBeDefined() }) }) diff --git a/designer/client/src/javascripts/preview/uk-address.test.js b/designer/client/src/javascripts/preview/uk-address.test.js index c74eca1f96..073e66ec0f 100644 --- a/designer/client/src/javascripts/preview/uk-address.test.js +++ b/designer/client/src/javascripts/preview/uk-address.test.js @@ -1,4 +1,4 @@ -import { UkAddressQuestion } from '@defra/forms-model' +import { ComponentType, UkAddressQuestion } from '@defra/forms-model' import { questionDetailsLeftPanelHTML, @@ -13,7 +13,7 @@ describe('address', () => { it('should create class', () => { document.body.innerHTML = questionDetailsLeftPanelHTML + questionDetailsPreviewHTML - const res = SetupPreview.UkAddressField() + const res = SetupPreview(ComponentType.UkAddressField) expect(res).toBeInstanceOf(UkAddressQuestion) expect(res).toBeDefined() expect(res.renderInput).toEqual({ diff --git a/designer/client/src/javascripts/setup-preview.js b/designer/client/src/javascripts/setup-preview.js index 54f517b811..7b9fb75eaa 100644 --- a/designer/client/src/javascripts/setup-preview.js +++ b/designer/client/src/javascripts/setup-preview.js @@ -69,34 +69,38 @@ import { UkAddressEventListeners } from '~/src/javascripts/preview/uk-address' -export const SetupPreview = - /** @type {Record PreviewQuestion>} */ ({ +const SetupPreviewDefaultQuestion = () => { + const questionElements = new QuestionDomElements() + const nunjucksRenderer = new NunjucksRenderer(questionElements) + const question = new Question(questionElements, nunjucksRenderer) + const listeners = new EventListeners(question, questionElements) + listeners.setupListeners() + + return question +} + +export const SetupPreviewPartial = + /** @type {Partial PreviewQuestion>>} */ ({ /** * @returns {Question} */ Question: () => { - const questionElements = new QuestionDomElements() - const nunjucksRenderer = new NunjucksRenderer(questionElements) - const question = new Question(questionElements, nunjucksRenderer) - const listeners = new EventListeners(question, questionElements) - listeners.setupListeners() - - return question + return SetupPreviewDefaultQuestion() }, Html: () => { - return SetupPreview.Question() + return SetupPreviewDefaultQuestion() }, InsetText: () => { - return SetupPreview.Question() + return SetupPreviewDefaultQuestion() }, Details: () => { - return SetupPreview.Question() + return SetupPreviewDefaultQuestion() }, List: () => { - return SetupPreview.Question() + return SetupPreviewDefaultQuestion() }, Markdown: () => { - return SetupPreview.Question() + return SetupPreviewDefaultQuestion() }, /** * @returns {ShortAnswerQuestion} @@ -397,9 +401,19 @@ export const SetupPreview = return latLongField }, HiddenField: () => { - return SetupPreview.Question() + return SetupPreviewDefaultQuestion() } }) + +/** + * @param {PreviewType} type + * @returns {PreviewQuestion} + */ +export function SetupPreview(type) { + const preview = SetupPreviewPartial[type] + return preview ? preview() : SetupPreviewDefaultQuestion() +} + /** - * @import { PreviewQuestion, ComponentType } from '@defra/forms-model' + * @import { PreviewType, PreviewQuestion } from '@defra/forms-model' */ diff --git a/designer/client/src/javascripts/setup-preview.test.js b/designer/client/src/javascripts/setup-preview.test.js index 3d64c5bd30..f6ab018555 100644 --- a/designer/client/src/javascripts/setup-preview.test.js +++ b/designer/client/src/javascripts/setup-preview.test.js @@ -1,6 +1,7 @@ import { AutocompleteQuestion, CheckboxSortableQuestion, + ComponentType, DateInputQuestion, DeclarationQuestion, EmailAddressQuestion, @@ -9,6 +10,7 @@ import { MonthYearQuestion, NumberOnlyQuestion, PhoneNumberQuestion, + PreviewTypeEnum, Question, RadioSortableQuestion, SelectSortableQuestion, @@ -36,7 +38,7 @@ describe('SetupPreview', () => { describe('Question', () => { it('should create Question instance', () => { - const result = SetupPreview.Question() + const result = SetupPreview(PreviewTypeEnum.Question) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -44,7 +46,7 @@ describe('SetupPreview', () => { describe('Html', () => { it('should create Question instance', () => { - const result = SetupPreview.Html() + const result = SetupPreview(ComponentType.Html) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -52,7 +54,7 @@ describe('SetupPreview', () => { describe('InsetText', () => { it('should create Question instance', () => { - const result = SetupPreview.InsetText() + const result = SetupPreview(ComponentType.InsetText) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -60,7 +62,7 @@ describe('SetupPreview', () => { describe('Details', () => { it('should create Question instance', () => { - const result = SetupPreview.Details() + const result = SetupPreview(ComponentType.Details) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -68,7 +70,7 @@ describe('SetupPreview', () => { describe('List', () => { it('should create Question instance', () => { - const result = SetupPreview.List() + const result = SetupPreview(ComponentType.List) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -76,7 +78,7 @@ describe('SetupPreview', () => { describe('Markdown', () => { it('should create Question instance', () => { - const result = SetupPreview.Markdown() + const result = SetupPreview(ComponentType.Markdown) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -84,7 +86,7 @@ describe('SetupPreview', () => { describe('TextField', () => { it('should create ShortAnswerQuestion instance', () => { - const result = SetupPreview.TextField() + const result = SetupPreview(ComponentType.TextField) expect(result).toBeInstanceOf(ShortAnswerQuestion) expect(result).toBeDefined() }) @@ -92,7 +94,7 @@ describe('SetupPreview', () => { describe('NumberField', () => { it('should create NumberOnlyQuestion instance', () => { - const result = SetupPreview.NumberField() + const result = SetupPreview(ComponentType.NumberField) expect(result).toBeInstanceOf(NumberOnlyQuestion) expect(result).toBeDefined() }) @@ -100,7 +102,7 @@ describe('SetupPreview', () => { describe('MultilineTextField', () => { it('should create LongAnswerQuestion instance', () => { - const result = SetupPreview.MultilineTextField() + const result = SetupPreview(ComponentType.MultilineTextField) expect(result).toBeInstanceOf(LongAnswerQuestion) expect(result).toBeDefined() }) @@ -108,7 +110,7 @@ describe('SetupPreview', () => { describe('DatePartsField', () => { it('should create DateInputQuestion instance', () => { - const result = SetupPreview.DatePartsField() + const result = SetupPreview(ComponentType.DatePartsField) expect(result).toBeInstanceOf(DateInputQuestion) expect(result).toBeDefined() }) @@ -116,7 +118,7 @@ describe('SetupPreview', () => { describe('MonthYearField', () => { it('should create MonthYearQuestion instance', () => { - const result = SetupPreview.MonthYearField() + const result = SetupPreview(ComponentType.MonthYearField) expect(result).toBeInstanceOf(MonthYearQuestion) expect(result).toBeDefined() }) @@ -124,7 +126,7 @@ describe('SetupPreview', () => { describe('EmailAddressField', () => { it('should create EmailAddressQuestion instance', () => { - const result = SetupPreview.EmailAddressField() + const result = SetupPreview(ComponentType.EmailAddressField) expect(result).toBeInstanceOf(EmailAddressQuestion) expect(result).toBeDefined() }) @@ -132,7 +134,7 @@ describe('SetupPreview', () => { describe('FileUploadField', () => { it('should create SupportingEvidenceQuestion instance', () => { - const result = SetupPreview.FileUploadField() + const result = SetupPreview(ComponentType.FileUploadField) expect(result).toBeInstanceOf(SupportingEvidenceQuestion) expect(result).toBeDefined() }) @@ -140,7 +142,7 @@ describe('SetupPreview', () => { describe('UkAddressField', () => { it('should create UkAddressQuestion instance', () => { - const result = SetupPreview.UkAddressField() + const result = SetupPreview(ComponentType.UkAddressField) expect(result).toBeInstanceOf(UkAddressQuestion) expect(result).toBeDefined() }) @@ -148,7 +150,7 @@ describe('SetupPreview', () => { describe('YesNoField', () => { it('should create YesNoQuestion instance', () => { - const result = SetupPreview.YesNoField() + const result = SetupPreview(ComponentType.YesNoField) expect(result).toBeInstanceOf(YesNoQuestion) expect(result).toBeDefined() }) @@ -156,7 +158,7 @@ describe('SetupPreview', () => { describe('TelephoneNumberField', () => { it('should create PhoneNumberQuestion instance', () => { - const result = SetupPreview.TelephoneNumberField() + const result = SetupPreview(ComponentType.TelephoneNumberField) expect(result).toBeInstanceOf(PhoneNumberQuestion) expect(result).toBeDefined() }) @@ -164,7 +166,7 @@ describe('SetupPreview', () => { describe('RadiosField', () => { it('should create RadioSortableQuestion instance', () => { - const result = SetupPreview.RadiosField() + const result = SetupPreview(ComponentType.RadiosField) expect(result).toBeInstanceOf(RadioSortableQuestion) expect(result).toBeDefined() }) @@ -172,7 +174,7 @@ describe('SetupPreview', () => { describe('SelectField', () => { it('should create SelectSortableQuestion instance', () => { - const result = SetupPreview.SelectField() + const result = SetupPreview(ComponentType.SelectField) expect(result).toBeInstanceOf(SelectSortableQuestion) expect(result).toBeDefined() }) @@ -180,7 +182,7 @@ describe('SetupPreview', () => { describe('CheckboxesField', () => { it('should create CheckboxSortableQuestion instance', () => { - const result = SetupPreview.CheckboxesField() + const result = SetupPreview(ComponentType.CheckboxesField) expect(result).toBeInstanceOf(CheckboxSortableQuestion) expect(result).toBeDefined() }) @@ -196,7 +198,7 @@ Helium:2 questionDetailsLeftPanelBuilder(autocompleteTextarea), questionDetailsPreviewHTML ) - const result = SetupPreview.AutocompleteField() + const result = SetupPreview(ComponentType.AutocompleteField) expect(result).toBeInstanceOf(AutocompleteQuestion) expect(result).toBeDefined() }) @@ -204,7 +206,7 @@ Helium:2 describe('ListSortable', () => { it('should create ListSortableQuestion instance', () => { - const result = SetupPreview.ListSortable() + const result = SetupPreview(PreviewTypeEnum.ListSortable) expect(result).toBeInstanceOf(ListSortableQuestion) expect(result).toBeDefined() }) @@ -212,7 +214,7 @@ Helium:2 describe('DeclarationField', () => { it('should create DeclarationQuestion instance', () => { - const result = SetupPreview.DeclarationField() + const result = SetupPreview(ComponentType.DeclarationField) expect(result).toBeInstanceOf(DeclarationQuestion) expect(result).toBeDefined() }) @@ -220,7 +222,7 @@ Helium:2 describe('EastingNorthingField', () => { it('should create Question instance', () => { - const result = SetupPreview.EastingNorthingField() + const result = SetupPreview(ComponentType.EastingNorthingField) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -228,7 +230,7 @@ Helium:2 describe('OsGridRefField', () => { it('should create Question instance', () => { - const result = SetupPreview.OsGridRefField() + const result = SetupPreview(ComponentType.OsGridRefField) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -236,7 +238,7 @@ Helium:2 describe('NationalGridFieldNumberField', () => { it('should create Question instance', () => { - const result = SetupPreview.NationalGridFieldNumberField() + const result = SetupPreview(ComponentType.NationalGridFieldNumberField) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) @@ -244,15 +246,14 @@ Helium:2 describe('LatLongField', () => { it('should create Question instance', () => { - const result = SetupPreview.LatLongField() - expect(result).toBeInstanceOf(Question) + const result = SetupPreview(ComponentType.LatLongField) expect(result).toBeDefined() }) }) describe('HiddenField', () => { it('should create Question instance', () => { - const result = SetupPreview.HiddenField() + const result = SetupPreview(ComponentType.HiddenField) expect(result).toBeInstanceOf(Question) expect(result).toBeDefined() }) diff --git a/designer/server/src/common/components/preview-panel/template.njk b/designer/server/src/common/components/preview-panel/template.njk index e7b14a8fc9..0687bb1354 100644 --- a/designer/server/src/common/components/preview-panel/template.njk +++ b/designer/server/src/common/components/preview-panel/template.njk @@ -4,6 +4,6 @@ {% call appPreviewPanelLayout({}) %} {% call appPreviewPanelTabs(params) %} {% set model = params.model %} - {%- include 'preview-components/' + params.questionType | lower + ".njk" -%} + {%- include 'preview-components/' + params.questionType | lower + ".njk" ignore missing -%} {% endcall %} {% endcall %} diff --git a/designer/server/src/lib/editor.test.js b/designer/server/src/lib/editor.test.js index f74379dce7..8e2ea74216 100644 --- a/designer/server/src/lib/editor.test.js +++ b/designer/server/src/lib/editor.test.js @@ -139,12 +139,14 @@ const formDefinitionRepeater = { lists: [] } +/** @type {Partial} */ const questionDetails = { title: 'What is your name?', name: 'what-is-your-name', type: ComponentType.TextField } +/** @type {Partial} */ const radioQuestionDetails = { title: 'What is your favourite colour?', name: 'what-is-your-fav-colour', @@ -152,6 +154,7 @@ const radioQuestionDetails = { list: 'my-list' } +/** @type {Partial} */ const guidanceDetails = { name: 'markdown-component', type: ComponentType.Markdown, @@ -455,6 +458,7 @@ describe('editor.js', () => { body: { id: '456' } }) + /** @type {Partial} */ const questionDetailsFileUpload = { type: ComponentType.FileUploadField } @@ -494,6 +498,7 @@ describe('editor.js', () => { body: { id: '456' } }) + /** @type {Partial} */ const questionDetailsFileUpload = { type: ComponentType.FileUploadField } @@ -776,6 +781,7 @@ describe('editor.js', () => { formsEndpoint ) + /** @type {Partial} */ const questionDetails = { type: ComponentType.TextField, title: 'My first question' diff --git a/designer/server/src/lib/error-preview-helper.js b/designer/server/src/lib/error-preview-helper.js index f00c81c468..56cb5eec80 100644 --- a/designer/server/src/lib/error-preview-helper.js +++ b/designer/server/src/lib/error-preview-helper.js @@ -6,7 +6,7 @@ import { allowedErrorTemplateFunctions } from '@defra/forms-model' -const fieldMappings = /** @type {AdvancedFieldMappingsType } */ ({ +const fieldMappings = /** @type { Partial } */ ({ TextField: { min: 'minLength', max: 'maxLength' diff --git a/designer/server/src/models/forms/editor-v2/advanced-settings-config.js b/designer/server/src/models/forms/editor-v2/advanced-settings-config.js index 69b591a01a..5c46ad1518 100644 --- a/designer/server/src/models/forms/editor-v2/advanced-settings-config.js +++ b/designer/server/src/models/forms/editor-v2/advanced-settings-config.js @@ -6,10 +6,10 @@ import { /** * Configuration mapping component types to their available advanced settings - * @type {Record} + * @type {Partial>} */ export const advancedSettingsPerComponentType = - /** @type {Record } */ ({ + /** @type {Partial>} */ ({ TextField: [ QuestionAdvancedSettings.MinLength, QuestionAdvancedSettings.MaxLength, diff --git a/designer/server/src/models/forms/editor-v2/enhanced-fields.js b/designer/server/src/models/forms/editor-v2/enhanced-fields.js index fe4c675f36..339b0a173c 100644 --- a/designer/server/src/models/forms/editor-v2/enhanced-fields.js +++ b/designer/server/src/models/forms/editor-v2/enhanced-fields.js @@ -2,7 +2,7 @@ import { QuestionEnhancedFields } from '~/src/common/constants/editor.js' import { GOVUK_LABEL__M } from '~/src/models/forms/editor-v2/common.js' export const enhancedFieldsPerComponentType = - /** @type {Record } */ ({ + /** @type {Partial>} */ ({ TextField: [], MultilineTextField: [], YesNoField: [], diff --git a/designer/server/src/models/forms/editor-v2/question-details.js b/designer/server/src/models/forms/editor-v2/question-details.js index 1475e2a750..ffc0d9f3af 100644 --- a/designer/server/src/models/forms/editor-v2/question-details.js +++ b/designer/server/src/models/forms/editor-v2/question-details.js @@ -78,6 +78,19 @@ export function hasDataOrErrorForDisplay( return false } +/** + * @param { ComponentType | undefined } questionType + */ +export function buildComponentDef(questionType) { + return /** @type {ComponentDef} */ ({ + type: questionType ?? ComponentType.Html, + title: 'Dummy', + name: 'dummy', + options: { required: true }, + schema: {} + }) +} + /** * @param { ComponentType | undefined } questionType * @returns {{ @@ -90,16 +103,21 @@ export function getErrorTemplates(questionType) { return YesNoField.getAllPossibleErrors() } - const component = createComponent( - /** @type {ComponentDef} */ ({ - type: questionType ?? ComponentType.Html, - title: 'Dummy', - name: 'dummy', - options: { required: true }, - schema: {} - }), - {} - ) + let component + try { + component = createComponent(buildComponentDef(questionType), {}) + } catch (err) { + // @ts-expect-error - generic error object + if (err?.message === `Component type ${questionType} does not exist`) { + // Default to a TextFIeld if not yet configure for 'preview' + component = createComponent( + buildComponentDef(ComponentType.TextField), + {} + ) + } else { + throw err + } + } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const errorTemplates = @@ -174,11 +192,9 @@ export function getDetails( * @returns {GovukField[]} */ export function getExtraFields(question, validation) { - const extraFieldNames = /** @type {ComponentType[]} */ ( - advancedSettingsPerComponentType[question.type] - ) + const extraFieldNames = advancedSettingsPerComponentType[question.type] - if (extraFieldNames.length) { + if (extraFieldNames?.length) { return advancedSettingsFields( extraFieldNames, /** @type {TextFieldComponent} */ (question), @@ -194,11 +210,9 @@ export function getExtraFields(question, validation) { * @returns {GovukField[]} */ export function getEnhancedFields(question, validation) { - const extraFieldNames = /** @type {ComponentType[]} */ ( - enhancedFieldsPerComponentType[question.type] - ) + const extraFieldNames = enhancedFieldsPerComponentType[question.type] - if (extraFieldNames.length) { + if (extraFieldNames?.length) { return enhancedFields( extraFieldNames, /** @type {TextFieldComponent} */ (question), diff --git a/designer/server/src/models/forms/editor-v2/question-details.test.js b/designer/server/src/models/forms/editor-v2/question-details.test.js index b43e23886f..25ffee2d6a 100644 --- a/designer/server/src/models/forms/editor-v2/question-details.test.js +++ b/designer/server/src/models/forms/editor-v2/question-details.test.js @@ -544,6 +544,23 @@ describe('editor-v2 - question details model', () => { expect(result).toBeDefined() expect(result).toHaveProperty('baseErrors') }) + + test('should handle new questionType that isnt yet in the plugin - should default to TextField', () => { + // @ts-expect-error - dynamic question type not yet defined in types + const result = getErrorTemplates('newType') + expect(result).toBeDefined() + expect(result).toHaveProperty('baseErrors') + expect(result.advancedSettingsErrors).toEqual([ + { + template: '{{#label}} must be {{#limit}} characters or more', + type: 'min' + }, + { + template: '{{#label}} must be {{#limit}} characters or less', + type: 'max' + } + ]) + }) }) describe('questionDetailsViewModel', () => { diff --git a/designer/server/src/models/forms/editor-v2/question-details/preview.js b/designer/server/src/models/forms/editor-v2/question-details/preview.js index a4bfe653ab..0266c52d37 100644 --- a/designer/server/src/models/forms/editor-v2/question-details/preview.js +++ b/designer/server/src/models/forms/editor-v2/question-details/preview.js @@ -242,7 +242,7 @@ export class EmptyRender { const emptyRender = new EmptyRender() export const ModelFactory = - /** @type {Record Question>} */ ({ + /** @type {Partial Question>>} */ ({ /** * @param {QuestionElements} questionElements * @returns {Question} @@ -446,10 +446,16 @@ export const ModelFactory = * @returns {Question} */ export function getPreviewConstructor(componentType, questionOrListElements) { - let QuestionConstructor = ModelFactory.Question + let QuestionConstructor = + /** @type {((q: ListElements | AutocompleteElements | NumberElements) => Question)} */ ( + ModelFactory.Question + ) if (componentType) { - QuestionConstructor = ModelFactory[componentType] + QuestionConstructor = + /** @type {((q: ListElements | AutocompleteElements | NumberElements) => Question)} */ ( + ModelFactory[componentType] ?? ModelFactory.Question + ) } return QuestionConstructor(questionOrListElements) diff --git a/designer/server/src/routes/forms/editor-v2/edit-list-resolve.test.js b/designer/server/src/routes/forms/editor-v2/edit-list-resolve.test.js index 2e9b211035..406e34a2ef 100644 --- a/designer/server/src/routes/forms/editor-v2/edit-list-resolve.test.js +++ b/designer/server/src/routes/forms/editor-v2/edit-list-resolve.test.js @@ -112,6 +112,7 @@ describe('Editor v2 edit-list-resolve routes', () => { { id: 'new-id', text: 'New Item 3', value: 'New Item 3' } ]) + /** @type {Partial} */ const questionDetails = { id: 'q-id', type: ComponentType.AutocompleteField, @@ -169,5 +170,5 @@ describe('Editor v2 edit-list-resolve routes', () => { /** * @import { Server } from '@hapi/hapi' - * @import { ListConflict, ListItem, PageQuestion } from '@defra/forms-model' + * @import { ComponentDef, ListConflict, ListItem, PageQuestion } from '@defra/forms-model' */ diff --git a/model/src/components/enums.ts b/model/src/components/enums.ts index 94eed2d702..0e022a5e33 100644 --- a/model/src/components/enums.ts +++ b/model/src/components/enums.ts @@ -25,3 +25,9 @@ export enum ComponentType { LatLongField = 'LatLongField', HiddenField = 'HiddenField' } + +export const PreviewTypeEnum = { + ...ComponentType, + Question: 'Question', + ListSortable: 'ListSortable' +} as Record diff --git a/model/src/components/index.ts b/model/src/components/index.ts index 3356e5eeb2..c728257638 100644 --- a/model/src/components/index.ts +++ b/model/src/components/index.ts @@ -1,5 +1,5 @@ export { ComponentTypes } from '~/src/components/component-types.js' -export { ComponentType } from '~/src/components/enums.js' +export { ComponentType, PreviewTypeEnum } from '~/src/components/enums.js' export { allDocumentTypes, allImageTypes, diff --git a/model/src/components/types.ts b/model/src/components/types.ts index ef46dfc588..995ca3057b 100644 --- a/model/src/components/types.ts +++ b/model/src/components/types.ts @@ -1,6 +1,9 @@ import { type LanguageMessages } from 'joi' -import { type ComponentType } from '~/src/components/enums.js' +import { + type ComponentType, + type PreviewTypeEnum +} from '~/src/components/enums.js' import { type ListTypeContent, type ListTypeOption @@ -380,3 +383,5 @@ export type ConditionalComponentsDef = Exclude< | UkAddressFieldComponent | FileUploadFieldComponent > + +export type PreviewType = keyof typeof PreviewTypeEnum diff --git a/model/src/conditions/condition-operators.ts b/model/src/conditions/condition-operators.ts index 2c7297cd9c..ff9c16cd71 100644 --- a/model/src/conditions/condition-operators.ts +++ b/model/src/conditions/condition-operators.ts @@ -55,7 +55,11 @@ const relativeDateOperators = { ) } -export const customOperators = { +export type CustomOperators< + T extends ConditionalComponentType = ConditionalComponentType +> = Partial | undefined>> + +export const customOperators: CustomOperators = { [ComponentType.AutocompleteField]: defaultOperators, [ComponentType.RadiosField]: defaultOperators, [ComponentType.CheckboxesField]: { diff --git a/model/src/form/form-editor/preview/helpers.js b/model/src/form/form-editor/preview/helpers.js index 94678f52f4..6715b6c72c 100644 --- a/model/src/form/form-editor/preview/helpers.js +++ b/model/src/form/form-editor/preview/helpers.js @@ -57,10 +57,15 @@ import { import { YesNoQuestion } from '~/src/form/form-editor/preview/yes-no.js' import { findDefinitionListFromComponent } from '~/src/form/utils/list.js' /** - * @type {Record} + * @type {typeof PreviewComponent} + */ +const InputFieldComponentDefault = ShortAnswerQuestion + +/** + * @type {Partial>} */ const InputFieldComponentDictionary = { - [ComponentType.TextField]: ShortAnswerQuestion, + [ComponentType.TextField]: InputFieldComponentDefault, [ComponentType.Details]: ShortAnswerQuestion, [ComponentType.InsetText]: ShortAnswerQuestion, [ComponentType.Html]: ShortAnswerQuestion, @@ -170,8 +175,9 @@ export function mapComponentToPreviewQuestion(questionRenderer, definition) { questionElements = new ComponentElements(component) } - const QuestionConstructor = InputFieldComponentDictionary[component.type] - + const QuestionConstructor = + InputFieldComponentDictionary[component.type] ?? + InputFieldComponentDefault const previewComponent = new QuestionConstructor( questionElements, questionRenderer