зеркало из https://github.com/nextcloud/forms.git
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:
Родитель
81fbbd2103
Коммит
d03c0458ac
|
@ -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 }
|
||||
}
|
Загрузка…
Ссылка в новой задаче