Add support to use only camera on image field

pull/402/head
Vincent Barrier 10 months ago
parent a21328afd1
commit 9f32484a6f

@ -0,0 +1,21 @@
# frozen_string_literal: true
class SubmitFormTakePhotoController < ApplicationController
layout false
around_action :with_browser_locale, only: %i[show]
skip_before_action :authenticate_user!
skip_authorization_check
def show
@submitter = Submitter.find_by!(slug: params[:slug])
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
if @submitter.submission.template.archived_at? || @submitter.submission.archived_at?
return redirect_to submit_form_path(@submitter.slug)
end
render :show
end
end

@ -0,0 +1,142 @@
window.customElements.define('file-photo', class extends HTMLElement {
connectedCallback () {
this.clearButton.addEventListener('click', (e) => {
e.preventDefault()
this.valueInput.value = null
this.inputFile.click()
})
this.inputFile.addEventListener('change', (e) => {
e.preventDefault()
this.updateSubmitButtonVisibility()
this.uploadFiles(this.inputFile.files)
})
this.form.addEventListener('submit', (e) => {
e.preventDefault();
this.submitButton.disabled = true
fetch(this.form.action, {
method: 'PUT',
body: new FormData(this.form)
}).then((response) => {
this.form.classList.add('hidden')
this.success.classList.remove('hidden')
return response
}).finally(() => {
this.submitButton.disabled = false
})
})
}
toggleLoading = (e) => {
this.updateSubmitButtonVisibility()
if (e && e.target && !e.target.contains(this)) {
return
}
this.loading.classList.toggle('hidden')
this.icon.classList.toggle('hidden')
this.classList.toggle('opacity-50')
}
async uploadFiles (files) {
this.toggleLoading()
return await Promise.all(
Array.from(files).map(async (file) => {
const formData = new FormData()
if (file.type === 'image/bmp') {
file = await this.convertBmpToPng(file)
}
formData.append('file', file)
formData.append('submitter_slug', this.dataset.slug)
formData.append('name', 'attachments')
return fetch('/api/attachments', {
method: 'POST',
body: formData
}).then(resp => resp.json()).then((data) => {
return data
})
})).then((result) => {
this.valueInput.value = result[0].uuid
return result[0]
}).finally(() => {
this.toggleLoading()
})
}
convertBmpToPng (bmpFile) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = function (event) {
const img = new Image()
img.onload = function () {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
canvas.toBlob(function (blob) {
const newFile = new File([blob], bmpFile.name.replace(/\.\w+$/, '.png'), { type: 'image/png' })
resolve(newFile)
}, 'image/png')
}
img.src = event.target.result
}
reader.onerror = reject
reader.readAsDataURL(bmpFile)
})
}
updateSubmitButtonVisibility () {
if (!this.valueInput.value) {
this.submitButton.style.display = 'none'
this.placeholderButton.style.display = 'block'
} else {
this.submitButton.style.display = 'block'
this.placeholderButton.style.display = 'none'
}
}
get submitButton () {
return this.querySelector('button[type="submit"]')
}
get clearButton () {
return this.querySelector('button[aria-label="Clear"]')
}
get placeholderButton () {
return this.querySelector('button[disabled]')
}
get valueInput () {
return this.querySelector('input[name^="values"]')
}
get inputFile () {
return this.querySelector('input[id="file"]')
}
get icon () {
return this.querySelector('#file-photo-icon')
}
get loading () {
return this.querySelector('#file-photo-loading')
}
get form () {
return this.querySelector('form')
}
get success () {
return this.querySelector('#success')
}
})

