From fbccfac04fc0f20c0a38cda84dbb3008bcc22853 Mon Sep 17 00:00:00 2001 From: global-prog Date: Wed, 9 Sep 2026 04:18:09 +0300 Subject: [PATCH] feat: pick number, phone, email and web address when adding a question The validation for phone numbers, email addresses and numbers already exists, but it can only be reached from the input-type menu inside a short-text question that has already been created. Someone adding a question sees only "Short answer" and has no reason to know the rest is there. These four appear directly in the add-question menu. Each is a preset: an ordinary short-text question created with its validation already set, applied client-side after creation, so no new backend type is introduced and nothing changes on the server. Email is deliberately a preset rather than a type of its own. Question::checkEmailType() recognises an email question only as short text carrying email validation, and the confirmation-email recipient picker depends on that test, so a dedicated type would have quietly broken it. Web address uses the existing regex validation, since there is no dedicated URL validator. The pattern is kept simple because it must be valid in both JavaScript and PCRE - the browser and the server each apply it - and was checked against both to behave identically: http and https in any case are accepted, other schemes, bare text and addresses containing spaces are rejected. The preset mechanism is generic, so further presets cost only an entry in the answer type list. Signed-off-by: global-prog --- src/models/AnswerTypes.ts | 72 +++++++++++++++++++++++++++++++++++++++ src/views/Create.vue | 44 ++++++++++++++++++++++-- 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/src/models/AnswerTypes.ts b/src/models/AnswerTypes.ts index e2bc47b9e..bd6517ae9 100644 --- a/src/models/AnswerTypes.ts +++ b/src/models/AnswerTypes.ts @@ -6,13 +6,17 @@ import type { Component } from 'vue' import type { FormsOption, FormsQuestion } from '../types/Entities.d.ts' +import IconNumeric from '@material-symbols/svg-400/outlined/123.svg?raw' import IconNumeric from '@material-symbols/svg-400/outlined/123.svg?raw' import IconArrowDownDropCircleOutline from '@material-symbols/svg-400/outlined/arrow_drop_down_circle.svg?raw' import IconCalendar from '@material-symbols/svg-400/outlined/calendar_today.svg?raw' +import IconPhone from '@material-symbols/svg-400/outlined/call.svg?raw' import IconCheckboxOutline from '@material-symbols/svg-400/outlined/check_box.svg?raw' import IconFile from '@material-symbols/svg-400/outlined/draft.svg?raw' import IconGrid from '@material-symbols/svg-400/outlined/grid_view.svg?raw' import IconLinearScale from '@material-symbols/svg-400/outlined/linear_scale.svg?raw' +import IconLink from '@material-symbols/svg-400/outlined/link.svg?raw' +import IconEMail from '@material-symbols/svg-400/outlined/mail.svg?raw' import IconPalette from '@material-symbols/svg-400/outlined/palette.svg?raw' import IconRadioboxMarked from '@material-symbols/svg-400/outlined/radio_button_checked.svg?raw' import IconClockOutline from '@material-symbols/svg-400/outlined/schedule.svg?raw' @@ -55,6 +59,15 @@ export interface AnswerTypeConfig { warningInvalid: string unique?: boolean subtypes?: Record + /** + * Creates an existing question type with its settings already filled in, so an input + * type can be picked directly when adding a question. + */ + preset?: { + type: string + subtype?: string + extraSettings?: Record + } pickerType?: string storageFormat?: string momentFormat?: string @@ -267,6 +280,65 @@ const answerTypes: Record = { warningInvalid: t('forms', 'This question needs a title!'), }, + /** + * The input types below are presets over short text. The validation they use already + * exists, but could previously only be reached from the input-type menu inside an + * existing short-text question, so it could not be picked when adding a question. + * + * Email is deliberately a preset rather than its own type: Question::checkEmailType() + * recognises an email question only as short text carrying email validation, and the + * confirmation-email recipient picker relies on that. + */ + number: { + component: markRaw(QuestionShort), + icon: IconNumeric, + label: t('forms', 'Number'), + predefined: false, + preset: { type: 'short', extraSettings: { validationType: 'number' } }, + + titlePlaceholder: t('forms', 'Number question title'), + warningInvalid: t('forms', 'This question needs a title!'), + }, + + phone: { + component: markRaw(QuestionShort), + icon: IconPhone, + label: t('forms', 'Phone number'), + predefined: false, + preset: { type: 'short', extraSettings: { validationType: 'phone' } }, + + titlePlaceholder: t('forms', 'Phone number question title'), + warningInvalid: t('forms', 'This question needs a title!'), + }, + + email: { + component: markRaw(QuestionShort), + icon: IconEMail, + label: t('forms', 'Email address'), + predefined: false, + preset: { type: 'short', extraSettings: { validationType: 'email' } }, + + titlePlaceholder: t('forms', 'Email question title'), + warningInvalid: t('forms', 'This question needs a title!'), + }, + + link: { + component: markRaw(QuestionShort), + icon: IconLink, + label: t('forms', 'Web address'), + predefined: false, + preset: { + type: 'short', + extraSettings: { + validationType: 'regex', + validationRegex: '/^https?:\\/\\/\\S+$/i', + }, + }, + + titlePlaceholder: t('forms', 'Web address question title'), + warningInvalid: t('forms', 'This question needs a title!'), + }, + color: { component: markRaw(QuestionColor), icon: IconPalette, diff --git a/src/views/Create.vue b/src/views/Create.vue index 3bdd25f39..e512dca2f 100644 --- a/src/views/Create.vue +++ b/src/views/Create.vue @@ -641,12 +641,21 @@ export default defineComponent({ isLoadingQuestions.value = true try { + // A preset is an ordinary question type created with its settings already + // filled in, so that an input type such as a phone number can be picked + // directly instead of being found inside an existing short-text question. + // Applied client-side, so presets need no new backend type. + const preset = answerTypes[type]?.preset const body: { type: string text: string subtype: string | null position?: number - } = { type, text, subtype } + } = { + type: preset?.type ?? type, + text, + subtype: preset?.subtype ?? subtype, + } if (position !== null) { // position: current question position + 2 (0-based index: +1, next position: +1) body.position = position + 2 @@ -660,8 +669,39 @@ export default defineComponent({ ) const question = OcsResponse2Data(response) + // Apply the preset's settings, then carry them into the local copy so the + // question appears configured straight away rather than after a reload. + if (preset?.extraSettings) { + try { + await axios.patch( + generateOcsUrl( + 'apps/forms/api/v3/forms/{id}/questions/{questionId}', + { id: props.form.id, questionId: question.id }, + ), + { + keyValuePairs: { + extraSettings: preset.extraSettings, + }, + }, + ) + question.extraSettings = { + ...(question.extraSettings ?? {}), + ...preset.extraSettings, + } + } catch (error) { + // The question exists and is usable; only its preconfiguration failed. + logger.error('Error while applying question preset', { + error, + }) + } + } + // Delegate insertion & focus handling to helper - insertQuestion(question, { text, type, answers: [] }, position) + insertQuestion( + question, + { text, type: body.type, answers: [] }, + position, + ) } catch (error) { logger.error('Error while adding new question', { error }) showError(