add default user signatures

pull/133/head
Alex Turchyn 2 years ago
parent 380f553a17
commit 5643094a7a

@ -21,7 +21,6 @@ class StartFormController < ApplicationController
else else
@submitter.assign_attributes( @submitter.assign_attributes(
uuid: @template.submitters.first['uuid'], uuid: @template.submitters.first['uuid'],
opened_at: Time.current,
ip: request.remote_ip, ip: request.remote_ip,
ua: request.user_agent ua: request.user_agent
) )

@ -15,6 +15,8 @@ class SubmitFormController < ApplicationController
return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at? return redirect_to submit_form_completed_path(@submitter.slug) if @submitter.completed_at?
Submitters::MaybeUpdateDefaultValues.call(@submitter, current_user)
cookies[:submitter_sid] = @submitter.signed_id cookies[:submitter_sid] = @submitter.signed_id
end end

@ -0,0 +1,37 @@
# frozen_string_literal: true
class UserSignaturesController < ApplicationController
before_action :load_user_config
authorize_resource :user_config
def edit; end
def update
file = params[:file]
return redirect_to settings_profile_index_path, notice: 'Unable to save signature' if file.blank?
blob = ActiveStorage::Blob.create_and_upload!(io: file.open,
filename: file.original_filename,
content_type: file.content_type)
attachment = ActiveStorage::Attachment.create!(
blob:,
name: 'signature',
record: current_user
)
if @user_config.update(value: attachment.uuid)
redirect_to settings_profile_index_path, notice: 'Signature has been saved'
else
redirect_to settings_profile_index_path, notice: 'Unable to save signature'
end
end
private
def load_user_config
@user_config =
UserConfig.find_or_initialize_by(user: current_user, key: UserConfig::SIGNATURE_KEY)
end
end