@ -1,7 +1,8 @@
<template>
<div
id="dropzone"
class="flex h-32 w-full"
class="flex w-full"
:class="{'h-20': onlyWithCamera, 'h-32': !onlyWithCamera}"
@dragover.prevent
@drop.prevent="onDropFiles"
>
@ -18,6 +19,11 @@
:width="30"
:height="30"
/>
<IconCamera
v-else-if="onlyWithCamera"
:width="30"
:height="30"
/>
<IconCloudUpload
v-else
:width="30"
@ -29,7 +35,7 @@
>
{{ message }}
</div>
<div class="text-xs">
<div class="text-xs" v-if="!onlyWithCamera">
<span class="font-medium">{{ t('click_to_upload') }}</span> {{ t('or_drag_and_drop_files') }}
</div>
</div>
@ -39,7 +45,7 @@
ref="input"
:multiple="multiple"
:accept="accept"
:capture="onlyWithCamera == true ? `camera` : false"
:capture="onlyWithCamera === true ? `camera` : null"
type="file"
class="hidden"
@change="onSelectFiles"
@ -49,11 +55,13 @@
</template>
<script>
import { IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
import { IconCamera, IconCloudUpload, IconInnerShadowTop } from '@tabler/icons-vue'
import field from "../template_builder/field.vue";
export default {
name: 'FileDropzone',
components: {
IconCamera,
IconCloudUpload,
IconInnerShadowTop
},
@ -95,13 +103,16 @@ export default {
}
},
computed: {
field() {
return field
},
inputId () {
return 'el' + Math.random().toString(32).split('.')[1]
}
},
methods: {
onDropFiles (e) {
if(!this.onlyUseCamera){
if(!this.onlyWithCamera){
this.uploadFiles(e.dataTransfer.files)
}
},

@ -94,7 +94,11 @@ const en = {
upload: 'Upload',
files: 'Files',
signature_is_too_small_please_redraw: 'Signature is too small. Please redraw.',
wait_countdown_seconds: 'Wait {countdown} seconds'
wait_countdown_seconds: 'Wait {countdown} seconds',
photo: 'Photo',
take: 'Take',
retake: 'Retake',
scan_the_qr_code_with_your_mobile_camera_app_to_open_the_form_and_take_a_photo: 'Scan the QR code with your mobile camera app to open the form and take a photo',
}
const es = {
@ -486,7 +490,11 @@ const fr = {
upload: 'Télécharger',
files: 'Fichiers',
signature_is_too_small_please_redraw: 'La signature est trop petite. Veuillez la redessiner.',
wait_countdown_seconds: 'Attendez {countdown} secondes'
wait_countdown_seconds: 'Attendez {countdown} secondes',
photo: 'Photo',
take: 'Prendre',
retake: 'Reprendre',
scan_the_qr_code_with_your_mobile_camera_app_to_open_the_form_and_take_a_photo: 'Scannez le code QR avec l\'application de l\'appareil photo pour ouvrir le formulaire et prendre une photo',
}
const pl = {

@ -9,7 +9,7 @@
@click.prevent="remove"
>
<IconReload :width="16" />
{{ t('reupload') }}
{{ field.preferences?.only_with_camera ? t('retake') : t('reupload') }}
</button>
</div>
<div>
@ -35,13 +35,43 @@
<MarkdownContent :string="field.description" />
</div>
<FileDropzone
:message="`${t('upload')} ${field.name || t('image')}${field.required ? '' : ` (${t('optional')})`}`"
v-if="!field.preferences.only_with_camera || (isMobile && field.preferences.only_with_camera)"
:message="`${field.preferences?.only_with_camera ? t('take') : t('upload')} ${field.name || (field.preferences?.only_with_camera ? t('photo') : t('image'))}${field.required ? '' : ` (${t('optional')})`}`"
:submitter-slug="submitterSlug"
:dry-run="dryRun"
:accept="'image/*'"
:only-with-camera="field.preferences?.only_with_camera == true"
:only-with-camera="field.preferences?.only_with_camera === true"
@upload="onImageUpload"
/>
<div
v-else
class="relative"
>
<div
class="bg-base-content/10 rounded-2xl"
>
<div
class="flex items-center justify-center w-full h-full p-4"
>
<div
class="bg-white p-4 rounded-xl h-full"
>
<canvas
ref="qrCanvas"
class="h-full"
width="132"
height="132"
/>
</div>
</div>
</div>
<div
dir="auto"
class="text-base-content/60 text-xs text-center w-full mt-1"
>
{{ t('scan_the_qr_code_with_your_mobile_camera_app_to_open_the_form_and_take_a_photo') }}
</div>
</div>
</div>
</template>
@ -92,11 +122,64 @@ export default {
methods: {
remove () {
this.$emit('update:model-value', '')
this.showQr()
},
onImageUpload (attachments) {
this.$emit('attached', attachments[0])
this.$emit('update:model-value', attachments[0].uuid)
this.stopCheckPhoto() //just in case
},
showQr() {
this.$nextTick(() => {
import('qr-creator').then(({ default: Qr }) => {
if (this.$refs.qrCanvas && !this.isMobile) {
Qr.render({
text: `${document.location.origin}/t/${this.submitterSlug}?f=${this.field.uuid.split('-')[0]}`,
radius: 0.0,
ecLevel: 'H',
background: null,
size: 132
}, this.$refs.qrCanvas)
this.startCheckPhoto()
}
})
})
},
startCheckPhoto() {
const after = JSON.stringify(new Date())
this.checkPhotoInterval = setInterval(() => {
this.checkPhoto({ after })
}, 2000)
},
stopCheckPhoto() {
if (this.checkPhotoInterval) {
clearInterval(this.checkPhotoInterval)
}
},
checkPhoto(params = {}) {
return fetch(document.location.origin + '/s/' + this.submitterSlug + '/values?field_uuid=' + this.field.uuid + '&after=' + params.after, {
method: 'GET'
}).then(async (resp) => {
const { attachment } = await resp.json()
if (attachment?.uuid) {
this.$emit('attached', attachment)
this.$emit('update:model-value', attachment.uuid)
this.stopCheckPhoto()
}
})
}
},
mounted() {
this.showQr()
},
unmounted() {
this.stopCheckPhoto()
},
computed: {
isMobile() {
return screen.width <= 760
}
}
}

@ -263,10 +263,10 @@
>
<label class="cursor-pointer py-1.5">
<input
:checked="field.preferences?.only_with_camera != false"
:checked="field.preferences?.only_with_camera == true"
type="checkbox"
class="toggle toggle-xs"
@change="[field.preferences ||= {}, field.preferences.only_with_camera = field.preferences.only_with_camera == false, save()]"
@change="[field.preferences ||= {}, field.preferences.only_with_camera = $event.target.checked, save()]"
>
<span class="label-text">{{ t('only_with_camera') }}</span>
</label>
@ -376,7 +376,10 @@
:stroke-width="1.6"
/>
{{ t('page') }}
<template v-if="template.schema.length > 1">{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-</template>{{ area.page + 1 }}
<template v-if="template.schema.length > 1">
{{ template.schema.findIndex((item) => item.attachment_uuid === area.attachment_uuid) + 1 }}-
</template>
{{ area.page + 1 }}
<IconX
:width="12"
class="group-hover/1:inline hidden"
@ -414,7 +417,15 @@
</template>
<script>
import { IconRouteAltLeft, IconShape, IconX, IconMathFunction, IconNewSection, IconInfoCircle, IconCopy } from '@tabler/icons-vue'
import {
IconRouteAltLeft,
IconShape,
IconX,
IconMathFunction,
IconNewSection,
IconInfoCircle,
IconCopy
} from '@tabler/icons-vue'
export default {
name: 'FieldSettings',
@ -461,8 +472,7 @@ export default {
},
emits: ['set-draw', 'scroll-to', 'click-formula', 'click-description', 'click-condition', 'remove-area'],
data() {
return {
}
return {}
},
computed: {
schemaAttachmentsIndexes() {

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<path d="M5 7h1a2 2 0 0 0 2 -2a1 1 0 0 1 1 -1h6a1 1 0 0 1 1 1a2 2 0 0 0 2 2h1a2 2 0 0 1 2 2v9a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-9a2 2 0 0 1 2 -2" />
<path d="M9 13a3 3 0 1 0 6 0a3 3 0 0 0 -6 0" />
</svg>

After

Width:  |  Height:  |  Size: 446 B

@ -0,0 +1,67 @@
<!DOCTYPE html>
<html data-theme="docuseal" lang="en">
<head>
<%= render 'layouts/head_tags' %>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<% if ENV['ROLLBAR_CLIENT_TOKEN'] %>
<meta name="rollbar-token" content="<%= ENV.fetch('ROLLBAR_CLIENT_TOKEN', nil) %>">
<%= javascript_pack_tag 'rollbar', 'photo', defer: true %>
<% else %>
<%= javascript_pack_tag 'photo', defer: true %>
<% end %>
<%= stylesheet_pack_tag 'form', media: 'all' %>
<%= render 'shared/posthog' if ENV['POSTHOG_TOKEN'] %>
</head>
<body>
<% field = (@submitter.submission.template_fields || @submitter.template.fields).find { |f| f['type'] == 'image' && f['uuid'].starts_with?(params[:f]) } %>
<file-photo data-slug="<%= params[:slug] %>" class="flex items-center h-screen p-2 justify-center">
<%= form_for '', url: submit_form_path(params[:slug]), html: { style: 'max-width: 900px; width: 100%; margin-bottom: 120px' }, method: :put do |f| %>
<input value="" type="hidden" name="values[<%= field['uuid'] %>]">
<% if field['description'] %>
<div class="w-full mb-2">
<%= field['description'] %>
</div>
<% end %>
<label for="file" class="w-full block h-32 relative bg-base-200 hover:bg-base-200/70 rounded-md border border-base-content border-dashed">
<div class="absolute top-0 right-0 left-0 bottom-0 flex items-center justify-center p-2">
<div class="flex flex-col items-center text-center">
<span id="file-photo-icon">
<%= svg_icon('camera', class: 'w-10 h-10') %>
</span>
<span id="file-photo-loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
<%= t('take_photo') %>
</div>
</div>
<button aria-label="Clear" class="hidden btn btn-ghost btn-sm font-medium top-0 right-0 absolute mt-1 mr-1">
<%= svg_icon('reload', class: 'w-5 h-5') %>
<span class="inline"><%= t('clear') %></span>
</button>
<input id="file" class="hidden" name="files[]" type="file" accept="image/*" capture="camera">
</div>
</label>
<div class="mt-4">
<button disabled class="base-button w-full">
<%= t('submit') %>
</button>
<%= f.button button_title(title: t('submit')), class: 'base-button w-full', style: 'display: none' %>
</div>
<% end %>
<div id="success" class="text-center p-2 hidden" style="margin-bottom: 100px">
<div class="flex items-center space-x-1 items-center justify-center text-2xl font-semibold mb-2">
<%= svg_icon('circle_check', class: 'text-green-600') %>
<span>
<%= t('photo_uploaded') %>
</span>
</div>
<div>
<%= t('return_back_to_your_desktop_device_to_complete_the_form_or_continue_on_mobile_html', link: submit_form_path(params[:slug])) %>
</div>
</div>
</file-photo>
</body>
</html>

@ -391,6 +391,7 @@ en: &en
draw_signature: Draw Signature
clear: Clear
signature_uploaded: Signature Uploaded
photo_uploaded: Photo Uploaded
submission_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_: 'Submission deletion is irreversible and will permanently remove all associated signed documents with it. Are you sure?'
return_back_to_your_desktop_device_to_complete_the_form_or_continue_on_mobile_html: 'Return back to your desktop device to complete the form or <a class="link" href="%{link}">continue on mobile</a>'
template_deletion_is_irreversible_and_will_permanently_remove_all_associated_signed_documents_with_it_are_you_sure_: Template deletion is irreversible and will permanently remove all associated signed documents with it. Are you sure?

@ -139,6 +139,8 @@ Rails.application.routes.draw do
resources :submit_form_draw_signature, only: %i[show], path: 'p', param: 'slug'
resources :submit_form_take_photo, only: %i[show], path: 't', param: 'slug'
resources :submissions_preview, only: %i[show], path: 'e', param: 'slug' do
get :completed
end

Loading…
Cancel
Save