Work in progress
This commit is contained in:
46
client/components/forms/CheckboxInput.vue
Normal file
46
client/components/forms/CheckboxInput.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<input-wrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<span />
|
||||
</template>
|
||||
|
||||
<v-checkbox :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" :name="name">
|
||||
<slot name="label">
|
||||
{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span>
|
||||
</slot>
|
||||
</v-checkbox>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import VCheckbox from './components/VCheckbox.vue'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
export default {
|
||||
name: 'CheckboxInput',
|
||||
|
||||
components: { InputWrapper, VCheckbox },
|
||||
props: {
|
||||
...inputProps
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.compVal = !!this.compVal
|
||||
}
|
||||
}
|
||||
</script>
|
||||
65
client/components/forms/CodeInput.vue
Normal file
65
client/components/forms/CodeInput.vue
Normal file
@@ -0,0 +1,65 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<div
|
||||
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
|
||||
>
|
||||
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
|
||||
:options="cmOptions"
|
||||
:style="inputStyle" :name="name"
|
||||
:placeholder="placeholder"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { codemirror } from 'vue-codemirror'
|
||||
import 'codemirror/lib/codemirror.css'
|
||||
|
||||
import 'codemirror/mode/htmlmixed/htmlmixed.js'
|
||||
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
export default {
|
||||
name: 'CodeInput',
|
||||
|
||||
components: { InputWrapper, codemirror },
|
||||
props: {
|
||||
...inputProps
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
cmOptions: {
|
||||
// codemirror options
|
||||
tabSize: 4,
|
||||
mode: 'text/html',
|
||||
theme: 'default',
|
||||
lineNumbers: true,
|
||||
line: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
45
client/components/forms/ColorInput.vue
Normal file
45
client/components/forms/ColorInput.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<input-wrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<span />
|
||||
</template>
|
||||
|
||||
<div class="flex items-center">
|
||||
<input :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
|
||||
type="color" class="mr-2"
|
||||
:name="name"
|
||||
>
|
||||
<slot name="label">
|
||||
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
|
||||
export default {
|
||||
name: 'ColorInput',
|
||||
components: { InputWrapper },
|
||||
|
||||
props: {
|
||||
...inputProps
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
190
client/components/forms/DateInput.vue
Normal file
190
client/components/forms/DateInput.vue
Normal file
@@ -0,0 +1,190 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div v-if="!dateRange" class="flex">
|
||||
<input :id="id?id:name" v-model="fromDate" :type="useTime ? 'datetime-local' : 'date'" :class="inputClasses"
|
||||
:disabled="disabled?true:null"
|
||||
:style="inputStyle" :name="name" data-date-format="YYYY-MM-DD"
|
||||
:min="setMinDate" :max="setMaxDate"
|
||||
>
|
||||
</div>
|
||||
<div v-else :class="inputClasses">
|
||||
<div class="flex -mx-2">
|
||||
<p class="text-gray-900 px-4">
|
||||
From
|
||||
</p>
|
||||
<input :id="id?id:name" v-model="fromDate" :type="useTime ? 'datetime-local' : 'date'" :disabled="disabled?true:null"
|
||||
:style="inputStyle" :name="name" data-date-format="YYYY-MM-DD"
|
||||
class="flex-grow border-transparent focus:outline-none "
|
||||
:min="setMinDate" :max="setMaxDate"
|
||||
>
|
||||
<p class="text-gray-900 px-4">
|
||||
To
|
||||
</p>
|
||||
<input v-if="dateRange" :id="id?id:name" v-model="toDate" :type="useTime ? 'datetime-local' : 'date'"
|
||||
:disabled="disabled?true:null"
|
||||
:style="inputStyle" :name="name" class="flex-grow border-transparent focus:outline-none"
|
||||
:min="setMinDate" :max="setMaxDate"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import { fixedClasses } from '../../plugins/config/vue-tailwind/datePicker.js'
|
||||
|
||||
export default {
|
||||
name: 'DateInput',
|
||||
components: { InputWrapper },
|
||||
mixins: [],
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
withTime: { type: Boolean, default: false },
|
||||
dateRange: { type: Boolean, default: false },
|
||||
disablePastDates: { type: Boolean, default: false },
|
||||
disableFutureDates: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
fixedClasses: fixedClasses,
|
||||
fromDate: null,
|
||||
toDate: null
|
||||
}),
|
||||
|
||||
computed: {
|
||||
inputClasses () {
|
||||
let str = 'border border-gray-300 dark:bg-notion-dark-light dark:border-gray-600 dark:placeholder-gray-500 dark:text-gray-300 flex-1 focus:border-transparent focus:outline-none focus:ring-2 focus:ring-opacity-100 placeholder-gray-400 px-4 py-2 rounded-lg shadow-sm text-base text-black text-gray-700'
|
||||
str += this.dateRange ? ' w-50' : ' w-full'
|
||||
str += this.disabled ? ' !cursor-not-allowed !bg-gray-200' : ''
|
||||
return str
|
||||
},
|
||||
useTime () {
|
||||
return this.withTime && !this.dateRange
|
||||
},
|
||||
setMinDate () {
|
||||
if (this.disablePastDates) {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
return false
|
||||
},
|
||||
setMaxDate () {
|
||||
if (this.disableFutureDates) {
|
||||
return new Date().toISOString().split('T')[0]
|
||||
}
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
color: {
|
||||
handler () {
|
||||
this.setInputColor()
|
||||
},
|
||||
immediate: true
|
||||
},
|
||||
fromDate: {
|
||||
handler (val) {
|
||||
if (this.dateRange) {
|
||||
if (!Array.isArray(this.compVal)) {
|
||||
this.compVal = []
|
||||
}
|
||||
this.compVal[0] = this.dateToUTC(val)
|
||||
} else {
|
||||
this.compVal = this.dateToUTC(val)
|
||||
}
|
||||
},
|
||||
immediate: false
|
||||
},
|
||||
toDate: {
|
||||
handler (val) {
|
||||
if (this.dateRange) {
|
||||
if (!Array.isArray(this.compVal)) {
|
||||
this.compVal = [null]
|
||||
}
|
||||
this.compVal[1] = this.dateToUTC(val)
|
||||
} else {
|
||||
this.compVal = null
|
||||
}
|
||||
},
|
||||
immediate: false
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (this.compVal) {
|
||||
if (Array.isArray(this.compVal)) {
|
||||
this.fromDate = this.compVal[0] ?? null
|
||||
this.toDate = this.compVal[1] ?? null
|
||||
} else {
|
||||
this.fromDate = this.dateToLocal(this.compVal)
|
||||
}
|
||||
}
|
||||
|
||||
this.fixedClasses.input = this.theme.default.input
|
||||
this.setInputColor()
|
||||
},
|
||||
|
||||
methods: {
|
||||
/**
|
||||
* Pressing enter won't submit form
|
||||
* @param event
|
||||
* @returns {boolean}
|
||||
*/
|
||||
onEnterPress (event) {
|
||||
event.preventDefault()
|
||||
return false
|
||||
},
|
||||
setInputColor () {
|
||||
if (this.$refs.datepicker) {
|
||||
const dateInput = this.$refs.datepicker.$el.getElementsByTagName('input')[0]
|
||||
dateInput.style.setProperty('--tw-ring-color', this.color)
|
||||
}
|
||||
},
|
||||
dateToUTC (val) {
|
||||
if (!val) {
|
||||
return null
|
||||
}
|
||||
if (!this.useTime) {
|
||||
return val
|
||||
}
|
||||
return new Date(val).toISOString()
|
||||
},
|
||||
dateToLocal (val) {
|
||||
if (!val) {
|
||||
return null
|
||||
}
|
||||
const dateObj = new Date(val)
|
||||
let dateStr = dateObj.getFullYear() + '-' +
|
||||
String(dateObj.getMonth() + 1).padStart(2, '0') + '-' +
|
||||
String(dateObj.getDate()).padStart(2, '0')
|
||||
if (this.useTime) {
|
||||
dateStr += 'T' + String(dateObj.getHours()).padStart(2, '0') + ':' +
|
||||
String(dateObj.getMinutes()).padStart(2, '0')
|
||||
}
|
||||
return dateStr
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
239
client/components/forms/FileInput.vue
Normal file
239
client/components/forms/FileInput.vue
Normal file
@@ -0,0 +1,239 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div class="flex w-full items-center justify-center transition-colors duration-40"
|
||||
:class="[{'!cursor-not-allowed':disabled, 'cursor-pointer':!disabled,
|
||||
[theme.fileInput.inputHover.light + ' dark:'+theme.fileInput.inputHover.dark]: uploadDragoverEvent,
|
||||
['hover:'+theme.fileInput.inputHover.light +' dark:hover:'+theme.fileInput.inputHover.dark]: !loading}, theme.fileInput.input]"
|
||||
@dragover.prevent="uploadDragoverEvent=true"
|
||||
@dragleave.prevent="uploadDragoverEvent=false"
|
||||
@drop.prevent="onUploadDropEvent"
|
||||
@click="openFileUpload"
|
||||
>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
<loader class="mx-auto h-6 w-6" />
|
||||
<p class="mt-2 text-center text-sm text-gray-500">
|
||||
Uploading your file...
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="text-center">
|
||||
<input
|
||||
ref="actual-input"
|
||||
class="hidden"
|
||||
:multiple="multiple"
|
||||
type="file"
|
||||
:name="name"
|
||||
:accept="acceptExtensions"
|
||||
@change="manualFileUpload"
|
||||
>
|
||||
<div v-if="files.length" class="flex flex-wrap items-center justify-center gap-4">
|
||||
<uploaded-file
|
||||
v-for="file in files"
|
||||
:key="file.url"
|
||||
:file="file"
|
||||
:theme="theme"
|
||||
@remove="clearFile(file)"
|
||||
/>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div class="text-gray-500 w-full flex justify-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="w-6 h-6"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-sm text-gray-500 font-semibold select-none">
|
||||
Click to choose {{ multiple ? 'file(s)' : 'a file' }} or drag here
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-400 dark:text-gray-600 select-none">
|
||||
Size limit: {{ mbLimit }}MB per file
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import UploadedFile from './components/UploadedFile.vue'
|
||||
|
||||
export default {
|
||||
name: 'FileInput',
|
||||
components: { InputWrapper, UploadedFile },
|
||||
mixins: [],
|
||||
props: {
|
||||
...inputProps,
|
||||
multiple: { type: Boolean, default: true },
|
||||
mbLimit: { type: Number, default: 5 },
|
||||
accept: { type: String, default: '' },
|
||||
moveToFormAssets: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
files: [],
|
||||
uploadDragoverEvent: false,
|
||||
loading: false
|
||||
}),
|
||||
|
||||
computed: {
|
||||
currentUrl () {
|
||||
return this.form[this.name]
|
||||
},
|
||||
acceptExtensions () {
|
||||
if (!this.accept) {
|
||||
return null
|
||||
}
|
||||
return this.accept
|
||||
.split(',')
|
||||
.map((i) => {
|
||||
return '.' + i.trim()
|
||||
})
|
||||
.join(',')
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
files: {
|
||||
deep: true,
|
||||
handler (files) {
|
||||
this.compVal = files.map((file) => file.url)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
async created () {
|
||||
if (typeof this.compVal === 'string' || this.compVal instanceof String) {
|
||||
await this.getFileFromUrl(this.compVal).then((fileObj) => {
|
||||
this.files = [{
|
||||
file: fileObj,
|
||||
url: this.compVal,
|
||||
src: this.getFileSrc(fileObj)
|
||||
}]
|
||||
})
|
||||
} else if (this.compVal && this.compVal.length > 0) {
|
||||
const tmpFiles = []
|
||||
for (let i = 0; i < this.compVal.length; i++) {
|
||||
await this.getFileFromUrl(this.compVal[i]).then((fileObj) => {
|
||||
tmpFiles.push({
|
||||
file: fileObj,
|
||||
url: this.compVal[i],
|
||||
src: this.getFileSrc(fileObj)
|
||||
})
|
||||
})
|
||||
}
|
||||
this.files = tmpFiles
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
clearAll () {
|
||||
this.files = []
|
||||
},
|
||||
clearFile (index) {
|
||||
this.files.splice(index, 1)
|
||||
},
|
||||
onUploadDropEvent (e) {
|
||||
this.uploadDragoverEvent = false
|
||||
this.droppedFiles(e.dataTransfer.files)
|
||||
},
|
||||
droppedFiles (droppedFiles) {
|
||||
if (!droppedFiles || this.disabled) return
|
||||
|
||||
for (let i = 0; i < droppedFiles.length; i++) {
|
||||
this.uploadFileToServer(droppedFiles.item(i))
|
||||
}
|
||||
},
|
||||
openFileUpload () {
|
||||
if (this.disabled) return
|
||||
this.$refs['actual-input'].click()
|
||||
},
|
||||
manualFileUpload (e) {
|
||||
const files = e.target.files
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
this.uploadFileToServer(files.item(i))
|
||||
}
|
||||
},
|
||||
uploadFileToServer (file) {
|
||||
if (this.disabled) return
|
||||
this.loading = true
|
||||
this.storeFile(file)
|
||||
.then((response) => {
|
||||
if (!this.multiple) {
|
||||
this.files = []
|
||||
}
|
||||
if (this.moveToFormAssets) {
|
||||
// Move file to permanent storage for form assets
|
||||
axios.post('/api/open/forms/assets/upload', {
|
||||
type: 'files',
|
||||
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
|
||||
}).then(moveFileResponse => {
|
||||
this.files.push({
|
||||
file: file,
|
||||
url: moveFileResponse.data.url,
|
||||
src: this.getFileSrc(file)
|
||||
})
|
||||
this.loading = false
|
||||
}).catch((error) => {
|
||||
this.loading = false
|
||||
})
|
||||
} else {
|
||||
this.files.push({
|
||||
file: file,
|
||||
url: file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension,
|
||||
src: this.getFileSrc(file)
|
||||
})
|
||||
this.loading = false
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
this.clearAll()
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
async getFileFromUrl (url, defaultType = 'image/jpeg') {
|
||||
const response = await fetch(url)
|
||||
const data = await response.blob()
|
||||
const name = url.replace(/^.*(\\|\/|\:)/, '')
|
||||
return new File([data], name, {
|
||||
type: data.type || defaultType
|
||||
})
|
||||
},
|
||||
getFileSrc (file) {
|
||||
if (file.type && file.type.split('/')[0] === 'image') {
|
||||
return URL.createObjectURL(file)
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
96
client/components/forms/FlatSelectInput.vue
Normal file
96
client/components/forms/FlatSelectInput.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
<div v-for="(option, index) in options" v-else :key="option[optionKey]" role="button"
|
||||
:class="[theme.default.input,'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-900 flex',{ 'mb-2': index !== options.length,'!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
|
||||
@click="onSelect(option[optionKey])"
|
||||
>
|
||||
<p class="flex-grow">
|
||||
{{ option[displayKey] }}
|
||||
</p>
|
||||
<div v-if="isSelected(option[optionKey])" class="flex items-center">
|
||||
<svg :color="color" xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
/**
|
||||
* Options: {name,value} objects
|
||||
*/
|
||||
export default {
|
||||
name: 'FlatSelectInput',
|
||||
components: { InputWrapper },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
options: { type: Array, required: true },
|
||||
optionKey: { type: String, default: 'value' },
|
||||
emitKey: { type: String, default: 'value' },
|
||||
displayKey: { type: String, default: 'name' },
|
||||
loading: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false }
|
||||
},
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
methods: {
|
||||
onSelect (value) {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.multiple) {
|
||||
const emitValue = Array.isArray(this.compVal) ? [...this.compVal] : []
|
||||
|
||||
// Already in value, remove it
|
||||
if (this.isSelected(value)) {
|
||||
this.compVal = emitValue.filter((item) => {
|
||||
return item !== value
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise add value
|
||||
emitValue.push(value)
|
||||
this.compVal = emitValue
|
||||
} else {
|
||||
this.compVal = (this.compVal === value) ? null : value
|
||||
}
|
||||
},
|
||||
isSelected (value) {
|
||||
if (!this.compVal) return false
|
||||
|
||||
if (this.multiple) {
|
||||
return this.compVal.includes(value)
|
||||
}
|
||||
return this.compVal === value
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
215
client/components/forms/ImageInput.vue
Normal file
215
client/components/forms/ImageInput.vue
Normal file
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<span class="inline-block w-full rounded-md shadow-sm">
|
||||
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
|
||||
class="cursor-pointer relative w-full" :class="[theme.default.input,{'ring-red-500 ring-2': hasValidation && form.errors.has(name)}]"
|
||||
:style="inputStyle" @click.prevent="showUploadModal=true"
|
||||
>
|
||||
<div v-if="currentUrl==null" class="h-6 text-gray-600 dark:text-gray-400">
|
||||
Upload image <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6 inline" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div v-else class="h-6 text-gray-600 dark:text-gray-400 flex">
|
||||
<div class="flex-grow">
|
||||
<img :src="currentUrl" class="h-6 rounded shadow-md">
|
||||
</div>
|
||||
<a href="#" class="hover:text-nt-blue flex" @click.prevent="clearUrl">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg></a>
|
||||
</div>
|
||||
</button>
|
||||
</span>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
|
||||
<!-- Modal -->
|
||||
<modal :show="showUploadModal" @close="showUploadModal=false">
|
||||
<h2 class="text-lg font-semibold">
|
||||
Upload an image
|
||||
</h2>
|
||||
|
||||
<div class="max-w-3xl mx-auto lg:max-w-none">
|
||||
<div class="sm:mt-5 sm:grid sm:grid-cols-1 sm:gap-4 sm:items-start sm:pt-5">
|
||||
<div class="mt-2 sm:mt-0 sm:col-span-2 mb-5">
|
||||
<div
|
||||
v-cloak
|
||||
class="w-full flex justify-center items-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md h-128"
|
||||
@dragover.prevent="onUploadDragoverEvent($event)"
|
||||
@drop.prevent="onUploadDropEvent($event)"
|
||||
>
|
||||
<div v-if="loading" class="text-gray-600 dark:text-gray-400">
|
||||
<loader class="h-6 w-6 mx-auto m-10" />
|
||||
<p class="text-center mt-6">
|
||||
Uploading your file...
|
||||
</p>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
class="absolute rounded-full bg-gray-100 h-20 w-20 z-10 transition-opacity duration-500 ease-in-out"
|
||||
:class="{
|
||||
'opacity-100': uploadDragoverTracking,
|
||||
'opacity-0': !uploadDragoverTracking
|
||||
}"
|
||||
/>
|
||||
<div class="relative z-20 text-center">
|
||||
<input ref="actual-input" class="hidden" type="file" :name="name"
|
||||
accept="image/png, image/gif, image/jpeg, image/bmp, image/svg+xml" @change="manualFileUpload"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="mx-auto h-24 w-24 text-gray-200" fill="none"
|
||||
viewBox="0 0 24 24" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"
|
||||
/>
|
||||
</svg>
|
||||
<p class="mt-5 text-sm text-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
class="font-semibold text-nt-blue hover:text-nt-blue-dark focus:outline-none focus:underline transition duration-150 ease-in-out"
|
||||
@click="openFileUpload"
|
||||
>
|
||||
Upload your image,
|
||||
</button>
|
||||
use drag and drop or paste it
|
||||
</p>
|
||||
<p class="mt-1 text-xs text-gray-500">
|
||||
.jpg, .jpeg, .png, .bmp, .gif, .svg up to 5mb
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modal>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from 'axios'
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import Modal from '../global/Modal.vue'
|
||||
|
||||
export default {
|
||||
name: 'ImageInput',
|
||||
components: { InputWrapper, Modal },
|
||||
mixins: [],
|
||||
props: {
|
||||
...inputProps
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
showUploadModal: false,
|
||||
|
||||
file: [],
|
||||
uploadDragoverTracking: false,
|
||||
uploadDragoverEvent: false,
|
||||
loading: false
|
||||
}),
|
||||
|
||||
computed: {
|
||||
currentUrl () {
|
||||
return this.compVal
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
showUploadModal: {
|
||||
handler (val) {
|
||||
if (process.server) return
|
||||
document.removeEventListener('paste', this.onUploadPasteEvent)
|
||||
if (this.showUploadModal) {
|
||||
document.addEventListener('paste', this.onUploadPasteEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
clearUrl () {
|
||||
this.form[this.name] = null
|
||||
},
|
||||
onUploadDragoverEvent (e) {
|
||||
this.uploadDragoverEvent = true
|
||||
this.uploadDragoverTracking = true
|
||||
},
|
||||
onUploadDropEvent (e) {
|
||||
this.uploadDragoverEvent = false
|
||||
this.uploadDragoverTracking = false
|
||||
this.droppedFiles(e.dataTransfer.files)
|
||||
},
|
||||
onUploadPasteEvent (e) {
|
||||
if (!this.showUploadModal) return
|
||||
this.uploadDragoverEvent = false
|
||||
this.uploadDragoverTracking = false
|
||||
this.droppedFiles(e.clipboardData.files)
|
||||
},
|
||||
droppedFiles (droppedFiles) {
|
||||
if (!droppedFiles) return
|
||||
|
||||
this.file = droppedFiles[0]
|
||||
this.uploadFileToServer()
|
||||
},
|
||||
openFileUpload () {
|
||||
this.$refs['actual-input'].click()
|
||||
},
|
||||
manualFileUpload (e) {
|
||||
this.file = e.target.files[0]
|
||||
this.uploadFileToServer()
|
||||
},
|
||||
uploadFileToServer () {
|
||||
this.loading = true
|
||||
// Store file in s3
|
||||
this.storeFile(this.file).then(response => {
|
||||
// Move file to permanent storage for form assets
|
||||
axios.post('/api/open/forms/assets/upload', {
|
||||
url: this.file.name.split('.').slice(0, -1).join('.') + '_' + response.uuid + '.' + response.extension
|
||||
}).then(moveFileResponse => {
|
||||
if (!this.multiple) {
|
||||
this.files = []
|
||||
}
|
||||
this.compVal = moveFileResponse.data.url
|
||||
this.showUploadModal = false
|
||||
this.loading = false
|
||||
}).catch((error) => {
|
||||
this.compVal = null
|
||||
this.showUploadModal = false
|
||||
this.loading = false
|
||||
})
|
||||
}).catch((error) => {
|
||||
this.compVal = null
|
||||
this.showUploadModal = false
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
145
client/components/forms/PhoneInput.vue
Normal file
145
client/components/forms/PhoneInput.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div :id="id ? id : name" :name="name" :style="inputStyle" class="flex items-center">
|
||||
<v-select v-model="selectedCountryCode" class="w-[130px]" dropdown-class="w-[300px]" input-class="rounded-r-none"
|
||||
:data="countries"
|
||||
:disabled="(disabled || countries.length===1)?true:null" :searchable="true" :search-keys="['name']" :option-key="'code'" :color="color"
|
||||
:has-error="hasValidation && form.errors.has(name)"
|
||||
:placeholder="'Select a country'" :uppercase-labels="true" :theme="theme" @update:model-value="onChangeCountryCode"
|
||||
>
|
||||
<template #option="props">
|
||||
<div class="flex items-center space-x-2 hover:text-white">
|
||||
<country-flag size="normal" class="!-mt-[9px]" :country="props.option.code" />
|
||||
<span class="grow">{{ props.option.name }}</span>
|
||||
<span>{{ props.option.dial_code }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #selected="props">
|
||||
<div class="flex items-center space-x-2 justify-center overflow-hidden">
|
||||
<country-flag size="normal" class="!-mt-[9px]" :country="props.option.code" />
|
||||
<span>{{ props.option.dial_code }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</v-select>
|
||||
<input v-model="inputVal" type="text" class="inline-flex-grow !border-l-0 !rounded-l-none" :disabled="disabled?true:null"
|
||||
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200': disabled }]"
|
||||
:placeholder="placeholder" :style="inputStyle" @update:model-value="onInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import countryCodes from '../../../data/country_codes.json'
|
||||
import CountryFlag from 'vue-country-flag-next'
|
||||
import parsePhoneNumber from 'libphonenumber-js'
|
||||
|
||||
export default {
|
||||
phone: 'PhoneInput',
|
||||
components: { InputWrapper, CountryFlag },
|
||||
props: {
|
||||
...inputProps,
|
||||
canOnlyCountry: { type: Boolean, default: false },
|
||||
unavailableCountries: { type: Array, default: () => [] }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
selectedCountryCode: null,
|
||||
inputVal: null
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
countries () {
|
||||
return countryCodes.filter((item) => {
|
||||
return !this.unavailableCountries.includes(item.code)
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
watch: {
|
||||
inputVal: {
|
||||
handler (val) {
|
||||
if (val && val.startsWith('0')) {
|
||||
val = val.substring(1)
|
||||
}
|
||||
if (this.canOnlyCountry) {
|
||||
this.compVal = (val) ? this.selectedCountryCode.code + this.selectedCountryCode.dial_code + val : this.selectedCountryCode.code + this.selectedCountryCode.dial_code
|
||||
} else {
|
||||
this.compVal = (val) ? this.selectedCountryCode.code + this.selectedCountryCode.dial_code + val : null
|
||||
}
|
||||
}
|
||||
},
|
||||
selectedCountryCode (newVal, oldVal) {
|
||||
if (this.compVal && newVal && oldVal) {
|
||||
this.compVal = this.compVal.replace(oldVal.code + oldVal.dial_code, newVal.code + newVal.dial_code)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (this.compVal) {
|
||||
if (!this.compVal.startsWith('+')) {
|
||||
this.selectedCountryCode = this.getCountryBy(this.compVal.substring(2, 0))
|
||||
}
|
||||
|
||||
const phoneObj = parsePhoneNumber(this.compVal)
|
||||
if (phoneObj !== undefined && phoneObj) {
|
||||
if (!this.selectedCountryCode && phoneObj.country !== undefined && phoneObj.country) {
|
||||
this.selectedCountryCode = this.getCountryBy(phoneObj.country)
|
||||
}
|
||||
this.inputVal = phoneObj.nationalNumber
|
||||
}
|
||||
}
|
||||
if (!this.selectedCountryCode) {
|
||||
this.selectedCountryCode = this.getCountryBy()
|
||||
}
|
||||
if (!this.selectedCountryCode || this.countries.length === 1) {
|
||||
this.selectedCountryCode = this.countries[0]
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
getCountryBy (code = 'US', type = 'code') {
|
||||
if (!code) code = 'US' // Default US
|
||||
return this.countries.find((item) => {
|
||||
return item[type] === code
|
||||
}) ?? null
|
||||
},
|
||||
onInput (event) {
|
||||
this.inputVal = event.target.value.replace(/[^0-9]/g, '')
|
||||
},
|
||||
onChangeCountryCode () {
|
||||
if (!this.selectedCountryCode && this.countries.length > 0) {
|
||||
this.selectedCountryCode = this.countries[0]
|
||||
}
|
||||
if (this.canOnlyCountry && (this.inputVal === null || this.inputVal === '' || !this.inputVal)) {
|
||||
this.compVal = this.selectedCountryCode.code + this.selectedCountryCode.dial_code
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
85
client/components/forms/RatingInput.vue
Normal file
85
client/components/forms/RatingInput.vue
Normal file
@@ -0,0 +1,85 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div class="stars-outer">
|
||||
<div v-for="i in numberOfStars" :key="i"
|
||||
class="cursor-pointer inline-block text-gray-200 dark:text-gray-800"
|
||||
:class="{'!text-yellow-400 active-star':i<=compVal, '!text-yellow-200 !dark:text-yellow-800 hover-star':i>compVal && i<=hoverRating, '!cursor-not-allowed':disabled}"
|
||||
role="button" @click="setRating(i)"
|
||||
@mouseenter="onMouseHover(i)"
|
||||
@mouseleave="hoverRating = -1"
|
||||
>
|
||||
<svg class="w-8 h-8" fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
export default {
|
||||
name: 'RatingInput',
|
||||
components: { InputWrapper },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
numberOfStars: { type: Number, default: 5 }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
hoverRating: -1
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (!this.compVal) this.compVal = 0
|
||||
},
|
||||
|
||||
updated () {
|
||||
if (!this.compVal) {
|
||||
this.compVal = 0
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onMouseHover (i) {
|
||||
this.hoverRating = (this.disabled) ? -1 : i
|
||||
},
|
||||
setRating (val) {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
if (this.compVal === val) {
|
||||
this.compVal = 0
|
||||
} else {
|
||||
this.compVal = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
81
client/components/forms/RichTextAreaInput.vue
Normal file
81
client/components/forms/RichTextAreaInput.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled?true:null"
|
||||
:placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]"
|
||||
:editor-toolbar="editorToolbar" class="rich-editor resize-y"
|
||||
:style="inputStyle"
|
||||
/>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import { VueEditor, Quill } from 'vue3-editor'
|
||||
|
||||
Quill.imports['formats/link'].PROTOCOL_WHITELIST.push('notion')
|
||||
|
||||
export default {
|
||||
name: 'RichTextAreaInput',
|
||||
components: { InputWrapper, VueEditor },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
editorToolbar: {
|
||||
type: Array,
|
||||
default: () => {
|
||||
return [
|
||||
[{ header: 1 }, { header: 2 }],
|
||||
['bold', 'italic', 'underline', 'link'],
|
||||
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||
[{ color: [] }]
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.rich-editor {
|
||||
.ql-container {
|
||||
border-bottom: 0px !important;
|
||||
border-right: 0px !important;
|
||||
border-left: 0px !important;
|
||||
|
||||
.ql-editor {
|
||||
min-height: 100px !important;
|
||||
}
|
||||
}
|
||||
|
||||
.ql-toolbar {
|
||||
border-top: 0px !important;
|
||||
border-right: 0px !important;
|
||||
border-left: 0px !important;
|
||||
}
|
||||
|
||||
.ql-snow .ql-toolbar .ql-picker-item.ql-selected, .ql-snow .ql-toolbar .ql-picker-item:hover, .ql-snow .ql-toolbar .ql-picker-label.ql-active, .ql-snow .ql-toolbar .ql-picker-label:hover, .ql-snow .ql-toolbar button.ql-active, .ql-snow .ql-toolbar button:focus, .ql-snow .ql-toolbar button:hover, .ql-snow.ql-toolbar .ql-picker-item.ql-selected, .ql-snow.ql-toolbar .ql-picker-item:hover, .ql-snow.ql-toolbar .ql-picker-label.ql-active, .ql-snow.ql-toolbar .ql-picker-label:hover, .ql-snow.ql-toolbar button.ql-active, .ql-snow.ql-toolbar button:focus, .ql-snow.ql-toolbar button:hover {
|
||||
@apply text-nt-blue;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
107
client/components/forms/ScaleInput.vue
Normal file
107
client/components/forms/ScaleInput.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<div class="rectangle-outer grid grid-cols-5 gap-2">
|
||||
<div v-for="i in scaleList" :key="i"
|
||||
:class="[{'font-semibold':compVal===i},theme.ScaleInput.button, compVal!==i ? unselectedButtonClass: '']"
|
||||
:style="btnStyle(i===compVal)"
|
||||
role="button" @click="setScale(i)"
|
||||
>
|
||||
{{ i }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
export default {
|
||||
name: 'ScaleInput',
|
||||
components: { InputWrapper },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
minScale: { type: Number, default: 1 },
|
||||
maxScale: { type: Number, default: 5 },
|
||||
stepScale: { type: Number, default: 1 }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {}
|
||||
},
|
||||
|
||||
computed: {
|
||||
scaleList () {
|
||||
const list = []
|
||||
for (let i = this.minScale; i <= this.maxScale; i += this.stepScale) {
|
||||
list.push(i)
|
||||
}
|
||||
return list
|
||||
},
|
||||
unselectedButtonClass () {
|
||||
return this.theme.ScaleInput.unselectedButton
|
||||
},
|
||||
textColor () {
|
||||
const color = (this.color.charAt(0) === '#') ? this.color.substring(1, 7) : this.color
|
||||
const r = parseInt(color.substring(0, 2), 16) // hexToR
|
||||
const g = parseInt(color.substring(2, 4), 16) // hexToG
|
||||
const b = parseInt(color.substring(4, 6), 16) // hexToB
|
||||
const uicolors = [r / 255, g / 255, b / 255]
|
||||
const c = uicolors.map((col) => {
|
||||
if (col <= 0.03928) {
|
||||
return col / 12.92
|
||||
}
|
||||
return Math.pow((col + 0.055) / 1.055, 2.4)
|
||||
})
|
||||
const L = (0.2126 * c[0]) + (0.7152 * c[1]) + (0.0722 * c[2])
|
||||
return (L > 0.55) ? '#000000' : '#FFFFFF'
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
if (this.compVal && typeof this.compVal === 'string') {
|
||||
this.compVal = parseInt(this.compVal)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
btnStyle (isSelected) {
|
||||
if (!isSelected) return {}
|
||||
return {
|
||||
color: this.textColor,
|
||||
backgroundColor: this.color
|
||||
}
|
||||
},
|
||||
setScale (val) {
|
||||
if (this.disabled) {
|
||||
return
|
||||
}
|
||||
if (this.compVal === val) {
|
||||
this.compVal = null
|
||||
} else {
|
||||
this.compVal = val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
131
client/components/forms/SelectInput.vue
Normal file
131
client/components/forms/SelectInput.vue
Normal file
@@ -0,0 +1,131 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<v-select v-model="compVal"
|
||||
:data="finalOptions"
|
||||
:label="label"
|
||||
:option-key="optionKey"
|
||||
:emit-key="emitKey"
|
||||
:required="required"
|
||||
:multiple="multiple"
|
||||
:searchable="searchable"
|
||||
:loading="loading"
|
||||
:color="color"
|
||||
:placeholder="placeholder"
|
||||
:uppercase-labels="uppercaseLabels"
|
||||
:theme="theme"
|
||||
:has-error="hasValidation && form.errors.has(name)"
|
||||
:allow-creation="allowCreation"
|
||||
:disabled="disabled?true:null"
|
||||
:help="help"
|
||||
:help-position="helpPosition"
|
||||
|
||||
@update-options="updateOptions"
|
||||
>
|
||||
<template #selected="{option}">
|
||||
<slot name="selected" :option="option" :optionName="getOptionName(option)">
|
||||
<template v-if="multiple">
|
||||
<div class="flex items-center truncate mr-6">
|
||||
<span v-for="(item,index) in option" :key="item" class="truncate">
|
||||
<span v-if="index!==0">, </span>
|
||||
{{ getOptionName(item) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex items-center truncate mr-6">
|
||||
<div>{{ getOptionName(option) }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</slot>
|
||||
</template>
|
||||
<template #option="{option, selected}">
|
||||
<slot name="option" :option="option" :selected="selected">
|
||||
<span class="flex group-hover:text-white">
|
||||
<p class="flex-grow group-hover:text-white">
|
||||
{{ option.name }}
|
||||
</p>
|
||||
<span v-if="selected" class="absolute inset-y-0 right-0 flex items-center pr-4 dark:text-white">
|
||||
<svg class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</span>
|
||||
</slot>
|
||||
</template>
|
||||
</v-select>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
/**
|
||||
* Options: {name,value} objects
|
||||
*/
|
||||
export default {
|
||||
name: 'SelectInput',
|
||||
components: { InputWrapper },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
options: { type: Array, required: true },
|
||||
optionKey: { type: String, default: 'value' },
|
||||
emitKey: { type: String, default: 'value' },
|
||||
displayKey: { type: String, default: 'name' },
|
||||
loading: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false },
|
||||
searchable: { type: Boolean, default: false },
|
||||
allowCreation: { type: Boolean, default: false }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
additionalOptions: []
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
finalOptions () {
|
||||
return this.options.concat(this.additionalOptions)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getOptionName (val) {
|
||||
const option = this.finalOptions.find((optionCandidate) => {
|
||||
return optionCandidate[this.optionKey] === val
|
||||
})
|
||||
if (option) return option[this.displayKey]
|
||||
return null
|
||||
},
|
||||
updateOptions (newItem) {
|
||||
if (newItem) {
|
||||
this.additionalOptions.push(newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
62
client/components/forms/SignatureInput.vue
Normal file
62
client/components/forms/SignatureInput.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<VueSignaturePad ref="signaturePad"
|
||||
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
|
||||
height="150px"
|
||||
:name="name"
|
||||
:options="{ onEnd }"
|
||||
/>
|
||||
|
||||
<template #bottom_after_help>
|
||||
<small :class="theme.default.help">
|
||||
<a :class="theme.default.help" href="#" @click.prevent="clear">Clear</a>
|
||||
</small>
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
import { VueSignaturePad } from 'vue-signature-pad'
|
||||
|
||||
export default {
|
||||
name: 'SignatureInput',
|
||||
components: { InputWrapper, VueSignaturePad },
|
||||
|
||||
props: {
|
||||
...inputProps
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
clear () {
|
||||
this.$refs.signaturePad.clearSignature()
|
||||
this.onEnd()
|
||||
},
|
||||
onEnd () {
|
||||
if (this.disabled) {
|
||||
this.$refs.signaturePad.clearSignature()
|
||||
} else {
|
||||
const { isEmpty, data } = this.$refs.signaturePad.saveSignature()
|
||||
this.form[this.name] = (!isEmpty && data) ? data : null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
56
client/components/forms/TextAreaInput.vue
Normal file
56
client/components/forms/TextAreaInput.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<textarea :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
|
||||
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasValidation && form.errors.has(name), '!cursor-not-allowed !bg-gray-200':disabled }]"
|
||||
class="resize-y"
|
||||
:name="name" :style="inputStyle"
|
||||
:placeholder="placeholder"
|
||||
:maxlength="maxCharLimit"
|
||||
/>
|
||||
|
||||
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
|
||||
<small :class="theme.default.help">
|
||||
{{ charCount }}/{{ maxCharLimit }}
|
||||
</small>
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
export default {
|
||||
name: 'TextAreaInput',
|
||||
components: { InputWrapper },
|
||||
mixins: [],
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
maxCharLimit: { type: Number, required: false, default: null },
|
||||
showCharLimit: { type: Boolean, required: false, default: false }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
charCount () {
|
||||
return (this.compVal) ? this.compVal.length : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
76
client/components/forms/TextInput.vue
Normal file
76
client/components/forms/TextInput.vue
Normal file
@@ -0,0 +1,76 @@
|
||||
<template>
|
||||
<input-wrapper
|
||||
v-bind="inputWrapperProps"
|
||||
>
|
||||
<template #label>
|
||||
<slot name="label" />
|
||||
</template>
|
||||
|
||||
<input :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
|
||||
:type="nativeType"
|
||||
:pattern="pattern"
|
||||
:style="inputStyle"
|
||||
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled }]"
|
||||
:name="name" :accept="accept"
|
||||
:placeholder="placeholder" :min="min" :max="max" :maxlength="maxCharLimit"
|
||||
@change="onChange" @keydown.enter.prevent="onEnterPress"
|
||||
>
|
||||
|
||||
<template v-if="maxCharLimit && showCharLimit" #bottom_after_help>
|
||||
<small :class="theme.default.help">
|
||||
{{ charCount }}/{{ maxCharLimit }}
|
||||
</small>
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
|
||||
export default {
|
||||
name: 'TextInput',
|
||||
components: { InputWrapper },
|
||||
|
||||
props: {
|
||||
...inputProps,
|
||||
nativeType: { type: String, default: 'text' },
|
||||
accept: { type: String, default: null },
|
||||
min: { type: Number, required: false, default: null },
|
||||
max: { type: Number, required: false, default: null },
|
||||
maxCharLimit: { type: Number, required: false, default: null },
|
||||
showCharLimit: { type: Boolean, required: false, default: false },
|
||||
pattern: { type: String, default: null }
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
const onChange = (event) => {
|
||||
if (props.nativeType !== 'file') return
|
||||
|
||||
const file = event.target.files[0]
|
||||
// eslint-disable-next-line vue/no-mutating-props
|
||||
props.form[props.name] = file
|
||||
}
|
||||
|
||||
const onEnterPress = (event) => {
|
||||
event.preventDefault()
|
||||
return false
|
||||
}
|
||||
|
||||
return {
|
||||
...useFormInput(props, context, props.nativeType === 'file' ? 'file-' : null),
|
||||
onEnterPress,
|
||||
onChange
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
charCount () {
|
||||
return (this.compVal) ? this.compVal.length : 0
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
46
client/components/forms/ToggleSwitchInput.vue
Normal file
46
client/components/forms/ToggleSwitchInput.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<input-wrapper v-bind="inputWrapperProps">
|
||||
<template #label>
|
||||
<span />
|
||||
</template>
|
||||
|
||||
<div class="flex">
|
||||
<v-switch :id="id?id:name" v-model="compVal" class="inline-block mr-2" :disabled="disabled?true:null" />
|
||||
<slot name="label">
|
||||
<span>{{ label }} <span v-if="required" class="text-red-500 required-dot">*</span></span>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<template #help>
|
||||
<slot name="help" />
|
||||
</template>
|
||||
|
||||
<template #error>
|
||||
<slot name="error" />
|
||||
</template>
|
||||
</input-wrapper>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { inputProps, useFormInput } from './useFormInput.js'
|
||||
import VSwitch from './components/VSwitch.vue'
|
||||
import InputWrapper from './components/InputWrapper.vue'
|
||||
export default {
|
||||
name: 'ToggleSwitchInput',
|
||||
|
||||
components: { InputWrapper, VSwitch },
|
||||
props: {
|
||||
...inputProps
|
||||
},
|
||||
|
||||
setup (props, context) {
|
||||
return {
|
||||
...useFormInput(props, context)
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.compVal = !!this.compVal
|
||||
}
|
||||
}
|
||||
</script>
|
||||
21
client/components/forms/components/InputHelp.vue
Normal file
21
client/components/forms/components/InputHelp.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<div class="flex mb-1 input-help">
|
||||
<small :class="theme.default.help" class="grow flex">
|
||||
<slot name="help"><span class="field-help" v-html="help" /></slot>
|
||||
</small>
|
||||
<slot name="after-help">
|
||||
<small class="flex-grow" />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'InputHelp',
|
||||
|
||||
props: {
|
||||
theme: { type: Object, required: true },
|
||||
help: { type: String, required: false }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
25
client/components/forms/components/InputLabel.vue
Normal file
25
client/components/forms/components/InputLabel.vue
Normal file
@@ -0,0 +1,25 @@
|
||||
<template>
|
||||
<label :for="nativeFor"
|
||||
class="input-label"
|
||||
:class="[theme.default.label,{'uppercase text-xs': uppercaseLabels, 'text-sm': !uppercaseLabels}]"
|
||||
>
|
||||
<slot>
|
||||
{{ label }}
|
||||
<span v-if="required" class="text-red-500 required-dot">*</span>
|
||||
</slot>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'InputLabel',
|
||||
|
||||
props: {
|
||||
nativeFor: { type: String, default: null },
|
||||
theme: { type: Object, required: true },
|
||||
uppercaseLabels: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false },
|
||||
label: { type: String, required: true }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
54
client/components/forms/components/InputWrapper.vue
Normal file
54
client/components/forms/components/InputWrapper.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<template>
|
||||
<div :class="wrapperClass" :style="inputStyle">
|
||||
<slot name="label">
|
||||
<input-label v-if="label && !hideFieldName"
|
||||
:label="label"
|
||||
:theme="theme"
|
||||
:required="required"
|
||||
:native-for="id?id:name"
|
||||
:uppercase-labels="uppercaseLabels"
|
||||
/>
|
||||
</slot>
|
||||
<slot v-if="help && helpPosition==='above_input'" name="help">
|
||||
<input-help :help="help" :theme="theme" />
|
||||
</slot>
|
||||
<slot />
|
||||
|
||||
<slot v-if="(help && helpPosition==='below_input') || $slots.bottom_after_help" name="help">
|
||||
<input-help :help="help" :theme="theme">
|
||||
<template #after-help>
|
||||
<slot name="bottom_after_help" />
|
||||
</template>
|
||||
</input-help>
|
||||
</slot>
|
||||
<slot name="error">
|
||||
<has-error v-if="hasValidation && form" :form="form" :field="name" />
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import InputLabel from './InputLabel.vue'
|
||||
import InputHelp from './InputHelp.vue'
|
||||
|
||||
export default {
|
||||
name: 'InputWrapper',
|
||||
components: { InputLabel, InputHelp },
|
||||
|
||||
props: {
|
||||
id: { type: String, required: false },
|
||||
name: { type: String, required: false },
|
||||
label: { type: String, required: false },
|
||||
form: { type: Object, required: false },
|
||||
theme: { type: Object, required: true },
|
||||
wrapperClass: { type: String, required: false },
|
||||
inputStyle: { type: Object, required: false },
|
||||
help: { type: String, required: false },
|
||||
helpPosition: { type: String, default: 'below_input' },
|
||||
uppercaseLabels: { type: Boolean, default: true },
|
||||
hideFieldName: { type: Boolean, default: true },
|
||||
required: { type: Boolean, default: false },
|
||||
hasValidation: { type: Boolean, default: true }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
63
client/components/forms/components/UploadedFile.vue
Normal file
63
client/components/forms/components/UploadedFile.vue
Normal file
@@ -0,0 +1,63 @@
|
||||
<template>
|
||||
<div
|
||||
:class="[theme.fileInput.uploadedFile, 'overflow-hidden']"
|
||||
:title="file.file.name"
|
||||
>
|
||||
<div v-if="file.src && !isImageHide" class="h-20 overflow-hidden flex">
|
||||
<img class="block object-cover object-center w-full" :src="file.src" @error="isImageHide=true">
|
||||
</div>
|
||||
<div v-else class="h-20 flex items-center justify-center">
|
||||
<svg class="w-10 h-10 text-gray-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"
|
||||
stroke-width="0.8" stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex gap-2 items-center border-t py-1 px-2">
|
||||
<p class="flex-grow text-left truncate text-gray-500 text-xs">
|
||||
{{ file.file.name }}
|
||||
</p>
|
||||
<a
|
||||
href="javascript:void(0);"
|
||||
class="flex text-gray-400 rounded hover:bg-neutral-50 hover:text-red-500 dark:text-gray-600 p-1"
|
||||
role="button"
|
||||
title="Remove"
|
||||
@click.stop="$emit('remove')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'UploadedFile',
|
||||
|
||||
props: {
|
||||
file: { default: null },
|
||||
theme: { type: Object }
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
isImageHide: false
|
||||
}),
|
||||
|
||||
computed: {}
|
||||
}
|
||||
</script>
|
||||
72
client/components/forms/components/VCheckbox.vue
Normal file
72
client/components/forms/components/VCheckbox.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div class="flex items-center">
|
||||
<input
|
||||
:id="id || name"
|
||||
:name="name"
|
||||
:checked="internalValue"
|
||||
type="checkbox"
|
||||
:class="sizeClasses"
|
||||
class="rounded border-gray-500 cursor-pointer"
|
||||
:disabled="disabled?true:null"
|
||||
@click="handleClick"
|
||||
>
|
||||
<label :for="id || name" class="text-gray-700 dark:text-gray-300 ml-2" :class="{'!cursor-not-allowed':disabled}">
|
||||
<slot />
|
||||
</label>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted, defineProps, defineEmits, defineOptions } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
name: 'VCheckbox'
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
id: { type: String, default: null },
|
||||
name: { type: String, default: 'checkbox' },
|
||||
modelValue: { type: [Boolean, String], default: false },
|
||||
checked: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
sizeClasses: { type: String, default: 'w-4 h-4' }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'click'])
|
||||
|
||||
const internalValue = ref(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, val => {
|
||||
internalValue.value = val
|
||||
})
|
||||
|
||||
watch(() => props.checked, val => {
|
||||
internalValue.value = val
|
||||
})
|
||||
|
||||
watch(() => internalValue.value, (val, oldVal) => {
|
||||
if (val === 0 || val === '0') val = false
|
||||
if (val === 1 || val === '1') val = true
|
||||
|
||||
if (val !== oldVal) {
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
})
|
||||
|
||||
if ('checked' in props) {
|
||||
internalValue.value = props.checked
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
emit('update:modelValue', internalValue.value)
|
||||
})
|
||||
|
||||
const handleClick = (e) => {
|
||||
emit('click', e)
|
||||
|
||||
if (!e.isPropagationStopped) {
|
||||
internalValue.value = e.target.checked
|
||||
emit('update:modelValue', internalValue.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
224
client/components/forms/components/VSelect.vue
Normal file
224
client/components/forms/components/VSelect.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div class="v-select relative">
|
||||
<span class="inline-block w-full rounded-md">
|
||||
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
|
||||
class="cursor-pointer"
|
||||
:style="inputStyle"
|
||||
:class="[theme.SelectInput.input,{'py-2': !multiple || loading,'py-1': multiple, '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled}, inputClass]"
|
||||
@click.stop="toggleDropdown"
|
||||
>
|
||||
<div :class="{'h-6': !multiple, 'min-h-8': multiple && !loading}">
|
||||
<transition name="fade" mode="out-in">
|
||||
<loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
<div v-else-if="modelValue" key="value" class="flex" :class="{'min-h-8': multiple}">
|
||||
<slot name="selected" :option="modelValue" />
|
||||
</div>
|
||||
<div v-else key="placeholder">
|
||||
<slot name="placeholder">
|
||||
<div class="text-gray-400 dark:text-gray-500 w-full text-left truncate pr-3"
|
||||
:class="{'py-1': multiple && !loading}"
|
||||
>
|
||||
{{ placeholder }}
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
<span class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
|
||||
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="none" stroke="currentColor">
|
||||
<path d="M7 7l3-3 3 3m0 6l-3 3-3-3" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
<collapsible v-model="isOpen"
|
||||
class="absolute mt-1 rounded-md bg-white dark:bg-notion-dark-light shadow-xl z-10"
|
||||
:class="dropdownClass"
|
||||
>
|
||||
<ul tabindex="-1" role="listbox"
|
||||
class="rounded-md text-base leading-6 shadow-xs overflow-auto focus:outline-none sm:text-sm sm:leading-5 relative"
|
||||
:class="{'max-h-42 py-1': !isSearchable,'max-h-48 pb-1': isSearchable}"
|
||||
>
|
||||
<div v-if="isSearchable" class="px-2 pt-2 sticky top-0 bg-white dark-bg-notion-dark-light z-10">
|
||||
<text-input v-model="searchTerm" name="search" :color="color" :theme="theme"
|
||||
placeholder="Search..."
|
||||
/>
|
||||
</div>
|
||||
<div v-if="loading" class="w-full py-2 flex justify-center">
|
||||
<loader class="h-6 w-6 text-nt-blue mx-auto" />
|
||||
</div>
|
||||
<template v-if="filteredOptions.length > 0">
|
||||
<li v-for="item in filteredOptions" :key="item[optionKey]" role="option" :style="optionStyle"
|
||||
:class="{'px-3 pr-9': multiple, 'px-3': !multiple}"
|
||||
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
|
||||
@click="select(item)"
|
||||
>
|
||||
<slot name="option" :option="item" :selected="isSelected(item)" />
|
||||
</li>
|
||||
</template>
|
||||
<p v-else-if="!loading && !(allowCreation && searchTerm)" class="w-full text-gray-500 text-center py-2">
|
||||
{{ (allowCreation ? 'Type something to add an option' : 'No option available') }}.
|
||||
</p>
|
||||
<li v-if="allowCreation && searchTerm" role="option" :style="optionStyle"
|
||||
:class="{'px-3 pr-9': multiple, 'px-3': !multiple}"
|
||||
class="text-gray-900 cursor-default select-none relative py-2 cursor-pointer group hover:text-white hover:bg-form-color focus:outline-none focus-text-white focus-nt-blue"
|
||||
@click="createOption(searchTerm)"
|
||||
>
|
||||
Create <b class="px-1 bg-gray-300 rounded group-hover-text-black">{{ searchTerm }}</b>
|
||||
</li>
|
||||
</ul>
|
||||
</collapsible>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Collapsible from '~/components/global/transitions/Collapsible.vue'
|
||||
import { themes } from '../../../config/form-themes'
|
||||
import TextInput from '../TextInput.vue'
|
||||
import debounce from 'debounce'
|
||||
import Fuse from 'fuse.js'
|
||||
|
||||
export default {
|
||||
name: 'VSelect',
|
||||
components: { Collapsible, TextInput },
|
||||
directives: {},
|
||||
props: {
|
||||
data: Array,
|
||||
modelValue: { default: null },
|
||||
inputClass: { type: String, default: null },
|
||||
dropdownClass: { type: String, default: 'w-full' },
|
||||
loading: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false },
|
||||
multiple: { type: Boolean, default: false },
|
||||
searchable: { type: Boolean, default: false },
|
||||
hasError: { type: Boolean, default: false },
|
||||
remote: { type: Function, default: null },
|
||||
searchKeys: { type: Array, default: () => ['name'] },
|
||||
optionKey: { type: String, default: 'id' },
|
||||
emitKey: { type: String, default: null },
|
||||
color: { type: String, default: '#3B82F6' },
|
||||
placeholder: { type: String, default: null },
|
||||
uppercaseLabels: { type: Boolean, default: true },
|
||||
theme: { type: Object, default: () => themes.default },
|
||||
allowCreation: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
isOpen: false,
|
||||
searchTerm: '',
|
||||
defaultValue: this.modelValue ?? null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
optionStyle () {
|
||||
return {
|
||||
'--bg-form-color': this.color
|
||||
}
|
||||
},
|
||||
inputStyle () {
|
||||
return {
|
||||
'--tw-ring-color': this.color
|
||||
}
|
||||
},
|
||||
debouncedRemote () {
|
||||
if (this.remote) {
|
||||
return debounce(this.remote, 300)
|
||||
}
|
||||
return null
|
||||
},
|
||||
filteredOptions () {
|
||||
if (!this.data) return []
|
||||
if (!this.searchable || this.remote || this.searchTerm === '') {
|
||||
return this.data
|
||||
}
|
||||
|
||||
// Fuse search
|
||||
const fuzeOptions = {
|
||||
keys: this.searchKeys
|
||||
}
|
||||
const fuse = new Fuse(this.data, fuzeOptions)
|
||||
return fuse.search(this.searchTerm).map((res) => {
|
||||
return res.item
|
||||
})
|
||||
},
|
||||
isSearchable () {
|
||||
return this.searchable || this.remote !== null || this.allowCreation
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
searchTerm (val) {
|
||||
if (!this.debouncedRemote) return
|
||||
if ((this.remote && val) || (val === '' && !this.modelValue) || (val === '' && this.isOpen)) {
|
||||
return this.debouncedRemote(val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
isSelected (value) {
|
||||
if (!this.modelValue) return false
|
||||
|
||||
if (this.emitKey && value[this.emitKey]) {
|
||||
value = value[this.emitKey]
|
||||
}
|
||||
|
||||
if (this.multiple) {
|
||||
return this.modelValue.includes(value)
|
||||
}
|
||||
return this.modelValue === value
|
||||
},
|
||||
toggleDropdown () {
|
||||
if (this.disabled) {
|
||||
this.isOpen = false
|
||||
}
|
||||
this.isOpen = !this.isOpen
|
||||
if (!this.isOpen) {
|
||||
this.searchTerm = ''
|
||||
}
|
||||
},
|
||||
select (value) {
|
||||
if (!this.multiple) {
|
||||
// Close after select
|
||||
this.toggleDropdown()
|
||||
}
|
||||
|
||||
if (this.emitKey) {
|
||||
value = value[this.emitKey]
|
||||
}
|
||||
|
||||
if (this.multiple) {
|
||||
const emitValue = Array.isArray(this.modelValue) ? [...this.modelValue] : []
|
||||
|
||||
if (this.isSelected(value)) {
|
||||
this.$emit('update:modelValue', emitValue.filter((item) => {
|
||||
if (this.emitKey) {
|
||||
return item !== value
|
||||
}
|
||||
return item[this.optionKey] !== value && item[this.optionKey] !== value[this.optionKey]
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
emitValue.push(value)
|
||||
this.$emit('update:modelValue', emitValue)
|
||||
} else {
|
||||
if (this.modelValue === value) {
|
||||
this.$emit('update:modelValue', this.defaultValue ?? null)
|
||||
} else {
|
||||
this.$emit('update:modelValue', value)
|
||||
}
|
||||
}
|
||||
},
|
||||
createOption (newOption) {
|
||||
if (newOption) {
|
||||
const newItem = {
|
||||
name: newOption,
|
||||
value: newOption
|
||||
}
|
||||
this.$emit('update-options', newItem)
|
||||
this.select(newItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
22
client/components/forms/components/VSwitch.vue
Normal file
22
client/components/forms/components/VSwitch.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<div role="button" @click="onClick">
|
||||
<div class="inline-flex items-center h-6 w-12 p-1 bg-gray-300 border rounded-full cursor-pointer focus:outline-none transition-all transform ease-in-out duration-100" :class="{'bg-nt-blue': modelValue}">
|
||||
<div class="inline-block h-4 w-4 rounded-full bg-white shadow transition-all transform ease-in-out duration-150 rounded-2xl scale-100" :class="{'translate-x-5.5': modelValue}" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps, defineEmits } from 'vue'
|
||||
|
||||
const { modelValue, disabled } = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const onClick = () => {
|
||||
if (disabled) return
|
||||
emit('update:modelValue', !modelValue)
|
||||
}
|
||||
</script>
|
||||
55
client/components/forms/index.js
vendored
Normal file
55
client/components/forms/index.js
vendored
Normal file
@@ -0,0 +1,55 @@
|
||||
import { defineAsyncComponent } from 'vue'
|
||||
|
||||
import HasError from './validation/HasError.vue'
|
||||
import AlertError from './validation/AlertError.vue'
|
||||
import AlertSuccess from './validation/AlertSuccess.vue'
|
||||
import VCheckbox from './components/VCheckbox.vue'
|
||||
import TextInput from './TextInput.vue'
|
||||
import TextAreaInput from './TextAreaInput.vue'
|
||||
import VSelect from './components/VSelect.vue'
|
||||
import CheckboxInput from './CheckboxInput.vue'
|
||||
import SelectInput from './SelectInput.vue'
|
||||
import ColorInput from './ColorInput.vue'
|
||||
import FileInput from './FileInput.vue'
|
||||
import ImageInput from './ImageInput.vue'
|
||||
import RatingInput from './RatingInput.vue'
|
||||
import FlatSelectInput from './FlatSelectInput.vue'
|
||||
import ToggleSwitchInput from './ToggleSwitchInput.vue'
|
||||
import ScaleInput from './ScaleInput.vue'
|
||||
|
||||
export function registerComponents (app) {
|
||||
[
|
||||
HasError,
|
||||
AlertError,
|
||||
AlertSuccess,
|
||||
VCheckbox,
|
||||
VSelect,
|
||||
CheckboxInput,
|
||||
ColorInput,
|
||||
TextInput,
|
||||
SelectInput,
|
||||
TextAreaInput,
|
||||
FileInput,
|
||||
ImageInput,
|
||||
RatingInput,
|
||||
FlatSelectInput,
|
||||
ToggleSwitchInput,
|
||||
ScaleInput
|
||||
].forEach(Component => {
|
||||
Component.name ? app.component(Component.name, Component) : app.component(Component.name, Component)
|
||||
})
|
||||
|
||||
// Register async components
|
||||
app.component('SignatureInput', defineAsyncComponent(() =>
|
||||
import('./SignatureInput.vue')
|
||||
))
|
||||
app.component('RichTextAreaInput', defineAsyncComponent(() =>
|
||||
import('./RichTextAreaInput.vue')
|
||||
))
|
||||
app.component('PhoneInput', defineAsyncComponent(() =>
|
||||
import('./PhoneInput.vue')
|
||||
))
|
||||
app.component('DateInput', defineAsyncComponent(() =>
|
||||
import('./DateInput.vue')
|
||||
))
|
||||
}
|
||||
88
client/components/forms/useFormInput.js
vendored
Normal file
88
client/components/forms/useFormInput.js
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { themes } from '~/config/form-themes.js'
|
||||
|
||||
export const inputProps = {
|
||||
id: { type: String, default: null },
|
||||
name: { type: String, required: true },
|
||||
label: { type: String, required: false },
|
||||
form: { type: Object, required: false },
|
||||
theme: { type: Object, default: () => themes.default },
|
||||
modelValue: { required: false },
|
||||
required: { type: Boolean, default: false },
|
||||
disabled: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: null },
|
||||
uppercaseLabels: { type: Boolean, default: false },
|
||||
hideFieldName: { type: Boolean, default: false },
|
||||
help: { type: String, default: null },
|
||||
helpPosition: { type: String, default: 'below_input' },
|
||||
color: { type: String, default: '#3B82F6' },
|
||||
wrapperClass: { type: String, default: 'relative mb-3' }
|
||||
}
|
||||
|
||||
export function useFormInput (props, context, formPrefixKey = null) {
|
||||
const content = ref(props.modelValue)
|
||||
|
||||
const inputStyle = computed(() => {
|
||||
return {
|
||||
'--tw-ring-color': props.color
|
||||
}
|
||||
})
|
||||
|
||||
const hasValidation = computed(() => {
|
||||
return props.form !== null && props.form !== undefined && props.form.hasOwnProperty('errors')
|
||||
})
|
||||
|
||||
const hasError = computed(() => {
|
||||
return hasValidation && props.form?.errors?.has(name)
|
||||
})
|
||||
|
||||
const compVal = computed({
|
||||
get: () => {
|
||||
if (props.form) {
|
||||
return props.form[(formPrefixKey || '') + props.name]
|
||||
}
|
||||
return content.value
|
||||
},
|
||||
set: (val) => {
|
||||
if (props.form) {
|
||||
props.form[(formPrefixKey || '') + props.name] = val
|
||||
} else {
|
||||
content.value = val
|
||||
}
|
||||
|
||||
if (hasValidation.value) {
|
||||
props.form.errors.clear(props.name)
|
||||
}
|
||||
|
||||
context.emit('update:modelValue', compVal.value)
|
||||
}
|
||||
})
|
||||
|
||||
const inputWrapperProps = computed(() => {
|
||||
const wrapperProps = {}
|
||||
Object.keys(inputProps).forEach((key) => {
|
||||
if (!['modelValue', 'disabled', 'placeholder', 'color'].includes(key)) {
|
||||
wrapperProps[key] = props[key]
|
||||
}
|
||||
})
|
||||
return wrapperProps
|
||||
})
|
||||
|
||||
// Watch for changes in props.modelValue and update the local content
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue) => {
|
||||
if (content.value !== newValue) {
|
||||
content.value = newValue
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
return {
|
||||
compVal,
|
||||
inputStyle,
|
||||
hasValidation,
|
||||
hasError,
|
||||
inputWrapperProps
|
||||
}
|
||||
}
|
||||
21
client/components/forms/validation/Alert.js
vendored
Normal file
21
client/components/forms/validation/Alert.js
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
export default {
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
|
||||
dismissible: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
dismiss () {
|
||||
if (this.dismissible) {
|
||||
this.form.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
29
client/components/forms/validation/AlertError.vue
Normal file
29
client/components/forms/validation/AlertError.vue
Normal file
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<div v-if="form.errors.any()" class="alert alert-danger alert-dismissible" role="alert">
|
||||
<button v-if="dismissible" type="button" class="close" aria-label="Close" @click="dismiss">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
|
||||
<slot>
|
||||
<div v-if="form.errors.has('error')" v-html="form.errors.get('error')"/>
|
||||
<div v-else v-html="message"/>
|
||||
</slot>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Alert from './Alert.js'
|
||||
|
||||
export default {
|
||||
name: 'AlertError',
|
||||
|
||||
extends: Alert,
|
||||
|
||||
props: {
|
||||
message: {
|
||||
type: String,
|
||||
default: 'There were some problems with your input.'
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
37
client/components/forms/validation/AlertSuccess.vue
Normal file
37
client/components/forms/validation/AlertSuccess.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="form.successful" class="bg-green-200 border-green-600 text-green-600 border-l-4 p-4 relative rounded-lg"
|
||||
role="alert">
|
||||
<button v-if="dismissible"
|
||||
type="button"
|
||||
@click.prevent="dismiss()"
|
||||
class="absolute right-2 top-0 -mr-1 flex-shrink-0 flex p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-green-500 sm:-mr-2">
|
||||
<span class="sr-only">
|
||||
Dismiss
|
||||
</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="h-6 w-6 text-green-500"
|
||||
viewBox="0 0 1792 1792">
|
||||
<path
|
||||
d="M1490 1322q0 40-28 68l-136 136q-28 28-68 28t-68-28l-294-294-294 294q-28 28-68 28t-68-28l-136-136q-28-28-28-68t28-68l294-294-294-294q-28-28-28-68t28-68l136-136q28-28 68-28t68 28l294 294 294-294q28-28 68-28t68 28l136 136q28 28 28 68t-28 68l-294 294 294 294q28 28 28 68z">
|
||||
</path>
|
||||
</svg>
|
||||
</button>
|
||||
<p class="font-bold">
|
||||
Success
|
||||
</p>
|
||||
<div v-html="message"/>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Alert from './Alert.js'
|
||||
|
||||
export default {
|
||||
name: 'AlertSuccess',
|
||||
extends: Alert,
|
||||
props: {
|
||||
message: { type: String, default: '' }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
43
client/components/forms/validation/HasError.vue
Normal file
43
client/components/forms/validation/HasError.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<transition name="fade">
|
||||
<div v-if="errorMessage" class="has-error text-sm text-red-500 -bottom-3"
|
||||
v-html="errorMessage"
|
||||
/>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HasError',
|
||||
props: {
|
||||
form: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
field: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
errorMessage () {
|
||||
if (!this.form || !this.form.errors || !this.form.errors.any()) return null
|
||||
const subErrorsKeys = Object.keys(this.form.errors.all()).filter((key) => {
|
||||
return key.startsWith(this.field) && key !== this.field
|
||||
})
|
||||
const baseError = this.form.errors.get(this.field) ?? (subErrorsKeys.length ? 'This field has some errors:' : null)
|
||||
// If no error and no sub errors, return
|
||||
if (!baseError) return null
|
||||
|
||||
return `<p class="text-red-500">${baseError}</p><ul class="list-disc list-inside">${subErrorsKeys.map((key) => {
|
||||
return '<li>' + this.getSubError(key) + '</li>'
|
||||
})}</ul>`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
getSubError (subErrorKey) {
|
||||
return this.form.errors.get(subErrorKey).replace(subErrorKey, 'item')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user