@ -17,6 +17,7 @@ import SetTimezone from './elements/set_timezone'
import AutoresizeTextarea from './elements/autoresize_textarea' import AutoresizeTextarea from './elements/autoresize_textarea'
import SubmittersAutocomplete from './elements/submitter_autocomplete' import SubmittersAutocomplete from './elements/submitter_autocomplete'
import FolderAutocomplete from './elements/folder_autocomplete' import FolderAutocomplete from './elements/folder_autocomplete'
import SignatureForm from './elements/signature_form'
import * as TurboInstantClick from './lib/turbo_instant_click' import * as TurboInstantClick from './lib/turbo_instant_click'
@ -47,6 +48,7 @@ window.customElements.define('set-timezone', SetTimezone)
window.customElements.define('autoresize-textarea', AutoresizeTextarea) window.customElements.define('autoresize-textarea', AutoresizeTextarea)
window.customElements.define('submitters-autocomplete', SubmittersAutocomplete) window.customElements.define('submitters-autocomplete', SubmittersAutocomplete)
window.customElements.define('folder-autocomplete', FolderAutocomplete) window.customElements.define('folder-autocomplete', FolderAutocomplete)
window.customElements.define('signature-form', SignatureForm)
document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody) document.addEventListener('turbo:before-fetch-request', encodeMethodIntoRequestBody)
document.addEventListener('turbo:submit-end', async (event) => { document.addEventListener('turbo:submit-end', async (event) => {

@ -0,0 +1,46 @@
import { target, targetable } from '@github/catalyst/lib/targetable'
import { cropCanvasAndExportToPNG } from '../submission_form/crop_canvas'
export default targetable(class extends HTMLElement {
static [target.static] = ['canvas', 'input', 'clear', 'button']
async connectedCallback () {
this.canvas.width = this.canvas.parentNode.parentNode.clientWidth
this.canvas.height = this.canvas.parentNode.parentNode.clientWidth / 3
const { default: SignaturePad } = await import('signature_pad')
this.pad = new SignaturePad(this.canvas)
this.clear.addEventListener('click', (e) => {
e.preventDefault()
this.pad.clear()
})
this.button.addEventListener('click', (e) => {
e.preventDefault()
this.button.disabled = true
this.submit()
})
}
async submit () {
const blob = await cropCanvasAndExportToPNG(this.canvas)
const file = new File([blob], 'signature.png', { type: 'image/png' })
const dataTransfer = new DataTransfer()
dataTransfer.items.add(file)
this.input.files = dataTransfer.files
if (this.input.webkitEntries.length) {
this.input.dataset.file = `${dataTransfer.files[0].name}`
}
this.closest('form').requestSubmit()
}
})

@ -13,6 +13,7 @@ window.customElements.define('submission-form', class extends HTMLElement {
authenticityToken: this.dataset.authenticityToken, authenticityToken: this.dataset.authenticityToken,
canSendEmail: this.dataset.canSendEmail === 'true', canSendEmail: this.dataset.canSendEmail === 'true',
isDirectUpload: this.dataset.isDirectUpload === 'true', isDirectUpload: this.dataset.isDirectUpload === 'true',
goToLast: this.dataset.goToLast === 'true',
isDemo: this.dataset.isDemo === 'true', isDemo: this.dataset.isDemo === 'true',
attribution: this.dataset.attribution !== 'false', attribution: this.dataset.attribution !== 'false',
withConfetti: true, withConfetti: true,

@ -0,0 +1,49 @@
function cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
}
export { cropCanvasAndExportToPNG }

@ -415,6 +415,11 @@ export default {
required: false, required: false,
default: false default: false
}, },
goToLast: {
type: Boolean,
required: false,
default: true
},
isDemo: { isDemo: {
type: Boolean, type: Boolean,
required: false, required: false,
@ -488,10 +493,12 @@ export default {
} }
}, },
mounted () { mounted () {
this.currentStep = Math.min( if (this.goToLast) {
this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1, this.currentStep = Math.min(
this.stepFields.length - 1 this.stepFields.indexOf([...this.stepFields].reverse().find((fields) => fields.some((f) => !!this.values[f.uuid]))) + 1,
) this.stepFields.length - 1
)
}
if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) { if (/iPhone|iPad|iPod/i.test(navigator.userAgent)) {
this.$nextTick(() => { this.$nextTick(() => {

@ -76,7 +76,7 @@
</template> </template>
<script> <script>
import SignatureStep from './signature_step' import { cropCanvasAndExportToPNG } from './crop_canvas'
import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconSignature, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
export default { export default {
@ -146,7 +146,6 @@ export default {
} }
}, },
methods: { methods: {
cropCanvasAndExportToPNG: SignatureStep.methods.cropCanvasAndExportToPNG,
remove () { remove () {
this.$emit('update:model-value', '') this.$emit('update:model-value', '')
}, },
@ -195,7 +194,7 @@ export default {
} }
return new Promise((resolve) => { return new Promise((resolve) => {
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'initials.png', { type: 'image/png' }) const file = new File([blob], 'initials.png', { type: 'image/png' })
if (this.isDirectUpload) { if (this.isDirectUpload) {

@ -92,6 +92,7 @@
<script> <script>
import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue' import { IconReload, IconCamera, IconTextSize, IconArrowsDiagonalMinimize2 } from '@tabler/icons-vue'
import { cropCanvasAndExportToPNG } from './crop_canvas'
export default { export default {
name: 'SignatureStep', name: 'SignatureStep',
@ -248,60 +249,13 @@ export default {
reader.readAsDataURL(file) reader.readAsDataURL(file)
} }
}, },
cropCanvasAndExportToPNG (canvas) {
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
let topmost = height
let bottommost = 0
let leftmost = width
let rightmost = 0
const imageData = ctx.getImageData(0, 0, width, height)
const pixels = imageData.data
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const pixelIndex = (y * width + x) * 4
const alpha = pixels[pixelIndex + 3]
if (alpha !== 0) {
topmost = Math.min(topmost, y)
bottommost = Math.max(bottommost, y)
leftmost = Math.min(leftmost, x)
rightmost = Math.max(rightmost, x)
}
}
}
const croppedWidth = rightmost - leftmost + 1
const croppedHeight = bottommost - topmost + 1
const croppedCanvas = document.createElement('canvas')
croppedCanvas.width = croppedWidth
croppedCanvas.height = croppedHeight
const croppedCtx = croppedCanvas.getContext('2d')
croppedCtx.drawImage(canvas, leftmost, topmost, croppedWidth, croppedHeight, 0, 0, croppedWidth, croppedHeight)
return new Promise((resolve, reject) => {
croppedCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('Failed to create a PNG blob.'))
}
}, 'image/png')
})
},
async submit () { async submit () {
if (this.modelValue) { if (this.modelValue) {
return Promise.resolve({}) return Promise.resolve({})
} }
return new Promise((resolve) => { return new Promise((resolve) => {
this.cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => { cropCanvasAndExportToPNG(this.$refs.canvas).then(async (blob) => {
const file = new File([blob], 'signature.png', { type: 'image/png' }) const file = new File([blob], 'signature.png', { type: 'image/png' })
if (this.isDirectUpload) { if (this.isDirectUpload) {

@ -52,6 +52,7 @@ class User < ApplicationRecord
belongs_to :account belongs_to :account
has_one :access_token, dependent: :destroy has_one :access_token, dependent: :destroy
has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author has_many :templates, dependent: :destroy, foreign_key: :author_id, inverse_of: :author
has_many :user_configs, dependent: :destroy
devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable devise :two_factor_authenticatable, :recoverable, :rememberable, :validatable, :trackable
devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant? devise :registerable, :omniauthable, omniauth_providers: [:google_oauth2] if Docuseal.multitenant?

@ -0,0 +1,29 @@
# frozen_string_literal: true
# == Schema Information
#
# Table name: user_configs
#
# id :bigint not null, primary key
# key :string not null
# value :text not null
# created_at :datetime not null
# updated_at :datetime not null
# user_id :bigint not null
#
# Indexes
#
# index_user_configs_on_user_id (user_id)
# index_user_configs_on_user_id_and_key (user_id,key) UNIQUE
#
# Foreign Keys
#
# fk_rails_... (user_id => users.id)
#
class UserConfig < ApplicationRecord
SIGNATURE_KEY = 'signature'
belongs_to :user
serialize :value, JSON
end

@ -21,6 +21,14 @@
<%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %> <%= f.button button_title(title: 'Update', disabled_with: 'Updating'), class: 'base-button' %>
</div> </div>
<% end %> <% end %>
<p class="text-2xl font-bold mt-8 mb-4">Signature</p>
<% signature = UserConfigs.load_signature(current_user) %>
<% if signature %>
<div class="flex justify-center mb-4">
<img src="<%= signature.url %>" style="max-height: 200px; width: auto" width="<%= signature.metadata['width'] %>" height="<%= signature.metadata['height'] %>">
</div>
<% end %>
<a href="<%= edit_user_signature_path %>" data-turbo-frame="modal" class="base-button w-full">Update Signature</a>
<p class="text-2xl font-bold mt-8 mb-4">Change Password</p> <p class="text-2xl font-bold mt-8 mb-4">Change Password</p>
<%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %> <%= form_for current_user, url: update_password_settings_profile_index_path, method: :patch, html: { autocomplete: 'off', class: 'space-y-4' } do |f| %>
<div class="form-control"> <div class="form-control">

@ -20,7 +20,7 @@
<%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %> <%= form_for @submitter, url: start_form_path(@template.slug), data: { turbo_frame: :_top }, method: :put, html: { class: 'space-y-4' } do |f| %>
<div class="form-control !mt-0"> <div class="form-control !mt-0">
<%= f.label :email, class: 'label' %> <%= f.label :email, class: 'label' %>
<%= f.email_field :email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %> <%= f.email_field :email, value: current_user&.email, required: true, class: 'base-input', placeholder: 'Provide your email to start' %>
</div> </div>
<div class="form-control"> <div class="form-control">
<%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %> <%= f.button button_title(title: 'Start', disabled_with: 'Starting'), class: 'base-button' %>

@ -1,3 +1,3 @@
<% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %> <% data_attachments = attachments_index.values.select { |e| e.record_id == submitter.id }.to_json(only: %i[uuid], methods: %i[url filename content_type]) %>
<% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %> <% data_fields = (submitter.submission.template_fields || submitter.submission.template.fields).select { |f| f['submitter_uuid'] == submitter.uuid }.to_json %>
<submission-form data-is-demo="<%= Docuseal.demo? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form> <submission-form data-is-demo="<%= Docuseal.demo? %>" data-go-to-last="<%= submitter.opened_at? %>" data-is-direct-upload="<%= Docuseal.active_storage_public? %>" data-submitter="<%= submitter.to_json(only: %i[uuid slug name phone email]) %>" data-can-send-email="<%= Accounts.can_send_emails?(Struct.new(:id).new(@submitter.submission.template.account_id)) %>" data-attachments="<%= data_attachments %>" data-fields="<%= data_fields %>" data-authenticity-token="<%= form_authenticity_token %>" data-values="<%= submitter.values.to_json %>"></submission-form>

@ -0,0 +1,55 @@
<%= render 'shared/turbo_modal', title: 'Update Signature' do %>
<% options = [%w[Draw draw], %w[Upload upload]] %>
<toggle-visible data-element-ids="<%= options.map(&:last).to_json %>" class="relative text-center mt-4 block">
<div class="join">
<% options.each_with_index do |(label, value), index| %>
<span>
<%= radio_button_tag 'option', value, value == 'draw', class: 'peer hidden', data: { action: 'change:toggle-visible#trigger' } %>
<label for="option_<%= value %>" class="<%= '!rounded-s-full' if index.zero? %> btn btn-focus btn-sm join-item w-28 peer-checked:btn-active normal-case">
<%= label %>
</label>
</span>
<% end %>
</div>
</toggle-visible>
<div id="draw" class="mt-3">
<%= form_for @user_config, url: user_signature_path, method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off, enctype: 'multipart/form-data' } do |f| %>
<signature-form class="relative block">
<a class="absolute top-1 right-1 link text-sm" data-target="signature-form.clear" href="#">Clear</a>
<canvas data-target="signature-form.canvas" class="bg-white border border-base-300 rounded"></canvas>
<input name="file" class="hidden" data-target="signature-form.input" type="file" accept="image/png,image/jpeg,image/jpg">
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button', data: { target: 'signature-form.button' } %>
</div>
</signature-form>
<% end %>
</div>
<div id="upload" class="hidden mt-3">
<%= form_for @user_config, url: user_signature_path, method: :put, data: { turbo_frame: :_top }, html: { autocomplete: :off, enctype: 'multipart/form-data' } do |f| %>
<file-dropzone data-is-direct-upload="false" data-submit-on-upload="true" class="w-full">
<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">
<div class="flex flex-col items-center">
<span data-target="file-dropzone.icon">
<%= svg_icon('cloud_upload', class: 'w-10 h-10') %>
</span>
<span data-target="file-dropzone.loading" class="hidden">
<%= svg_icon('loader', class: 'w-10 h-10 animate-spin') %>
</span>
<div class="font-medium mb-1">
Upload Signature
</div>
<div class="text-xs">
<span class="font-medium">Click to upload</span> or drag and drop
</div>
</div>
<input id="file" name="file" class="hidden" data-action="change:file-dropzone#onSelectFiles" data-target="file-dropzone.input" type="file" accept="image/png,image/jpeg,image/jpg">
</div>
</label>
</file-dropzone>
<div class="form-control mt-4">
<%= f.button button_title(title: 'Save', disabled_with: 'Saving'), class: 'base-button' %>
</div>
<% end %>
</div>
<% end %>

@ -48,6 +48,7 @@ Rails.application.routes.draw do
resources :setup, only: %i[index create] resources :setup, only: %i[index create]
resource :newsletter, only: %i[show update] resource :newsletter, only: %i[show update]
resources :users, only: %i[new create edit update destroy] resources :users, only: %i[new create edit update destroy]
resource :user_signature, only: %i[edit update]
resources :submissions, only: %i[show destroy] resources :submissions, only: %i[show destroy]
resources :console_redirect, only: %i[index] resources :console_redirect, only: %i[index]
resource :templates_upload, only: %i[create] resource :templates_upload, only: %i[create]

@ -0,0 +1,15 @@
# frozen_string_literal: true
class CreateUserConfigs < ActiveRecord::Migration[7.0]
def change
create_table :user_configs do |t|
t.references :user, null: false, foreign_key: true, index: true
t.string :key, null: false
t.text :value, null: false
t.index %i[user_id key], unique: true
t.timestamps
end
end
end

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.0].define(version: 2023_09_20_202947) do ActiveRecord::Schema[7.0].define(version: 2023_09_22_072041) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
@ -168,6 +168,16 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_20_202947) do
t.index ["slug"], name: "index_templates_on_slug", unique: true t.index ["slug"], name: "index_templates_on_slug", unique: true
end end
create_table "user_configs", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "key", null: false
t.text "value", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "key"], name: "index_user_configs_on_user_id_and_key", unique: true
t.index ["user_id"], name: "index_user_configs_on_user_id"
end
create_table "users", force: :cascade do |t| create_table "users", force: :cascade do |t|
t.string "first_name" t.string "first_name"
t.string "last_name" t.string "last_name"
@ -216,5 +226,6 @@ ActiveRecord::Schema[7.0].define(version: 2023_09_20_202947) do
add_foreign_key "templates", "accounts" add_foreign_key "templates", "accounts"
add_foreign_key "templates", "template_folders", column: "folder_id" add_foreign_key "templates", "template_folders", column: "folder_id"
add_foreign_key "templates", "users", column: "author_id" add_foreign_key "templates", "users", column: "author_id"
add_foreign_key "user_configs", "users"
add_foreign_key "users", "accounts" add_foreign_key "users", "accounts"
end end

@ -11,6 +11,7 @@ class Ability
can :manage, User, account_id: user.account_id can :manage, User, account_id: user.account_id
can :manage, EncryptedConfig, account_id: user.account_id can :manage, EncryptedConfig, account_id: user.account_id
can :manage, AccountConfig, account_id: user.account_id can :manage, AccountConfig, account_id: user.account_id
can :manage, UserConfig, user_id: user.id
can :manage, Account, id: user.account_id can :manage, Account, id: user.account_id
can :manage, AccessToken, user_id: user.id can :manage, AccessToken, user_id: user.id
end end

@ -0,0 +1,48 @@
# frozen_string_literal: true
module Submitters
module MaybeUpdateDefaultValues
module_function
def call(submitter, current_user)
user =
if current_user && current_user.email == submitter.email
current_user
else
submitter.account.users.find_by(email: submitter.email)
end
return if user.blank?
fields = submitter.submission.template_fields || submitter.submission.template.fields
fields.each do |field|
next if field['submitter_uuid'] != submitter.uuid
submitter.values[field['uuid']] ||= get_default_value_for_field(field, user, submitter)
end
submitter.save!
end
def get_default_value_for_field(field, user, submitter)
field_name = field['name'].to_s.downcase
if field_name.in?(['full name', 'legal name'])
user.full_name
elsif field_name == 'first name'
user.first_name
elsif field_name == 'last name'
user.last_name
elsif field['type'] == 'signature' && (signature = UserConfigs.load_signature(user))
attachment = ActiveStorage::Attachment.find_or_create_by!(
blob_id: signature.blob_id,
name: 'attachments',
record: submitter
)
attachment.uuid
end
end
end
end

@ -0,0 +1,13 @@
# frozen_string_literal: true
module UserConfigs
module_function
def load_signature(user)
return if user.blank?
uuid = user.user_configs.find_or_initialize_by(key: UserConfig::SIGNATURE_KEY).value
ActiveStorage::Attachment.find_by(uuid:, record: user, name: 'signature') if uuid.present?
end
end
Loading…
Cancel
Save