feat: Allow selecting other input types for short answers

* Add fallback validation for short input
  Some browsers do not provide input validation for all available types,
  e.g. firefox on desktop does not validate telephone numbers.
* Clicking the input should enable edit mode on create view

Co-authored-by: Chartman123 <chris-hartmann@gmx.de>
Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2023-02-05 14:06:00 +01:00 коммит произвёл Ferdinand Thiessen
Родитель 81fbbd2103
Коммит d03c0458ac
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 45FAE7268762B400
4 изменённых файлов: 368 добавлений и 15 удалений

Просмотреть файл

@ -37,42 +37,189 @@
dir="auto"
:maxlength="maxStringLengths.answerText"
minlength="1"
type="text"
:type="validationObject.inputType"
:step="validationObject.inputType === 'number' ? 'any' : undefined"
@input="onInput"
@keydown.enter.exact.prevent="onKeydownEnter">
<NcActions v-if="edit"
:id="validationTypeMenuId"
:aria-label="t('forms', 'Input types')"
:container="`#${validationTypeMenuId}`"
:open.sync="isValidationTypeMenuOpen"
class="validation-type-menu__toggle"
type="tertiary-no-background">
<template #icon>
<component :is="validationObject.icon" :size="20" />
</template>
<NcActionRadio v-for="(validationTypeObject, validationTypeName) in validationTypes"
:key="validationTypeName"
:checked="validationType === validationTypeName"
:name="validationTypeName"
@update:checked="onChangeValidationType(validationTypeName)">
{{ validationTypeObject.label }}
</NcActionRadio>
<NcActionInput v-if="validationType === 'regex'"
ref="regexInput"
:label="t('forms', 'Regular expression for input validation')"
:value="validationRegex"
@input="onInputRegex"
@submit="onSubmitRegex">
<template #icon>
<IconRegex :size="20" />
</template>
/^[a-z]{3}$/i
<!-- ^ Some example RegExp for the placeholder text -->
</NcActionInput>
</NcActions>
</div>
</Question>
</template>
<script>
import { splitRegex, validateExpression } from '../../utils/RegularExpression.js'
import validationTypes from '../../models/ValidationTypes.js'
import QuestionMixin from '../../mixins/QuestionMixin.js'
import IconRegex from 'vue-material-design-icons/Regex.vue'
import NcActions from '@nextcloud/vue/dist/Components/NcActions.js'
import NcActionInput from '@nextcloud/vue/dist/Components/NcActionInput.js'
import NcActionRadio from '@nextcloud/vue/dist/Components/NcActionRadio.js'
export default {
name: 'QuestionShort',
components: {
IconRegex,
NcActions,
NcActionInput,
NcActionRadio,
},
mixins: [QuestionMixin],
data() {
return {
validationTypes,
typeMenuOpen: false,
}
},
computed: {
submissionInputPlaceholder() {
if (this.readOnly) {
return this.answerType.submitPlaceholder
if (this.edit) {
return this.validationObject.createPlaceholder || this.answerType.createPlaceholder
}
return this.answerType.createPlaceholder
return this.validationObject.submitPlaceholder || this.answerType.submitPlaceholder
},
/**
* Current user input validation type
*/
validationObject() {
return validationTypes[this.validationType]
},
/**
* Name of the current validation type, fallsback to 'text'
*/
validationType() {
return this.extraSettings?.validationType || 'text'
},
/**
* Id of the validation type menu
*/
validationTypeMenuId() {
return 'q' + this.$attrs.index + '__validation_menu'
},
/**
* The regular expression
*/
validationRegex() {
return this.extraSettings?.validationRegex || ''
},
},
methods: {
onInput() {
/** @type {HTMLObjectElement} */
const input = this.$refs.input
this.$emit('update:values', [input.value])
/** @type {string} */
const value = input.value
input.setCustomValidity('')
// Only check non empty values, this question might not be required, if not already invalid
if (value) {
// Then check native browser validation (might be better then our)
// If the browsers validation succeeds either the browser does not implement a validation
// or it is valid, so we double check by running our custom validation.
if (!input.checkValidity() || !this.validationObject.validate(value, splitRegex(this.validationRegex))) {
input.setCustomValidity(this.validationObject.errorMessage)
}
}
this.$emit('update:values', [value])
},
/**
* Change input type
*
* @param {string} validationType new input type
*/
onChangeValidationType(validationType) {
if (validationType === 'regex') {
// Make sure to also submit a regex (even if empty)
this.onExtraSettingsChange({ validationType, validationRegex: this.validationRegex })
} else {
// For all other types except regex we close the menu (for regex we keep it open to allow entering a regex)
this.typeMenuOpen = false
this.onExtraSettingsChange({ validationType: validationType === 'text' ? undefined : validationType })
}
},
/**
* Validate and save regex if valid
*
* Ensures the regex is enclosed with delimters, as required for PCRE,
* and regex is only using modifiers supported by JS *and* PHP
*
* @param {InputEvent|SubmitEvent} event input event
* @return {boolean} true if the regex is valid
*/
onInputRegex(event) {
if (event?.isComposing) {
return false
}
const input = this.$refs.regexInput.$el.querySelector('input')
const validationRegex = input.value
// remove potential previous validity
input.setCustomValidity('')
if (!validateExpression(validationRegex)) {
input.setCustomValidity(t('forms', 'Invalid regular expression'))
return false
}
this.onExtraSettingsChange({ validationRegex })
return true
},
/**
* Same as `onInputRegex` but for convinience also closes the menu
*
* @param {SubmitEvent} event regex submit event
*/
onSubmitRegex(event) {
if (this.onInputRegex(event)) {
this.typeMenuOpen = false
}
},
},
}
</script>
<style lang="scss" scoped>
// Using type to have a higher order than the input styling of server
.question__input[type=text] {
.question__input {
width: 100%;
min-height: 44px;
@ -81,4 +228,15 @@ export default {
margin-inline-start: -12px;
}
}
.validation-type-menu__toggle {
position: relative;
left: calc(100% - 44px);
top: -47px; // input height + margin
}
:deep(input:invalid) {
// nextcloud/server#36548
border-color: var(--color-error)!important;
}
</style>

Просмотреть файл

@ -229,14 +229,14 @@ export default {
/**
* Create mapper to forward the required change to the parent and store to db
*
* @param {string} parameter Name of the setting that changed
* @param {any} value New value of the setting
* Either an object containing the *changed* settings.
*
* @param {object} newSettings changed settings
*/
onExtraSettingsChange: debounce(function(parameter, value) {
const newSettings = Object.assign({}, this.extraSettings)
newSettings[parameter] = value
this.$emit('update:extraSettings', newSettings)
this.saveQuestionProperty('extraSettings', newSettings)
onExtraSettingsChange: debounce(function(newSettings) {
const newExtraSettings = { ...this.extraSettings, ...newSettings }
this.$emit('update:extraSettings', newExtraSettings)
this.saveQuestionProperty('extraSettings', newExtraSettings)
}, 200),
/**
@ -255,7 +255,7 @@ export default {
* @param {boolean} shuffle Should options be shuffled
*/
onShuffleOptionsChange(shuffle) {
return this.onExtraSettingsChange('shuffleOptions', shuffle)
return this.onExtraSettingsChange({ shuffleOptions: shuffle })
},
/**

Просмотреть файл

@ -0,0 +1,122 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <rpm@fthiessen.de>
*
* @author Ferdinand Thiessen <rpm@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
import { translate as t } from '@nextcloud/l10n'
import IconEMail from 'vue-material-design-icons/Email.vue'
import IconPhone from 'vue-material-design-icons/Phone.vue'
import IconRegex from 'vue-material-design-icons/Regex.vue'
import IconTextShort from 'vue-material-design-icons/TextShort.vue'
import IconNumeric from 'vue-material-design-icons/Numeric.vue'
/**
* @callback ValidationFunction
* @param {string} input User input text
* @param {?Record<any>} options Optional setting for validation, like regex pattern.
* @return {boolean} True if the input is valid, false otherwise
*/
/**
* @typedef {object} ValidationType
* @property {string} label The validation-type label, that users will see.
* @property {string} inputType The HTML <input> type used.
* @property {string} errorMessage The error message shown if the validation fails.
* @property {string|undefined} createPlaceholder *optional* A typed placeholder that is visible in edit-mode, to indicate a submission form-input field
* @property {string|undefined} submitPlaceholder *optional* A typed placeholder that is visible in submit-mode, to indicate a form input-field
* @property {import('vue').Component} icon The icon users will see on the input field.
* @property {ValidationFunction} validate Function for validating user input to match the selected input type.
*/
// !! Keep in SYNC with lib/Constants.php for supported types of input validation !!
export default {
/**
* Default, not validated, text input
*
* @type {ValidationType}
*/
text: {
icon: IconTextShort,
inputType: 'text',
label: t('forms', 'Text'),
validate: () => true,
errorMessage: '',
},
/**
* Phone number validation
*
* @type {ValidationType}
*/
phone: {
icon: IconPhone,
inputType: 'tel',
label: t('forms', 'Phone number'),
// Remove common separator symbols, like space or braces, and validate rest are pure numbers
validate: (input) => /^\+?[0-9]{3,}$/.test(input.replace(/[\s()-/x.]/ig, '')),
errorMessage: t('forms', 'The input is not a valid phone number'),
createPlaceholder: t('forms', 'People can enter a telephone number'),
submitPlaceholder: t('forms', 'Enter a telephone number'),
},
/**
* Email address validation
*
* @type {ValidationType}
*/
email: {
icon: IconEMail,
inputType: 'email',
label: t('forms', 'Email address'),
// Simplified email regex as a real one would be too complex, so we validate on backend
validate: (input) => /^[^@]+@[^@]+\.[^.]{2,}$/.test(input),
errorMessage: t('forms', 'The input is not a valid email address'),
createPlaceholder: t('forms', 'People can enter an email address'),
submitPlaceholder: t('forms', 'Enter an email address'),
},
/**
* Numeric input validation
*
* @type {ValidationType}
*/
number: {
icon: IconNumeric,
inputType: 'number',
label: t('forms', 'Number'),
validate: (input) => !isNaN(input) || !isNaN(parseFloat(input)),
errorMessage: t('forms', 'The input is not a valid number'),
createPlaceholder: t('forms', 'People can enter a number'),
submitPlaceholder: t('forms', 'Enter a number'),
},
/**
* Custom regular expression validation
*
* @type {ValidationType}
*/
regex: {
icon: IconRegex,
inputType: 'text',
label: t('forms', 'Custom regular expression'),
validate: (input, { pattern, modifiers }) => (new RegExp(pattern, modifiers)).test(input),
errorMessage: t('forms', 'The input does not match the required pattern'),
},
}

Просмотреть файл

@ -0,0 +1,73 @@
/**
* @copyright Copyright (c) 2023 Ferdinand Thiessen <rpm@fthiessen.de>
*
* @author Ferdinand Thiessen <rpm@fthiessen.de>
*
* @license AGPL-3.0-or-later
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
/**
* Validate a regex, ensures enclosed with delimiters and only supported modifiers by PHP *and* JS
*/
const REGEX_WITH_DELIMITERS = /^\/(.+)\/([smi]{0,3})$/
/**
* Find unescaped slashes within a string
*/
const REGEX_UNESCAPED_SLASH = /(?<=(^|[^\\]))(\\\\)*\//
/**
* Check if a regex is valid and enclosed with delimiters
*
* @param {string} input regular expression
* @return {boolean}
*/
export function validateExpression(input) {
// empty regex passes
if (input.length === 0) {
return true
}
// Validate regex has delimters
if (!REGEX_WITH_DELIMITERS.test(input)) {
return false
}
// Check pattern is escaped
const { pattern, modifiers } = splitRegex(input)
if (REGEX_UNESCAPED_SLASH.test(pattern)) {
return false
}
// Check if regular expression can be compiled
try {
(() => new RegExp(pattern, modifiers))()
return true
} catch (e) {
return false
}
}
/**
* Split an enclosed regular expression into pattern and modifiers
*
* @param {string} regex regular expression with delimiters
* @return {{pattern: string, modifiers: string}} pattern and modifiers
*/
export function splitRegex(regex) {
const [, pattern, modifiers] = regex.match(REGEX_WITH_DELIMITERS) || ['', '', '']
return { pattern, modifiers }
}