Merge master

This commit is contained in:
Julien Nahum 2024-03-13 09:01:22 +01:00
commit af573334bd
16 changed files with 174 additions and 10 deletions

View File

@ -218,4 +218,24 @@ class FormController extends Controller
return redirect()->to(Storage::temporaryUrl($path, now()->addMinutes(5))); return redirect()->to(Storage::temporaryUrl($path, now()->addMinutes(5)));
} }
/**
* Updates a form's workspace
*/
public function updateWorkspace($id, $workspace_id)
{
$form = Form::findOrFail($id);
$workspace = Workspace::findOrFail($workspace_id);
$this->authorize('update', $form);
$this->authorize('view', $workspace);
$form->workspace_id = $workspace_id;
$form->creator_id = auth()->user()->id;
$form->save();
return $this->success([
'message' => 'Form workspace updated successfully.',
]);
}
} }

View File

@ -27,6 +27,12 @@ class AnswerFormRequest extends FormRequest
$this->maxFileSize = $this->form->workspace->max_file_size; $this->maxFileSize = $this->form->workspace->max_file_size;
} }
private function getFieldMaxFileSize($fieldProps)
{
return array_key_exists('max_file_size', $fieldProps) ?
min($fieldProps['max_file_size'] * 1000000, $this->maxFileSize) : $this->maxFileSize;
}
/** /**
* Validate form before use it * Validate form before use it
* *
@ -180,7 +186,7 @@ class AnswerFormRequest extends FormRequest
if (! empty($property['allowed_file_types'])) { if (! empty($property['allowed_file_types'])) {
$allowedFileTypes = explode(',', $property['allowed_file_types']); $allowedFileTypes = explode(',', $property['allowed_file_types']);
} }
$this->requestRules[$property['id'].'.*'] = [new StorageFile($this->maxFileSize, $allowedFileTypes, $this->form)]; $this->requestRules[$property['id'] . '.*'] = [new StorageFile($this->getFieldMaxFileSize($property), $allowedFileTypes, $this->form)];
return ['array']; return ['array'];
case 'email': case 'email':

View File

@ -118,6 +118,9 @@ abstract class UserFormRequest extends \Illuminate\Foundation\Http\FormRequest
'properties.*.generates_uuid' => 'boolean|nullable', 'properties.*.generates_uuid' => 'boolean|nullable',
'properties.*.generates_auto_increment_id' => 'boolean|nullable', 'properties.*.generates_auto_increment_id' => 'boolean|nullable',
// For file (min and max)
'properties.*.max_file_size' => 'min:1|numeric',
// Security & Privacy // Security & Privacy
'can_be_indexed' => 'boolean', 'can_be_indexed' => 'boolean',
'password' => 'sometimes|nullable', 'password' => 'sometimes|nullable',

View File

@ -11,7 +11,7 @@
</template> </template>
<div <div
:class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]" :class="[theme.CodeInput.input,{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
> >
<codemirror :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" <codemirror :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:extensions="extensions" :extensions="extensions"

View File

@ -8,7 +8,7 @@
<Loader v-if="loading" key="loader" class="h-6 w-6 text-nt-blue mx-auto" /> <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" <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': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]" :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 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
@click="onSelect(option[optionKey])" @click="onSelect(option[optionKey])"
> >
<p class="flex-grow"> <p class="flex-grow">

View File

@ -7,7 +7,7 @@
</template> </template>
<vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled?true:null" <vue-editor :id="id?id:name" ref="editor" v-model="compVal" :disabled="disabled?true:null"
:placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]" :placeholder="placeholder" :class="[{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }, theme.RichTextAreaInput.input]"
:editor-toolbar="editorToolbar" class="rich-editor resize-y" :editor-toolbar="editorToolbar" class="rich-editor resize-y"
:style="inputStyle" :style="inputStyle"
/> />

View File

@ -7,7 +7,7 @@
</template> </template>
<VueSignaturePad ref="signaturePad" <VueSignaturePad ref="signaturePad"
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]" :class="[theme.default.input,{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
height="150px" height="150px"
:name="name" :name="name"
:options="{ onEnd }" :options="{ onEnd }"

View File

@ -7,7 +7,7 @@
</template> </template>
<textarea :id="id?id:name" v-model="compVal" :disabled="disabled?true:null" <textarea :id="id?id:name" v-model="compVal" :disabled="disabled?true:null"
:class="[theme.default.input,{ '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]" :class="[theme.default.input,{ '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200':disabled }]"
class="resize-y" class="resize-y"
:name="name" :style="inputStyle" :name="name" :style="inputStyle"
:placeholder="placeholder" :placeholder="placeholder"

View File

@ -10,7 +10,7 @@
:type="nativeType" :autocomplete="autocomplete" :type="nativeType" :autocomplete="autocomplete"
:pattern="pattern" :pattern="pattern"
:style="inputStyle" :style="inputStyle"
:class="[theme.default.input, { '!ring-red-500 !ring-2': hasError, '!cursor-not-allowed !bg-gray-200': disabled }]" :class="[theme.default.input, { '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled }]"
:name="name" :accept="accept" :name="name" :accept="accept"
:placeholder="placeholder" :min="min" :max="max" :maxlength="maxCharLimit" :placeholder="placeholder" :min="min" :max="max" :maxlength="maxCharLimit"
@change="onChange" @keydown.enter.prevent="onEnterPress" @change="onChange" @keydown.enter.prevent="onEnterPress"

View File

@ -4,7 +4,7 @@
<button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label" <button type="button" aria-haspopup="listbox" aria-expanded="true" aria-labelledby="listbox-label"
class="cursor-pointer" class="cursor-pointer"
:style="inputStyle" :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]" :class="[theme.SelectInput.input,{'py-2': !multiple || loading,'py-1': multiple, '!ring-red-500 !ring-2 !border-transparent': hasError, '!cursor-not-allowed !bg-gray-200': disabled}, inputClass]"
@click="toggleDropdown" @click="toggleDropdown"
> >
<div :class="{'h-6': !multiple, 'min-h-8': multiple && !loading}"> <div :class="{'h-6': !multiple, 'min-h-8': multiple && !loading}">

View File

@ -303,7 +303,7 @@ export default {
} }
} else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) { } else if (field.type === 'files' || (field.type === 'url' && field.file_upload)) {
inputProperties.multiple = (field.multiple !== undefined && field.multiple) inputProperties.multiple = (field.multiple !== undefined && field.multiple)
inputProperties.mbLimit = this.form?.max_file_size ?? this.currentWorkspace?.max_file_size inputProperties.mbLimit = Math.min(Math.max(field.max_file_size, 1), this.form?.max_file_size ?? this.currentWorkspace?.max_file_size)
inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : '' inputProperties.accept = (this.form.is_pro && field.allowed_file_types) ? field.allowed_file_types : ''
} else if (field.type === 'number' && field.is_rating) { } else if (field.type === 'number' && field.is_rating) {
inputProperties.numberOfStars = parseInt(field.rating_max_value) inputProperties.numberOfStars = parseInt(field.rating_max_value)

View File

@ -0,0 +1,103 @@
<template>
<modal :show="show" @close="emit('close')">
<template #icon>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-8 h-8">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" />
</svg>
</template>
<template #title>
Change form workspace
</template>
<div class="p-4">
<div class="flex space-x-4 items-center">
<p>Current workspace:</p>
<div class="flex items-center cursor group p-2 rounded border">
<div class="rounded-full h-8 8">
<img v-if="isUrl(workspace.icon)"
:src="workspace.icon"
:alt="workspace.name + ' icon'" class="flex-shrink-0 h-8 w-8 rounded-full shadow"
/>
<div v-else class="rounded-full pt-2 text-xs truncate bg-nt-blue-lighter h-8 w-8 text-center shadow"
v-text="workspace.icon"
/>
</div>
<p class="lg:block max-w-10 truncate ml-2 text-gray-800 dark:text-gray-200">
{{ workspace.name }}
</p>
</div>
</div>
<form @submit.prevent="onSubmit">
<div class=" my-4">
<select-input name="workspace" class=""
:options="workspacesSelectOptions"
v-model="selectedWorkspace"
:required="true"
label="Select workspace"
/>
</div>
<div class="flex justify-end mt-4 pb-5">
<v-button class="mr-2" :loading="loading">
Change workspace
</v-button>
<v-button color="white" @click.prevent="emit('close')">
Close
</v-button>
</div>
</form>
</div>
</modal>
</template>
<script setup>
import { ref, defineProps, defineEmits, computed } from 'vue'
const emit = defineEmits(['close'])
const workspacesStore = useWorkspacesStore()
const formsStore = useFormsStore()
const selectedWorkspace = ref(null);
const props = defineProps({
show: { type: Boolean, required: true },
form: { type: Object, required: true },
})
const workspaces = computed(() => workspacesStore.getAll)
const workspace = computed(() => workspacesStore.getByKey(props.form?.workspace_id))
const loading = ref(false)
const workspacesSelectOptions = computed(()=> workspaces.value.filter((w)=>{
return w.id !== workspace.value.id
}).map(workspace => ({ name: workspace.name, value: workspace.id })))
const onSubmit = () => {
const endpoint = '/open/forms/' + props.form.id + '/workspace/' + selectedWorkspace.value
if(! selectedWorkspace.value) {
useAlert().error('Please select a workspace!')
return;
}
opnFetch(endpoint, { method: 'POST' }).then(data => {
loading.value = false;
emit('close')
useAlert().success('Form workspace updated successfully.')
workspacesStore.setCurrentId(selectedWorkspace.value)
formsStore.resetState()
formsStore.loadAll(selectedWorkspace.value)
const router = useRouter()
const route = useRoute()
if (route.name !== 'home') {
router.push({ name: 'home' })
}
formsStore.loadAll(selectedWorkspace.value)
}).catch((error) => {
useAlert().error(error?.data?.message ?? 'Something went wrong, please try again!')
loading.value = false;
})
}
const isUrl = (str) => {
try {
new URL(str)
} catch (_) {
return false
}
return true
}
</script>

View File

@ -24,7 +24,7 @@
> >
<img alt="Logo Picture" :src="coverPictureSrc(form.logo_picture)" <img alt="Logo Picture" :src="coverPictureSrc(form.logo_picture)"
:class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}" :class="{'top-5':!form.cover_picture, '-top-10':form.cover_picture}"
class="w-20 h-20 object-contain absolute left-5 transition-all" class="max-w-60 h-20 object-contain absolute left-5 transition-all"
/> />
</div> </div>
</div> </div>

View File

@ -60,6 +60,13 @@
label="Allowed file types" placeholder="jpg,jpeg,png,gif" label="Allowed file types" placeholder="jpg,jpeg,png,gif"
help="Comma separated values, leave blank to allow all file types" help="Comma separated values, leave blank to allow all file types"
/> />
<text-input name="max_file_size" class="mt-3" :form="field" native-type="number"
:min="1"
:max="mbLimit"
label="Maximum file size (in MB)" :placeholder="`1MB - ${mbLimit}MB`"
help="Set the maximum file size that can be uploaded"
/>
</div> </div>
<!-- Number Options --> <!-- Number Options -->
@ -428,6 +435,9 @@ export default {
required: false required: false
} }
}, },
setup() {
return { currentWorkspace: computed(() => useWorkspacesStore().getCurrent), }
},
data () { data () {
return { return {
typesWithoutPlaceholder: ['date', 'checkbox', 'files'], typesWithoutPlaceholder: ['date', 'checkbox', 'files'],
@ -442,6 +452,9 @@ export default {
hasPlaceholder () { hasPlaceholder () {
return !this.typesWithoutPlaceholder.includes(this.field.type) return !this.typesWithoutPlaceholder.includes(this.field.type)
}, },
mbLimit() {
return this.form?.max_file_size ?? this.currentWorkspace?.max_file_size
},
prefillSelectsOptions () { prefillSelectsOptions () {
if (!['select', 'multi_select'].includes(this.field.type)) return {} if (!['select', 'multi_select'].includes(this.field.type)) return {}
@ -504,6 +517,9 @@ export default {
if (['text', 'number', 'url', 'email'].includes(this.field?.type) && !this.field?.max_char_limit) { if (['text', 'number', 'url', 'email'].includes(this.field?.type) && !this.field?.max_char_limit) {
this.field.max_char_limit = 2000 this.field.max_char_limit = 2000
} }
if (this.field.type == 'files') {
this.field.max_file_size = Math.min((this.field.max_file_size ?? this.mbLimit), this.mbLimit)
}
}, },
methods: { methods: {

View File

@ -107,6 +107,17 @@
</svg> </svg>
Create Template Create Template
</a> </a>
<a v-track.change_workspace_click="{form_id:form.id, form_slug:form.slug}" href="#"
class="block block px-4 py-2 text-md text-gray-700 dark:text-white hover:bg-gray-100 hover:text-gray-900 dark:text-gray-100 dark:hover:text-white dark:hover:bg-gray-600 flex items-center"
@click.prevent="showFormWorkspaceModal=true"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-4 h-4 mr-2">
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 21h19.5m-18-18v18m10.5-18v18m6-13.5V21M6.75 6.75h.75m-.75 3h.75m-.75 3h.75m3-6h.75m-.75 3h.75m-.75 3h.75M6.75 21v-3.375c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21M3 3h12m-.75 4.5H21m-3.75 3.75h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Zm0 3h.008v.008h-.008v-.008Z" />
</svg>
Change workspace
</a>
<a v-track.delete_form_click="{form_id:form.id, form_slug:form.slug}" <a v-track.delete_form_click="{form_id:form.id, form_slug:form.slug}"
href="#" href="#"
class="block block px-4 py-2 text-md text-red-600 hover:bg-red-50 flex items-center" class="block block px-4 py-2 text-md text-red-600 hover:bg-red-50 flex items-center"
@ -152,6 +163,8 @@
</div> </div>
</modal> </modal>
<form-template-modal v-if="!isMainPage && user" :form="form" :show="showFormTemplateModal" @close="showFormTemplateModal=false" /> <form-template-modal v-if="!isMainPage && user" :form="form" :show="showFormTemplateModal" @close="showFormTemplateModal=false" />
<form-workspace-modal v-if="user" :form="form" :show="showFormWorkspaceModal" @close="showFormWorkspaceModal=false" />
</div> </div>
</template> </template>
@ -159,6 +172,7 @@
import { ref, defineProps, computed } from 'vue' import { ref, defineProps, computed } from 'vue'
import Dropdown from '~/components/global/Dropdown.vue' import Dropdown from '~/components/global/Dropdown.vue'
import FormTemplateModal from '../../../open/forms/components/templates/FormTemplateModal.vue' import FormTemplateModal from '../../../open/forms/components/templates/FormTemplateModal.vue'
import FormWorkspaceModal from '../../../open/forms/components/FormWorkspaceModal.vue'
const { copy } = useClipboard() const { copy } = useClipboard()
const router = useRouter() const router = useRouter()
@ -177,6 +191,7 @@ let loadingDuplicate = ref(false)
let loadingDelete = ref(false) let loadingDelete = ref(false)
let showDeleteFormModal = ref(false) let showDeleteFormModal = ref(false)
let showFormTemplateModal = ref(false) let showFormTemplateModal = ref(false)
let showFormWorkspaceModal = ref(false)
const copyLink = () => { const copyLink = () => {
copy(props.form.share_url) copy(props.form.share_url)

View File

@ -89,6 +89,7 @@ Route::group(['middleware' => 'auth:api'], function () {
Route::prefix('forms')->name('forms.')->group(function () { Route::prefix('forms')->name('forms.')->group(function () {
Route::post('/', [FormController::class, 'store'])->name('store'); Route::post('/', [FormController::class, 'store'])->name('store');
Route::post('/{id}/workspace/{workspace_id}', [FormController::class, 'updateWorkspace'])->name('workspace.update');
Route::put('/{id}', [FormController::class, 'update'])->name('update'); Route::put('/{id}', [FormController::class, 'update'])->name('update');
Route::delete('/{id}', [FormController::class, 'destroy'])->name('destroy'); Route::delete('/{id}', [FormController::class, 'destroy'])->name('destroy');